feat: extension support (#78)

This commit is contained in:
Levi authored and GitHub committed 2023-10-16 09:47:31 +08:00
1 parent 82d316af83
commit c073fc0a67
118 files changed
+6971 -780

No files matched your search

@@ -334,7 +334,7 @@ class AppController extends GetxController with WindowListener, TrayListener {
}
final extra = config.extra;
if (extra.themeMode.isEmpty) {
extra.themeMode = ThemeMode.system.name;
extra.themeMode = ThemeMode.dark.name;
}
if (extra.locale.isEmpty) {
final systemLocale = getLocaleKey(PlatformDispatcher.instance.locale);
@@ -8,8 +8,8 @@ class CreateController extends GetxController
with GetSingleTickerProviderStateMixin {
// final files = [].obs;
final RxList fileInfos = [].obs;
final RxList selectedIndexes = [].obs;
final RxList openedFolders = [].obs;
final selectedIndexes = <int>[].obs;
final isResolving = false.obs;
final showAdvanced = false.obs;
late TabController advancedTabController;
@@ -4,13 +4,16 @@ import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import '../../../../api/model/resolve_result.dart';
import 'package:path/path.dart' as path;
import 'package:rounded_loading_button/rounded_loading_button.dart';
import '../../../../api/api.dart';
import '../../../../api/model/create_task.dart';
import '../../../../api/model/options.dart';
import '../../../../api/model/request.dart';
import '../../../../api/model/resolve_result.dart';
import '../../../../api/model/resource.dart';
import '../../../../util/input_formatter.dart';
import '../../../../util/message.dart';
import '../../../../util/util.dart';
@@ -37,30 +40,31 @@ class CreateView extends GetView<CreateController> {
@override
Widget build(BuildContext context) {
final String? filePath = Get.rootDelegate.arguments();
if (_urlController.text.isEmpty && filePath != null) {
_urlController.text = filePath;
_urlController.selection = TextSelection.fromPosition(
TextPosition(offset: _urlController.text.length));
}
if (_urlController.text.isEmpty) {
if (filePath?.isNotEmpty ?? false) {
// get file path from route arguments
_urlController.text = filePath!;
_urlController.selection = TextSelection.fromPosition(
TextPosition(offset: _urlController.text.length));
} else {
// read clipboard
Clipboard.getData('text/plain').then((value) {
if (value?.text?.isNotEmpty ?? false) {
if (_availableSchemes
.where((e) =>
value!.text!.startsWith(e) ||
value.text!.startsWith(e.toUpperCase()))
.isNotEmpty) {
_urlController.text = value!.text!;
_urlController.selection = TextSelection.fromPosition(
TextPosition(offset: _urlController.text.length));
return;
}
// if no file path, read from clipboard
if (filePath?.isEmpty ?? true) {
Clipboard.getData('text/plain').then((value) {
if (value?.text?.isNotEmpty ?? false) {
if (_availableSchemes
.where((e) =>
value!.text!.startsWith(e) ||
value.text!.startsWith(e.toUpperCase()))
.isNotEmpty) {
_urlController.text = value!.text!;
_urlController.selection = TextSelection.fromPosition(
TextPosition(offset: _urlController.text.length));
return;
recognizeMagnetUri(value!.text!);
}
recognizeMagnetUri(value!.text!);
}
});
});
}
}
return Scaffold(
@@ -266,29 +270,29 @@ class CreateView extends GetView<CreateController> {
try {
_confirmController.start();
if (_resolveFormKey.currentState!.validate()) {
Object? extra;
final submitUrl = Util.isWeb() && controller.fileDataUri.isNotEmpty
? controller.fileDataUri.value
: _urlController.text;
if (controller.showAdvanced.value) {
final u = Uri.parse(submitUrl);
if (u.scheme.startsWith("http")) {
extra = ReqExtraHttp()
..header = {
"User-Agent": _httpUaController.text,
"Cookie": _httpCookieController.text,
"Referer": _httpRefererController.text,
};
} else {
extra = ReqExtraBt()
..trackers = Util.textToLines(_btTrackerController.text);
}
// check if is multi line urls
final urls = Util.textToLines(_urlController.text);
ResolveResult rr;
bool isMuiltiLine = false;
if (urls.length > 1) {
isMuiltiLine = true;
rr = ResolveResult(
res: Resource(
files: urls
.map((u) => FileInfo(
name: u,
req: Request(url: u, extra: parseReqExtra(u))))
.toList()));
} else {
final submitUrl = Util.isWeb() && controller.fileDataUri.isNotEmpty
? controller.fileDataUri.value
: _urlController.text;
rr = await resolve(Request(
url: submitUrl,
extra: parseReqExtra(_urlController.text),
));
}
final rr = await resolve(Request(
url: submitUrl,
extra: extra,
));
await _showResolveDialog(rr);
await _showResolveDialog(rr, isMuiltiLine);
}
} catch (e) {
showErrorMessage(e);
@@ -298,6 +302,25 @@ class CreateView extends GetView<CreateController> {
}
}
Object? parseReqExtra(String url) {
Object? reqExtra;
if (controller.showAdvanced.value) {
final u = Uri.parse(_urlController.text);
if (u.scheme.startsWith("http")) {
reqExtra = ReqExtraHttp()
..header = {
"User-Agent": _httpUaController.text,
"Cookie": _httpCookieController.text,
"Referer": _httpRefererController.text,
};
} else {
reqExtra = ReqExtraBt()
..trackers = Util.textToLines(_btTrackerController.text);
}
}
return reqExtra;
}
String _hitText() {
return 'downloadLinkHit'.trParams({
'append':
@@ -305,8 +328,7 @@ class CreateView extends GetView<CreateController> {
});
}
Future<void> _showResolveDialog(ResolveResult rr) async {
final files = rr.res.files;
Future<void> _showResolveDialog(ResolveResult rr, bool isMuiltiLine) async {
final appController = Get.find<AppController>();
final createFormKey = GlobalKey<FormState>();
@@ -319,6 +341,7 @@ class CreateView extends GetView<CreateController> {
context: Get.context!,
barrierDismissible: false,
builder: (_) => AlertDialog(
title: rr.res.name.isEmpty ? null : Text(rr.res.name),
content: Builder(
builder: (context) {
// Get available height and width of the build area of this widget. Make a choice depending on the size.
@@ -333,7 +356,7 @@ class CreateView extends GetView<CreateController> {
autovalidateMode: AutovalidateMode.always,
child: Column(
children: [
Expanded(child: FileListView(files: files)),
Expanded(child: FileListView(files: rr.res.files)),
TextFormField(
controller: nameController,
decoration: InputDecoration(
@@ -391,19 +414,37 @@ class CreateView extends GetView<CreateController> {
showMessage('tip'.tr, 'noFileSelected'.tr);
return;
}
Object? optExtra = connectionsController.text.isEmpty
? null
: (OptsExtraHttp()
..connections =
int.parse(connectionsController.text));
if (createFormKey.currentState!.validate()) {
await createTask(CreateTask(
rid: rr.id,
opts: Options(
name: nameController.text,
path: pathController.text,
selectFiles:
controller.selectedIndexes.cast<int>(),
extra: connectionsController.text.isEmpty
? null
: (OptsExtraHttp()
..connections = int.parse(
connectionsController.text)))));
if (rr.id.isEmpty) {
// create task batch, there has two ways to batch create task
// 1. from multi line urls
// 2. from extension resolve result
await Future.wait(
controller.selectedIndexes.map((index) {
final file = rr.res.files[index];
return createTask(CreateTask(
req: file.req!,
opt: Options(
name: isMuiltiLine ? "" : file.name,
path: path.join(
pathController.text, rr.res.name),
selectFiles: [],
extra: optExtra)));
}));
} else {
await createTask(CreateTask(
rid: rr.id,
opt: Options(
name: nameController.text,
path: pathController.text,
selectFiles: controller.selectedIndexes,
extra: optExtra)));
}
Get.back();
Get.rootDelegate.offNamed(Routes.TASK);
}
@@ -0,0 +1,12 @@
import 'package:get/get.dart';
import '../controllers/extension_controller.dart';
class ExtensionBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<ExtensionController>(
() => ExtensionController(),
);
}
}
@@ -0,0 +1,46 @@
import 'package:get/get.dart';
import 'package:gopeed/api/api.dart';
import '../../../../api/model/extension.dart';
class ExtensionController extends GetxController {
final extensions = <Extension>[].obs;
final updateFlags = <String, String>{}.obs;
final devMode = false.obs;
var _devModeCount = 0;
@override
void onInit() async {
super.onInit();
await load();
checkUpdate();
}
Future<void> load() async {
extensions.value = await getExtensions();
}
Future<void> checkUpdate() async {
for (final ext in extensions) {
final resp = await upgradeCheckExtension(ext.identity);
if (resp.newVersion.isNotEmpty) {
updateFlags[ext.identity] = resp.newVersion;
}
}
}
// Try to open dev mode when install button is clicked 5 times in 2 seconds
void tryOpenDevMode() {
if (_devModeCount == 0) {
Future.delayed(const Duration(seconds: 2), () {
if (devMode.value) return;
devMode.value = false;
_devModeCount = 0;
});
}
_devModeCount++;
if (_devModeCount >= 5) {
devMode.value = true;
}
}
}
@@ -0,0 +1,438 @@
import 'dart:io';
import 'package:badges/badges.dart' as badges;
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:form_builder_validators/form_builder_validators.dart';
import 'package:get/get.dart';
import 'package:gopeed/app/views/text_button_loading.dart';
import '../../../../api/model/update_extension_settings.dart';
import '../../../../util/mac_secure_util.dart';
import '../../../../util/message.dart';
import 'package:path/path.dart' as path;
import 'package:rounded_loading_button/rounded_loading_button.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../../api/api.dart';
import '../../../../api/model/extension.dart';
import '../../../../api/model/install_extension.dart';
import '../../../../api/model/switch_extension.dart';
import '../../../../util/util.dart';
import '../../../views/icon_button_loading.dart';
import '../controllers/extension_controller.dart';
class ExtensionView extends GetView<ExtensionController> {
ExtensionView({Key? key}) : super(key: key);
final _installUrlController = TextEditingController();
final _installBtnController = IconButtonLoadingController();
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 20),
Obx(() => Row(
children: [
Expanded(
child: TextField(
controller: _installUrlController,
decoration: InputDecoration(
labelText: 'extensionInstallUrl'.tr,
),
),
),
const SizedBox(width: 10),
IconButtonLoading(
controller: _installBtnController,
onPressed: () async {
if (_installUrlController.text.isEmpty) {
controller.tryOpenDevMode();
return;
}
_installBtnController.start();
try {
await installExtension(InstallExtension(
url: _installUrlController.text));
Get.snackbar('tip'.tr, 'extensionInstallSuccess'.tr);
await controller.load();
} catch (e) {
showErrorMessage(e);
} finally {
_installBtnController.stop();
}
},
icon: const Icon(Icons.download)),
controller.devMode.value && Util.isDesktop()
? IconButton(
icon: const Icon(Icons.folder_open),
onPressed: () async {
var dir =
await FilePicker.platform.getDirectoryPath();
if (dir != null) {
MacSecureUtil.saveBookmark(dir);
try {
await installExtension(
InstallExtension(devMode: true, url: dir));
Get.snackbar(
'tip'.tr, 'extensionInstallSuccess'.tr);
await controller.load();
} catch (e) {
showErrorMessage(e);
}
}
})
: Container()
],
)),
const SizedBox(height: 32),
Expanded(
child: Obx(() => ListView.builder(
itemCount: controller.extensions.length,
itemBuilder: (context, index) {
final extension = controller.extensions[index];
return SizedBox(
height: 112,
child: Card(
elevation: 4.0,
child: Column(
children: [
Expanded(
child: ListTile(
leading: extension.icon.isEmpty
? Image.asset(
"assets/extension/default_icon.png",
width: 48,
height: 48,
)
: Util.isWeb()
? Image.network(join(
'/fs/extensions/${extension.identity}/${extension.icon}'))
: Image.file(
File(path.join(
Util.getStorageDir(),
"extensions",
extension.identity,
extension.icon)),
width: 48,
height: 48,
),
trailing: Switch(
value: !extension.disabled,
onChanged: (value) async {
try {
await switchExtension(
extension.identity,
SwitchExtension(status: value));
await controller.load();
} catch (e) {
showErrorMessage(e);
}
},
),
title: Row(
children: () {
final list = [
Text(extension.title),
const SizedBox(width: 8),
Chip(
label: Text('v${extension.version}'),
)
];
if (extension.devMode) {
list.add(const SizedBox(width: 8));
list.add(Chip(
label: Text('dev'.tr),
backgroundColor: Colors.redAccent,
));
}
return list;
}(),
),
subtitle: Text(extension.description),
),
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
extension.homepage.isNotEmpty == true
? IconButton(
onPressed: () {
launchUrl(
Uri.parse(extension.homepage),
mode: LaunchMode
.externalApplication);
},
icon: const Icon(Icons.home))
: null,
extension.repository?.url.isNotEmpty == true
? IconButton(
onPressed: () {
launchUrl(
Uri.parse(
extension.repository!.url),
mode: LaunchMode
.externalApplication);
},
icon: const Icon(Icons.code))
: null,
extension.settings?.isNotEmpty == true
? IconButton(
onPressed: () {
_showSettingDialog(extension);
},
icon: const Icon(Icons.settings))
: null,
IconButton(
onPressed: () {
_showDeleteDialog(extension);
},
icon: const Icon(Icons.delete)),
Obx(() => controller.updateFlags
.containsKey(extension.identity)
? badges.Badge(
position:
badges.BadgePosition.topStart(
start: 36),
child: IconButton(
onPressed: () {
_showUpdateDialog(extension);
},
icon: const Icon(Icons.refresh)))
: IconButton(
onPressed: () {
showMessage('tip'.tr,
'extensionAlreadyLatest'.tr);
},
icon: const Icon(Icons.refresh))),
]
.where((e) => e != null)
.map((e) => e!)
.toList(),
).paddingOnly(right: 16)
],
),
),
).paddingOnly(top: 8);
},
))),
],
).paddingAll(32),
);
}
Future<void> _showSettingDialog(Extension extension) async {
final formKey = GlobalKey<FormBuilderState>();
final confrimController = RoundedLoadingButtonController();
return showDialog<void>(
context: Get.context!,
barrierDismissible: false,
builder: (_) => AlertDialog(
content: Builder(builder: (context) {
final height = MediaQuery.of(context).size.height;
final width = MediaQuery.of(context).size.width;
return SizedBox(
height: height * 0.75,
width: width,
child: FormBuilder(
key: formKey,
// autovalidateMode: AutovalidateMode.always,
child: Column(children: [
Text('setting'.tr),
Expanded(
child: SingleChildScrollView(
child: Column(
children: extension.settings!
.map((e) => _buildSettingItem(e))
.toList()),
),
),
]),
),
);
}),
actions: [
ConstrainedBox(
constraints: BoxConstraints.tightFor(
width: Get.theme.buttonTheme.minWidth,
height: Get.theme.buttonTheme.height,
),
child: ElevatedButton(
style:
ElevatedButton.styleFrom(shape: const StadiumBorder())
.copyWith(
backgroundColor: MaterialStateProperty.all(
Get.theme.colorScheme.background)),
onPressed: () {
Get.back();
},
child: Text('cancel'.tr),
),
),
ConstrainedBox(
constraints: BoxConstraints.tightFor(
width: Get.theme.buttonTheme.minWidth,
height: Get.theme.buttonTheme.height,
),
child: RoundedLoadingButton(
color: Get.theme.colorScheme.secondary,
onPressed: () async {
try {
confrimController.start();
if (formKey.currentState?.saveAndValidate() == true) {
await updateExtensionSettings(
extension.identity,
UpdateExtensionSettings(
settings: formKey.currentState!.value));
await controller.load();
Get.back();
}
} catch (e) {
showErrorMessage(e);
} finally {
confrimController.reset();
}
},
controller: confrimController,
child: Text(
'confirm'.tr,
)),
),
],
));
}
Widget _buildSettingItem(Setting setting) {
final requiredValidator =
setting.required ? FormBuilderValidators.required() : null;
Widget buildTextField(TextInputFormatter? inputFormatter,
FormFieldValidator<String>? validator, TextInputType? keyBoardType) {
return FormBuilderTextField(
name: setting.name,
decoration: InputDecoration(
labelText: setting.title, hintText: setting.description),
initialValue: setting.value?.toString(),
inputFormatters: inputFormatter != null ? [inputFormatter] : null,
keyboardType: keyBoardType,
validator: FormBuilderValidators.compose([
requiredValidator,
validator,
].where((e) => e != null).map((e) => e!).toList()),
);
}
Widget buildDropdown() {
return FormBuilderDropdown<String>(
name: setting.name,
decoration: InputDecoration(
labelText: setting.title, hintText: setting.description),
initialValue: setting.value?.toString(),
validator: FormBuilderValidators.compose([
requiredValidator,
].where((e) => e != null).map((e) => e!).toList()),
items: setting.options!
.map((e) => DropdownMenuItem(
value: e.value.toString(),
child: Text(e.label),
))
.toList(),
);
}
switch (setting.type) {
case SettingType.string:
return setting.options?.isNotEmpty == true
? buildDropdown()
: buildTextField(null, null, null);
case SettingType.number:
return setting.options?.isNotEmpty == true
? buildDropdown()
: buildTextField(
FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d*')),
FormBuilderValidators.numeric(),
const TextInputType.numberWithOptions(decimal: true));
case SettingType.boolean:
return FormBuilderSwitch(
name: setting.name,
initialValue: (setting.value as bool?) ?? false,
title: Text(setting.title),
validator: requiredValidator,
);
}
}
void _showDeleteDialog(Extension extension) {
showDialog(
context: Get.context!,
barrierDismissible: false,
builder: (_) => AlertDialog(
title: Text('extensionDelete'.tr),
actions: [
TextButton(
child: Text('cancel'.tr),
onPressed: () => Get.back(),
),
TextButton(
child: Text(
'confirm'.tr,
style: const TextStyle(color: Colors.redAccent),
),
onPressed: () async {
try {
await deleteExtension(extension.identity);
await controller.load();
Get.back();
} catch (e) {
showErrorMessage(e);
}
},
),
],
));
}
void _showUpdateDialog(Extension extension) {
final confrimController = TextButtonLoadingController();
showDialog(
context: Get.context!,
builder: (context) => AlertDialog(
content: Text('newVersionTitle'.trParams({
'version': 'v${controller.updateFlags[extension.identity]!}'
})),
actions: [
TextButton(
onPressed: () {
Get.back();
},
child: Text('newVersionLater'.tr),
),
TextButtonLoading(
controller: confrimController,
onPressed: () async {
confrimController.start();
try {
await updateExtension(extension.identity);
await controller.load();
controller.updateFlags.remove(extension.identity);
Get.back();
showMessage('tip'.tr, 'extensionUpdateSuccess'.tr);
} catch (e) {
showErrorMessage(e);
} finally {
confrimController.stop();
}
},
child: Text('newVersionUpdate'.tr),
),
],
));
}
}
@@ -12,9 +12,12 @@ class HomeView extends GetView<HomeController> {
Widget build(BuildContext context) {
return GetRouterOutlet.builder(builder: (context, delegate, currentRoute) {
switch (currentRoute?.location) {
case Routes.SETTING:
case Routes.EXTENSION:
controller.currentIndex.value = 1;
break;
case Routes.SETTING:
controller.currentIndex.value = 2;
break;
default:
controller.currentIndex.value = 0;
break;
@@ -33,23 +36,30 @@ class HomeView extends GetView<HomeController> {
groupAlignment: 0,
// useIndicator: false,
onDestinationSelected: (int index) {
controller.currentIndex.value = index;
switch (index) {
case 0:
controller.currentIndex.value = 0;
delegate.toNamed(Routes.TASK);
delegate.offAndToNamed(Routes.TASK);
break;
case 1:
controller.currentIndex.value = 1;
delegate.toNamed(Routes.SETTING);
delegate.offAndToNamed(Routes.EXTENSION);
break;
case 2:
delegate.offAndToNamed(Routes.SETTING);
break;
}
},
destinations: [
NavigationRailDestination(
icon: const Icon(Icons.list),
selectedIcon: const Icon(Icons.list),
icon: const Icon(Icons.task),
selectedIcon: const Icon(Icons.task),
label: Text('task'.tr),
),
NavigationRailDestination(
icon: const Icon(Icons.extension),
selectedIcon: const Icon(Icons.extension),
label: Text('extensions'.tr),
),
NavigationRailDestination(
icon: const Icon(Icons.settings),
selectedIcon: const Icon(Icons.settings),
@@ -76,9 +86,13 @@ class HomeView extends GetView<HomeController> {
? BottomNavigationBar(
items: <BottomNavigationBarItem>[
BottomNavigationBarItem(
icon: const Icon(Icons.list),
icon: const Icon(Icons.task),
label: 'task'.tr,
),
BottomNavigationBarItem(
icon: const Icon(Icons.extension),
label: 'extensions'.tr,
),
BottomNavigationBarItem(
icon: const Icon(Icons.settings),
label: 'setting'.tr,
@@ -87,14 +101,16 @@ class HomeView extends GetView<HomeController> {
currentIndex: controller.currentIndex.value,
// selectedItemColor: Get.theme.highlightColor,
onTap: (index) {
controller.currentIndex.value = index;
switch (index) {
case 0:
controller.currentIndex.value = 0;
delegate.toNamed(Routes.TASK);
delegate.offAndToNamed(Routes.TASK);
break;
case 1:
controller.currentIndex.value = 1;
delegate.toNamed(Routes.SETTING);
delegate.offAndToNamed(Routes.EXTENSION);
break;
case 2:
delegate.offAndToNamed(Routes.SETTING);
break;
}
},
@@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'package:badges/badges.dart' as badges;
import 'package:flutter/material.dart';
@@ -27,7 +27,7 @@ class TaskFilesController extends GetxController {
final taskId = Get.rootDelegate.parameters['id'];
final tasks = await getTasks([]);
task.value = tasks.firstWhere((element) => element.id == taskId);
parseDirMap(task.value!.meta.res.files);
parseDirMap(task.value!.meta.res!.files);
toDir("/");
}
@@ -20,7 +20,7 @@ class TaskFilesView extends GetView<TaskFilesController> {
icon: const Icon(Icons.arrow_back),
onPressed: () => Get.rootDelegate.popRoute()),
// actions: [],
title: Obx(() => Text(controller.task.value?.meta.res.name ?? "")),
title: Obx(() => Text(controller.task.value!.meta.res!.name)),
),
body: Obx(() {
final fileList = controller.fileList;
@@ -52,6 +52,8 @@ class TaskFilesView extends GetView<TaskFilesController> {
itemBuilder: (context, index) {
final meta = controller.task.value!.meta;
final file = fileList[index];
final filePath = Util.safePathJoin(
[meta.opts.path, meta.res!.name, file.fullPath]);
return ListTile(
leading: file.isDirectory
? const Icon(Icons.folder)
@@ -74,22 +76,12 @@ class TaskFilesView extends GetView<TaskFilesController> {
IconButton(
icon: const Icon(Icons.play_circle),
onPressed: () {
OpenFile.open(
Util.safePathJoin([
meta.opts.path,
meta.res.rootDir,
file.fullPath
]),
);
OpenFile.open(filePath);
}),
IconButton(
icon: const Icon(Icons.share),
onPressed: () {
final xfile = XFile(Util.safePathJoin([
meta.opts.path,
meta.res.rootDir,
file.fullPath
]));
final xfile = XFile(filePath);
Share.shareXFiles([xfile]);
})
],