Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion docs/app.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/初始化时仅显示当前界面,不关闭编辑、
Expand Down Expand Up @@ -167,11 +175,14 @@ FakeDNS/Fragment、DNS 和嗅探沿同一内容流排列,保留规则新增、
服务器的订阅按 ID 升序,普通手动节点可整理为一层自定义分组;未分组固定第一,空自定义组保留。
支持新建、重命名和通过节点菜单移组;解散分组保留节点,删除分组及节点需确认数量与运行影响。
自定义组仅用于浏览和管理,不创建连接范围;全部手动节点入口仍覆盖所有组,节点可以单独选择。
自动选择的候选范围统一在连接页设置;服务器列表和手动分组管理页不再提供范围快捷按钮,来源下拉仅筛选列表。
Raw 与提供方完整配置不参与手动分组,不提供节点位置 Tab、国家分组或地区选择。
服务器列表及分组详情提供默认、名称和延迟排序,默认顺序不因测速结果更新而变化。
选择作为本机偏好立即保存,同步服务器浏览、最终出口选择与桌面托盘;搜索和收藏筛选保留所选顺序。
搜索非空时提供清除按钮,零匹配使用通用结果提示并提供清除搜索操作,不改写排序或收藏偏好。
订阅标签位于列表上方,来源管理不依赖滚动到节点末尾;空手动组不影响来源入口可用。
节点主列表提供批量测速,范围固定为点击时来源、搜索和收藏筛选后的可测速普通节点;不改变连接选择。
测速期间切换筛选仍可取消原任务;取消等待当前已发送批次结束,保留已完成结果并停止后续批次。
自定义分组和组内节点分别进入整理页面,手机长按手柄、桌面拖动手柄,也可用上下移动按钮。
主节点列表在搜索为空、未启用收藏筛选且来源为全部来源或手动添加时提供“整理节点顺序”;
只有一个非空手动组时直接进入该组,多个非空组先选择完整分组范围,空组、单节点组及忙碌组不可整理。
Expand Down
5 changes: 5 additions & 0 deletions docs/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 恢复稳定;
准备阶段保持窗口隐藏,不提前破坏后台启动策略。
- 云备份需真实提供商的授权、文件覆盖、重新打开及跨设备恢复;本地写入成功不等于云端同步完成。
- 完成后区分自动测试、模拟器、真机、签名渠道及未测项;测试输入使用脱敏数据,并保留原有数据。
2 changes: 2 additions & 0 deletions lib/pages/servers/controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ class ServersPageState {

class ServersController extends PageCubit<ServersPageState>
with ServerLabels, ServerGroups {
static const mainListTestGroupId = 'main-list';

ServersController({
AppDatabase? database,
ConnectionCoordinator? coordinator,
Expand Down
66 changes: 31 additions & 35 deletions lib/pages/servers/view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -206,6 +205,36 @@ class _ServerBrowserState extends State<ServerBrowser> {
.contains(query));
}

Widget _testVisibleNodes(BuildContext context, List<CoreConfigData> 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 =
Expand Down Expand Up @@ -293,24 +322,7 @@ class _ServerBrowserState extends State<ServerBrowser> {
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),
Expand Down Expand Up @@ -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),
Expand Down
19 changes: 16 additions & 3 deletions lib/service/shared/menu/window/service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,15 @@ final class WindowService {
Application.instance.setDockIconVisible,
AppPlatform.isDesktop,
AppPlatform.isMacOS,
AppPlatform.isLinux,
);

WindowService._(
this._readWindow,
this._setDockIconVisible,
this._isDesktop,
this._isMacOS,
this._isLinux,
);

@visibleForTesting
Expand All @@ -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<double?>(null);
Expand Down Expand Up @@ -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);
Expand Down
108 changes: 79 additions & 29 deletions linux/runner/my_application.cc
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
#include "my_application.h"

#include <flutter_linux/flutter_linux.h>
#ifdef GDK_WINDOWING_X11
#include <gdk/gdkx.h>
#endif

#include "flutter/generated_plugin_registrant.h"

Expand All @@ -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);
Expand All @@ -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);

Expand All @@ -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.
Expand Down
Loading