mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-07 17:10:29 +00:00
fix: prefill scheme extension installs and forward iOS deep links
This commit is contained in:
1 parent
62e8ab3cef
commit
1c57b07575
6 files changed
+177
-12
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,8 @@ class ExtensionsPage extends ConsumerStatefulWidget {
|
||||
class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
final _searchController = TextEditingController();
|
||||
final _installController = TextEditingController();
|
||||
final _installAnchorKey = GlobalKey();
|
||||
bool _installDevMode = false;
|
||||
final _listScrollController = ScrollController();
|
||||
final Map<String, TextEditingController> _settingControllers = {};
|
||||
ExtensionListItem? _detailItem;
|
||||
@@ -76,15 +78,20 @@ 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;
|
||||
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<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,
|
||||
@@ -167,7 +175,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 {
|
||||
@@ -191,6 +201,11 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
|
||||
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<void>(
|
||||
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<String> onSearch;
|
||||
final ValueChanged<StoreExtensionSort> onSort;
|
||||
final ValueChanged<ExtensionListFilter> onFilter;
|
||||
final GlobalKey installAnchorKey;
|
||||
final ValueChanged<BuildContext> 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<String> onSearch;
|
||||
final ValueChanged<StoreExtensionSort> onSort;
|
||||
final VoidCallback onRefresh;
|
||||
final GlobalKey installAnchorKey;
|
||||
final ValueChanged<BuildContext> 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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<AppTextField>(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<AppTextField>(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<AppTextField>(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<void> installFromUrl(String url, {bool devInstall = false}) async {
|
||||
installCalls++;
|
||||
lastInstallUrl = url;
|
||||
}
|
||||
|
||||
int removeCalls = 0;
|
||||
|
||||
@override
|
||||
|
||||
Reference in new issue
Block a user