diff --git a/docs/app.md b/docs/app.md index 373e340c0..e2728b81a 100644 --- a/docs/app.md +++ b/docs/app.md @@ -77,11 +77,19 @@ Flutter 窗口背景和侧栏分别使用不透明的主题背景色与侧栏色 `PageAppBar` 仅为进入 macOS 原生标题栏区域的可见按钮注册点击转发;隐藏或退出页面后移除。 弹窗不穿透遮罩,空白标题栏保留系统拖动和双击语义。`macos_window_utils` 管理原生材质及标题栏, `window_manager` 管理窗口事件,不同时启用两套窗口 delegate。 +macOS 原生侧栏使用普通标题栏样式和 `setContentUnderTitleBar(true)`,保持透明、全尺寸内容及 AppKit 手势; +隐藏标题栏样式用于需要自行处理手势的独立 Flutter 控制栏。 窗口服务使用 `window_manager` 0.6 的原生 `Window` API,准备阶段只设置窗口属性; 实际显示或隐藏继续由启动策略决定。关闭请求先取消再隐藏,恢复最小化窗口后才显示并聚焦。 macOS Dock 图标通过原生 Application 控制,窗口菜单的可见性不代替 Dock 可见性。 Windows/Linux 保留独立纯色控制栏,布局扣除控制栏高度;Windows Snap Layout 在对应系统验收。 -Linux runner 在创建 Flutter View 前关闭 Impeller,其他平台不受影响。 +Linux runner 在创建 Flutter View 前关闭 Impeller,并安装始终隐藏的 GTK HeaderBar,保留 GTK 的 +CSD、原生缩放边缘与可用阴影;无合成器的 X11 使用 GTK 的实色边框。Wayland 与 X11 均由 Flutter +绘制唯一可见控制栏,非 GNOME 的 X11 窗口管理器也不额外添加系统标题栏。 +Linux 窗口服务不再通过 nativeapi 重设标题栏样式或原生控制按钮,避免替换 runner 已建立的 GTK 装饰策略; +窗口尺寸、最小尺寸、居中、关闭隐藏及恢复聚焦仍由同一窗口服务管理。 +系统接管控制栏拖动后若消费了鼠标释放事件,runner 在下一条真实指针事件确认按键已松开时补发给 Flutter, +避免拖动后的首次点击失效;仍按住鼠标时不提前结束拖动。 macOS 应用菜单“设置…”及 ⌘, 恢复并聚焦既有主窗口,进入设置分支并保留其当前子页, 不重置其它分支草稿、不重复压栈。存在模态或尚在 Setup/初始化时仅显示当前界面,不关闭编辑、 @@ -167,11 +175,14 @@ FakeDNS/Fragment、DNS 和嗅探沿同一内容流排列,保留规则新增、 服务器的订阅按 ID 升序,普通手动节点可整理为一层自定义分组;未分组固定第一,空自定义组保留。 支持新建、重命名和通过节点菜单移组;解散分组保留节点,删除分组及节点需确认数量与运行影响。 自定义组仅用于浏览和管理,不创建连接范围;全部手动节点入口仍覆盖所有组,节点可以单独选择。 +自动选择的候选范围统一在连接页设置;服务器列表和手动分组管理页不再提供范围快捷按钮,来源下拉仅筛选列表。 Raw 与提供方完整配置不参与手动分组,不提供节点位置 Tab、国家分组或地区选择。 服务器列表及分组详情提供默认、名称和延迟排序,默认顺序不因测速结果更新而变化。 选择作为本机偏好立即保存,同步服务器浏览、最终出口选择与桌面托盘;搜索和收藏筛选保留所选顺序。 搜索非空时提供清除按钮,零匹配使用通用结果提示并提供清除搜索操作,不改写排序或收藏偏好。 订阅标签位于列表上方,来源管理不依赖滚动到节点末尾;空手动组不影响来源入口可用。 +节点主列表提供批量测速,范围固定为点击时来源、搜索和收藏筛选后的可测速普通节点;不改变连接选择。 +测速期间切换筛选仍可取消原任务;取消等待当前已发送批次结束,保留已完成结果并停止后续批次。 自定义分组和组内节点分别进入整理页面,手机长按手柄、桌面拖动手柄,也可用上下移动按钮。 主节点列表在搜索为空、未启用收藏筛选且来源为全部来源或手动添加时提供“整理节点顺序”; 只有一个非空手动组时直接进入该组,多个非空组先选择完整分组范围,空组、单节点组及忙碌组不可整理。 diff --git a/docs/validation.md b/docs/validation.md index 2fd078001..e7ff74761 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -309,5 +309,10 @@ iOS 同样沿用正常 bundle ID、App Group 和已有真机,使用签名包 - macOS 桌面 UI 使用 Flutter Debug 验证;VPN、Apple 授权、按需连接、SE 跨容器交接 和签名包需单独人工验收,不以纯 Dart/Swift 测试替代。 - Windows/Linux 在对应环境验收;Windows 区分 EXE/ZIP 与 MSIX 的启动、退出、权限和数据根。 + Linux 分别覆盖 Wayland 与非 GNOME X11(含无合成器),核对只有一条可见控制栏、GTK 标题栏始终隐藏, + 并用真实指针按下与拖动验证 Flutter 控制栏移动、四边及角落缩放和最小尺寸;程序调用移动或设置尺寸不代替输入验收。 + 连续执行控制栏拖动后立即点击最大化,首次点击必须响应;按键持续按住时拖动不得提前结束。 + 检查控制栏最大化/还原、双击、关闭隐藏及重新显示聚焦,缩放和最大化往返后首帧完整且空闲 CPU 恢复稳定; + 准备阶段保持窗口隐藏,不提前破坏后台启动策略。 - 云备份需真实提供商的授权、文件覆盖、重新打开及跨设备恢复;本地写入成功不等于云端同步完成。 - 完成后区分自动测试、模拟器、真机、签名渠道及未测项;测试输入使用脱敏数据,并保留原有数据。 diff --git a/lib/pages/servers/controller.dart b/lib/pages/servers/controller.dart index de8a735e0..866eb4898 100644 --- a/lib/pages/servers/controller.dart +++ b/lib/pages/servers/controller.dart @@ -132,6 +132,8 @@ class ServersPageState { class ServersController extends PageCubit with ServerLabels, ServerGroups { + static const mainListTestGroupId = 'main-list'; + ServersController({ AppDatabase? database, ConnectionCoordinator? coordinator, diff --git a/lib/pages/servers/view.dart b/lib/pages/servers/view.dart index ddba4c480..d53d38717 100644 --- a/lib/pages/servers/view.dart +++ b/lib/pages/servers/view.dart @@ -16,7 +16,6 @@ import 'package:onexray/pages/shared/widgets/button_progress.dart'; import 'package:onexray/pages/shared/widgets/app_activity.dart'; import 'package:onexray/pages/shared/widgets/page_empty_state.dart'; import 'package:onexray/pages/shared/widgets/page_help.dart'; -import 'package:onexray/service/connect/settings.dart'; import 'package:onexray/service/connect/coordinator.dart'; import 'package:onexray/service/servers/catalog.dart'; import 'package:onexray/pages/servers/details.dart'; @@ -206,6 +205,36 @@ class _ServerBrowserState extends State { .contains(query)); } + Widget _testVisibleNodes(BuildContext context, List rows) { + final l = AppLocalizations.of(context)!; + const groupId = ServersController.mainListTestGroupId; + final testing = controller.testingGroup(groupId); + final cancelling = controller.cancellingGroup(groupId); + final testable = rows.where(controller.canTest).toList(); + return OutlinedButton.icon( + key: const ValueKey('server-test-visible'), + onPressed: testing + ? cancelling + ? null + : () => controller.cancelTest(groupId) + : controller.busy || + testable.isEmpty || + testable.any(controller.serverBusy) + ? null + : () => controller.test(context, testable, groupId: groupId), + icon: testing + ? const ButtonProgressIndicator() + : const Icon(LucideIcons.activity, size: 17), + label: Text( + testing + ? cancelling + ? l.prototypePleaseWait + : l.prototypeCancel + : l.prototypeTestServers, + ), + ); + } + Widget _resources(BuildContext context, {required bool complete}) { final l = AppLocalizations.of(context)!; final mobile = @@ -293,24 +322,7 @@ class _ServerBrowserState extends State { key: ValueKey('server-probe-activity'), downloading: false, ), - if (!mobile) - OutlinedButton.icon( - onPressed: () => controller.choose( - context, - const ServerSelection.automatic(), - ), - icon: const Icon(LucideIcons.shuffle, size: 17), - label: Text(l.prototypeAutomaticSelection), - ), - if (!mobile) - OutlinedButton.icon( - onPressed: () => controller.choose( - context, - const ServerSelection.source(0), - ), - icon: const Icon(LucideIcons.layers, size: 17), - label: Text(l.prototypeManualAdditions), - ), + _testVisibleNodes(context, ordinary), ] else ...[ FilledButton.icon( onPressed: () => controller.addServers(context), @@ -524,22 +536,6 @@ class ServerManualGroupsPage extends StatelessWidget { controller: controller, showLabel: true, ), - OutlinedButton.icon( - onPressed: () => controller.choose( - context, - const ServerSelection.automatic(), - ), - icon: const Icon(LucideIcons.shuffle, size: 17), - label: Text(l.prototypeAutomaticSelection), - ), - OutlinedButton.icon( - onPressed: () => controller.choose( - context, - const ServerSelection.source(0), - ), - icon: const Icon(LucideIcons.layers, size: 17), - label: Text(l.prototypeManualAdditions), - ), ], ), const SizedBox(height: 18), diff --git a/lib/service/shared/menu/window/service.dart b/lib/service/shared/menu/window/service.dart index 30f79fc78..7cd3a7f7a 100644 --- a/lib/service/shared/menu/window/service.dart +++ b/lib/service/shared/menu/window/service.dart @@ -20,6 +20,7 @@ final class WindowService { Application.instance.setDockIconVisible, AppPlatform.isDesktop, AppPlatform.isMacOS, + AppPlatform.isLinux, ); WindowService._( @@ -27,6 +28,7 @@ final class WindowService { this._setDockIconVisible, this._isDesktop, this._isMacOS, + this._isLinux, ); @visibleForTesting @@ -35,12 +37,14 @@ final class WindowService { required bool Function(bool) setDockIconVisible, bool isDesktop = true, bool isMacOS = true, - }) : this._(readWindow, setDockIconVisible, isDesktop, isMacOS); + bool isLinux = false, + }) : this._(readWindow, setDockIconVisible, isDesktop, isMacOS, isLinux); final Window? Function() _readWindow; final bool Function(bool) _setDockIconVisible; final bool _isDesktop; final bool _isMacOS; + final bool _isLinux; Window? _window; ListenerId? _listenerId; final _titlebarHeight = ValueNotifier(null); @@ -76,9 +80,18 @@ final class WindowService { ygLogger('initialize window material failed: $error\n$stackTrace'); } } - window.titleBarStyle = TitleBarStyle.hidden; - window.isWindowControlButtonsVisible = true; + // The Linux runner hides GTK's header while retaining its decoration and + // resize handling. Changing the native style would install core shadows. + if (!_isLinux) { + // Native macOS chrome keeps AppKit's drag and double-click handling. + // The hidden style transfers those gestures to a custom Flutter caption. + window.titleBarStyle = _hasSidebarMaterial + ? TitleBarStyle.normal + : TitleBarStyle.hidden; + window.isWindowControlButtonsVisible = true; + } if (_hasSidebarMaterial) { + window.setContentUnderTitleBar(true); window.backgroundColor = const Color(0x00000000).toNative(); } window.setSize(size.toNative(), false); diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index e35f44245..1d7efa7e1 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -1,9 +1,6 @@ #include "my_application.h" #include -#ifdef GDK_WINDOWING_X11 -#include -#endif #include "flutter/generated_plugin_registrant.h" @@ -14,6 +11,74 @@ struct _MyApplication { G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) +// A native move can consume the release of the press delivered to Flutter. +// Reconcile it before the next pointer event, otherwise Flutter drops the next +// click because it still considers the primary button pressed. +static gboolean synchronize_pointer_release(GtkWidget* widget, GdkEvent* event, + gpointer user_data) { + const char* key = "onexray-primary-button-down"; + GdkEventType type = gdk_event_get_event_type(event); + guint button = 0; + if (type == GDK_BUTTON_RELEASE && gdk_event_get_button(event, &button) && + button == GDK_BUTTON_PRIMARY) { + g_object_set_data(G_OBJECT(widget), key, nullptr); + return FALSE; + } + + if (type != GDK_MOTION_NOTIFY && type != GDK_ENTER_NOTIFY && + type != GDK_BUTTON_PRESS) { + return FALSE; + } + + GdkModifierType state; + gdouble x, y; + GdkWindow* window = gdk_event_get_window(event); + GdkDevice* device = gdk_event_get_device(event); + if (g_object_get_data(G_OBJECT(widget), key) != nullptr && + window != nullptr && device != nullptr && + gdk_event_get_state(event, &state) && !(state & GDK_BUTTON1_MASK) && + gdk_event_get_coords(event, &x, &y)) { + // Use GTK's normal widget dispatch so the engine and Dart both receive the + // missing release. Never release a button the current GDK event holds down. + g_object_set_data(G_OBJECT(widget), key, nullptr); + GdkEvent* release = gdk_event_new(GDK_BUTTON_RELEASE); + release->button.window = GDK_WINDOW(g_object_ref(window)); + release->button.send_event = TRUE; + release->button.time = gdk_event_get_time(event); + release->button.x = x; + release->button.y = y; + release->button.state = state | GDK_BUTTON1_MASK; + release->button.button = GDK_BUTTON_PRIMARY; + gdk_event_get_root_coords(event, &release->button.x_root, + &release->button.y_root); + gdk_event_set_device(release, device); + GdkDevice* source_device = gdk_event_get_source_device(event); + if (source_device != nullptr) { + gdk_event_set_source_device(release, source_device); + } + gtk_widget_event(widget, release); + gdk_event_free(release); + } + + if (type == GDK_BUTTON_PRESS && gdk_event_get_button(event, &button) && + button == GDK_BUTTON_PRIMARY) { + g_object_set_data(G_OBJECT(widget), key, GINT_TO_POINTER(1)); + } + return FALSE; +} + +static void watch_flutter_pointer_events(GtkWidget* widget, gpointer user_data) { + if (GTK_IS_EVENT_BOX(widget)) { + // The generic event signal precedes Flutter's type-specific handlers. + g_signal_connect(widget, "event", G_CALLBACK(synchronize_pointer_release), + nullptr); + } + if (GTK_IS_CONTAINER(widget)) { + gtk_container_foreach(GTK_CONTAINER(widget), watch_flutter_pointer_events, + nullptr); + } +} + // Implements GApplication::activate. static void my_application_activate(GApplication* application) { MyApplication* self = MY_APPLICATION(application); @@ -28,32 +93,16 @@ static void my_application_activate(GApplication* application) { GtkWindow* window = GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); - // Use a header bar when running in GNOME as this is the common style used - // by applications and is the setup most users will be using (e.g. Ubuntu - // desktop). - // If running on X and not using GNOME then just use a traditional title bar - // in case the window manager does more exotic layout, e.g. tiling. - // If running on Wayland assume the header bar will work (may need changing - // if future cases occur). - gboolean use_header_bar = TRUE; -#ifdef GDK_WINDOWING_X11 - GdkScreen* screen = gtk_window_get_screen(window); - if (GDK_IS_X11_SCREEN(screen)) { - const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); - if (g_strcmp0(wm_name, "GNOME Shell") != 0) { - use_header_bar = FALSE; - } - } -#endif - if (use_header_bar) { - GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); - gtk_widget_show(GTK_WIDGET(header_bar)); - gtk_header_bar_set_title(header_bar, "OneXray"); - gtk_header_bar_set_show_close_button(header_bar, TRUE); - gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); - } else { - gtk_window_set_title(window, "OneXray"); - } + // GTK owns Linux decorations before the Flutter view is realized. A hidden + // HeaderBar also requests CSD on X11: the window manager adds no second + // caption, while GTK keeps its native resize edges and shadow. Flutter draws + // the visible caption; nativeapi must not replace this titlebar policy. + gtk_window_set_title(window, "OneXray"); + GtkWidget* header_bar = gtk_header_bar_new(); + gtk_widget_set_no_show_all(header_bar, TRUE); + gtk_header_bar_set_title(GTK_HEADER_BAR(header_bar), "OneXray"); + gtk_header_bar_set_show_close_button(GTK_HEADER_BAR(header_bar), TRUE); + gtk_window_set_titlebar(window, header_bar); gtk_window_set_default_size(window, 1280, 720); @@ -63,6 +112,7 @@ static void my_application_activate(GApplication* application) { project, self->dart_entrypoint_arguments); FlView* view = fl_view_new(project); + watch_flutter_pointer_events(GTK_WIDGET(view), nullptr); GdkRGBA background_color; // Background defaults to black, override it here if necessary, e.g. #00000000 // for transparent. diff --git a/test/pages/servers/main_list_ping_test.dart b/test/pages/servers/main_list_ping_test.dart new file mode 100644 index 000000000..78d2fb4e0 --- /dev/null +++ b/test/pages/servers/main_list_ping_test.dart @@ -0,0 +1,407 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:drift/drift.dart' show Value; +import 'package:drift/native.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:onexray/core/db/database/constants.dart'; +import 'package:onexray/core/db/database/database.dart'; +import 'package:onexray/core/db/database/enum.dart'; +import 'package:onexray/core/pigeon/messages.g.dart'; +import 'package:onexray/l10n/localizations/app_localizations.dart'; +import 'package:onexray/pages/servers/controller.dart'; +import 'package:onexray/pages/servers/view.dart'; +import 'package:onexray/pages/theme/theme.dart'; +import 'package:onexray/service/connect/coordinator.dart'; +import 'package:onexray/service/connect/runtime.dart'; +import 'package:onexray/service/connect/runtime_host.dart'; +import 'package:onexray/service/connect/settings.dart'; +import 'package:onexray/service/servers/outbound/state_db.dart'; +import 'package:onexray/service/settings/language/locale.dart'; +import 'package:onexray/service/shared/event_bus/service.dart'; +import 'package:onexray/service/shared/ping/batch.dart'; +import 'package:onexray/service/shared/ping/service.dart'; +import 'package:shadcn_ui/shadcn_ui.dart' + show ShadOption, ShadTheme, ShadToaster; +// ignore: depend_on_referenced_packages +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +// ignore: depend_on_referenced_packages +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import 'fixtures/server_test_router.dart'; + +void main() { + late AppDatabase db; + late ConnectionCoordinator coordinator; + late ServersController controller; + late ScrollController scroll; + GoRouter? router; + late int sourceId; + late List targetIds; + late List nodeIds; + late Map idsByTag; + late Completer firstEntered; + late Completer> firstResults; + late List> batches; + + setUp(() async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + final bus = AppEventBus(); + addTearDown(bus.close); + db = AppDatabase.forTesting(NativeDatabase.memory()); + coordinator = ConnectionCoordinator( + database: db, + readRuntime: () async => null, + inspect: (_, {observedStatus}) async => + const HostConnection(VpnStatus.disconnected), + prepare: (_, _) async => + throw StateError('List tests must not prepare VPN'), + start: (_) async => throw StateError('List tests must not start VPN'), + stop: () async => throw StateError('List tests must not stop VPN'), + ); + await coordinator.initialize(observe: false, registerReferences: false); + Future source(String name, {bool provider = false}) => + db.subscriptionDao.insertRow( + SubscriptionCompanion.insert( + name: name, + url: 'https://example.test/$name', + mode: Value( + provider + ? SubscriptionMode.providerManaged.name + : SubscriptionMode.appManaged.name, + ), + timestamp: DateTime(2026, 11, 9), + ), + ); + sourceId = await source('First source'); + final otherSourceId = await source('Second source'); + final providerSourceId = await source('Complete source', provider: true); + idsByTag = {}; + nodeIds = []; + Future node(String name, int subId, {bool favorite = true}) async { + final id = await db.coreConfigDao.insertAssetRow( + outboundCompanion({'tag': name, 'protocol': 'vless'}) + .copyWith(subId: Value(subId), favorite: Value(favorite)), + ); + idsByTag[name] = id; + nodeIds.add(id); + return id; + } + + targetIds = []; + for (var index = 0; index < PingBatchRunner.maxBatchSize + 1; index++) { + targetIds.add(await node('Target $index', sourceId)); + } + await node('Target not favorite', sourceId, favorite: false); + await node('Other name', sourceId); + await node('Target local', 0); + await node('Target other source', otherSourceId); + final graph = base64Encode( + utf8.encode('{"outbounds":[{"tag":"direct","protocol":"freedom"}]}'), + ); + for (final (name, type, subId) in [ + ('Target complete profile', CoreConfigType.Remnawave, providerSourceId), + ('Target Raw', CoreConfigType.raw, 0), + ]) { + await db.coreConfigDao.insertAssetRow( + CoreConfigCompanion.insert( + name: name, + type: type.name, + tags: '', + data: Value(graph), + delay: PingDelayConstants.unknown, + subId: subId, + favorite: const Value(true), + ), + ); + } + await db.connectionConfigDao.commit( + configurationJson: ConnectionConfiguration( + connection: ConnectionSettings( + selection: ServerSelection.server(targetIds.first), + trafficMode: TrafficMode.allVpn, + ), + ).encode(), + ); + firstEntered = Completer(); + firstResults = Completer>(); + batches = []; + final ping = PingService.forTesting( + database: db, + automaticEnabled: false, + runBatch: (sources, _) async { + batches.add([ + for (final source in sources) + idsByTag[((jsonDecode(source.xrayJson) + as Map)['outbounds'] + as List) + .first['tag'] + as String]!, + ]); + if (!firstEntered.isCompleted) { + firstEntered.complete(); + return firstResults.future; + } + return [for (final _ in sources) const PingBatchResult(true, 25, '')]; + }, + ); + controller = ServersController( + database: db, + coordinator: coordinator, + ping: ping, + ); + await controller.initialize(); + bool loaded(ServersPageState state) => + state.servers.length == nodeIds.length + 1 && + state.rawConfigurations.length == 1; + if (!loaded(controller.state)) { + await controller.stream + .firstWhere(loaded) + .timeout(const Duration(seconds: 5)); + } + scroll = ScrollController(); + }); + + tearDown(() async { + if (!firstResults.isCompleted) { + firstResults.complete([ + for (final _ in batches.isEmpty ? [] : batches.first) + const PingBatchResult(true, 25, ''), + ]); + } + if (controller.state.serverTests.isNotEmpty) { + await controller.stream + .firstWhere((state) => state.serverTests.isEmpty) + .timeout(const Duration(seconds: 5)); + } + router?.dispose(); + router = null; + scroll.dispose(); + await controller.close(); + coordinator.dispose(); + await db.close(); + }); + + Future open(WidgetTester tester, double width) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = Size(width, 1000); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + router = serverTestRouter( + home: (_) => Scaffold( + body: BlocBuilder( + bloc: controller, + builder: (_, _) => + ServerBrowser(controller: controller, scroll: scroll), + ), + ), + ); + await tester.pumpWidget( + MaterialApp.router( + routerConfig: router, + theme: AppTheme.light, + locale: const Locale('en'), + localizationsDelegates: AppLocalePolicy.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (_, child) => ShadTheme( + data: AppTheme.shad(Brightness.light), + child: ShadToaster(child: child!), + ), + ), + ); + await tester.pumpAndSettle(); + return AppLocalizations.of(tester.element(find.byType(ServerBrowser)))!; + } + + final testAction = find.byKey(const ValueKey('server-test-visible')); + + Future filterSource(WidgetTester tester) async { + await tester.tap(find.byKey(const ValueKey(('server-source-filter', -1)))); + await tester.pumpAndSettle(); + await tester.tap( + find.byWidgetPredicate( + (widget) => widget is ShadOption && widget.value == sourceId, + ), + ); + await tester.pumpAndSettle(); + } + + Future start(WidgetTester tester) async { + await tester.ensureVisible(testAction); + await tester.runAsync(() async { + // Reusing the same callback before a rebuild must not duplicate a task. + await tester.tap(testAction); + await tester.tap(testAction); + await firstEntered.future.timeout(const Duration(seconds: 5)); + }); + await tester.pump(); + expect(controller.state.serverTests, hasLength(1)); + expect( + controller.testingGroup(ServersController.mainListTestGroupId), + isTrue, + ); + } + + Future finish(WidgetTester tester) async { + await tester.runAsync(() async { + firstResults.complete([ + for (final _ in batches.first) const PingBatchResult(true, 25, ''), + ]); + if (controller.state.serverTests.isNotEmpty) { + await controller.stream + .firstWhere((state) => state.serverTests.isEmpty) + .timeout(const Duration(seconds: 5)); + } + }); + await tester.pumpAndSettle(); + } + + testWidgets( + 'main list tests a snapshot of source, search and favorite matches on mobile', + (tester) async { + final before = (await tester.runAsync(() => coordinator.configuration))! + .encode(); + final l = await open(tester, 390); + await filterSource(tester); + await tester.tap(find.byKey(const ValueKey('server-favorites-filter'))); + await tester.enterText( + find.byKey(const ValueKey('server-search')), + 'Target', + ); + await tester.pumpAndSettle(); + await start(tester); + expect(batches.single, targetIds.take(PingBatchRunner.maxBatchSize)); + expect( + find.descendant(of: testAction, matching: find.text(l.prototypeCancel)), + findsOneWidget, + ); + + await tester.enterText( + find.byKey(const ValueKey('server-search')), + 'Other name', + ); + await tester.pump(); + await finish(tester); + expect(batches.expand((batch) => batch), targetIds); + expect(batches.map((batch) => batch.length), [5, 1]); + expect( + (await tester.runAsync(() => coordinator.configuration))!.encode(), + before, + ); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'main list cancellation keeps its task after filtering and leaves later nodes unmeasured', + (tester) async { + final before = (await tester.runAsync(() => coordinator.configuration))! + .encode(); + final l = await open(tester, 1160); + expect(controller.configuration.connection.selection.id, targetIds.first); + await filterSource(tester); + await start(tester); + final request = controller.state.serverTests.keys.single; + final inFlightIds = batches.single.toList(); + expect(inFlightIds, hasLength(PingBatchRunner.maxBatchSize)); + await tester.enterText( + find.byKey(const ValueKey('server-search')), + 'no matching nodes', + ); + await tester.pump(); + expect(find.text(l.serverNoMatchingResources), findsOneWidget); + expect(tester.widget(testAction).onPressed, isNotNull); + await tester.tap(testAction); + await tester.pump(); + expect(controller.state.serverTests.keys.single, same(request)); + expect( + controller.cancellingGroup(ServersController.mainListTestGroupId), + isTrue, + ); + expect(tester.widget(testAction).onPressed, isNull); + expect( + find.descendant( + of: testAction, + matching: find.text(l.prototypePleaseWait), + ), + findsOneWidget, + ); + await finish(tester); + expect(batches, hasLength(1)); + final rows = await tester.runAsync( + () => Future.wait(nodeIds.map(db.coreConfigDao.searchRow)), + ); + for (final row in rows!) { + expect( + row!.delay, + inFlightIds.contains(row.id) ? 25 : PingDelayConstants.unknown, + ); + } + expect(tester.widget(testAction).onPressed, isNull); + expect( + (await tester.runAsync(() => coordinator.configuration))!.encode(), + before, + ); + expect(coordinator.state.value.phase, ConnectionPhase.disconnected); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('main list disables testing for no matches or a busy selection', ( + tester, + ) async { + await open(tester, 1032); + expect(tester.widget(testAction).onPressed, isNotNull); + await tester.enterText( + find.byKey(const ValueKey('server-search')), + 'no matching nodes', + ); + await tester.pumpAndSettle(); + expect(tester.widget(testAction).onPressed, isNull); + await tester.tap(find.byKey(const ValueKey('server-search-clear'))); + await tester.pumpAndSettle(); + controller.selecting = ServerSelection.server(targetIds.first); + await tester.pumpAndSettle(); + expect(tester.widget(testAction).onPressed, isNull); + controller.selecting = null; + await tester.pumpAndSettle(); + expect(tester.widget(testAction).onPressed, isNotNull); + expect(batches, isEmpty); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'main list and manual group management omit connection scope buttons', + (tester) async { + final l = await open(tester, 1160); + void expectNoScopeButtons() { + expect( + find.widgetWithText(OutlinedButton, l.prototypeAutomaticSelection), + findsNothing, + ); + expect( + find.widgetWithText(OutlinedButton, l.prototypeManualAdditions), + findsNothing, + ); + } + + expectNoScopeButtons(); + final groups = find.text(l.manualGroupsTitle); + await tester.ensureVisible(groups); + await tester.tap(groups); + await tester.pumpAndSettle(); + expect(find.byType(ServerManualGroupsPage), findsOneWidget); + expectNoScopeButtons(); + expect( + find.widgetWithText(FilledButton, l.manualGroupNew), + findsOneWidget, + ); + expect(controller.state.serverTests, isEmpty); + expect(tester.takeException(), isNull); + }, + ); +} diff --git a/test/service/shared/menu/window_service_test.dart b/test/service/shared/menu/window_service_test.dart index af78bc2dd..8c625d0bf 100644 --- a/test/service/shared/menu/window_service_test.dart +++ b/test/service/shared/menu/window_service_test.dart @@ -11,6 +11,7 @@ class TestWindow implements native.Window { FutureOr Function(native.WindowEvent)? listener; native.TitleBarStyle? style; bool? controlsVisible; + bool? contentUnderTitleBar; native.Size? preparedSize; native.Size? minSize; native.Color? background; @@ -19,11 +20,21 @@ class TestWindow implements native.Window { bool isFullScreen = false; @override bool isMinimized = false; + @override + bool isVisible = false; + @override + bool isFocused = false; @override set titleBarStyle(native.TitleBarStyle value) => style = value; @override set isWindowControlButtonsVisible(bool value) => controlsVisible = value; + @override + bool setContentUnderTitleBar(bool value) { + contentUnderTitleBar = value; + return true; + } + @override set backgroundColor(native.Color value) => background = value; @override @@ -35,11 +46,24 @@ class TestWindow implements native.Window { @override void center() => calls.add('center'); @override - void show() => calls.add('show'); + void show() { + calls.add('show'); + isVisible = true; + } + @override - void focus() => calls.add('focus'); + void focus() { + calls.add('focus'); + isFocused = isVisible && !isMinimized; + } + @override - void hide() => calls.add('hide'); + void hide() { + calls.add('hide'); + isVisible = false; + isFocused = false; + } + @override void restore() { calls.add('restore'); @@ -113,6 +137,8 @@ void main() { ); await prepare(); expect(service.hasSidebarMaterial, isFalse); + expect(window.style, native.TitleBarStyle.hidden); + expect(window.contentUnderTitleBar, isNull); materialAvailable = true; calls.clear(); await prepare(); @@ -124,7 +150,8 @@ void main() { expect(calls.singleWhere((c) => c.method == 'setMaterial').arguments, { 'material': NSVisualEffectViewMaterial.sidebar.index, }); - expect(window.style, native.TitleBarStyle.hidden); + expect(window.style, native.TitleBarStyle.normal); + expect(window.contentUnderTitleBar, isTrue); expect(window.controlsVisible, isTrue); expect( [window.preparedSize!.width, window.preparedSize!.height], @@ -185,6 +212,86 @@ void main() { }, ); + test('Linux preserves GTK chrome during window actions', () async { + final window = TestWindow() + ..style = native.TitleBarStyle.normal + ..controlsVisible = false; + final service = WindowService.forTesting( + readWindow: () => window, + isMacOS: false, + isLinux: true, + setDockIconVisible: (_) => throw StateError('macOS-only API'), + ); + addTearDown(service.dispose); + + await service.prepare( + size: const Size(1160, 720), + minimumSize: const Size(480, 600), + ); + + // GTK retains its decorated window with a hidden native header. Changing + // either native property would replace that runner-owned chrome policy. + expect(window.style, native.TitleBarStyle.normal); + expect(window.controlsVisible, isFalse); + expect(window.contentUnderTitleBar, isNull); + expect(window.background, isNull); + expect(service.hasSidebarMaterial, isFalse); + expect(service.titlebarHeight, isNull); + expect( + [window.preparedSize!.width, window.preparedSize!.height], + [1160, 720], + ); + expect([window.minSize!.width, window.minSize!.height], [480, 600]); + expect(window.isVisible, isFalse); + expect(window.isFocused, isFalse); + expect(window.calls.where((c) => c == 'show' || c == 'focus'), isEmpty); + + await service.showAndFocus(); + expect(window.isVisible, isTrue); + expect(window.isFocused, isTrue); + + final request = CloseRequest(); + await window.listener!( + native.WindowCloseRequestedEvent(windowId: 1, request: request), + ); + expect(request.cancelled, isTrue); + expect(window.isVisible, isFalse); + expect(window.isFocused, isFalse); + + window.calls.clear(); + window.isMinimized = true; + await service.showAndFocus(); + expect(window.isMinimized, isFalse); + expect(window.isVisible, isTrue); + expect(window.isFocused, isTrue); + expect(window.calls, ['restore', 'show', 'focus']); + expect(window.style, native.TitleBarStyle.normal); + expect(window.controlsVisible, isFalse); + }); + + test('Windows prepares hidden chrome without showing the window', () async { + final window = TestWindow() + ..style = native.TitleBarStyle.normal + ..controlsVisible = false; + final service = WindowService.forTesting( + readWindow: () => window, + isMacOS: false, + setDockIconVisible: (_) => throw StateError('macOS-only API'), + ); + addTearDown(service.dispose); + + await service.prepare( + size: const Size(1160, 720), + minimumSize: const Size(480, 600), + ); + + expect(window.style, native.TitleBarStyle.hidden); + expect(window.controlsVisible, isTrue); + expect(service.hasSidebarMaterial, isFalse); + expect(window.isVisible, isFalse); + expect(window.isFocused, isFalse); + }); + test( 'other desktops change taskbar visibility without the macOS Dock API', () async {