Merge PR #1510 into 2.0.0-beta.2

This commit is contained in:
Levi committed 2026-09-13 15:30:10 +08:00
commit 6331060171
6 files changed
+216 -15

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>;
}
@@ -46,6 +46,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;
@@ -77,15 +80,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: [
@@ -153,6 +169,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,
@@ -168,7 +185,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,6 +211,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,
@@ -200,7 +224,14 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
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<String> onSearch;
final ValueChanged<StoreExtensionSort> onSort;
final ValueChanged<ExtensionListFilter> onFilter;
final GlobalKey installAnchorKey;
final ValueChanged<BuildContext> 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<String> onSearch;
final ValueChanged<StoreExtensionSort> onSort;
final VoidCallback onRefresh;
final GlobalKey installAnchorKey;
final ValueChanged<BuildContext> 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<void> 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();
}
}
@@ -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;
+93
View File
@@ -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<void>();
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, 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<void>();
container.read(pendingExtensionInstallProvider.notifier).set(InstallExtension(url: secondUrl));
await tester.pump();
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(tester.widget<AppTextField>(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<void>();
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<void>? installGate;
@override
Future<void> installFromUrl(String url, {bool devInstall = false}) async {
installCalls++;
lastInstallUrl = url;
await installGate?.future;
}
int removeCalls = 0;
@override