diff --git a/ui/flutter/ios/Runner/Info.plist b/ui/flutter/ios/Runner/Info.plist index fd2ce83a..4c19e631 100644 --- a/ui/flutter/ios/Runner/Info.plist +++ b/ui/flutter/ios/Runner/Info.plist @@ -24,6 +24,9 @@ ???? CFBundleVersion $(FLUTTER_BUILD_NUMBER) + + FlutterDeepLinkingEnabled + CFBundleURLTypes diff --git a/ui/flutter/ios/Runner/SceneDelegate.swift b/ui/flutter/ios/Runner/SceneDelegate.swift index b9ce8ea2..8b34f6cb 100644 --- a/ui/flutter/ios/Runner/SceneDelegate.swift +++ b/ui/flutter/ios/Runner/SceneDelegate.swift @@ -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) { + super.scene(scene, openURLContexts: URLContexts) + forwardGopeedLinks(URLContexts) + } + + private func forwardGopeedLinks(_ contexts: Set) { + // 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) + } + } } diff --git a/ui/flutter/lib/app/application/app_deep_link_controller.dart b/ui/flutter/lib/app/application/app_deep_link_controller.dart index 548287c9..f960dcbd 100644 --- a/ui/flutter/lib/app/application/app_deep_link_controller.dart +++ b/ui/flutter/lib/app/application/app_deep_link_controller.dart @@ -144,7 +144,7 @@ class AppDeepLinkController extends AsyncNotifier { Map _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; } diff --git a/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart b/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart index b77cbe21..de7aa2be 100644 --- a/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart +++ b/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart @@ -46,6 +46,9 @@ class ExtensionsPage extends ConsumerStatefulWidget { class _ExtensionsPageState extends ConsumerState { final _searchController = TextEditingController(); final _installController = TextEditingController(); + final _installAnchorKey = GlobalKey(); + final _installPopoverKey = GlobalKey<_InstallPopoverState>(); + bool _installDevMode = false; final _listScrollController = ScrollController(); final Map _settingControllers = {}; ExtensionListItem? _detailItem; @@ -77,15 +80,28 @@ class _ExtensionsPageState extends ConsumerState { 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: [ @@ -153,6 +169,7 @@ class _ExtensionsPageState extends ConsumerState { 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, @@ -168,7 +185,9 @@ class _ExtensionsPageState extends ConsumerState { Future _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 _loadNextPageIfNeeded() async { @@ -192,6 +211,11 @@ class _ExtensionsPageState extends ConsumerState { void _openInstallPopover(BuildContext anchorContext) { ref.read(extensionsControllerProvider.notifier).tryOpenDevMode(); + _installDevMode = false; + _showInstallPopover(anchorContext); + } + + void _showInstallPopover(BuildContext anchorContext) { if (_installPopover != null) return; final overlay = const shad.PopoverOverlayHandler().show( context: anchorContext, @@ -200,7 +224,14 @@ class _ExtensionsPageState extends ConsumerState { offset: const Offset(0, 8), modal: false, consumeOutsideTaps: false, - builder: (context) => _InstallPopover(controller: _installController, onInstallUrl: _installFromUrl), + builder: (context) => _InstallPopover( + key: _installPopoverKey, + controller: _installController, + onInstallUrl: _installFromUrl, + onFinished: () { + if (mounted && ref.read(pendingExtensionInstallProvider) != null) setState(() {}); + }, + ), ); _installPopover = overlay; unawaited( @@ -283,6 +314,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, @@ -300,6 +332,7 @@ class _Content extends StatelessWidget { final ValueChanged onSearch; final ValueChanged onSort; final ValueChanged onFilter; + final GlobalKey installAnchorKey; final ValueChanged onOpenInstall; final VoidCallback onDevelopExtension; final VoidCallback onInstallFolder; @@ -321,6 +354,7 @@ class _Content extends StatelessWidget { onSearch: onSearch, onSort: onSort, onRefresh: onRefresh, + installAnchorKey: installAnchorKey, onOpenInstall: onOpenInstall, onDevelopExtension: onDevelopExtension, onInstallFolder: onInstallFolder, @@ -613,6 +647,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, @@ -623,6 +658,7 @@ class _Toolbar extends StatelessWidget { final ValueChanged onSearch; final ValueChanged onSort; final VoidCallback onRefresh; + final GlobalKey installAnchorKey; final ValueChanged onOpenInstall; final VoidCallback onDevelopExtension; final VoidCallback onInstallFolder; @@ -683,6 +719,7 @@ class _Toolbar extends StatelessWidget { const SizedBox(width: 8), ], Builder( + key: installAnchorKey, builder: (buttonContext) => _OutlineToolbarIconButton( key: const ValueKey('install-extension-button'), tooltip: context.l10n.extensionInstallFromUrl, @@ -795,10 +832,11 @@ class _OutlineToolbarIconButton extends StatelessWidget { } 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 Function() onInstallUrl; + final VoidCallback onFinished; @override State<_InstallPopover> createState() => _InstallPopoverState(); @@ -860,8 +898,8 @@ class _InstallPopoverState extends State<_InstallPopover> { setState(() => _installing = true); await widget.onInstallUrl(); if (!mounted) return; - setState(() => _installing = false); await shad.closeOverlay(context); + widget.onFinished(); } } diff --git a/ui/flutter/test/app_deep_link_controller_test.dart b/ui/flutter/test/app_deep_link_controller_test.dart index 324e026f..ea24bbda 100644 --- a/ui/flutter/test/app_deep_link_controller_test.dart +++ b/ui/flutter/test/app_deep_link_controller_test.dart @@ -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.delayed(Duration.zero); + expect(container.read(pendingExtensionInstallProvider)?.url, 'https://github.com/author/second'); + } finally { + container.dispose(); + await Future.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.broadcast(); bool get hasListener => _links.hasListener; @override - Future getInitialLink() async => null; + Future getInitialLink() async => initialLink; @override Stream get uriLinkStream => _links.stream; diff --git a/ui/flutter/test/widget_test.dart b/ui/flutter/test/widget_test.dart index 5e472b03..04b15304 100644 --- a/ui/flutter/test/widget_test.dart +++ b/ui/flutter/test/widget_test.dart @@ -23,6 +23,8 @@ import 'package:gopeed/app/application/app_notification_controller.dart'; import 'package:gopeed/app/application/app_platform_controller.dart'; import 'package:gopeed/app/application/app_runtime_controller.dart'; import 'package:gopeed/api/model/create_task.dart'; +import 'package:gopeed/api/model/install_extension.dart'; +import 'package:gopeed/features/extensions/application/pending_extension_install.dart'; import 'package:gopeed/api/model/downloader_config.dart'; import 'package:gopeed/api/model/extension.dart' as api_extension; import 'package:gopeed/api/model/meta.dart'; @@ -880,6 +882,87 @@ void main() { expect(tester.takeException(), isNull); }); + for (final (width, platform) in [ + (320.0, TargetPlatform.android), + (390.0, TargetPlatform.iOS), + (768.0, TargetPlatform.android), + (1100.0, TargetPlatform.macOS), + ]) { + testWidgets( + 'scheme install automatically runs before and after mounting at width $width', + (tester) async { + await _setTestSize(tester, Size(width, 900)); + final controller = FakeExtensionsController()..installGate = Completer(); + final container = ProviderContainer(overrides: [extensionsControllerProvider.overrideWith(() => controller)]); + addTearDown(container.dispose); + const firstUrl = 'https://github.com/author/扩展'; + container.read(pendingExtensionInstallProvider.notifier).set(InstallExtension(url: firstUrl)); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: const ExtensionsPage(), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + final input = find.byKey(const ValueKey('extension-install-url-input')); + expect(tester.widget(input).controller!.text, firstUrl); + expect(container.read(pendingExtensionInstallProvider), isNull); + expect(controller.installCalls, 1); + expect(controller.lastInstallUrl, firstUrl); + final popoverRect = tester.getRect(find.byKey(const ValueKey('extension-install-popover'))); + expect(popoverRect.left, greaterThanOrEqualTo(0)); + expect(popoverRect.right, lessThanOrEqualTo(width)); + expect(find.descendant(of: input, matching: find.byType(shad.CircularProgressIndicator)), findsOneWidget); + + // Rebuilds and manual submission must not duplicate an active install. + await tester.tap(find.descendant(of: input, matching: find.byType(shad.IconButton))); + await tester.pump(); + expect(controller.installCalls, 1); + controller.installGate!.complete(); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('extension-install-popover')), findsNothing); + + // A warm link automatically installs from an already-open form too. + await tester.tap(find.byKey(const ValueKey('install-extension-button'))); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + const secondUrl = 'https://github.com/author/second'; + controller.installGate = Completer(); + container.read(pendingExtensionInstallProvider.notifier).set(InstallExtension(url: secondUrl)); + await tester.pump(); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(tester.widget(input).controller!.text, secondUrl); + expect(controller.installCalls, 2); + expect(controller.lastInstallUrl, secondUrl); + + // A request arriving during installation waits until the active form closes. + container.read(pendingExtensionInstallProvider.notifier).set(InstallExtension(url: firstUrl)); + await tester.pump(); + expect(controller.installCalls, 2); + controller.installGate!.complete(); + controller.installGate = Completer(); + for (var i = 0; i < 8; i++) { + await tester.pump(const Duration(milliseconds: 200)); + } + expect(controller.installCalls, 3); + expect(controller.lastInstallUrl, firstUrl); + controller.installGate!.complete(); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + }, + variant: TargetPlatformVariant.only(platform), + ); + } + testWidgets('extension install popover unlocks local loading and installed cards use switches', ( WidgetTester tester, ) async { @@ -4983,6 +5066,16 @@ class FailingTasksController extends TasksController { class FakeExtensionsController extends ExtensionsController { int installCalls = 0; + String? lastInstallUrl; + Completer? installGate; + + @override + Future installFromUrl(String url, {bool devInstall = false}) async { + installCalls++; + lastInstallUrl = url; + await installGate?.future; + } + int removeCalls = 0; @override