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 5aa57e6a..fb82f64f 100644
--- a/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart
+++ b/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart
@@ -45,6 +45,8 @@ class ExtensionsPage extends ConsumerStatefulWidget {
class _ExtensionsPageState extends ConsumerState {
final _searchController = TextEditingController();
final _installController = TextEditingController();
+ final _installAnchorKey = GlobalKey();
+ bool _installDevMode = false;
final _listScrollController = ScrollController();
final Map _settingControllers = {};
ExtensionListItem? _detailItem;
@@ -76,15 +78,20 @@ 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;
+ final anchorContext = _installAnchorKey.currentContext;
+ if (anchorContext == null) return;
+ _installController.text = pendingInstall.url;
+ _installDevMode = pendingInstall.devMode;
+ _showInstallPopover(anchorContext);
+ ref.read(pendingExtensionInstallProvider.notifier).clear();
+ });
+ }
final stateAsync = ref.watch(extensionsControllerProvider);
final body = Stack(
children: [
@@ -152,6 +159,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,
@@ -167,7 +175,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 {
@@ -191,6 +201,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,
@@ -282,6 +297,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,
@@ -299,6 +315,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;
@@ -320,6 +337,7 @@ class _Content extends StatelessWidget {
onSearch: onSearch,
onSort: onSort,
onRefresh: onRefresh,
+ installAnchorKey: installAnchorKey,
onOpenInstall: onOpenInstall,
onDevelopExtension: onDevelopExtension,
onInstallFolder: onInstallFolder,
@@ -612,6 +630,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,
@@ -622,6 +641,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;
@@ -682,6 +702,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,
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 4db9414e..4606fe80 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,70 @@ 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 prefills URL before and after mounting at width $width',
+ (tester) async {
+ await _setTestSize(tester, Size(width, 900));
+ final controller = FakeExtensionsController();
+ 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, 0);
+ final popoverRect = tester.getRect(find.byKey(const ValueKey('extension-install-popover')));
+ expect(popoverRect.left, greaterThanOrEqualTo(0));
+ expect(popoverRect.right, lessThanOrEqualTo(width));
+
+ // A second link updates the already-open input without installing twice.
+ const secondUrl = 'https://github.com/author/second';
+ container.read(pendingExtensionInstallProvider.notifier).set(InstallExtension(url: secondUrl));
+ await tester.pump();
+ await tester.pump(const Duration(seconds: 1));
+ expect(tester.widget(input).controller!.text, secondUrl);
+ expect(find.byKey(const ValueKey('extension-install-popover')), findsOneWidget);
+ expect(controller.installCalls, 0);
+ await tester.tap(find.descendant(of: input, matching: find.byType(shad.IconButton)));
+ await tester.pump();
+ await tester.pump(const Duration(seconds: 1));
+ expect(controller.installCalls, 1);
+ expect(controller.lastInstallUrl, secondUrl);
+
+ // A warm link also reopens a dismissed form.
+ container.read(pendingExtensionInstallProvider.notifier).set(InstallExtension(url: firstUrl));
+ await tester.pump();
+ await tester.pump(const Duration(seconds: 1));
+ expect(tester.widget(input).controller!.text, firstUrl);
+ expect(controller.installCalls, 1);
+ 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 +5049,14 @@ class FailingTasksController extends TasksController {
class FakeExtensionsController extends ExtensionsController {
int installCalls = 0;
+ String? lastInstallUrl;
+
+ @override
+ Future installFromUrl(String url, {bool devInstall = false}) async {
+ installCalls++;
+ lastInstallUrl = url;
+ }
+
int removeCalls = 0;
@override