fix: extension install by deeplink (#1510)

This commit is contained in:
Levi authored and GitHub committed 2026-09-13 23:40:59 +08:00
1 parent a760290213
commit cee5f3336e
36 files changed
+869 -143

No files matched your search

+3
View File
@@ -24,6 +24,9 @@
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<!-- app_links owns scheme navigation for both task creation and extension installation. -->
<key>FlutterDeepLinkingEnabled</key>
<false/>
<key>CFBundleURLTypes</key>
<array>
<dict>
+21
View File
@@ -1,6 +1,27 @@
import Flutter
import UIKit
import app_links
class SceneDelegate: FlutterSceneDelegate {
override func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
super.scene(scene, willConnectTo: session, options: connectionOptions)
forwardGopeedLinks(connectionOptions.urlContexts)
}
override func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
super.scene(scene, openURLContexts: URLContexts)
forwardGopeedLinks(URLContexts)
}
private func forwardGopeedLinks(_ contexts: Set<UIOpenURLContext>) {
// app_links 6.x only registers AppDelegate callbacks. UIScene delivers
// both cold-start and running-app scheme links here instead.
for context in contexts where context.url.scheme?.lowercased() == "gopeed" {
AppLinks.shared.handleLink(url: context.url)
}
}
}
@@ -144,7 +144,7 @@ class AppDeepLinkController extends AsyncNotifier<AppDeepLinkState> {
Map<String, dynamic> _decodeParams(String params) {
final safeParams = params.replaceAll('"', '').replaceAll(' ', '+');
final paramsJson = String.fromCharCodes(base64Decode(base64.normalize(safeParams)));
final paramsJson = utf8.decode(base64Decode(base64.normalize(safeParams)));
return jsonDecode(paramsJson) as Map<String, dynamic>;
}
@@ -19,7 +19,7 @@ Future<bool> open(String filePath) async {
Future<bool> reveal(String filePath) async {
try {
if (!(Platform.isWindows || Platform.isMacOS || Platform.isLinux)) {
return open(filePath);
return false;
}
if (await FileSystemEntity.isFile(filePath)) {
@@ -26,6 +26,7 @@ import '../../application/pending_extension_install.dart';
import '../widgets/extension_detail_view.dart';
import '../widgets/extension_icon.dart';
import '../widgets/extension_setting_field.dart';
import '../widgets/extension_update_dialog.dart';
const _extensionCardMinWidth = 290.0;
const _extensionGridSpacing = 10.0;
@@ -47,6 +48,9 @@ class ExtensionsPage extends ConsumerStatefulWidget {
class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
final _searchController = TextEditingController();
final _installController = TextEditingController();
final _installAnchorKey = GlobalKey();
final _installPopoverKey = GlobalKey<_InstallPopoverState>();
bool _installDevMode = false;
final _listScrollController = ScrollController();
final Map<String, TextEditingController> _settingControllers = {};
ExtensionListItem? _detailItem;
@@ -78,15 +82,28 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
Widget build(BuildContext context) {
final palette = AppPalette.of(context);
final isDesktop = MediaQuery.sizeOf(context).width >= Breakpoints.mobile;
ref.listen(pendingExtensionInstallProvider, (previous, next) {
if (next == null) return;
ref.read(pendingExtensionInstallProvider.notifier).clear();
unawaited(
_runAction(
() => ref.read(extensionsControllerProvider.notifier).installFromUrl(next.url, devInstall: next.devMode),
),
);
});
final pendingInstall = ref.watch(pendingExtensionInstallProvider);
if (pendingInstall != null) {
// The request can arrive before this page is mounted. Consume it only
// after the toolbar is laid out, so cold and warm links share the same UI.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !identical(ref.read(pendingExtensionInstallProvider), pendingInstall)) return;
if (_installPopoverKey.currentState?._installing == true) return;
final anchorContext = _installAnchorKey.currentContext;
if (anchorContext == null) return;
_installController.text = pendingInstall.url;
_installDevMode = pendingInstall.devMode;
_showInstallPopover(anchorContext);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !identical(ref.read(pendingExtensionInstallProvider), pendingInstall)) return;
final popover = _installPopoverKey.currentState;
if (popover == null || popover._installing) return;
ref.read(pendingExtensionInstallProvider.notifier).clear();
unawaited(popover._install());
});
WidgetsBinding.instance.ensureVisualUpdate();
});
}
final stateAsync = ref.watch(extensionsControllerProvider);
final body = Stack(
children: [
@@ -154,6 +171,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
onSearch: (query) => _runAction(() => ref.read(extensionsControllerProvider.notifier).searchStore(query)),
onSort: (sort) => _runAction(() => ref.read(extensionsControllerProvider.notifier).changeSort(sort)),
onFilter: ref.read(extensionsControllerProvider.notifier).changeFilter,
installAnchorKey: _installAnchorKey,
onOpenInstall: _openInstallPopover,
onDevelopExtension: _openExtensionDevelopmentDocs,
onInstallFolder: _installFromFolder,
@@ -169,7 +187,9 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
Future<void> _installFromUrl() async {
final url = _installController.text.trim();
if (url.isEmpty) return;
await _runAction(() => ref.read(extensionsControllerProvider.notifier).installFromUrl(url));
await _runAction(
() => ref.read(extensionsControllerProvider.notifier).installFromUrl(url, devInstall: _installDevMode),
);
}
Future<void> _loadNextPageIfNeeded() async {
@@ -192,7 +212,14 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
}
void _openInstallPopover(BuildContext anchorContext) {
ref.read(extensionsControllerProvider.notifier).tryOpenDevMode();
if (AppWindowChrome.isDesktopWindow) {
ref.read(extensionsControllerProvider.notifier).tryOpenDevMode();
}
_installDevMode = false;
_showInstallPopover(anchorContext);
}
void _showInstallPopover(BuildContext anchorContext) {
if (_installPopover != null) return;
final overlay = const shad.PopoverOverlayHandler().show<void>(
context: anchorContext,
@@ -201,7 +228,17 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
offset: const Offset(0, 8),
modal: false,
consumeOutsideTaps: false,
builder: (context) => _InstallPopover(controller: _installController, onInstallUrl: _installFromUrl),
// Flutter's native selection toolbar uses the EditableText tap group.
// Pasting from it must not dismiss the form and restore search focus.
regionGroupId: EditableText,
builder: (context) => _InstallPopover(
key: _installPopoverKey,
controller: _installController,
onInstallUrl: _installFromUrl,
onFinished: () {
if (mounted && ref.read(pendingExtensionInstallProvider) != null) setState(() {});
},
),
);
_installPopover = overlay;
unawaited(
@@ -284,6 +321,7 @@ class _Content extends StatelessWidget {
required this.onSearch,
required this.onSort,
required this.onFilter,
required this.installAnchorKey,
required this.onOpenInstall,
required this.onDevelopExtension,
required this.onInstallFolder,
@@ -301,6 +339,7 @@ class _Content extends StatelessWidget {
final ValueChanged<String> onSearch;
final ValueChanged<StoreExtensionSort> onSort;
final ValueChanged<ExtensionListFilter> onFilter;
final GlobalKey installAnchorKey;
final ValueChanged<BuildContext> onOpenInstall;
final VoidCallback onDevelopExtension;
final VoidCallback onInstallFolder;
@@ -322,6 +361,7 @@ class _Content extends StatelessWidget {
onSearch: onSearch,
onSort: onSort,
onRefresh: onRefresh,
installAnchorKey: installAnchorKey,
onOpenInstall: onOpenInstall,
onDevelopExtension: onDevelopExtension,
onInstallFolder: onInstallFolder,
@@ -614,6 +654,7 @@ class _Toolbar extends StatelessWidget {
required this.onSearch,
required this.onSort,
required this.onRefresh,
required this.installAnchorKey,
required this.onOpenInstall,
required this.onDevelopExtension,
required this.onInstallFolder,
@@ -624,6 +665,7 @@ class _Toolbar extends StatelessWidget {
final ValueChanged<String> onSearch;
final ValueChanged<StoreExtensionSort> onSort;
final VoidCallback onRefresh;
final GlobalKey installAnchorKey;
final ValueChanged<BuildContext> onOpenInstall;
final VoidCallback onDevelopExtension;
final VoidCallback onInstallFolder;
@@ -631,7 +673,6 @@ class _Toolbar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final palette = AppPalette.of(context);
final manualInstallBusy = state.busyExtensionIds.contains(ExtensionsController.manualInstallBusyKey);
final search = AppTextField(
key: const ValueKey('extension-search-input'),
controller: searchController,
@@ -673,22 +714,21 @@ class _Toolbar extends StatelessWidget {
onPressed: onDevelopExtension,
),
const SizedBox(width: 8),
if (state.devMode) ...[
if (AppWindowChrome.isDesktopWindow && state.devMode) ...[
_OutlineToolbarIconButton(
key: const ValueKey('load-local-extension-button'),
tooltip: context.l10n.extensionLoadLocal,
icon: Icons.folder_open_outlined,
loading: manualInstallBusy,
onPressed: onInstallFolder,
),
const SizedBox(width: 8),
],
Builder(
key: installAnchorKey,
builder: (buttonContext) => _OutlineToolbarIconButton(
key: const ValueKey('install-extension-button'),
tooltip: context.l10n.extensionInstallFromUrl,
icon: Icons.add_link,
loading: manualInstallBusy,
onPressed: () => onOpenInstall(buttonContext),
),
),
@@ -764,18 +804,11 @@ class _FilterBar extends StatelessWidget {
}
class _OutlineToolbarIconButton extends StatelessWidget {
const _OutlineToolbarIconButton({
super.key,
required this.tooltip,
required this.icon,
required this.onPressed,
this.loading = false,
});
const _OutlineToolbarIconButton({super.key, required this.tooltip, required this.icon, required this.onPressed});
final String tooltip;
final IconData icon;
final VoidCallback? onPressed;
final bool loading;
@override
Widget build(BuildContext context) {
@@ -783,23 +816,18 @@ class _OutlineToolbarIconButton extends StatelessWidget {
message: tooltip,
child: SizedBox.square(
dimension: 32,
child: shad.IconButton.outline(
size: shad.ButtonSize.xSmall,
onPressed: loading ? null : onPressed,
icon: loading
? const SizedBox.square(dimension: 13, child: shad.CircularProgressIndicator())
: Icon(icon, size: 17),
),
child: shad.IconButton.outline(size: shad.ButtonSize.xSmall, onPressed: onPressed, icon: Icon(icon, size: 17)),
),
);
}
}
class _InstallPopover extends StatefulWidget {
const _InstallPopover({required this.controller, required this.onInstallUrl});
const _InstallPopover({super.key, required this.controller, required this.onInstallUrl, required this.onFinished});
final TextEditingController controller;
final Future<void> Function() onInstallUrl;
final VoidCallback onFinished;
@override
State<_InstallPopover> createState() => _InstallPopoverState();
@@ -861,8 +889,8 @@ class _InstallPopoverState extends State<_InstallPopover> {
setState(() => _installing = true);
await widget.onInstallUrl();
if (!mounted) return;
setState(() => _installing = false);
await shad.closeOverlay(context);
widget.onFinished();
}
}
@@ -1035,11 +1063,8 @@ class _ExtensionCard extends ConsumerWidget {
if (canUpdate && installed != null)
shad.GhostButton(
density: shad.ButtonDensity.icon,
onPressed: busy
? null
: () => onAction(
() => ref.read(extensionsControllerProvider.notifier).upgradeExtension(installed),
),
key: ValueKey('update-extension-${installed.identity}'),
onPressed: busy ? null : () => showExtensionUpdateDialog(context, installed),
child: const Icon(Icons.refresh),
),
if (installed == null && item.store != null)
@@ -20,6 +20,7 @@ import '../../../../shared/widgets/detail/app_detail_surface.dart';
import '../../../../util/util.dart';
import '../../application/extensions_controller.dart';
import 'extension_icon.dart';
import 'extension_update_dialog.dart';
class ExtensionDetailDrawer extends StatelessWidget {
const ExtensionDetailDrawer({super.key, required this.item, required this.onClose});
@@ -132,14 +133,10 @@ class ExtensionDetailView extends ConsumerWidget {
if (installed != null && canUpdate)
AppLoadingButton(
key: const ValueKey('extension-details-update'),
loading: busy,
loading: false,
variant: AppLoadingButtonVariant.primary,
icon: const Icon(Icons.refresh, size: 16),
onPressed: () => _runAction(
context,
() => ref.read(extensionsControllerProvider.notifier).upgradeExtension(installed),
successMessage: context.l10n.extensionUpdateSuccess,
),
onPressed: busy ? null : () => showExtensionUpdateDialog(context, installed),
child: Text(context.l10n.newVersionUpdate),
),
if (installed != null && !canUpdate)
@@ -0,0 +1,90 @@
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../api/model/extension.dart' as api;
import '../../../../l10n/l10n.dart';
import '../../../../shared/widgets/app_loading_button.dart';
import '../../../../shared/widgets/app_toast.dart';
import '../../application/extensions_controller.dart';
Future<void> showExtensionUpdateDialog(BuildContext context, api.Extension extension) async {
final overlay = const DialogOverlayHandler().show<bool>(
context: context,
alignment: Alignment.center,
barrierDismissable: false,
builder: (_) => _ExtensionUpdateDialog(extension: extension),
);
final updated = await overlay.future;
if (updated == true && context.mounted) {
showAppToast(context, context.l10n.extensionUpdateSuccess);
}
}
class _ExtensionUpdateDialog extends ConsumerStatefulWidget {
const _ExtensionUpdateDialog({required this.extension});
final api.Extension extension;
@override
ConsumerState<_ExtensionUpdateDialog> createState() => _ExtensionUpdateDialogState();
}
class _ExtensionUpdateDialogState extends ConsumerState<_ExtensionUpdateDialog> {
bool _updating = false;
bool _failed = false;
Future<void> _update() async {
if (_updating) return;
setState(() {
_updating = true;
_failed = false;
});
try {
await ref.read(extensionsControllerProvider.notifier).upgradeExtension(widget.extension);
} catch (_) {
if (!mounted) return;
setState(() {
_updating = false;
_failed = true;
});
return;
}
if (mounted) await closeOverlay(context, true);
}
@override
Widget build(BuildContext context) {
return PopScope(
canPop: !_updating,
child: AlertDialog(
key: const ValueKey('extension-update-dialog'),
title: Text(context.l10n.extensionCanUpdate),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(context.l10n.extensionUpdateConfirm(widget.extension.title)),
if (_failed) ...[
const SizedBox(height: 12),
Text(context.l10n.updateFailed, key: const ValueKey('extension-update-error')),
],
],
),
actions: [
SecondaryButton(
key: const ValueKey('cancel-update-extension-button'),
onPressed: _updating ? null : () => closeOverlay(context, false),
child: Text(context.l10n.cancel),
),
AppLoadingButton(
key: const ValueKey('confirm-update-extension-button'),
variant: AppLoadingButtonVariant.primary,
loading: _updating,
onPressed: _update,
child: Text(context.l10n.newVersionUpdate),
),
],
),
);
}
}
@@ -133,7 +133,7 @@ class TaskRecord {
status: status,
downloaded: _formatBytes(downloadedBytes),
total: totalBytes > 0 ? _formatBytes(totalBytes) : null,
speed: task.progress.speed > 0 ? TransferRateFormatter.format(task.progress.speed).text : null,
speed: totalBytes > 0 || task.progress.speed > 0 ? TransferRateFormatter.format(task.progress.speed).text : null,
uploadSpeed: task.uploading ? TransferRateFormatter.format(task.progress.uploadSpeed).text : null,
remainingSeconds: _remainingSeconds(task, totalBytes, downloadedBytes),
downloadDuration: task.progress.used > 0 ? Duration(microseconds: (task.progress.used + 999) ~/ 1000) : null,
@@ -37,6 +37,7 @@ import '../../../../shared/widgets/app_toast.dart';
import '../../../../l10n/l10n.dart';
import '../../../../util/util.dart';
import '../../application/pending_create_task.dart';
import '../../application/task_list_navigation.dart';
import '../widgets/history_search_field.dart';
import '../widgets/create_task_action_buttons.dart';
import '../widgets/resolve_file_tree.dart';
@@ -736,6 +737,8 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
} catch (error) {
debugPrint('Unable to select downloading tasks: $error');
}
} else {
ref.read(taskListNavigationProvider.notifier).showDownloading();
}
await _closeWindow();
}
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart' show Colors, Icons;
import 'package:flutter/widgets.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
import '../../../../../core/window/app_window_chrome.dart';
import '../../../../../shared/theme/app_design_tokens.dart';
import '../../../../../shared/theme/app_palette.dart';
import '../../../domain/task_record.dart';
@@ -237,7 +238,10 @@ class _ProgressContent extends StatelessWidget {
TaskStatus.downloading => [(Icons.pause, onPause), (Icons.close, onDelete)],
TaskStatus.paused => [(Icons.play_arrow, onResume), (Icons.close, onDelete)],
TaskStatus.failed => [(Icons.refresh, onResume), (Icons.delete_outline, onDelete)],
TaskStatus.completed => [(Icons.folder_open_outlined, null), (Icons.delete_outline, onDelete)],
TaskStatus.completed => [
if (AppWindowChrome.isDesktopWindow) (Icons.folder_open_outlined, null),
(Icons.delete_outline, onDelete),
],
};
return actions
@@ -306,7 +310,10 @@ class _CompletedContent extends StatelessWidget {
TaskStatus.downloading => [(Icons.pause, null), (Icons.close, onDelete)],
TaskStatus.paused => [(Icons.play_arrow, null), (Icons.close, onDelete)],
TaskStatus.failed => [(Icons.refresh, null), (Icons.delete_outline, onDelete)],
TaskStatus.completed => [(Icons.folder_open_outlined, onReveal), (Icons.delete_outline, onDelete)],
TaskStatus.completed => [
if (AppWindowChrome.isDesktopWindow) (Icons.folder_open_outlined, onReveal),
(Icons.delete_outline, onDelete),
],
};
return actions
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart' show Colors, Icons;
import 'package:flutter/widgets.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
import '../../../../../core/window/app_window_chrome.dart';
import '../../../../../shared/theme/app_design_tokens.dart';
import '../../../../../shared/theme/app_palette.dart';
import '../../../domain/task_record.dart';
@@ -171,7 +172,10 @@ class _TaskCardMobileState extends State<TaskCardMobile> {
TaskStatus.downloading => [(Icons.pause, widget.onPause), (Icons.close, widget.onDelete)],
TaskStatus.paused => [(Icons.play_arrow, widget.onResume), (Icons.close, widget.onDelete)],
TaskStatus.failed => [(Icons.refresh, widget.onResume), (Icons.delete_outline, widget.onDelete)],
TaskStatus.completed => [(Icons.folder_open_outlined, widget.onReveal), (Icons.delete_outline, widget.onDelete)],
TaskStatus.completed => [
if (AppWindowChrome.isDesktopWindow) (Icons.folder_open_outlined, widget.onReveal),
(Icons.delete_outline, widget.onDelete),
],
};
return actions
@@ -106,7 +106,7 @@ class TaskCardFooter extends StatelessWidget {
),
),
const SizedBox(width: 12),
if (task.speed != null && !isError) ...[
if (task.speed != null) ...[
_TaskTransferSpeed(icon: Icons.south, value: task.speed!, color: metaColor),
const SizedBox(width: 8),
TickDivider(color: palette.progressTrack),
+1
View File
@@ -103,6 +103,7 @@
"extensions": "الإضافات",
"extensionInstallUrl": "عنوان التثبيت",
"extensionInstallSuccess": "تم التثبيت بنجاح",
"extensionUpdateConfirm": "هل تريد تحديث «{name}» إلى أحدث إصدار؟",
"extensionUpdateSuccess": "تم التحديث بنجاح",
"extensionDelete": "حذف الإضافة",
"extensionAlreadyLatest": "هذا هو أحدث إصدار بالفعل",
+1
View File
@@ -110,6 +110,7 @@
"extensions": "Uzantılar",
"extensionInstallUrl": "Quraşdırma URL-i",
"extensionInstallSuccess": "Uğurla quraşdırıldı",
"extensionUpdateConfirm": "“{name}” ən son versiyaya yenilənsin?",
"extensionUpdateSuccess": "Uğurla yeniləndi",
"extensionDelete": "Uzantını Sil",
"extensionAlreadyLatest": "Artıq son versiyadır",
+1
View File
@@ -103,6 +103,7 @@
"extensions": "Extensions",
"extensionInstallUrl": "URL d'instal·lació",
"extensionInstallSuccess": "S'ha instal·lat amb èxit",
"extensionUpdateConfirm": "Vols actualitzar «{name}» a la versió més recent?",
"extensionUpdateSuccess": "S'ha actualitzat amb èxit",
"extensionDelete": "Suprimeix l'extensió",
"extensionAlreadyLatest": "Ja està actualitzada",
+1
View File
@@ -103,6 +103,7 @@
"extensions": "Erweiterungen",
"extensionInstallUrl": "Installations-URL",
"extensionInstallSuccess": "Erfolgreich installiert",
"extensionUpdateConfirm": "„{name}“ auf die neueste Version aktualisieren?",
"extensionUpdateSuccess": "Erfolgreich aktualisiert",
"extensionDelete": "Erweiterung löschen",
"extensionAlreadyLatest": "Es ist bereits die neueste Version",
+8
View File
@@ -110,6 +110,14 @@
"extensions": "Extensions",
"extensionInstallUrl": "Install URL",
"extensionInstallSuccess": "Installed successfully",
"extensionUpdateConfirm": "Update “{name}” to the latest version?",
"@extensionUpdateConfirm": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"extensionUpdateSuccess": "Updated successfully",
"extensionDelete": "Delete Extension",
"extensionAlreadyLatest": "It's already the latest version",
+1
View File
@@ -103,6 +103,7 @@
"extensions": "Extensiones",
"extensionInstallUrl": "URL de instalación",
"extensionInstallSuccess": "Instalado con éxito",
"extensionUpdateConfirm": "¿Actualizar «{name}» a la última versión?",
"extensionUpdateSuccess": "Actualizado con éxito",
"extensionDelete": "Eliminar Extensión",
"extensionAlreadyLatest": "Ya es la versión más reciente",
+1
View File
@@ -103,6 +103,7 @@
"extensions": "افزونه‌ها",
"extensionInstallUrl": "نشانی نصب",
"extensionInstallSuccess": "نصب شده با موفقیت",
"extensionUpdateConfirm": "«{name}» به آخرین نسخه به‌روزرسانی شود؟",
"extensionUpdateSuccess": "به روز رسانی موفقیت آمیز",
"extensionDelete": "حذف افزونه",
"extensionAlreadyLatest": "این نسخه در حال حاضر آخرین نسخه است",
+1
View File
@@ -103,6 +103,7 @@
"extensions": "Extensions",
"extensionInstallUrl": "URL d'installation",
"extensionInstallSuccess": "Installé avec succès",
"extensionUpdateConfirm": "Mettre à jour « {name} » vers la dernière version ?",
"extensionUpdateSuccess": "Mis à jour avec succès",
"extensionDelete": "Supprimer l'extension",
"extensionAlreadyLatest": "C'est déjà la dernière version",
+1
View File
@@ -103,6 +103,7 @@
"extensions": "Bővítmények",
"extensionInstallUrl": "Telepítési URL",
"extensionInstallSuccess": "Sikeresen telepítve",
"extensionUpdateConfirm": "Frissíti a(z) „{name}” bővítményt a legújabb verzióra?",
"extensionUpdateSuccess": "Sikeresen frissítve",
"extensionDelete": "Bővítmény törlése",
"extensionAlreadyLatest": "Ez már a legfrissebb verzió",
+1
View File
@@ -103,6 +103,7 @@
"extensions": "Ekstensi",
"extensionInstallUrl": "URL Instal",
"extensionInstallSuccess": "Diinstal dengan sukses",
"extensionUpdateConfirm": "Perbarui “{name}” ke versi terbaru?",
"extensionUpdateSuccess": "Diperbaruhi dengan sukses",
"extensionDelete": "Hapus Ekstensi",
"extensionAlreadyLatest": "Ini sudah versi terbaru",
+1
View File
@@ -103,6 +103,7 @@
"extensions": "Estensioni",
"extensionInstallUrl": "Installa URL",
"extensionInstallSuccess": "Installato con successo",
"extensionUpdateConfirm": "Aggiornare «{name}» alla versione più recente?",
"extensionUpdateSuccess": "Aggiornato con successo",
"extensionDelete": "Elimina estensione",
"extensionAlreadyLatest": "È già l'ultima versione",
+1
View File
@@ -103,6 +103,7 @@
"extensions": "拡張機能",
"extensionInstallUrl": "インストールURL",
"extensionInstallSuccess": "正常にインストールされました",
"extensionUpdateConfirm": "「{name}」を最新バージョンに更新しますか?",
"extensionUpdateSuccess": "正常に更新されました",
"extensionDelete": "拡張機能を削除",
"extensionAlreadyLatest": "既に最新バージョンです",
+1
View File
@@ -103,6 +103,7 @@
"extensions": "확장 프로그램",
"extensionInstallUrl": "설치 URL",
"extensionInstallSuccess": "설치되었습니다",
"extensionUpdateConfirm": "“{name}”을(를) 최신 버전으로 업데이트할까요?",
"extensionUpdateSuccess": "업데이트되었습니다",
"extensionDelete": "확장 프로그램 삭제",
"extensionAlreadyLatest": "이미 최신 버전입니다",
+1
View File
@@ -103,6 +103,7 @@
"extensions": "Wtyczki",
"extensionInstallUrl": "Instaluj z url",
"extensionInstallSuccess": "Instalacja zakończona",
"extensionUpdateConfirm": "Zaktualizować „{name}” do najnowszej wersji?",
"extensionUpdateSuccess": "Aktualizacja zakończona",
"extensionDelete": "Usuń wtyczkę",
"extensionAlreadyLatest": "Wtyczka w aktualnej wersji",
+1
View File
@@ -103,6 +103,7 @@
"extensions": "Extensões",
"extensionInstallUrl": "Link de Instalação",
"extensionInstallSuccess": "Instalado com Sucesso",
"extensionUpdateConfirm": "Atualizar “{name}” para a versão mais recente?",
"extensionUpdateSuccess": "Atualizado com Sucesso",
"extensionDelete": "Excluir Extensão",
"extensionAlreadyLatest": "Já é a última versão",
+1
View File
@@ -103,6 +103,7 @@
"extensions": "Расширения",
"extensionInstallUrl": "URL для установки",
"extensionInstallSuccess": "Установка завершена",
"extensionUpdateConfirm": "Обновить «{name}» до последней версии?",
"extensionUpdateSuccess": "Обновление завершено",
"extensionDelete": "Удалить расширение",
"extensionAlreadyLatest": "Это последняя версия",
+1
View File
@@ -103,6 +103,7 @@
"extensions": "நீட்டிப்புகள்",
"extensionInstallUrl": "நிறுவப்படும் URL",
"extensionInstallSuccess": "வெற்றிகரமாக நிறுவப்பட்டது",
"extensionUpdateConfirm": "“{name}” ஐ சமீபத்திய பதிப்பிற்குப் புதுப்பிக்கவா?",
"extensionUpdateSuccess": "வெற்றிகரமாக புதுப்பிக்கப்பட்டது",
"extensionDelete": "நீட்டிப்பை நீக்கு",
"extensionAlreadyLatest": "இது ஏற்கனவே சமீபத்திய பதிப்பாகும்",
+1
View File
@@ -103,6 +103,7 @@
"extensions": "Eklentiler",
"extensionInstallUrl": "Kurulum URL'si",
"extensionInstallSuccess": "Başarıyla kuruldu",
"extensionUpdateConfirm": "“{name}” en son sürüme güncellensin mi?",
"extensionUpdateSuccess": "Başarıyla güncellendi",
"extensionDelete": "Eklentiyi Sil",
"extensionAlreadyLatest": "Zaten en son sürüm",
+1
View File
@@ -103,6 +103,7 @@
"extensions": "Розширення",
"extensionInstallUrl": "URL для встановлення",
"extensionInstallSuccess": "Встановлено успішно",
"extensionUpdateConfirm": "Оновити «{name}» до останньої версії?",
"extensionUpdateSuccess": "Оновлено успішно",
"extensionDelete": "Видалити розширення",
"extensionAlreadyLatest": "Вже остання версія",
+1
View File
@@ -103,6 +103,7 @@
"extensions": "Tiện ích mở rộng",
"extensionInstallUrl": "Liên kết cài đặt",
"extensionInstallSuccess": "Cài đặt thành công",
"extensionUpdateConfirm": "Cập nhật “{name}” lên phiên bản mới nhất?",
"extensionUpdateSuccess": "Cập nhật thành công",
"extensionDelete": "Xóa tiện ích mở rộng",
"extensionAlreadyLatest": "Đây đã là phiên bản mới nhất",
+1
View File
@@ -103,6 +103,7 @@
"extensions": "扩展",
"extensionInstallUrl": "安装链接",
"extensionInstallSuccess": "安装成功",
"extensionUpdateConfirm": "确定将“{name}”更新到最新版本吗?",
"extensionUpdateSuccess": "更新成功",
"extensionDelete": "删除扩展",
"extensionAlreadyLatest": "已经是最新版本",
+1
View File
@@ -103,6 +103,7 @@
"extensions": "擴充功能",
"extensionInstallUrl": "安裝 URL",
"extensionInstallSuccess": "安裝成功",
"extensionUpdateConfirm": "確定將「{name}」更新到最新版本嗎?",
"extensionUpdateSuccess": "更新成功",
"extensionDelete": "刪除擴充功能",
"extensionAlreadyLatest": "已是最新版本",
@@ -1,4 +1,8 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/services.dart';
import 'package:gopeed/features/extensions/application/pending_extension_install.dart';
import 'package:app_links_platform_interface/app_links_platform_interface.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -11,6 +15,44 @@ import 'package:gopeed/core/common/start_config.dart';
import 'package:share_handler/share_handler.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('receives initial and subsequent scheme extension URLs with UTF-8 params', () async {
final originalPlatform = AppLinksPlatform.instance;
final initialUrl = 'https://github.com/author/扩展';
Uri link(String url) => Uri.parse('gopeed:///extension').replace(
queryParameters: {
'params': base64Encode(utf8.encode(jsonEncode({'url': url}))),
},
);
final platform = _FakeAppLinksPlatform(initialLink: link(initialUrl));
AppLinksPlatform.instance = platform;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('window_manager'),
(_) async => false,
);
final container = ProviderContainer(
overrides: [appRuntimeControllerProvider.overrideWith(_FakeRuntimeController.new)],
);
try {
await container.read(appDeepLinkControllerProvider.future);
expect(container.read(pendingExtensionInstallProvider)?.url, initialUrl);
container.read(pendingExtensionInstallProvider.notifier).clear();
platform.emit(link('https://github.com/author/second'));
await Future<void>.delayed(Duration.zero);
expect(container.read(pendingExtensionInstallProvider)?.url, 'https://github.com/author/second');
} finally {
container.dispose();
await Future<void>.delayed(Duration.zero);
AppLinksPlatform.instance = originalPlatform;
await platform.close();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('window_manager'),
null,
);
}
});
test('keeps the established path-style Gopeed deep-link routes', () {
expect(gopeedDeepLinkRoute(Uri.parse('gopeed:///create')), '/create');
expect(gopeedDeepLinkRoute(Uri.parse('gopeed:///extension')), '/extension');
@@ -63,12 +105,16 @@ void main() {
}
class _FakeAppLinksPlatform extends AppLinksPlatform {
_FakeAppLinksPlatform({this.initialLink});
final Uri? initialLink;
void emit(Uri uri) => _links.add(uri);
final _links = StreamController<Uri>.broadcast();
bool get hasListener => _links.hasListener;
@override
Future<Uri?> getInitialLink() async => null;
Future<Uri?> getInitialLink() async => initialLink;
@override
Stream<Uri> get uriLinkStream => _links.stream;
File diff suppressed because it is too large. Load diff