feat: torrent scheme deep link and file associations (#866)

This commit is contained in:
Levi authored and GitHub committed 2025-01-16 00:02:00 +08:00
1 parent ccb3267fba
commit acc7ed8f6b
30 files changed
+464 -79

No files matched your search

@@ -80,6 +80,7 @@ class ExtraConfig {
String locale;
bool lastDeleteTaskKeep;
bool defaultDirectDownload;
bool defaultBtClient;
ExtraConfigBt bt = ExtraConfigBt();
@@ -88,6 +89,7 @@ class ExtraConfig {
this.locale = '',
this.lastDeleteTaskKeep = false,
this.defaultDirectDownload = false,
this.defaultBtClient = true,
});
factory ExtraConfig.fromJson(Map<String, dynamic>? json) =>
@@ -27,6 +27,7 @@ import '../../../../util/log_util.dart';
import '../../../../util/package_info.dart';
import '../../../../util/util.dart';
import '../../../routes/app_pages.dart';
import '../../redirect/views/redirect_view.dart';
const unixSocketPath = 'gopeed.sock';
@@ -160,8 +161,7 @@ class AppController extends GetxController with WindowListener, TrayListener {
}
Future<void> _initDeepLinks() async {
// currently only support android
if (!Util.isAndroid()) {
if (Util.isWeb()) {
return;
}
@@ -169,13 +169,13 @@ class AppController extends GetxController with WindowListener, TrayListener {
// Handle link when app is in warm state (front or background)
_linkSubscription = _appLinks.uriLinkStream.listen((uri) async {
await _toCreate(uri);
await _handleDeepLink(uri);
});
// Check initial link if app was in cold state (terminated)
final uri = await _appLinks.getInitialLink();
if (uri != null) {
await _toCreate(uri);
await _handleDeepLink(uri);
}
}
@@ -304,13 +304,25 @@ class AppController extends GetxController with WindowListener, TrayListener {
}
}
Future<void> _toCreate(Uri uri) async {
final path = (uri.scheme == "magnet" ||
uri.scheme == "http" ||
uri.scheme == "https")
? uri.toString()
: (await toFile(uri.toString())).path;
await Get.rootDelegate.offAndToNamed(Routes.CREATE, arguments: path);
Future<void> _handleDeepLink(Uri uri) async {
// Wake up application only
if (uri.scheme == "gopeed") {
Get.rootDelegate.offAndToNamed(Routes.HOME);
return;
}
String path;
if (uri.scheme == "magnet" ||
uri.scheme == "http" ||
uri.scheme == "https") {
path = uri.toString();
} else if (uri.scheme == "file") {
path = Util.isWindows() ? uri.path.substring(1) : uri.path;
} else {
path = (await toFile(uri.toString())).path;
}
Get.rootDelegate.offAndToNamed(Routes.REDIRECT,
arguments: RedirectArgs(Routes.CREATE, arguments: path));
}
String runningAddress() {
@@ -9,7 +9,6 @@ import '../../app/controllers/app_controller.dart';
class CreateController extends GetxController
with GetSingleTickerProviderStateMixin {
// final files = [].obs;
final RxList fileInfos = [].obs;
final RxList openedFolders = [].obs;
final selectedIndexes = <int>[].obs;
@@ -61,31 +61,29 @@ class CreateView extends GetView<CreateController> {
}
final String? filePath = Get.rootDelegate.arguments();
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;
}
recognizeMagnetUri(value!.text!);
if (filePath?.isNotEmpty ?? false) {
// get file path from route arguments
_urlController.text = filePath!;
_urlController.selection = TextSelection.fromPosition(
TextPosition(offset: _urlController.text.length));
} else if (_urlController.text.isEmpty) {
// 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;
}
});
}
recognizeMagnetUri(value!.text!);
}
});
}
return Scaffold(
@@ -0,0 +1,12 @@
import 'package:get/get.dart';
import '../controllers/redirect_controller.dart';
class RedirectBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<RedirectController>(
() => RedirectController(),
);
}
}
@@ -0,0 +1,3 @@
import 'package:get/get.dart';
class RedirectController extends GetxController {}
@@ -0,0 +1,23 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/redirect_controller.dart';
class RedirectArgs {
final String page;
final dynamic arguments;
RedirectArgs(this.page, {this.arguments});
}
class RedirectView extends GetView<RedirectController> {
const RedirectView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final redirectArgs = Get.rootDelegate.arguments() as RedirectArgs;
Get.rootDelegate
.offAndToNamed(redirectArgs.page, arguments: redirectArgs.arguments);
return const SizedBox();
}
}
@@ -17,6 +17,7 @@ import '../../../../util/locale_manager.dart';
import '../../../../util/log_util.dart';
import '../../../../util/message.dart';
import '../../../../util/package_info.dart';
import '../../../../util/scheme_register/scheme_register.dart';
import '../../../../util/util.dart';
import '../../../views/check_list_view.dart';
import '../../../views/directory_selector.dart';
@@ -111,7 +112,7 @@ class SettingView extends GetView<SettingController> {
});
final buildDefaultDirectDownload =
_buildConfigItem('defaultDirectDownload'.tr, () {
_buildConfigItem('defaultDirectDownload', () {
return appController.downloaderConfig.value.extra.defaultDirectDownload
? 'on'.tr
: 'off'.tr;
@@ -161,7 +162,7 @@ class SettingView extends GetView<SettingController> {
// Currently auto startup only support Windows and Linux
final buildAutoStartup = !Util.isWindows() && !Util.isLinux()
? () => null
: _buildConfigItem('launchAtStartup'.tr, () {
: _buildConfigItem('launchAtStartup', () {
return appController.autoStartup.value ? 'on'.tr : 'off'.tr;
}, (Key key) {
return Container(
@@ -229,8 +230,9 @@ class SettingView extends GetView<SettingController> {
],
);
});
final buildHttpUseServerCtime = _buildConfigItem('useServerCtime'.tr,
() => httpConfig.useServerCtime ? 'on'.tr : 'off'.tr, (Key key) {
final buildHttpUseServerCtime = _buildConfigItem(
'useServerCtime', () => httpConfig.useServerCtime ? 'on'.tr : 'off'.tr,
(Key key) {
return Container(
alignment: Alignment.centerLeft,
child: Switch(
@@ -420,6 +422,37 @@ class SettingView extends GetView<SettingController> {
].where((e) => e != null).map((e) => e!).toList(),
);
});
final buildBtDefaultClientConfig = !Util.isWindows()
? () => null
: _buildConfigItem('setAsDefaultBtClient', () {
return appController.downloaderConfig.value.extra.defaultBtClient
? 'on'.tr
: 'off'.tr;
}, (Key key) {
return Container(
alignment: Alignment.centerLeft,
child: Switch(
value:
appController.downloaderConfig.value.extra.defaultBtClient,
onChanged: (bool value) async {
try {
if (value) {
registerDefaultTorrentClient();
} else {
unregisterDefaultTorrentClient();
}
appController.downloaderConfig.update((val) {
val!.extra.defaultBtClient = value;
});
await debounceSave();
} catch (e) {
showErrorMessage(e);
logger.e('register default torrent client fail', e);
}
},
),
);
});
// ui config items start
final buildTheme = _buildConfigItem(
@@ -947,6 +980,7 @@ class SettingView extends GetView<SettingController> {
buildBtTrackerSubscribeUrls(),
buildBtTrackers(),
buildBtSeedConfig(),
buildBtDefaultClientConfig(),
]),
)),
Text('ui'.tr),
+9 -2
View File
@@ -1,6 +1,4 @@
import 'package:get/get.dart';
import 'package:gopeed/app/modules/task/views/task_files_view.dart';
import 'package:gopeed/app/modules/task/views/task_view.dart';
import '../modules/create/bindings/create_binding.dart';
import '../modules/create/views/create_view.dart';
@@ -8,12 +6,16 @@ import '../modules/extension/bindings/extension_binding.dart';
import '../modules/extension/views/extension_view.dart';
import '../modules/home/bindings/home_binding.dart';
import '../modules/home/views/home_view.dart';
import '../modules/redirect/bindings/redirect_binding.dart';
import '../modules/redirect/views/redirect_view.dart';
import '../modules/root/bindings/root_binding.dart';
import '../modules/root/views/root_view.dart';
import '../modules/setting/bindings/setting_binding.dart';
import '../modules/setting/views/setting_view.dart';
import '../modules/task/bindings/task_binding.dart';
import '../modules/task/bindings/task_files_binding.dart';
import '../modules/task/views/task_files_view.dart';
import '../modules/task/views/task_view.dart';
part 'app_routes.dart';
@@ -68,6 +70,11 @@ class AppPages {
page: () => CreateView(),
binding: CreateBinding(),
),
GetPage(
name: _Paths.REDIRECT,
page: () => const RedirectView(),
binding: RedirectBinding(),
),
]),
];
}
@@ -3,6 +3,7 @@ part of 'app_pages.dart';
abstract class Routes {
Routes._();
static const ROOT = _Paths.ROOT;
static const HOME = _Paths.HOME;
static const CREATE = _Paths.CREATE;
@@ -10,10 +11,12 @@ abstract class Routes {
static const TASK_FILES = TASK + _Paths.TASK_FILES;
static const EXTENSION = _Paths.HOME + _Paths.EXTENSION;
static const SETTING = _Paths.HOME + _Paths.SETTING;
static const REDIRECT = _Paths.REDIRECT;
}
abstract class _Paths {
_Paths._();
static const ROOT = '/';
static const HOME = '/home';
static const CREATE = '/create';
@@ -21,4 +24,5 @@ abstract class _Paths {
static const TASK_FILES = '/files';
static const EXTENSION = '/extension';
static const SETTING = '/setting';
static const REDIRECT = '/redirect';
}
+1
View File
@@ -105,6 +105,7 @@ const enUS = {
'seedKeep': 'Keep seeding until manually stopped',
'seedRatio': 'Seed ratio',
'seedTime': 'Seed time (minutes)',
'setAsDefaultBtClient': 'Set as the default BT client',
'taskDetail': 'Task Detail',
'taskName': 'Task Name',
'taskUrl': 'Task URL',
+1
View File
@@ -103,6 +103,7 @@ const zhCN = {
'seedKeep': '持续做种直到手动停止',
'seedRatio': '做种分享率',
'seedTime': '做种时间(分钟)',
'setAsDefaultBtClient': '设为系统默认 BT 客户端',
'taskDetail': '任务详情',
'taskName': '任务名称',
'taskUrl': '任务链接',
+1
View File
@@ -103,6 +103,7 @@ const zhTW = {
'seedKeep': '持續做種直到手動停止',
'seedRatio': '做種分享率',
'seedTime': '做種時間(分鐘)',
'setAsDefaultBtClient': '設為系統預設 BT 客戶端',
'taskDetail': '任務詳情',
'taskName': '任務名稱',
'taskUrl': '任務連結',
+10
View File
@@ -14,6 +14,7 @@ import 'util/locale_manager.dart';
import 'util/log_util.dart';
import 'util/mac_secure_util.dart';
import 'util/package_info.dart';
import 'util/scheme_register/scheme_register.dart';
import 'util/util.dart';
class Args {
@@ -81,6 +82,15 @@ Future<void> init(Args args) async {
logger.e("libgopeed init fail", e);
}
try {
registerUrlScheme("gopeed");
if (controller.downloaderConfig.value.extra.defaultBtClient) {
registerDefaultTorrentClient();
}
} catch (e) {
logger.e("register scheme fail", e);
}
try {
await controller.loadDownloaderConfig();
MacSecureUtil.loadBookmark();
@@ -0,0 +1,92 @@
import 'dart:io';
import 'package:gopeed/util/util.dart';
import 'package:win32_registry/win32_registry.dart';
doRegisterUrlScheme(String scheme) {
if (Util.isWindows()) {
final schemeKey = 'Software\\Classes\\$scheme';
final appPath = Platform.resolvedExecutable;
_upsertRegistry(
schemeKey,
'URL Protocol',
'',
);
_upsertRegistry(
'$schemeKey\\shell\\open\\command',
'',
'"$appPath" "%1"',
);
}
}
doUnregisterUrlScheme(String scheme) {
if (Util.isWindows()) {
Registry.currentUser
.deleteKey('Software\\Classes\\$scheme', recursive: true);
}
}
const _torrentRegKey = 'Software\\Classes\\.torrent';
const _torrentRegValue = 'Gopeed_torrent';
const _torrentAppRegKey = 'Software\\Classes\\$_torrentRegValue';
/// Register as the system's default torrent client
/// 1. Register the scheme "magnet"
/// 2. Register the file type ".torrent"
doRegisterDefaultTorrentClient() {
if (Util.isWindows()) {
doRegisterUrlScheme("magnet");
final appPath = Platform.resolvedExecutable;
final iconPath =
'${File(appPath).parent.path}\\data\\flutter_assets\\assets\\tray_icon\\icon.ico';
_upsertRegistry(
_torrentRegKey,
'',
_torrentRegValue,
);
_upsertRegistry(
_torrentAppRegKey,
'',
'Torrent file',
);
_upsertRegistry(
'$_torrentAppRegKey\\DefaultIcon',
'',
iconPath,
);
_upsertRegistry(
'$_torrentAppRegKey\\shell\\open\\command',
'',
'"$appPath" "file:///%1"',
);
}
}
doUnregisterDefaultTorrentClient() {
if (Util.isWindows()) {
doUnregisterUrlScheme("magnet");
Registry.currentUser.deleteKey(_torrentRegKey, recursive: true);
Registry.currentUser.deleteKey(_torrentAppRegKey, recursive: true);
}
}
// Add Windows registry key and value if not exists
_upsertRegistry(String keyPath, String valueName, String value) {
RegistryKey regKey;
try {
regKey = Registry.openPath(RegistryHive.currentUser,
path: keyPath, desiredAccessRights: AccessRights.allAccess);
} catch (e) {
regKey = Registry.currentUser.createKey(keyPath);
}
if (regKey.getValueAsString(valueName) != value) {
regKey
.createValue(RegistryValue(valueName, RegistryValueType.string, value));
}
regKey.close();
}
@@ -0,0 +1,11 @@
import 'scheme_register_stub.dart'
if (dart.library.io) 'entry/scheme_register_native.dart';
registerUrlScheme(String scheme) => doRegisterUrlScheme(scheme);
unregisterUrlScheme(String scheme) => doUnregisterUrlScheme(scheme);
registerDefaultTorrentClient() => doRegisterDefaultTorrentClient();
unregisterDefaultTorrentClient() => doUnregisterDefaultTorrentClient();
@@ -0,0 +1,7 @@
doRegisterUrlScheme(String scheme) => throw UnimplementedError();
doUnregisterUrlScheme(String scheme) => throw UnimplementedError();
doRegisterDefaultTorrentClient() => throw UnimplementedError();
doUnregisterDefaultTorrentClient() => throw UnimplementedError();