diff --git a/AGENTS.md b/AGENTS.md index f9c718f26..8e6dc7c34 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,15 @@ describe shipped behavior; Git history holds retired plans and progress logs. - Edit source models, ARB, Pigeon and FFI definitions, then regenerate outputs. Generated Dart, Kotlin, Swift, Drift, FFI and localization files are not sources. +## Commit messages + +- Write one English line of at most 72 characters: `(): `. + Scope is optional; the complete message stays on one line. +- Use `feat`, `fix`, `refactor`, `perf`, `docs`, `test`, `build`, `ci` or `chore`. +- Name the concrete change with an imperative verb, for example + `fix(vpn): retry failed connections`. +- Keep each commit focused on one logical change. + ## Read for the task - Startup, permissions, navigation, UI or desktop windows: [App behavior](docs/app.md). diff --git a/docs/app.md b/docs/app.md index a84fbca93..373e340c0 100644 --- a/docs/app.md +++ b/docs/app.md @@ -77,6 +77,9 @@ Flutter 窗口背景和侧栏分别使用不透明的主题背景色与侧栏色 `PageAppBar` 仅为进入 macOS 原生标题栏区域的可见按钮注册点击转发;隐藏或退出页面后移除。 弹窗不穿透遮罩,空白标题栏保留系统拖动和双击语义。`macos_window_utils` 管理原生材质及标题栏, `window_manager` 管理窗口事件,不同时启用两套窗口 delegate。 +窗口服务使用 `window_manager` 0.6 的原生 `Window` API,准备阶段只设置窗口属性; +实际显示或隐藏继续由启动策略决定。关闭请求先取消再隐藏,恢复最小化窗口后才显示并聚焦。 +macOS Dock 图标通过原生 Application 控制,窗口菜单的可见性不代替 Dock 可见性。 Windows/Linux 保留独立纯色控制栏,布局扣除控制栏高度;Windows Snap Layout 在对应系统验收。 Linux runner 在创建 Flutter View 前关闭 Impeller,其他平台不受影响。 diff --git a/docs/xray-configuration.md b/docs/xray-configuration.md index abfc54d34..ff239b477 100644 --- a/docs/xray-configuration.md +++ b/docs/xray-configuration.md @@ -56,8 +56,8 @@ Windows 服务直连使用 Microsoft/Bing 分类,同时优先代理 GitHub; ### DNS 智能和常规自定义分别保存代理/直连主 resolver 地址及同出口有序备用列表。 -旧设置缺新增字段时两组主地址均为 `8.8.8.8`,全局缓存开启, -相同出口备用默认关闭。智能直连 DNS 关闭保留已填值,运行不派生直连 DNS 匹配。 +App 托管的缺省代理和直连 resolver 均为 `tcp://8.8.8.8`,全部使用 VPN 同样采用该缺省代理 resolver。 +全局缓存默认开启,相同出口备用默认关闭。智能直连 DNS 关闭保留已填值,运行不派生直连 DNS 匹配。 使用固定 `app-dns-proxy` / `app-dns-direct` tag 区分出口,DNS 规则来自同一份有序路由规则。 只投影无 IP/端口/网络/协议/系统/入站等附加条件的 proxy/direct 域名规则,因为查询不掌握后续连接条件。 代理例外先于宽泛直连分类;代理策略块末尾的真实 resolver 使用 `finalQuery`,不回退到直连 DNS。 @@ -75,12 +75,18 @@ Windows 服务直连使用 Microsoft/Bing 分类,同时优先代理 GitHub; 按需生成的服务器域名引导 DNS 独立直连,见 [服务器拨号](#服务器拨号)。 智能与常规自定义各自保存“其它 DNS 查询”策略,缺省为代理,包括旧设置和旧普通分享。 A/AAAA 始终劫持到内置 DNS;其它记录可选代理、显式直连或返回 NOERROR 空答案。 +App 生成的 `dnsOut` 使用 `settings.rewriteNetwork: tcp`,转发查询转换为 TCP DNS, +响应按客户端原来的 UDP/TCP 格式返回;仅改写传输网络,保留原目标地址、端口与所选出口。 +返回空答案的策略不因此建立上游连接;该转换也不会使 HTTP 代理支持通用 UDP 或 QUIC。 代理保留原查询目标,经 `app-dns-forward` loopback 和前置同名入站规则进入已有 `proxy` balancer, 不改用代理 resolver 地址,也不受智能直连 DNS 开关影响;局域网 resolver 需用户自行选择可达的出口。 该辅助出站不进入节点映射或观察范围,不监听端口;普通代理选路失败不隐式回退 direct。 普通模式的查询地址族仍由 App IPv6 设置管理,不额外生成根级 hosts/queryStrategy。 高级模板和 Raw 不应用这些普通 DNS 设置;模板 DNS 保持其原有管理边界。 Raw 与提供方完整配置缺少 Core DNS 服务器时,使用[完整配置的 VPN 适配](#完整配置的-vpn-适配)中的隧道 DNS 缺省策略。 +显式保存的 resolver 地址保持原值;调整缺省值不扫描或改写数据库、分享配置、订阅原文或已有启动请求, +也不清空、失效或重新生成保存的启动缓存。Widget/控制中心仍原样复用上次保存的请求, +后续由 App 正常连接时才按当次设置重新编译。 ### 服务器拨号 @@ -107,7 +113,8 @@ Core 在发送 UDP 前使用 WireGuard 自身的 socket 策略本地解析 peer 其 UDP 连接不应用仅适用于 TCP 的 HE 竞速,共享 Fragment 的 TCP 连接仍可使用 HE。 显式拨号需要解析域名时,App 从实际物理端点收集去重后的精确域名,前置专用 bootstrap resolver, -复用隧道 DNS 的 IPv4 地址和启用时的 IPv6 地址,并用独立 tag 的前置规则固定直连。 +以隧道 DNS 的 IPv4 地址和启用时的 IPv6 地址生成 TCP resolver,并用独立 tag 的前置规则固定直连。 +TCP URL 的 IPv6 host 使用方括号;系统隧道 DNS 字段继续保存 IP 字面量,不写入 `tcp://`,原生 DoT 设置独立生效。 这些精确匹配不成为通用备用,最后一个引导 resolver 截止普通代理/FakeDNS 回退, 避免连接代理前又经该代理解析其地址。匹配按域名而非请求调用者区分,App 查询恰好相同域名时也使用引导 DNS; 这会优先于该域名原有的业务 DNS 规则。仅 IP 字面端点不增加引导 resolver 或规则。 @@ -220,14 +227,21 @@ Observatory、direct/block、DNS 和资产路径,再由 libXray 构造验证 ## 高级自定义路由 高级模板保留 Map,类型存于 `RoutingProfile.advanced`,不嵌入 JSON;共享名称、数量和保存流程。 -outbounds 以 1–3 个连续空槽开始,后续只开放 freedom/blackhole/dns 辅助出站。 +outbounds 以 1–3 个连续空槽开始,后续只开放 freedom/blackhole/dns/loopback 辅助出站。 运行以真实节点替换槽并置顶,随后用户辅助出站,最后 App direct/block;固定节点选择替换整个槽区。 App 添加 proxy balancer、完整 selector 与运行观测范围。模板有限开放 strategy.type 的 roundRobin/leastPing,以及 observatory 的探测 URL/interval/concurrency;selector/fallback/subjectSelector 仍由 App 生成,不能定义任意额外 balancer。轮询 direct 回退,最低延迟回退首条完整代理链。 模板不能定义 direct/block/proxy 或引用内部 app-entry/app-exit tag,dialerProxy 不支持 balancer。 +loopback 的 `settings.inboundTag` 必须与自身 tag 同名,以该标签重新进入路由; +它不监听端口、不创建真实入站,转发规则可用同名 `inboundTag` 选择 `proxy` balancer。 模板完整管理 DNS、routing.domainStrategy、rules 顺序与 ruleTag,不隐式补 DNS server、53/853 规则。 +新建模板的默认 Core DNS 为 `tcp://8.8.8.8`,`dnsOut` 劫持 A/AAAA, +其它记录以 `rewriteNetwork: tcp` 保留原目标转发。`dnsOut` 的 `dialerProxy` 指向 `dns-forward` loopback, +最前的同名入站标签规则再交给 `proxy` balancer,沿用单节点或多接入节点的代理选路。 +这里 DNS 规则的 `action: direct` 表示转发原查询,出口仍经上述代理链,不是网络直连。 +已保存模板原文不改写,其显式 DNS、drop/return 或其它查询策略保持原值。 轮询不追加兜底;最低延迟只在用户规则之后为托管 tunIn 和启用的 app-lan-proxy 增加代理兜底。 额外高级入站由模板显式使用 balancerTag:proxy,不自动纳入兜底。 App 管理日志、metrics、统计、policy、env、运行 balancer/observatory 范围、出口网卡和 DNS 查询策略。 @@ -251,12 +265,13 @@ App 管理日志、metrics、统计、policy、env、运行 balancer/observatory 判断接受数字或字符串 53,不推断 network、domain/IP 条件、端口范围、规则优先级或目标协议是否覆盖全部 DNS。 缺少声明时复用第一个 DNS 出站,或新增一个,再前置限定 `tunIn + TCP/UDP 53` 的规则。 已有 DNS 出站的设置与目标不改写;新建 DNS 出站劫持 A/AAAA,其它查询类型直连转发, -通过专属 `app-vpn-dns-direct` freedom 出站拨号,不依赖用户代理链。 +使用 `rewriteNetwork: tcp`,通过专属 `app-vpn-dns-direct` freedom 出站拨号,不依赖用户代理链。 Raw 新建 DNS 出站使用 `app-vpn-dns`,提供方沿用 `app-remna-dns`;复用无 tag 的 DNS 出站时补上对应 tag。 这些补充不增加通用代理、DoT 或 TUN catch-all 规则。 运行编译时,`dns` 缺失或为 `null`,以及 `dns.servers` 缺失、为 `null` 或空列表时, 使用本次隧道 IPv4 DNS 和启用 IPv6 时的隧道 IPv6 DNS 补齐服务器,保留同一对象的 hosts、缓存及其它字段。 +补齐的 Core resolver 使用对应 IP 的 TCP URL,系统隧道 DNS 字段仍为 IP 字面量。 新增服务器使用 `app-vpn-default-dns` tag,并用限定该 tag 的前置规则直连 `app-vpn-dns-direct`, 避免连接域名代理前又通过该代理解析地址。仅 App 新增的缺省 DNS 采用此出口; 已有非空列表完整保留,包括显式 `localhost`、DoH 和 FakeDNS,查询地址族仍由 App 平台策略管理。 @@ -384,6 +399,9 @@ TUN 地址只读,隧道 IPv4/IPv6 DNS 与 Apple DoT 域名可编辑,独立 有效设置变化按已连接确认重连;停用的域名/IPv6 设置保留,不因其变化重连。 Windows/Linux 给 Xray 明确绑定已选网卡,Vole 不新增绑定要求,Raw 不能覆盖。 Windows EXE 使用 Xray 原生 TUN/Wintun,MSIX 使用私有 SOCKS 和 Vole UWP 系统隧道。 +两种 Windows 模式均使用 libXray 桌面 Core:MSIX 传入 `-set-dns=true`, +用隧道 DNS 和所选物理网卡设置进程 resolver;EXE 传入 `-set-dns=false`,由 Xray 原生 TUN 管理 resolver。 +该开关不修改系统网卡 DNS。 EXE 默认 `autoSystemWfpBlockLeak: ["dns"]`,Raw/提供方显式列表保留,`[]` 可关闭该项;不启用 `misconfigtun`。 Core v26.10.31 起,`dns` 包含 UDP/TCP 53、Windows DNS Client、TCP/UDP 853(DoT/DoQ); diff --git a/lib/core/desktop_window/native_window.dart b/lib/core/desktop_window/native_window.dart new file mode 100644 index 000000000..b32dcd75c --- /dev/null +++ b/lib/core/desktop_window/native_window.dart @@ -0,0 +1,53 @@ +import 'dart:ffi'; +import 'dart:io'; + +import 'package:ffi/ffi.dart'; +import 'package:nativeapi/nativeapi.dart' show Window, WindowManager; +import 'package:win32/win32.dart' as win32; + +/// Resolves this app's main window without showing or activating it. +Window? resolveDesktopWindow() { + if (Platform.isWindows) return _windowsMainWindow(); + if (Platform.isMacOS || Platform.isLinux) { + return WindowManager.instance.getCurrent(); + } + return null; +} + +Window? _windowsMainWindow() => using((arena) { + // Nativeapi's Windows getCurrent uses thread-local GetActiveWindow, which + // cannot resolve a hidden Flutter runner from the Dart isolate's thread. + final currentProcess = win32.GetCurrentProcessId(); + final processId = arena(); + const classNameCapacity = 256; + final className = win32.PWSTR(arena(classNameCapacity).cast()); + Pointer found = nullptr; + final callback = NativeCallable.isolateLocal(( + Pointer windowPointer, + int _, + ) { + final handle = win32.HWND(windowPointer); + final threadId = win32.GetWindowThreadProcessId(handle, processId); + if (threadId == 0 || processId.value != currentProcess) return win32.TRUE; + final length = win32.GetClassName( + handle, + className, + classNameCapacity, + ).value; + if (length == 0 || + className.toDartString(length: length) != + 'FLUTTER_RUNNER_WIN32_WINDOW') { + return win32.TRUE; + } + found = windowPointer.cast(); + return win32.FALSE; + }, exceptionalReturn: win32.FALSE); + try { + // EnumWindows includes hidden top-level windows. A false result is normal + // when the callback stops after finding the process-owned Flutter window. + win32.EnumWindows(callback.nativeFunction, const win32.LPARAM(0)); + } finally { + callback.close(); + } + return found == nullptr ? null : Window.createWithNativeWindow(found); +}); diff --git a/lib/core/ffi/base_ffi_api.dart b/lib/core/ffi/base_ffi_api.dart index 7b4cb3f0e..1a1ae01b3 100644 --- a/lib/core/ffi/base_ffi_api.dart +++ b/lib/core/ffi/base_ffi_api.dart @@ -17,6 +17,7 @@ List desktopCoreRunArguments({ required String interfaceName, required String configPath, String? errorFile, + bool? setDns, }) { if (dns.isEmpty || interfaceName.isEmpty || configPath.isEmpty) { throw const FormatException( @@ -25,6 +26,7 @@ List desktopCoreRunArguments({ } return [ 'run', + if (setDns != null) '-set-dns=$setDns', '-dns', '$dns:53', '-interface', diff --git a/lib/core/ffi/windows/exe_ffi_api.dart b/lib/core/ffi/windows/exe_ffi_api.dart index 538cecc28..4aa25fb93 100644 --- a/lib/core/ffi/windows/exe_ffi_api.dart +++ b/lib/core/ffi/windows/exe_ffi_api.dart @@ -108,6 +108,7 @@ class WindowsExeFfiApi extends WindowsFfiApi { final errorFile = desktopCoreErrorFile(config); await errorFile.writeAsString('', flush: true); final arguments = desktopCoreRunArguments( + setDns: false, dns: request.tun?.tunDnsIPv4 ?? '', interfaceName: request.tun?.autoOutboundsInterface ?? '', configPath: config, diff --git a/lib/core/ffi/windows/msix_ffi_api.dart b/lib/core/ffi/windows/msix_ffi_api.dart index 70d7596ef..a490c1ee8 100644 --- a/lib/core/ffi/windows/msix_ffi_api.dart +++ b/lib/core/ffi/windows/msix_ffi_api.dart @@ -174,6 +174,7 @@ class WindowsMsixFfiApi extends WindowsFfiApi { WindowsManagedProcess( executableRelativePath: _coreRelativePath, arguments: desktopCoreRunArguments( + setDns: true, dns: networkSettings.dnsIpv4Address, interfaceName: request.tun?.autoOutboundsInterface ?? '', configPath: coreConfig, diff --git a/lib/pages/advanced/xray/controller.dart b/lib/pages/advanced/xray/controller.dart index e32b7f881..3677a7d9a 100644 --- a/lib/pages/advanced/xray/controller.dart +++ b/lib/pages/advanced/xray/controller.dart @@ -186,6 +186,21 @@ class XrayRuntimeController extends PageCubit { } } + Future refreshPingSettings() async { + try { + final preferences = PingState(); + await preferences.readFromPreferences(); + emit( + state.copyWith( + pingTimeout: preferences.timeout, + pingUrl: preferences.url, + ), + ); + } catch (error) { + emit(state.copyWith(failed: true, failure: error)); + } + } + void setLog(String key, Object value) { if (busy || state.systemExtension) return; emit(state.copyWith(log: {...log, key: value})); diff --git a/lib/pages/advanced/xray/page.dart b/lib/pages/advanced/xray/page.dart index e4a812a55..c7034f902 100644 --- a/lib/pages/advanced/xray/page.dart +++ b/lib/pages/advanced/xray/page.dart @@ -35,7 +35,7 @@ class XrayRuntimePage extends StatelessWidget { }); final void Function(BuildContext) onGeodata; final void Function(BuildContext) onUpdates; - final void Function(BuildContext) onSpeedTest; + final Future Function(BuildContext) onSpeedTest; final void Function(BuildContext, ConfigFileViewerParams) onConfig; final void Function(BuildContext)? onDiagnostics; final Future Function(BuildContext)? onLogSettings; @@ -180,7 +180,12 @@ class XrayRuntimePage extends StatelessWidget { : _chevron(context), ), enabled: true, - onTap: () => onSpeedTest(context), + onTap: () async { + await onSpeedTest(context); + if (controller.isPageActive) { + await controller.refreshPingSettings(); + } + }, ), if (onServerDialing != null) _pushRow( diff --git a/lib/pages/main/desktop_window.dart b/lib/pages/main/desktop_window.dart index 575e27b22..a11c9f7b7 100644 --- a/lib/pages/main/desktop_window.dart +++ b/lib/pages/main/desktop_window.dart @@ -22,8 +22,7 @@ class DesktopWindowFrame extends StatefulWidget { State createState() => _DesktopWindowFrameState(); } -class _DesktopWindowFrameState extends State - with WindowListener { +class _DesktopWindowFrameState extends State { double? _titlebarHeight; bool _listening = false; @@ -34,34 +33,24 @@ class _DesktopWindowFrameState extends State unawaited(WindowService().updateAppearance(Theme.of(context).brightness)); if (!_listening && WindowService().hasSidebarMaterial) { _titlebarHeight = WindowService().titlebarHeight; - windowManager.addListener(this); + WindowService().titlebarChanges.addListener(_refreshTitlebar); _listening = true; } } } - Future _refreshTitlebar() async { - final height = await WindowService().refreshTitlebarHeight(); + void _refreshTitlebar() { + final height = WindowService().titlebarHeight; if (mounted && height != _titlebarHeight) { setState(() => _titlebarHeight = height); } } - @override - void onWindowResize() => unawaited(_refreshTitlebar()); - - @override - void onWindowResized() => unawaited(_refreshTitlebar()); - - @override - void onWindowEnterFullScreen() => unawaited(_refreshTitlebar()); - - @override - void onWindowLeaveFullScreen() => unawaited(_refreshTitlebar()); - @override void dispose() { - if (_listening) windowManager.removeListener(this); + if (_listening) { + WindowService().titlebarChanges.removeListener(_refreshTitlebar); + } super.dispose(); } @@ -101,7 +90,10 @@ class _DesktopWindowFrameState extends State : kWindowCaptionHeight, width: double.infinity, child: macOS - ? DragToMoveArea(child: ColoredBox(color: palette.header)) + ? DragToMoveArea( + window: WindowService().nativeWindow, + child: ColoredBox(color: palette.header), + ) : Directionality( // OS caption buttons stay on the right in RTL languages. textDirection: TextDirection.ltr, @@ -109,6 +101,7 @@ class _DesktopWindowFrameState extends State // ignore: deprecated_member_use child: MaterialUiCompatibilityBridge( child: WindowCaption( + window: WindowService().nativeWindow, backgroundColor: palette.header, brightness: theme.brightness, title: Text( diff --git a/lib/pages/settings/desktop/controller.dart b/lib/pages/settings/desktop/controller.dart index 7b546f573..7b7cf03be 100644 --- a/lib/pages/settings/desktop/controller.dart +++ b/lib/pages/settings/desktop/controller.dart @@ -11,7 +11,7 @@ import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/pages/shared/alert.dart'; import 'package:onexray/pages/shared/page_cubit.dart'; import 'package:onexray/service/launch/app_startup.dart'; -import 'package:window_manager/window_manager.dart'; +import 'package:onexray/service/shared/menu/window/service.dart'; class DesktopSettingsPageState { final LaunchAtLoginStatus launchAtLogin; @@ -182,12 +182,12 @@ class DesktopSettingsController extends PageCubit final previous = state.hideDockIcon; emit(state.copyWith(changingBehavior: true)); try { - await windowManager.setSkipTaskbar(enabled); + await WindowService().setHideDockIcon(enabled); await PreferencesKey().saveHideDockIcon(enabled); emit(state.copyWith(hideDockIcon: enabled)); } catch (error) { try { - await windowManager.setSkipTaskbar(previous); + await WindowService().setHideDockIcon(previous); } catch (_) {} if (context != null && context.mounted) { _behaviorFailed(context, error); diff --git a/lib/service/connect/routing/custom/advanced.dart b/lib/service/connect/routing/custom/advanced.dart index 7b02dc0bf..dfbec4245 100644 --- a/lib/service/connect/routing/custom/advanced.dart +++ b/lib/service/connect/routing/custom/advanced.dart @@ -20,11 +20,20 @@ final class AdvancedRoutingProfile implements RoutingConfiguration { "tag": "dnsOut", "protocol": "dns", "settings": { + "rewriteNetwork": "tcp", "rules": [ {"action": "hijack", "qType": "1,28"}, - {"action": "drop"} + {"action": "direct"} ] + }, + "streamSettings": { + "sockopt": {"dialerProxy": "dns-forward"} } + }, + { + "tag": "dns-forward", + "protocol": "loopback", + "settings": {"inboundTag": "dns-forward"} } ], "inbounds": [ @@ -38,11 +47,12 @@ final class AdvancedRoutingProfile implements RoutingConfiguration { } ], "dns": { - "servers": [{"tag": "dns-proxy", "address": "8.8.8.8"}] + "servers": [{"tag": "dns-proxy", "address": "tcp://8.8.8.8"}] }, "routing": { "domainStrategy": "IPIfNonMatch", "rules": [ + {"ruleTag": "Forward DNS", "inboundTag": ["dns-forward"], "balancerTag": "proxy"}, {"ruleTag": "Proxy DNS", "inboundTag": ["dns-proxy"], "balancerTag": "proxy"}, {"ruleTag": "Tunnel DNS", "inboundTag": ["tunIn"], "port": 53, "outboundTag": "dnsOut"}, {"ruleTag": "DNS over TLS", "inboundTag": ["tunIn"], "port": 853, "balancerTag": "proxy"} @@ -208,12 +218,24 @@ final class AdvancedRoutingDocument { 'freedom', 'blackhole', 'dns', + 'loopback', }.contains(outbound['protocol'])) { throw JsonDiagnostic( - '${_pathLabel(path)} supports only freedom, blackhole and dns', + '${_pathLabel(path)} supports only freedom, blackhole, dns and loopback', path: [...path, 'protocol'], ); } + if (outbound['protocol'] == 'loopback') { + final settingsPath = [...path, 'settings']; + final settings = _object(outbound['settings'], settingsPath); + _keys(settings, {'inboundTag'}, settingsPath); + if (settings['inboundTag'] != outbound['tag']) { + throw JsonDiagnostic( + 'Loopback inboundTag must match its outbound tag', + path: [...settingsPath, 'inboundTag'], + ); + } + } final stream = outbound['streamSettings']; if (stream != null) { final streamPath = [...path, 'streamSettings']; diff --git a/lib/service/connect/routing/options.dart b/lib/service/connect/routing/options.dart index 9bea91be3..d1463c66e 100644 --- a/lib/service/connect/routing/options.dart +++ b/lib/service/connect/routing/options.dart @@ -68,7 +68,7 @@ enum NonAddressDnsPolicy { proxy, direct, empty } /// App-owned DNS policy; runtime domain blocks are generated from routing. final class RoutingDnsSettings { - static const defaultAddress = '8.8.8.8'; + static const defaultAddress = 'tcp://8.8.8.8'; static const proxyTag = 'app-dns-proxy'; static const directTag = 'app-dns-direct'; static const defaultResolvers = [RoutingDnsResolver(address: defaultAddress)]; diff --git a/lib/service/shared/json_editing.dart b/lib/service/shared/json_editing.dart index 54d6b809a..1e6044428 100644 --- a/lib/service/shared/json_editing.dart +++ b/lib/service/shared/json_editing.dart @@ -701,6 +701,8 @@ class JsonEditing { .tags('inbounds') .where((tag) => !managed || _publicTag(tag) || tag == 'tunIn'), ...shape.dnsTags().where((tag) => !managed || _publicTag(tag)), + if (kind != JsonEditorKind.customRouting) + ...shape.loopbackTags().where((tag) => !managed || _publicTag(tag)), ]; } if (field == 'protocol') { @@ -730,7 +732,7 @@ class JsonEditing { _matches(parent, ['outbounds', '*'])) { if (kind == JsonEditorKind.customRouting) return const []; return kind == JsonEditorKind.advancedRouting - ? const ['freedom', 'blackhole', 'dns'] + ? const ['freedom', 'blackhole', 'dns', 'loopback'] : const [ 'vless', 'vmess', @@ -1310,6 +1312,14 @@ class _Shape { entry.value, ]; + List loopbackTags() => [ + for (final entry in strings) + if (_matches(entry.path, ['outbounds', '*', 'settings', 'inboundTag']) && + value([...entry.path.sublist(0, 2), 'protocol']) == 'loopback' && + entry.value.isNotEmpty) + entry.value, + ]; + List dnsTags() => [ for (final entry in strings) if ((_matches(entry.path, ['dns', 'tag']) || diff --git a/lib/service/shared/menu/tray/service.dart b/lib/service/shared/menu/tray/service.dart index 3a2adb161..4b945f366 100644 --- a/lib/service/shared/menu/tray/service.dart +++ b/lib/service/shared/menu/tray/service.dart @@ -30,7 +30,7 @@ import 'package:onexray/service/launch/app_startup.dart'; import 'package:onexray/service/shared/notification/service.dart'; import 'package:collection/collection.dart'; import 'package:onexray/core/tools/platform.dart'; -import 'package:window_manager/window_manager.dart'; +import 'package:onexray/service/shared/menu/window/service.dart'; final class TrayService { static final TrayService _singleton = TrayService._internal(); @@ -322,8 +322,7 @@ final class TrayService { await _coordinator.disconnect(); break; case _TrayMenuKey.showApp: - await windowManager.show(); - await windowManager.focus(); + await WindowService().showAndFocus(); break; case _TrayMenuKey.quitApp: if (!_canQuitWithoutStoppingVpn) { diff --git a/lib/service/shared/menu/window/service.dart b/lib/service/shared/menu/window/service.dart index eb3f3c411..30f79fc78 100644 --- a/lib/service/shared/menu/window/service.dart +++ b/lib/service/shared/menu/window/service.dart @@ -1,38 +1,74 @@ import 'dart:ui'; +import 'package:flutter/foundation.dart'; import 'package:macos_window_utils/macos_window_utils.dart'; import 'package:onexray/core/constants/preferences.dart'; +import 'package:onexray/core/desktop_window/native_window.dart'; import 'package:onexray/core/tools/logger.dart'; import 'package:onexray/core/tools/platform.dart'; +import 'package:nativeapi/nativeapi.dart' show Application; import 'package:window_manager/window_manager.dart'; -final class WindowService with WindowListener { +final class WindowService { static final WindowService _singleton = WindowService._internal(); factory WindowService() => _singleton; - WindowService._internal(); + WindowService._internal() + : this._( + resolveDesktopWindow, + Application.instance.setDockIconVisible, + AppPlatform.isDesktop, + AppPlatform.isMacOS, + ); + + WindowService._( + this._readWindow, + this._setDockIconVisible, + this._isDesktop, + this._isMacOS, + ); + + @visibleForTesting + WindowService.forTesting({ + required Window? Function() readWindow, + required bool Function(bool) setDockIconVisible, + bool isDesktop = true, + bool isMacOS = true, + }) : this._(readWindow, setDockIconVisible, isDesktop, isMacOS); + + final Window? Function() _readWindow; + final bool Function(bool) _setDockIconVisible; + final bool _isDesktop; + final bool _isMacOS; + Window? _window; + ListenerId? _listenerId; + final _titlebarHeight = ValueNotifier(null); //========================== var _initialized = false; - var _listening = false; var _hasSidebarMaterial = false; - double? _titlebarHeight; Brightness? _brightness; bool get hasSidebarMaterial => _hasSidebarMaterial; - double? get titlebarHeight => _titlebarHeight; + double? get titlebarHeight => _titlebarHeight.value; + ValueListenable get titlebarChanges => _titlebarHeight; + Window? get nativeWindow => _window; + + Window get _target => _window ??= + _readWindow() ?? + (throw StateError('The desktop application window is unavailable')); /// Configure the window before showing either Setup or the main shell. Future prepare({required Size size, required Size minimumSize}) async { - if (!AppPlatform.isDesktop) return; + if (!_isDesktop) return; _hasSidebarMaterial = false; - _titlebarHeight = null; + _titlebarHeight.value = null; _brightness = null; - await windowManager.ensureInitialized(); - if (AppPlatform.isMacOS) { + final window = _target; + if (_isMacOS) { try { - // Leave NSWindowDelegate ownership with window_manager. + // nativeapi owns the window events; avoid a second NSWindowDelegate. await WindowManipulator.initialize(enableWindowDelegate: false); await WindowManipulator.setMaterial(NSVisualEffectViewMaterial.sidebar); _hasSidebarMaterial = true; @@ -40,38 +76,42 @@ final class WindowService with WindowListener { ygLogger('initialize window material failed: $error\n$stackTrace'); } } - await windowManager.waitUntilReadyToShow( - WindowOptions( - size: size, - minimumSize: minimumSize, - center: true, - titleBarStyle: TitleBarStyle.hidden, - windowButtonVisibility: true, - backgroundColor: _hasSidebarMaterial ? const Color(0x00000000) : null, - ), - ); + window.titleBarStyle = TitleBarStyle.hidden; + window.isWindowControlButtonsVisible = true; + if (_hasSidebarMaterial) { + window.backgroundColor = const Color(0x00000000).toNative(); + } + window.setSize(size.toNative(), false); + window.minimumSize = minimumSize.toNative(); + window.center(); await refreshTitlebarHeight(); - await _listen(); + _listen(); } /// Window geometry changes on resize and fullscreen transitions, not a timer. Future refreshTitlebarHeight() async { - if (!_hasSidebarMaterial) return _titlebarHeight; + if (!_hasSidebarMaterial) return titlebarHeight; try { - _titlebarHeight = await windowManager.isFullScreen() + _titlebarHeight.value = _target.isFullScreen ? 0 : await WindowManipulator.getTitlebarHeight(); } catch (error, stackTrace) { ygLogger('read window titlebar height failed: $error\n$stackTrace'); } - return _titlebarHeight; + return titlebarHeight; } - Future _listen() async { - if (_listening) return; - await windowManager.setPreventClose(true); - windowManager.addListener(this); - _listening = true; + void _listen() { + _listenerId ??= _target.addListener((event) async { + if (event is WindowCloseRequestedEvent) { + event.request.cancel(); + await hide(); + } else if (event is WindowResizedEvent || + event is WindowEnteredFullScreenEvent || + event is WindowExitedFullScreenEvent) { + await refreshTitlebarHeight(); + } + }); } /// Receives the resolved Flutter brightness, including system-theme changes. @@ -89,43 +129,50 @@ final class WindowService with WindowListener { } Future asyncInit() async { - if (!AppPlatform.isDesktop || _initialized) { + if (!_isDesktop || _initialized) { return; } - await _listen(); + _listen(); _initialized = true; final hideDockIcon = await PreferencesKey().readHideDockIcon(); - await windowManager.setSkipTaskbar(hideDockIcon); + await setHideDockIcon(hideDockIcon); + } + + Future setHideDockIcon(bool hidden) async { + if (!_isDesktop) return; + if (_isMacOS) { + if (!_setDockIconVisible(!hidden)) { + throw StateError('Unable to update Dock icon visibility'); + } + } else { + _target.isVisibleInTaskbar = !hidden; + } } Future showAndFocus() async { - if (!AppPlatform.isDesktop) { + if (!_isDesktop) { return; } - await windowManager.show(); - await windowManager.focus(); + final window = _target; + if (window.isMinimized) window.restore(); + window.show(); + window.focus(); } Future hide() async { - if (!AppPlatform.isDesktop) { + if (!_isDesktop) { return; } - await windowManager.hide(); + _target.hide(); } void dispose() { - if (!AppPlatform.isDesktop || !_listening) { - return; + final listenerId = _listenerId; + if (listenerId != null) { + _window?.removeListener(listenerId); + _listenerId = null; } - windowManager.removeListener(this); - _listening = false; _initialized = false; } - - @override - Future onWindowClose() async { - super.onWindowClose(); - await windowManager.hide(); - } } diff --git a/lib/service/shared/xray/runtime_outbounds.dart b/lib/service/shared/xray/runtime_outbounds.dart index 097d2125a..13bf78016 100644 --- a/lib/service/shared/xray/runtime_outbounds.dart +++ b/lib/service/shared/xray/runtime_outbounds.dart @@ -22,6 +22,7 @@ XrayOutbound createDnsOutbound({ tag: tag, protocol: 'dns', settings: { + 'rewriteNetwork': 'tcp', 'rules': [ const XrayOutboundDnsRule(action: 'hijack', qType: '1,28'), XrayOutboundDnsRule(action: returnNonAddress ? 'return' : 'direct'), diff --git a/lib/service/shared/xray/server_dialing.dart b/lib/service/shared/xray/server_dialing.dart index aeec7124b..297d34380 100644 --- a/lib/service/shared/xray/server_dialing.dart +++ b/lib/service/shared/xray/server_dialing.dart @@ -142,7 +142,7 @@ abstract final class ManagedServerDialing { return [ for (final (index, address) in addresses.indexed) XrayDnsServer( - address: address, + address: Uri(scheme: 'tcp', host: address).toString(), tag: dnsTag, domains: List.of(rules), skipFallback: true, diff --git a/lib/service/shared/xray/vpn_config.dart b/lib/service/shared/xray/vpn_config.dart index 05a81530f..5cb383d65 100644 --- a/lib/service/shared/xray/vpn_config.dart +++ b/lib/service/shared/xray/vpn_config.dart @@ -106,7 +106,10 @@ abstract final class VpnConfigAdapter { _checkDefaultDnsInbound(inbounds); dns['servers'] = [ for (final address in [ipv4Address, ?ipv6Address]) - {'address': address, 'tag': defaultDnsTag}, + { + 'address': Uri(scheme: 'tcp', host: address).toString(), + 'tag': defaultDnsTag, + }, ]; config['dns'] = dns; _ensureDnsDirect(outbounds); diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 8b17840d7..5603ae674 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -8,9 +8,7 @@ #include #include -#include #include -#include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) app_links_linux_registrar = @@ -19,13 +17,7 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); file_selector_plugin_register_with_registrar(file_selector_linux_registrar); - g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin"); - screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); - g_autoptr(FlPluginRegistrar) window_manager_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin"); - window_manager_plugin_register_with_registrar(window_manager_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index bdf486a3e..e59f6f9ab 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -5,9 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST app_links_linux file_selector_linux - screen_retriever_linux url_launcher_linux - window_manager ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 1283442be..6500e0443 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -14,11 +14,9 @@ import in_app_review import macos_window_utils import mobile_scanner import package_info_plus -import screen_retriever_macos import share_plus import shared_preferences_foundation import url_launcher_macos -import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) @@ -30,9 +28,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { MacOSWindowUtilsPlugin.register(with: registry.registrar(forPlugin: "MacOSWindowUtilsPlugin")) MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) - ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) - WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index bf289c511..42af9fad5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: android_file_picker - sha256: "03d3dd3cb578ab26b1e528aeea27bf6b976942f5a24e3da82d270369b8e161a8" + sha256: "868183c959e754e915954d8074c20cfa4cfdb4684b35ecc08378437be1a542d8" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.3" app_links: dependency: "direct main" description: @@ -197,10 +197,10 @@ packages: dependency: transitive description: name: cnativeapi - sha256: "15c23cf1713a98ceba66d1068e9470621a47e10429c843884ad70ad3600071be" + sha256: c43703998995e132b0656d2b8a95ed2bedc6b22ccee2c827bcdc075e0330e319 url: "https://pub.dev" source: hosted - version: "0.5.7" + version: "0.5.10" code_assets: dependency: transitive description: @@ -269,10 +269,10 @@ packages: dependency: transitive description: name: cupertino_ui - sha256: e003b87afde18cb396ec15928c7894063d64af04462c68df9a1404c461316dd2 + sha256: "369e39a1c5c030a6212589c3da1809920b1c2cfade7b02aade7695bfa4b88c3a" url: "https://pub.dev" source: hosted - version: "1.1.5" + version: "1.1.6" dart_style: dependency: transitive description: @@ -405,26 +405,26 @@ packages: dependency: "direct main" description: name: file_picker - sha256: "02f2945a7f29535c735f6b283c3c5553810566d3052d730429aec78fe9d06592" + sha256: c64036857780d7125f011d543f89eedcb5df61da473668bace4eee9ab875284d url: "https://pub.dev" source: hosted - version: "13.3.1" + version: "13.3.2" file_picker_darwin: dependency: transitive description: name: file_picker_darwin - sha256: b845af092ca7a3cff0e768029f835157d371a8341750f4158a7ad358143b0e54 + sha256: daeb0219f6279c73eb9c63ae9a566beef04ae13d59b8feba0be2a7f3b258d3d4 url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" file_picker_linux: dependency: transitive description: name: file_picker_linux - sha256: c6562fc7d426db2fb85ea66fe2627953d2709932df5584ceaacf0aa5e39cf4da + sha256: eb00de71b123e91d3c61f389a05b8cc90f9cbe7e4c88da30ebe2f0442b281b61 url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.0.3" file_picker_platform_interface: dependency: transitive description: @@ -633,10 +633,10 @@ packages: dependency: "direct main" description: name: go_router - sha256: f1c2d3c2b0b2096b435d7af033dd4e586fa31f51d988c5985e64495cd926d872 + sha256: "5a44b0bd6131888bda5f770be5db2e08a59063724c6fa534ea4ec7c9fc3f841b" url: "https://pub.dev" source: hosted - version: "18.1.2" + version: "18.1.3" graphs: dependency: transitive description: @@ -705,10 +705,10 @@ packages: dependency: "direct main" description: name: icloud_storage_plus - sha256: ae83dd0cdfd209cea7d6244f5e9fcd6e34eee0aea93bb8fb500e1c3c8e1c3a74 + sha256: "067eecad8435d9c0c384380fc96d446d17bdbc76b7ed83cad24d6967466941f5" url: "https://pub.dev" source: hosted - version: "4.1.2" + version: "4.2.0" image: dependency: "direct main" description: @@ -745,10 +745,10 @@ packages: dependency: transitive description: name: image_picker_ios - sha256: b8f508306db2bf71141aba2de4ec13456338bf01e13f034bd2594f341a5f8eb2 + sha256: f4e5650a81b268af3886bc789216ff6d56bdfa79934f93f1a5a8a303b8f60f77 url: "https://pub.dev" source: hosted - version: "0.8.13+14" + version: "0.8.13+15" image_picker_linux: dependency: transitive description: @@ -833,10 +833,10 @@ packages: dependency: transitive description: name: jni - sha256: d8df6dab6847855512fe44dd9032b0bbe34dda7bf7597e41d30750879779813f + sha256: "2e91e6087291affd10b31b6c872f3e3ac7dc5d93b51b5ba485d930c8f08bde6c" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" jni_flutter: dependency: transitive description: @@ -961,10 +961,10 @@ packages: dependency: "direct main" description: name: material_ui - sha256: feb289df1e5f9ed9522f9fe3d909432a7f7bc76943f16abad09f80a16cd25196 + sha256: a30f5d0625b6b83770f53ca3f7aafd6fd9d147cb3853f54a70668541046fe5d6 url: "https://pub.dev" source: hosted - version: "1.9.0" + version: "1.10.0" meta: dependency: transitive description: @@ -1009,18 +1009,18 @@ packages: dependency: "direct main" description: name: nativeapi - sha256: c6155e67d3565b6b36947c376b420f84f0939ce893a2d2a0cf6108441df6dd2e + sha256: "118045bb549cb6fabad472a3a22a537e7310bbf20097350f5027808b4a5853ce" url: "https://pub.dev" source: hosted - version: "0.5.7" + version: "0.5.10" nativeapi_flutter: dependency: transitive description: name: nativeapi_flutter - sha256: ddde55e8460e744ae30f51dfdbed4bac2d9f53bcafe71acf595539b5eff87871 + sha256: "357a405c69212f5157da1bd3be183a53c2b841d0de0eb51f4a3947913712aec4" url: "https://pub.dev" source: hosted - version: "0.5.7" + version: "0.5.10" nested: dependency: transitive description: @@ -1169,10 +1169,10 @@ packages: dependency: transitive description: name: permission_handler_windows - sha256: caeae01858a0a7d2df67a445ac98e1ad95e55a0e77c73044f4e9b1c8c2289cbd + sha256: f411872409a099a7f4826f80cb98cb903317be890cbf8a2e6d062024cd88e4fb url: "https://pub.dev" source: hosted - version: "0.2.2" + version: "0.2.3" petitparser: dependency: transitive description: @@ -1333,46 +1333,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.3" - screen_retriever: - dependency: transitive - description: - name: screen_retriever - sha256: ace919117a7520c13a50a6259e60c4a0d4cbe98809468792a91b5c5adada2aa6 - url: "https://pub.dev" - source: hosted - version: "0.2.2" - screen_retriever_linux: - dependency: transitive - description: - name: screen_retriever_linux - sha256: "7b52006a5ceae1f3d5af7f77188c3290d6e7d8ded16d99809bea84967c65c257" - url: "https://pub.dev" - source: hosted - version: "0.2.2" - screen_retriever_macos: - dependency: transitive - description: - name: screen_retriever_macos - sha256: a1489b99cce597c45a54b9aae1cd94c8d4705353b7e0bb2457a6e4de44e0ad8a - url: "https://pub.dev" - source: hosted - version: "0.2.2" - screen_retriever_platform_interface: - dependency: transitive - description: - name: screen_retriever_platform_interface - sha256: "94a5535277510a63184ca178ce12a1449bc0b38618879aa1c18bf57369c5064a" - url: "https://pub.dev" - source: hosted - version: "0.2.2" - screen_retriever_windows: - dependency: transitive - description: - name: screen_retriever_windows - sha256: dafc6922b0bfbf1d48cf3ccbf519b4fff47bdcb820da1728ea6db675fecc9324 - url: "https://pub.dev" - source: hosted - version: "0.2.2" serial_csv: dependency: transitive description: @@ -1534,10 +1494,10 @@ packages: dependency: "direct main" description: name: sqlite3 - sha256: "5340531ad17f386ae444873f643f281060b6238cc702edeee6b1e90bc4c6874e" + sha256: b1ec21d306ddbec59a88ae3682ad81728561f7105db78fea0fdd67f06911a519 url: "https://pub.dev" source: hosted - version: "3.8.0" + version: "3.9.0" sqlite3_flutter_libs: dependency: transitive description: @@ -1630,10 +1590,10 @@ packages: dependency: "direct main" description: name: tray_manager - sha256: bc433331d714919a8d40b6575c084be78de56dbe2327c11ce995fca4ec51bbff + sha256: "30c67f4f27a9b2ad56606b369b75256fd9ef25851b66788597d4df3fbbfa536a" url: "https://pub.dev" source: hosted - version: "0.8.2" + version: "0.8.3" tuple: dependency: "direct main" description: @@ -1646,10 +1606,10 @@ packages: dependency: transitive description: name: two_dimensional_scrollables - sha256: "27ab607d5900b9907308367c76a62467c88b3ede3017fcbece3a2419dc51c3de" + sha256: edca2bc2334d3acc9caafb4a5b3cb7506ed6edf369567b73b4b58d95daf6ff8b url: "https://pub.dev" source: hosted - version: "0.5.5" + version: "0.5.6" typed_data: dependency: transitive description: @@ -1742,10 +1702,10 @@ packages: dependency: transitive description: name: vector_graphics - sha256: "4e5f106dfc6f0e4e7efe11603e38c34352d912905320b930128a9b1ad61363f4" + sha256: "82885f6ac633044e7a4e4d8d745724a67aa0dc50af5d8a81ec29379f63c29035" url: "https://pub.dev" source: hosted - version: "1.2.5" + version: "1.2.6" vector_graphics_codec: dependency: transitive description: @@ -1822,10 +1782,10 @@ packages: dependency: "direct main" description: name: window_manager - sha256: "05c231fd7b23d2380f14c5cc10b7b93d60d4fa4a2fb4e0f032de27e44b5560e9" + sha256: bdd6b1a2c1ce50f3bba7ffbaa0d5dc5feb6b6a24fd20269da4de90244f77a471 url: "https://pub.dev" source: hosted - version: "0.5.2" + version: "0.6.0" windows_file_picker: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index c8c1ab17a..d22601d73 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: onexray description: "Cross Platform Xray-core Client" publish_to: "none" -version: 26.11.2+1 +version: 26.11.3+1 environment: sdk: ">=3.13.0 <4.0.0" @@ -13,7 +13,7 @@ dependencies: flutter_localizations: sdk: flutter - material_ui: ^1.9.0 + material_ui: ^1.10.0 #---------- flutter.dev plugin path_provider: ^2.1.6 @@ -21,7 +21,7 @@ dependencies: url_launcher: ^6.3.3 quick_actions: ^1.1.1 home_widget: ^0.10.0 - go_router: ^18.1.2 + go_router: ^18.1.3 image_picker: ^1.2.4 process: ^5.0.6 app_links: ^7.2.2 @@ -48,16 +48,16 @@ dependencies: #---------- third party plugin drift: ^2.35.3 drift_flutter: ^0.3.1 - sqlite3: ^3.8.0 + sqlite3: ^3.9.0 dio: ^5.11.1 uuid: ^4.6.0 permission_handler: ^13.0.2 # qrcode - file_picker: ^13.3.1 + file_picker: ^13.3.2 # Reuse the existing desktop file-selector implementations for path-only saves. file_selector_platform_interface: ^2.7.0 - icloud_storage_plus: ^4.1.2 + icloud_storage_plus: ^4.2.0 saf_stream: ^4.0.3 mobile_scanner: ^7.5.0 image: ^4.12.0 @@ -80,10 +80,10 @@ dependencies: re_highlight: ^0.0.3 # desktop - nativeapi: ^0.5.7 - tray_manager: ^0.8.2 - # 0.6's Windows backend cannot resolve our hidden startup window reliably. - window_manager: ^0.5.2 + nativeapi: ^0.5.10 + tray_manager: ^0.8.3 + # Desktop window handling uses the nativeapi-backed 0.6 API. + window_manager: ^0.6.0 macos_window_utils: ^1.9.1 win32: ^6.4.0 diff --git a/swift/macOS/MainFlutterWindow.swift b/swift/macOS/MainFlutterWindow.swift index 34e1b7a60..8f59be353 100644 --- a/swift/macOS/MainFlutterWindow.swift +++ b/swift/macOS/MainFlutterWindow.swift @@ -1,9 +1,9 @@ import Cocoa import FlutterMacOS -import window_manager class MainFlutterWindow: NSWindow { private var flutterApi: AppFlutterApi? + private var hasHandledInitialOrder = false override func awakeFromNib() { MainActor.assumeIsolated { @@ -33,6 +33,10 @@ class MainFlutterWindow: NSWindow { override public func order(_ place: NSWindow.OrderingMode, relativeTo otherWin: Int) { super.order(place, relativeTo: otherWin) - hiddenWindowAtLaunch() + // Dart startup owns when the prepared window is first shown. + if !hasHandledInitialOrder { + hasHandledInitialOrder = true + setIsVisible(false) + } } } diff --git a/test/core/ffi/base_ffi_api_test.dart b/test/core/ffi/base_ffi_api_test.dart index 75d5cb732..16fe8cf48 100644 --- a/test/core/ffi/base_ffi_api_test.dart +++ b/test/core/ffi/base_ffi_api_test.dart @@ -13,6 +13,7 @@ void main() { test('builds protected desktop Core arguments', () { expect( desktopCoreRunArguments( + setDns: true, dns: '8.8.8.8', interfaceName: 'Ethernet', configPath: r'C:\run\xray.json', @@ -20,6 +21,7 @@ void main() { ), [ 'run', + '-set-dns=true', '-dns', '8.8.8.8:53', '-interface', diff --git a/test/core/ffi/windows_exe_test.dart b/test/core/ffi/windows_exe_test.dart index 2ed7b3469..d54e130e7 100644 --- a/test/core/ffi/windows_exe_test.dart +++ b/test/core/ffi/windows_exe_test.dart @@ -197,45 +197,41 @@ void main() { }, ); - test( - 'EXE starts with current CLI arguments and replaces old inputs', - () async { - final old = File( - p.join(directory.path, 'run', 'core-inputs', 'old.json'), - ); - await old.parent.create(recursive: true); - await old.writeAsString('{}'); - final api = create(); - final starting = api.startVpn(); - await process.launched.future; - expect((await api.readVpnStatus()).status, VpnStatus.connecting); - expect((await starting).status, VpnStatus.connected); - expect(await old.exists(), false); - expect(process.arguments!.take(5), [ - 'run', - '-dns', - '8.8.8.8:53', - '-interface', - 'Ethernet 2', - ]); - expect(process.arguments![5], '-config'); - expect( - await File(process.arguments![6]).readAsString(), - '{"inbounds":[]}', - ); - expect(process.arguments![7], '-error-file'); - expect(process.arguments!.last, '${process.arguments![6]}.error'); - expect(await File(process.arguments!.last).readAsString(), isEmpty); - expect( - await File(p.join(directory.path, 'run', 'core-process.json')).exists(), - isFalse, - ); - // A second App instance discovers and stops the same named process. - final reopened = create(); - expect((await reopened.readVpnStatus()).status, VpnStatus.connected); - expect((await reopened.stopVpn()).status, VpnStatus.disconnected); - }, - ); + test('EXE starts with SetDNS disabled and replaces old inputs', () async { + final old = File(p.join(directory.path, 'run', 'core-inputs', 'old.json')); + await old.parent.create(recursive: true); + await old.writeAsString('{}'); + final api = create(); + final starting = api.startVpn(); + await process.launched.future; + expect((await api.readVpnStatus()).status, VpnStatus.connecting); + expect((await starting).status, VpnStatus.connected); + expect(await old.exists(), false); + final arguments = process.arguments!; + final config = arguments[arguments.indexOf('-config') + 1]; + expect(arguments, [ + 'run', + '-set-dns=false', + '-dns', + '8.8.8.8:53', + '-interface', + 'Ethernet 2', + '-config', + config, + '-error-file', + '$config.error', + ]); + expect(await File(config).readAsString(), '{"inbounds":[]}'); + expect(await File(arguments.last).readAsString(), isEmpty); + expect( + await File(p.join(directory.path, 'run', 'core-process.json')).exists(), + isFalse, + ); + // A second App instance discovers and stops the same named process. + final reopened = create(); + expect((await reopened.readVpnStatus()).status, VpnStatus.connected); + expect((await reopened.stopVpn()).status, VpnStatus.disconnected); + }); test( 'UAC cancellation fails without leaving a running/connecting state', diff --git a/test/core/ffi/windows_msix_test.dart b/test/core/ffi/windows_msix_test.dart index 4cd0d6b4c..68165fb0f 100644 --- a/test/core/ffi/windows_msix_test.dart +++ b/test/core/ffi/windows_msix_test.dart @@ -165,6 +165,18 @@ void main() { expect(process['executableRelativePath'], 'OneXrayCore.exe'); arguments = (process['arguments'] as List).cast(); final config = arguments![arguments!.indexOf('-config') + 1]; + expect(arguments, [ + 'run', + '-set-dns=true', + '-dns', + '8.8.8.8:53', + '-interface', + 'Ethernet 2', + '-config', + config, + '-error-file', + '$config.error', + ]); errorFile = File('$config.error'); // The GUI creates the file before dispatching the managed process. expect(await errorFile!.readAsString(), isEmpty); diff --git a/test/pages/advanced/xray/dialing_test.dart b/test/pages/advanced/xray/dialing_test.dart index 58e31ed26..c9c98d2a5 100644 --- a/test/pages/advanced/xray/dialing_test.dart +++ b/test/pages/advanced/xray/dialing_test.dart @@ -276,7 +276,7 @@ void main() { createController: () => runtime, onGeodata: (_) {}, onUpdates: (_) {}, - onSpeedTest: (_) {}, + onSpeedTest: (_) async {}, onConfig: (_, _) {}, onServerDialing: (context) { opened = true; diff --git a/test/pages/advanced/xray/lan_proxy_test.dart b/test/pages/advanced/xray/lan_proxy_test.dart index 6355c4512..b5f4318cf 100644 --- a/test/pages/advanced/xray/lan_proxy_test.dart +++ b/test/pages/advanced/xray/lan_proxy_test.dart @@ -201,7 +201,7 @@ void main() { createController: () => controller, onGeodata: (_) {}, onUpdates: (_) {}, - onSpeedTest: (_) {}, + onSpeedTest: (_) async {}, onConfig: (_, _) {}, onLanProxy: (_) async {}, ), diff --git a/test/pages/advanced/xray/log_test.dart b/test/pages/advanced/xray/log_test.dart index 11015365a..7a9ea0db1 100644 --- a/test/pages/advanced/xray/log_test.dart +++ b/test/pages/advanced/xray/log_test.dart @@ -61,7 +61,7 @@ void main() { onDiagnostics: (_) {}, onGeodata: (_) {}, onUpdates: (_) {}, - onSpeedTest: (_) {}, + onSpeedTest: (_) async {}, onConfig: (_, _) {}, onLogSettings: (_) async => null, ), diff --git a/test/pages/advanced/xray/settings_ui_test.dart b/test/pages/advanced/xray/settings_ui_test.dart index 839c8e732..355ed9073 100644 --- a/test/pages/advanced/xray/settings_ui_test.dart +++ b/test/pages/advanced/xray/settings_ui_test.dart @@ -1,16 +1,20 @@ import 'dart:io'; import 'package:drift/native.dart'; +import 'package:flutter/services.dart'; import 'package:material_ui/material_ui.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; import 'package:onexray/core/constants/preferences.dart'; import 'package:onexray/core/db/database/database.dart'; +import 'package:onexray/core/pigeon/messages.g.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/service/settings/language/locale.dart'; +import 'package:onexray/pages/advanced/xray/controller.dart'; import 'package:onexray/pages/advanced/xray/log/controller.dart'; import 'package:onexray/pages/advanced/xray/log/params.dart'; +import 'package:onexray/pages/advanced/xray/page.dart'; import 'package:onexray/pages/advanced/xray/ping/page.dart'; import 'package:onexray/pages/advanced/xray/ping/controller.dart'; import 'package:onexray/pages/shared/widgets/page_action_bar.dart'; @@ -164,68 +168,159 @@ void main() { }); for (final save in [false, true]) { - testWidgets('ping URL ${save ? 'saves' : 'cancels'} its draft', ( - tester, - ) async { - await (PingState() - ..url = PingUrl.custom - ..customUrl = 'https://example.com/old' - ..timeout = 7) - .saveToPreferences(); - final router = GoRouter( - routes: [ - GoRoute( - path: '/', - builder: (context, _) => Scaffold( - body: TextButton( - onPressed: () => context.push('/ping'), - child: const Text('Open ping settings'), + testWidgets( + 'ping settings ${save ? 'save and refresh' : 'cancel and retain'} the Xray summary', + (tester) async { + await tester.binding.setSurfaceSize(const Size(1160, 900)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await (PingState() + ..url = PingUrl.google + ..customUrl = 'https://example.com/old' + ..timeout = 5) + .saveToPreferences(); + const systemExtensionChannel = BasicMessageChannel( + 'dev.flutter.pigeon.onexray.BridgeHostApi.useSystemExtension', + BridgeHostApi.pigeonChannelCodec, + ); + final messenger = tester.binding.defaultBinaryMessenger; + messenger.setMockDecodedMessageHandler( + systemExtensionChannel, + (_) async => [false], + ); + addTearDown( + () => messenger.setMockDecodedMessageHandler( + systemExtensionChannel, + null, + ), + ); + final db = AppDatabase.forTesting(NativeDatabase.memory()); + final coordinator = ConnectionCoordinator( + database: db, + readRuntime: () async => null, + ); + late XrayRuntimeController root; + await tester.runAsync(() async { + root = XrayRuntimeController(coordinator: coordinator); + await root.stream.firstWhere((state) => !state.loading); + }); + addTearDown( + () => tester.runAsync(() async { + await root.close(); + coordinator.dispose(); + await db.close(); + }), + ); + expect(root.failed, isFalse); + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (context, _) => XrayRuntimePage( + createController: () => root, + onGeodata: (_) {}, + onUpdates: (_) {}, + onSpeedTest: (context) => context.push('/ping'), + onConfig: (_, _) {}, ), ), + GoRoute(path: '/ping', builder: (_, _) => const PingPage()), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget( + MaterialApp.router( + theme: AppTheme.light, + locale: const Locale('en'), + localizationsDelegates: AppLocalePolicy.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + routerConfig: router, + builder: (_, child) => ShadTheme( + data: AppTheme.shad(Brightness.light), + child: ShadToaster(child: child!), + ), ), - GoRoute(path: '/ping', builder: (_, _) => const PingPage()), - ], - ); - addTearDown(router.dispose); - await tester.pumpWidget( - MaterialApp.router( - theme: AppTheme.light, - locale: const Locale('en'), - localizationsDelegates: AppLocalePolicy.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - routerConfig: router, - builder: (_, child) => ShadTheme( - data: AppTheme.shad(Brightness.light), - child: ShadToaster(child: child!), - ), - ), - ); - await tester.tap(find.text('Open ping settings')); - await tester.pumpAndSettle(); - await tester.ensureVisible(find.byType(TextField)); - await tester.enterText(find.byType(TextField), 'https://example.com/new'); - await tester.pumpAndSettle(); - final storedDraft = PingState(); - await storedDraft.readFromPreferences(); - expect(storedDraft.customUrl, 'https://example.com/old'); + ); + await tester.pumpAndSettle(); + final l = AppLocalizations.of( + tester.element(find.byType(XrayRuntimePage)), + )!; + final originalSummary = '${l.prototypeSeconds(5)} · Google'; + final savedSummary = + '${l.prototypeSeconds(3)} · ${l.prototypeCustomUrl}'; + expect(find.text(originalSummary), findsOneWidget); + await tester.ensureVisible(find.text(l.prototypeSpeedTest)); + await tester.tap(find.text(l.prototypeSpeedTest)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('ping-url-preset'))); + await tester.pumpAndSettle(); + await tester.tap(find.text(l.prototypeCustom).last); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('ping-timeout'))); + await tester.pumpAndSettle(); + await tester.tap(find.text(l.prototypeSeconds(3)).last); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.byType(TextField)); + await tester.enterText( + find.byType(TextField), + 'https://example.com/new', + ); + await tester.pumpAndSettle(); + final storedDraft = PingState(); + await storedDraft.readFromPreferences(); + expect(storedDraft.customUrl, 'https://example.com/old'); + expect(storedDraft.url, PingUrl.google); + expect(storedDraft.timeout, 5); - await tester.tap(find.text(save ? 'Save' : 'Cancel')); - await tester.pumpAndSettle(); - expect(find.byType(PingPage), findsNothing); - final restored = PingState(); - await restored.readFromPreferences(); - expect(restored.customUrl, 'https://example.com/${save ? 'new' : 'old'}'); - expect(restored.url, PingUrl.custom); - expect(restored.timeout, 7); + await tester.tap(find.text(save ? l.prototypeSave : l.prototypeCancel)); + await tester.pumpAndSettle(); + expect(find.byType(PingPage), findsNothing); + final restored = PingState(); + await restored.readFromPreferences(); + expect( + restored.customUrl, + 'https://example.com/${save ? 'new' : 'old'}', + ); + expect(restored.url, save ? PingUrl.custom : PingUrl.google); + expect(restored.timeout, save ? 3 : 5); + expect( + find.text(save ? savedSummary : originalSummary), + findsOneWidget, + ); - await tester.tap(find.text('Open ping settings')); - await tester.pumpAndSettle(); - expect( - tester.widget(find.byType(TextField)).controller!.text, - restored.customUrl, - ); - expect(tester.takeException(), isNull); - }); + await tester.tap(find.text(l.prototypeSpeedTest)); + await tester.pumpAndSettle(); + expect( + tester + .widget>( + find.byKey(const ValueKey('ping-url-preset')), + ) + .value, + restored.url.name, + ); + expect( + tester + .widget>( + find.byKey(const ValueKey('ping-timeout')), + ) + .value, + restored.timeout, + ); + if (!save) { + await tester.tap(find.byKey(const ValueKey('ping-url-preset'))); + await tester.pumpAndSettle(); + await tester.tap(find.text(l.prototypeCustom).last); + await tester.pumpAndSettle(); + } + expect( + tester.widget(find.byType(TextField)).controller!.text, + restored.customUrl, + ); + expect(tester.takeException(), isNull); + await tester.runAsync(root.close); + await tester.pumpWidget(const SizedBox()); + await tester.pumpAndSettle(); + }, + ); } for (final locale in AppLocalizations.supportedLocales) { diff --git a/test/pages/connect/routing/custom_ui_test.dart b/test/pages/connect/routing/custom_ui_test.dart index 0089674e3..8b55a22f2 100644 --- a/test/pages/connect/routing/custom_ui_test.dart +++ b/test/pages/connect/routing/custom_ui_test.dart @@ -396,7 +396,7 @@ void main() { expect(controller.profileState.directDnsAddress, '1.1.1.1'); final storedDns = jsonDecode(controller.previewState!.encode())['dns']; expect(storedDns['servers'], [ - {'tag': 'app-dns-proxy', 'address': '8.8.8.8'}, + {'tag': 'app-dns-proxy', 'address': 'tcp://8.8.8.8'}, {'tag': 'app-dns-direct', 'address': '1.1.1.1'}, {'tag': 'app-dns-fake', 'address': 'fakedns'}, ]); diff --git a/test/pages/connect/routing/options_form_test.dart b/test/pages/connect/routing/options_form_test.dart index 8e6ed3c6e..2f1f26030 100644 --- a/test/pages/connect/routing/options_form_test.dart +++ b/test/pages/connect/routing/options_form_test.dart @@ -172,7 +172,7 @@ void main() { await tester.pumpAndSettle(); expect( value.effectiveProxyResolvers.map((resolver) => resolver.address), - ['8.8.8.8', 'https://dns.example/dns-query', '9.9.9.9'], + ['tcp://8.8.8.8', 'https://dns.example/dns-query', '9.9.9.9'], ); final delete = find.descendant( of: extraFallback, @@ -196,9 +196,9 @@ void main() { final restored = RoutingDnsSettings.fromJson(saved!); expect( restored.effectiveProxyResolvers.map((resolver) => resolver.address), - ['8.8.8.8', 'https://dns.example/dns-query'], + ['tcp://8.8.8.8', 'https://dns.example/dns-query'], ); - expect(restored.directAddress, '8.8.8.8'); + expect(restored.directAddress, 'tcp://8.8.8.8'); expect(restored.sameExitOnly, true); expect(restored.disableCache, true); expect(find.byType(TextField), findsNWidgets(3)); diff --git a/test/pages/main/desktop_window_test.dart b/test/pages/main/desktop_window_test.dart index 69bc60c38..3a79b370b 100644 --- a/test/pages/main/desktop_window_test.dart +++ b/test/pages/main/desktop_window_test.dart @@ -1,4 +1,3 @@ -import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:material_ui/material_ui.dart'; import 'package:onexray/pages/main/desktop_window.dart'; @@ -9,19 +8,6 @@ import 'package:window_manager/window_manager.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - setUp(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( - const MethodChannel('window_manager'), - (call) async => call.method == 'isMaximized' ? false : null, - ); - }); - - tearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(const MethodChannel('window_manager'), null); - }); - for (final platform in [TargetPlatform.android, TargetPlatform.iOS]) { testWidgets('$platform keeps mobile layout unchanged', (tester) async { await tester.pumpWidget( diff --git a/test/service/advanced/local_api/configuration_test.dart b/test/service/advanced/local_api/configuration_test.dart index 7248d523a..c17bd2909 100644 --- a/test/service/advanced/local_api/configuration_test.dart +++ b/test/service/advanced/local_api/configuration_test.dart @@ -523,7 +523,7 @@ void main() { expect(compiled['env']['xray.location.asset'], VpnConstants.datDir); expect(compiled.containsKey('geodata'), false); if (kind == 'advanced-routing') { - expect(compiled['dns']['servers'].single['address'], '8.8.8.8'); + expect(compiled['dns']['servers'].single['address'], 'tcp://8.8.8.8'); } } }, diff --git a/test/service/connect/compiler_test.dart b/test/service/connect/compiler_test.dart index cc8472a44..6c357435b 100644 --- a/test/service/connect/compiler_test.dart +++ b/test/service/connect/compiler_test.dart @@ -745,7 +745,7 @@ void main() { {'ipPool': '198.19.0.0/16', 'poolSize': 32768}, {'ipPool': '2001:2::/48', 'poolSize': 32768}, ]); - expect(servers.last['address'], '8.8.8.8'); + expect(servers.last['address'], 'tcp://8.8.8.8'); final direct = servers.firstWhere( (server) => server['tag'] == 'app-dns-direct', ); @@ -902,7 +902,7 @@ void main() { ); final servers = compiled.config['dns']['servers'] as List; expect(servers.last['tag'], 'app-dns-proxy'); - expect(servers.last['address'], '8.8.8.8'); + expect(servers.last['address'], 'tcp://8.8.8.8'); expect(servers.last['queryStrategy'], 'UseIPv4'); expect( servers.length, @@ -934,7 +934,7 @@ void main() { options: options(), ).config['dns']['servers'] as List; - expect(disabled.last['address'], '8.8.8.8'); + expect(disabled.last['address'], 'tcp://8.8.8.8'); expect(disabled.last['domains'], isNull); }); @@ -1502,7 +1502,10 @@ void main() { 'ruleTag': 'app-custom-0', }); final servers = plan.config['dns']['servers'] as List; - expect(servers.map((server) => server['address']), ['8.8.8.8', '8.8.8.8']); + expect(servers.map((server) => server['address']), [ + 'tcp://8.8.8.8', + 'tcp://8.8.8.8', + ]); expect(servers.first['domains'], ['domain:direct-only.test']); expect(servers.first['skipFallback'], true); expect(template.encode(), original); @@ -2036,7 +2039,7 @@ void main() { }); expect(config['dns']['servers'], [ { - 'address': '8.8.8.8', + 'address': 'tcp://8.8.8.8', 'tag': ConnectionCompiler.dnsProxy, 'queryStrategy': ipv6 ? 'UseIP' : 'UseIPv4', }, diff --git a/test/service/connect/full_config_runtime_test.dart b/test/service/connect/full_config_runtime_test.dart index 96049cc1d..a0e235440 100644 --- a/test/service/connect/full_config_runtime_test.dart +++ b/test/service/connect/full_config_runtime_test.dart @@ -152,7 +152,7 @@ void main() { final runtime = compiled.config; _fixture('${provider ? 'provider' : 'raw'}-$dnsCase', compiled); expect(runtime['dns']['servers'], [ - for (final address in [_ipv4, _ipv6]) + for (final address in ['tcp://$_ipv4', 'tcp://[$_ipv6]']) { 'address': address, 'tag': VpnConfigAdapter.defaultDnsTag, @@ -357,8 +357,8 @@ void main() { final config = compiled.config; final servers = config['dns']['servers'] as List; expect(servers.map((value) => value['address']).toList(), [ - _ipv4, - if (ipv6) _ipv6, + 'tcp://$_ipv4', + if (ipv6) 'tcp://[$_ipv6]', ]); expect( servers.every( diff --git a/test/service/connect/managed_dialing_compiler_test.dart b/test/service/connect/managed_dialing_compiler_test.dart index 9674fe161..cdc8fc139 100644 --- a/test/service/connect/managed_dialing_compiler_test.dart +++ b/test/service/connect/managed_dialing_compiler_test.dart @@ -871,8 +871,8 @@ void _expectBootstrap( .where((server) => server['tag'] == 'app-dns-bootstrap') .toList(); expect(bootstrap.map((server) => server['address']), [ - '8.8.8.8', - if (ipv6) '2001:4860:4860::8888', + 'tcp://8.8.8.8', + if (ipv6) 'tcp://[2001:4860:4860::8888]', ]); expect(servers.take(bootstrap.length), bootstrap); for (final (index, server) in bootstrap.indexed) { diff --git a/test/service/connect/non_address_dns_test.dart b/test/service/connect/non_address_dns_test.dart index 95f3a30e3..dede48297 100644 --- a/test/service/connect/non_address_dns_test.dart +++ b/test/service/connect/non_address_dns_test.dart @@ -50,6 +50,7 @@ void main() { final forwarding = outbounds.singleWhere( (outbound) => outbound['tag'] == 'dnsOut', ); + expect(forwarding['settings']['rewriteNetwork'], 'tcp'); expect(forwarding['settings']['rules'], [ {'action': 'hijack', 'qType': '1,28'}, { diff --git a/test/service/connect/routing/custom/advanced_dns_test.dart b/test/service/connect/routing/custom/advanced_dns_test.dart new file mode 100644 index 000000000..cacc81fdf --- /dev/null +++ b/test/service/connect/routing/custom/advanced_dns_test.dart @@ -0,0 +1,167 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:onexray/service/connect/compiler.dart'; +import 'package:onexray/service/connect/routing/custom/advanced.dart'; +import 'package:onexray/service/connect/settings.dart'; + +import '../../compiler_test.dart' show catalog, options; +import '../../tcp_dns_test.dart' show httpNode; + +void main() { + for (final count in [1, 2, 3]) { + for (final strategy in ['roundRobin', 'leastPing']) { + test('new template forwards DNS through $count-entry $strategy', () { + final source = jsonDecode( + AdvancedRoutingProfile.defaultText, + ) as Map; + (source['outbounds'] as List).insertAll(0, [ + for (var i = 1; i < count; i++) {}, + ]); + source['routing']['balancers'] = [ + { + 'tag': 'proxy', + 'strategy': {'type': strategy}, + }, + ]; + source['observatory'] = { + 'probeURL': 'http://198.18.0.2:58162/health', + 'probeInterval': '10s', + }; + final profile = AdvancedRoutingDocument.fromJson( + source, + name: 'Default advanced DNS', + ).state; + final before = profile.encode(); + final plan = ConnectionCompiler.compile( + settings: ConnectionSettings(trafficMode: TrafficMode.custom), + custom: profile, + entries: [for (var i = 1; i <= count; i++) httpNode(i)], + regions: catalog, + options: options(platform: ConnectionPlatform.ios), + ); + final config = plan.config; + final outbounds = (config['outbounds'] as List).cast(); + final dns = outbounds.singleWhere((o) => o['tag'] == 'dnsOut'); + expect(dns['settings']['rewriteNetwork'], 'tcp'); + expect(dns['settings']['rules'], [ + {'action': 'hijack', 'qType': '1,28'}, + {'action': 'direct'}, + ]); + expect(dns['streamSettings']['sockopt']['dialerProxy'], 'dns-forward'); + final helper = outbounds.singleWhere((o) => o['tag'] == 'dns-forward'); + expect(helper['protocol'], 'loopback'); + expect(helper['settings'], {'inboundTag': 'dns-forward'}); + expect(config['routing']['rules'].first, { + 'ruleTag': 'Forward DNS', + 'inboundTag': ['dns-forward'], + 'balancerTag': 'proxy', + }); + final balancer = config['routing']['balancers'].single; + expect(balancer['selector'], [ + for (var i = 0; i < count; i++) 'app-entry-$i', + ]); + expect( + balancer['strategy']['type'], + count == 1 ? 'roundRobin' : strategy, + ); + expect(plan.nodeTags.length, count); + expect(profile.encode(), before); + const target = String.fromEnvironment('P2_FIXTURES'); + if (target.isNotEmpty) { + Directory(target).createSync(recursive: true); + File('$target/advanced-$strategy-$count.json') + .writeAsStringSync(plan.xrayJson); + } + }); + } + } + + test( + 'loopback helpers keep virtual inbound tags within their own namespace', + () { + for (final settings in [ + {}, + {'inboundTag': null}, + {'inboundTag': 42}, + {'inboundTag': ''}, + {'inboundTag': 'proxy'}, + {'inboundTag': 'direct'}, + {'inboundTag': 'block'}, + {'inboundTag': 'tunIn'}, + {'inboundTag': 'app-entry-0'}, + {'inboundTag': 'app-exit-1'}, + {'inboundTag': 'other-helper'}, + {'inboundTag': 'dns-forward', 'unexpected': true}, + ]) { + expect( + () => AdvancedRoutingDocument.fromJson({ + 'outbounds': [ + {}, + { + 'tag': 'dns-forward', + 'protocol': 'loopback', + 'settings': settings, + }, + ], + }, name: 'Invalid helper'), + throwsFormatException, + reason: '$settings', + ); + } + }, + ); + + test('existing advanced drop templates keep their DNS graph unchanged', () { + final source = { + 'outbounds': [ + {}, + { + 'tag': 'old-dns', + 'protocol': 'dns', + 'settings': { + 'rules': [ + {'action': 'hijack', 'qType': '1,28'}, + {'action': 'drop'}, + ], + }, + }, + ], + 'dns': { + 'servers': ['8.8.8.8'], + }, + 'routing': { + 'rules': [ + { + 'inboundTag': ['tunIn'], + 'port': 53, + 'outboundTag': 'old-dns', + }, + ], + }, + }; + final before = jsonEncode(source); + final profile = AdvancedRoutingDocument.fromJson(source, name: 'Old').state; + expect(profile.toJson(), source); + final plan = ConnectionCompiler.compile( + settings: ConnectionSettings(trafficMode: TrafficMode.custom), + custom: profile, + entries: [httpNode()], + regions: catalog, + options: options(), + ).config; + expect(plan['dns']['servers'], ['8.8.8.8']); + expect( + (plan['outbounds'] as List).singleWhere((o) => o['tag'] == 'old-dns'), + (source['outbounds'] as List).last, + ); + expect( + (plan['outbounds'] as List).any((o) => o['protocol'] == 'loopback'), + isFalse, + ); + expect(plan['routing']['rules'], source['routing']['rules']); + expect(profile.toJson(), source); + expect(jsonEncode(source), before); + }); +} diff --git a/test/service/connect/routing/custom/service_test.dart b/test/service/connect/routing/custom/service_test.dart index e36317293..46cf1f579 100644 --- a/test/service/connect/routing/custom/service_test.dart +++ b/test/service/connect/routing/custom/service_test.dart @@ -129,6 +129,7 @@ void main() { 'tag': 'dnsOut', 'protocol': 'dns', 'settings': { + 'rewriteNetwork': 'tcp', 'rules': [ {'action': 'hijack', 'qType': '1,28'}, {'action': 'direct'}, @@ -224,7 +225,7 @@ void main() { 'disableCache': false, 'disableFallbackIfMatch': false, 'servers': [ - {'tag': 'app-dns-proxy', 'address': '8.8.8.8'}, + {'tag': 'app-dns-proxy', 'address': 'tcp://8.8.8.8'}, {'tag': 'app-dns-direct', 'address': '9.9.9.9'}, ], }); diff --git a/test/service/connect/routing/custom/state_test.dart b/test/service/connect/routing/custom/state_test.dart index d54c7d299..d6aba53ff 100644 --- a/test/service/connect/routing/custom/state_test.dart +++ b/test/service/connect/routing/custom/state_test.dart @@ -403,7 +403,7 @@ void main() { RoutingProfileDocument.parse(jsonEncode(_document())) .state .directDnsAddress, - '8.8.8.8', + 'tcp://8.8.8.8', ); for (final (tag, addresses) in [ ('app-dns-proxy', ['1.1.1.1']), @@ -778,13 +778,13 @@ void _rejectAssets(Object? assets) => _reject({ }); Map _storedDns({ - String direct = '8.8.8.8', + String direct = 'tcp://8.8.8.8', bool fakeDns = false, }) => { 'disableCache': false, 'disableFallbackIfMatch': false, 'servers': [ - {'tag': 'app-dns-proxy', 'address': '8.8.8.8'}, + {'tag': 'app-dns-proxy', 'address': 'tcp://8.8.8.8'}, {'tag': 'app-dns-direct', 'address': direct}, if (fakeDns) {'tag': 'app-dns-fake', 'address': 'fakedns'}, ], diff --git a/test/service/connect/routing/smart/editor_test.dart b/test/service/connect/routing/smart/editor_test.dart index 15daff05c..6a10b00af 100644 --- a/test/service/connect/routing/smart/editor_test.dart +++ b/test/service/connect/routing/smart/editor_test.dart @@ -107,9 +107,12 @@ void main() { ); test( - 'Smart keeps legacy defaults and reconnects only for effective DNS changes', + 'Smart uses current defaults and reconnects only for effective DNS changes', () { - expect(SmartRoutingSettings.fromJson({}).directDnsAddress, '8.8.8.8'); + expect( + SmartRoutingSettings.fromJson({}).directDnsAddress, + 'tcp://8.8.8.8', + ); for (final enabled in [false, true]) { final before = SmartRoutingSettings( directDns: enabled, diff --git a/test/service/connect/runtime_network_policy_test.dart b/test/service/connect/runtime_network_policy_test.dart index b48afd618..a336fc30f 100644 --- a/test/service/connect/runtime_network_policy_test.dart +++ b/test/service/connect/runtime_network_policy_test.dart @@ -127,7 +127,7 @@ void main() { expect(config['outbounds'].first, source['outbounds']!.single); expect(config['dns']['hosts'], isNull); expect(config['dns']['queryStrategy'], 'UseIPv4'); - expect(config['dns']['servers'].single['address'], '8.8.8.8'); + expect(config['dns']['servers'].single['address'], 'tcp://8.8.8.8'); expect(config['routing']['rules'], everyElement(contains('inboundTag'))); }); diff --git a/test/service/connect/server_dialing_test.dart b/test/service/connect/server_dialing_test.dart index 4ceb44c12..88413195e 100644 --- a/test/service/connect/server_dialing_test.dart +++ b/test/service/connect/server_dialing_test.dart @@ -685,14 +685,14 @@ void main() { ); expect(servers.map((server) => server.toJson()), [ { - 'address': '192.0.2.53', + 'address': 'tcp://192.0.2.53', 'tag': 'app-dns-bootstrap', 'domains': ['full:entry.example', 'full:download.example'], 'skipFallback': true, 'queryStrategy': 'UseIP', }, { - 'address': '2001:db8::53', + 'address': 'tcp://[2001:db8::53]', 'tag': 'app-dns-bootstrap', 'domains': ['full:entry.example', 'full:download.example'], 'skipFallback': true, @@ -714,7 +714,7 @@ void main() { ipv6: false, ); expect(servers.single.toJson(), { - 'address': '192.0.2.53', + 'address': 'tcp://192.0.2.53', 'tag': 'app-dns-bootstrap', 'domains': ['full:entry.example'], 'skipFallback': true, diff --git a/test/service/connect/tcp_dns_test.dart b/test/service/connect/tcp_dns_test.dart new file mode 100644 index 000000000..300c1f594 --- /dev/null +++ b/test/service/connect/tcp_dns_test.dart @@ -0,0 +1,192 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:onexray/core/model/xray_json.dart'; +import 'package:onexray/service/connect/compiler.dart'; +import 'package:onexray/service/connect/routing/custom/advanced.dart'; +import 'package:onexray/service/connect/routing/custom/state.dart'; +import 'package:onexray/service/connect/routing/options.dart'; +import 'package:onexray/service/connect/settings.dart'; + +import 'compiler_test.dart' show catalog, options; + +ResolvedServer httpNode([int id = 266]) => ResolvedServer( + id: id, + sourceId: 0, + outbound: { + 'tag': 'HTTPS proxy', + 'protocol': 'http', + 'settings': { + 'servers': [ + {'address': '192.0.2.1', 'port': 443}, + ], + }, + 'streamSettings': {'security': 'tls'}, + }, +); + +void main() { + test('HTTP proxy uses TCP for default resolvers and forwarded DNS', () { + for (final mode in TrafficMode.values) { + final plan = ConnectionCompiler.compile( + settings: ConnectionSettings( + trafficMode: mode, + smart: SmartRoutingSettings( + directRegions: [], + directPrivate: false, + directApple: false, + directWindows: false, + ), + ), + custom: mode == TrafficMode.custom + ? RoutingProfileState(name: 'Default DNS') + : null, + entries: [httpNode()], + regions: catalog, + options: options(platform: ConnectionPlatform.ios), + ); + final config = plan.config; + expect( + (config['dns']['servers'] as List).map((server) => server['address']), + everyElement('tcp://8.8.8.8'), + ); + final forwarding = (config['outbounds'] as List).singleWhere( + (outbound) => outbound['tag'] == 'dnsOut', + ); + expect(forwarding['settings'], { + 'rewriteNetwork': 'tcp', + 'rules': [ + {'action': 'hijack', 'qType': '1,28'}, + {'action': 'direct'}, + ], + }); + expect( + forwarding['streamSettings']['sockopt']['dialerProxy'], + mode == TrafficMode.allVpn ? 'app-entry-0' : 'app-dns-forward', + ); + const target = String.fromEnvironment('P2_FIXTURES'); + if (target.isNotEmpty) { + Directory(target).createSync(recursive: true); + File('$target/default-http-${mode.name}.json') + .writeAsStringSync(plan.xrayJson); + } + } + }); + + test( + 'new advanced template forwards other DNS over TCP through a helper', + () { + final template = jsonDecode(AdvancedRoutingProfile.defaultText); + expect(template['dns']['servers'].single['address'], 'tcp://8.8.8.8'); + final forwarding = (template['outbounds'] as List).singleWhere( + (outbound) => outbound['tag'] == 'dnsOut', + ); + expect(forwarding['settings']['rewriteNetwork'], 'tcp'); + expect(forwarding['settings']['rules'].last, {'action': 'direct'}); + expect( + forwarding['streamSettings']['sockopt']['dialerProxy'], + 'dns-forward', + ); + }, + ); + + test( + 'stored ordinary resolver addresses retain their explicit transport', + () { + final stored = { + 'proxyResolvers': [ + {'address': '8.8.8.8'}, + {'address': 'https://1.1.1.1/dns-query'}, + ], + 'directResolvers': [ + {'address': '192.168.1.1'}, + ], + 'disableCache': false, + 'sameExitOnly': false, + 'nonAddressPolicy': 'proxy', + }; + final before = jsonEncode(stored); + final settings = RoutingDnsSettings.fromJson(stored); + expect(settings.toJson(), stored); + final oldSmart = SmartRoutingSettings.fromJson({'dns': stored}); + expect(oldSmart.dns.toJson(), stored); + final custom = RoutingProfileState(name: 'Existing', dns: settings); + final document = custom.toJson(); + final restored = RoutingProfileState.fromXrayJson( + name: 'Existing', + xrayJson: XrayJson.fromJson(document), + ); + expect(restored.toJson(), document); + for (final mode in [TrafficMode.smart, TrafficMode.custom]) { + final compiled = ConnectionCompiler.compile( + settings: ConnectionSettings(trafficMode: mode, smart: oldSmart), + custom: mode == TrafficMode.custom ? restored : null, + entries: [httpNode()], + regions: catalog, + options: options(), + ).config; + final addresses = (compiled['dns']['servers'] as List) + .map((server) => server['address']) + .toSet(); + expect(addresses, contains('8.8.8.8')); + expect(addresses, contains('https://1.1.1.1/dns-query')); + expect(addresses, isNot(contains('tcp://8.8.8.8'))); + } + expect(jsonEncode(stored), before); + }, + ); + + test('Raw and provider DNS definitions are not rewritten', () { + final source = { + 'dns': { + 'servers': ['8.8.8.8'], + }, + 'outbounds': [ + {'tag': 'proxy', 'protocol': 'freedom'}, + { + 'tag': 'user-dns', + 'protocol': 'dns', + 'settings': { + 'rewriteNetwork': 'udp', + 'rewriteAddress': '8.8.4.4', + 'rules': [ + {'action': 'direct'}, + ], + }, + }, + ], + 'routing': { + 'rules': [ + {'port': '53', 'outboundTag': 'user-dns'}, + ], + }, + }; + final before = jsonEncode(source); + for (final provider in [false, true]) { + final plan = ConnectionCompiler.compile( + settings: ConnectionSettings(expert: !provider), + entries: const [], + raw: provider ? null : source, + profile: provider + ? ResolvedProfile( + id: 266, + sourceId: 1, + name: 'Provider', + config: source, + ) + : null, + regions: catalog, + options: options(), + ).config; + expect(plan['dns']['servers'], source['dns']['servers']); + expect( + (plan['outbounds'] as List).singleWhere( + (outbound) => outbound['tag'] == 'user-dns', + ), + (source['outbounds'] as List).last, + ); + } + expect(jsonEncode(source), before); + }); +} diff --git a/test/service/shared/json_editing_test.dart b/test/service/shared/json_editing_test.dart index b7e58beed..2ab41b33f 100644 --- a/test/service/shared/json_editing_test.dart +++ b/test/service/shared/json_editing_test.dart @@ -25,6 +25,26 @@ List labels(String marked, JsonEditorKind kind) => completions(marked, kind).map((value) => value.label).toList(); void main() { + test('advanced loopback completions include its virtual inbound tag', () { + expect( + labels( + '{"outbounds":[{}, {"protocol":"lo|"}]}', + JsonEditorKind.advancedRouting, + ), + ['loopback'], + ); + const source = + '{"outbounds":[{}, {"tag":"dns-forward","protocol":"loopback","settings":{"inboundTag":"dns-forward"}}],"routing":{"rules":[{"inboundTag":["dns-|"]}]}}'; + expect(labels(source, JsonEditorKind.advancedRouting), ['dns-forward']); + expect( + labels( + '{"outbounds":[{}, {"protocol":"lo|"}]}', + JsonEditorKind.customRouting, + ), + isEmpty, + ); + }); + test('ordinary DNS helper completions match the editable record policy', () { String source(String rules) => '{"outbounds":[{}, {"tag":"dnsOut","protocol":"dns","settings":{"rules":[$rules]}}]}'; diff --git a/test/service/shared/menu/window_service_test.dart b/test/service/shared/menu/window_service_test.dart index 2ac2d4ba1..af78bc2dd 100644 --- a/test/service/shared/menu/window_service_test.dart +++ b/test/service/shared/menu/window_service_test.dart @@ -1,125 +1,218 @@ -import 'dart:io'; +import 'dart:async'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:macos_window_utils/macos_window_utils.dart'; +import 'package:nativeapi/nativeapi.dart' as native; import 'package:onexray/service/shared/menu/window/service.dart'; +class TestWindow implements native.Window { + final calls = []; + FutureOr Function(native.WindowEvent)? listener; + native.TitleBarStyle? style; + bool? controlsVisible; + native.Size? preparedSize; + native.Size? minSize; + native.Color? background; + bool? taskbarVisible; + @override + bool isFullScreen = false; + @override + bool isMinimized = false; + + @override + set titleBarStyle(native.TitleBarStyle value) => style = value; + @override + set isWindowControlButtonsVisible(bool value) => controlsVisible = value; + @override + set backgroundColor(native.Color value) => background = value; + @override + set minimumSize(native.Size value) => minSize = value; + @override + set isVisibleInTaskbar(bool value) => taskbarVisible = value; + @override + void setSize(native.Size value, bool animate) => preparedSize = value; + @override + void center() => calls.add('center'); + @override + void show() => calls.add('show'); + @override + void focus() => calls.add('focus'); + @override + void hide() => calls.add('hide'); + @override + void restore() { + calls.add('restore'); + isMinimized = false; + } + + @override + native.ListenerId addListener( + FutureOr Function(native.WindowEvent) callback, + ) { + calls.add('listen'); + listener = callback; + return 1; + } + + @override + bool removeListener(native.ListenerId id) { + expect(id, 1); + calls.add('unlisten'); + listener = null; + return true; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class CloseRequest implements native.EventRequest { + bool cancelled = false; + @override + bool cancel() => cancelled = true; + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); test( - 'macOS preparation preserves visibility policy and window ownership', + 'macOS preparation, close and geometry preserve startup visibility', () async { final calls = []; var materialAvailable = false; final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; - const managerChannel = MethodChannel('window_manager'); - const materialChannel = MethodChannel( - 'macos_window_utils/window_manipulator', - ); - const screenChannel = MethodChannel( - 'dev.leanflutter.plugins/screen_retriever', - ); - const display = { - 'id': 'test-display', - 'size': {'width': 1920.0, 'height': 1080.0}, - 'visiblePosition': {'dx': 0.0, 'dy': 0.0}, - }; - messenger.setMockMethodCallHandler(screenChannel, (call) async { - return switch (call.method) { - 'getPrimaryDisplay' => display, - 'getAllDisplays' => { - 'displays': [display], - }, - 'getCursorScreenPoint' => {'dx': 100.0, 'dy': 100.0}, - _ => null, - }; - }); - messenger.setMockMethodCallHandler(managerChannel, (call) async { - calls.add(call); - return switch (call.method) { - 'isFullScreen' || 'isMaximized' || 'isMinimized' => false, - 'getBounds' => {'x': 0.0, 'y': 0.0, 'width': 1160.0, 'height': 720.0}, - _ => null, - }; - }); - messenger.setMockMethodCallHandler(materialChannel, (call) async { + const channel = MethodChannel('macos_window_utils/window_manipulator'); + messenger.setMockMethodCallHandler(channel, (call) async { calls.add(call); if (!materialAvailable) { throw MissingPluginException('material unavailable'); } return call.method == 'getTitlebarHeight' ? 28.0 : null; }); - final service = WindowService(); + final window = TestWindow(); + final dockVisibility = []; + var allowDockChange = true; + final service = WindowService.forTesting( + readWindow: () => window, + setDockIconVisible: (visible) { + dockVisibility.add(visible); + return allowDockChange; + }, + ); addTearDown(() { service.dispose(); - messenger.setMockMethodCallHandler(managerChannel, null); - messenger.setMockMethodCallHandler(materialChannel, null); - messenger.setMockMethodCallHandler(screenChannel, null); + messenger.setMockMethodCallHandler(channel, null); }); - - await service.prepare( + Future prepare() => service.prepare( size: const Size(1160, 720), minimumSize: const Size(480, 600), ); + await prepare(); expect(service.hasSidebarMaterial, isFalse); - expect( - calls.where((call) => call.method == 'show' || call.method == 'focus'), - isEmpty, - ); - materialAvailable = true; calls.clear(); - await service.prepare( - size: const Size(1160, 720), - minimumSize: const Size(480, 600), - ); + await prepare(); expect(service.hasSidebarMaterial, isTrue); expect(service.titlebarHeight, 28); + expect(calls.singleWhere((c) => c.method == 'initialize').arguments, { + 'enableWindowDelegate': false, + }); + expect(calls.singleWhere((c) => c.method == 'setMaterial').arguments, { + 'material': NSVisualEffectViewMaterial.sidebar.index, + }); + expect(window.style, native.TitleBarStyle.hidden); + expect(window.controlsVisible, isTrue); expect( - calls.singleWhere((call) => call.method == 'initialize').arguments, - {'enableWindowDelegate': false}, - ); - expect( - calls.singleWhere((call) => call.method == 'setMaterial').arguments, - {'material': NSVisualEffectViewMaterial.sidebar.index}, - ); - expect( - calls - .singleWhere((call) => call.method == 'setTitleBarStyle') - .arguments, - {'titleBarStyle': 'hidden', 'windowButtonVisibility': true}, - ); - expect( - calls.where((call) => call.method == 'show' || call.method == 'focus'), - isEmpty, + [window.preparedSize!.width, window.preparedSize!.height], + [1160, 720], ); + expect([window.minSize!.width, window.minSize!.height], [480, 600]); + expect(window.background, isNotNull); + expect(window.calls.where((c) => c == 'show' || c == 'focus'), isEmpty); + expect(window.calls.where((c) => c == 'listen'), hasLength(1)); await service.updateAppearance(Brightness.dark); await service.updateAppearance(Brightness.dark); await service.updateAppearance(Brightness.light); expect( calls - .where((call) => call.method == 'overrideMacOSBrightness') - .map((call) => call.arguments), + .where((c) => c.method == 'overrideMacOSBrightness') + .map((c) => c.arguments), [ {'dark': true}, {'dark': false}, ], ); - calls.clear(); - await service.onWindowClose(); - expect(calls.map((call) => call.method), ['hide']); - calls.clear(); + + final request = CloseRequest(); + window.calls.clear(); + await window.listener!( + native.WindowCloseRequestedEvent(windowId: 1, request: request), + ); + expect(request.cancelled, isTrue); + expect(window.calls, ['hide']); + window.calls.clear(); + window.isMinimized = true; await service.showAndFocus(); - expect(calls.map((call) => call.method), [ - 'isMinimized', - 'show', - 'focus', - ]); + expect(window.calls, ['restore', 'show', 'focus']); + + final heights = []; + service.titlebarChanges.addListener( + () => heights.add(service.titlebarHeight), + ); + window.isFullScreen = true; + await window.listener!( + const native.WindowEnteredFullScreenEvent(windowId: 1), + ); + window.isFullScreen = false; + await window.listener!( + const native.WindowExitedFullScreenEvent(windowId: 1), + ); + expect(heights, [0, 28]); + + await service.setHideDockIcon(true); + await service.setHideDockIcon(false); + expect(dockVisibility, [false, true]); + expect(window.taskbarVisible, isNull); + allowDockChange = false; + await expectLater(service.setHideDockIcon(true), throwsStateError); + service.dispose(); + expect(window.listener, isNull); }, - skip: !Platform.isMacOS, ); + + test( + 'other desktops change taskbar visibility without the macOS Dock API', + () async { + final window = TestWindow(); + final service = WindowService.forTesting( + readWindow: () => window, + isMacOS: false, + setDockIconVisible: (_) => throw StateError('macOS-only API'), + ); + await service.setHideDockIcon(true); + expect(window.taskbarVisible, isFalse); + await service.setHideDockIcon(false); + expect(window.taskbarVisible, isTrue); + }, + ); + + test('mobile does not resolve or operate a native desktop window', () async { + final service = WindowService.forTesting( + readWindow: () => throw StateError('desktop-only API'), + isDesktop: false, + setDockIconVisible: (_) => throw StateError('desktop-only API'), + ); + await service.prepare( + size: const Size(1160, 720), + minimumSize: const Size(480, 600), + ); + await service.showAndFocus(); + await service.hide(); + await service.setHideDockIcon(true); + }); } diff --git a/test/service/shared/share/configuration_transfer_test.dart b/test/service/shared/share/configuration_transfer_test.dart index 0530b49fc..3b7049811 100644 --- a/test/service/shared/share/configuration_transfer_test.dart +++ b/test/service/shared/share/configuration_transfer_test.dart @@ -178,7 +178,7 @@ void main() { (json['dns']['servers'] as List).singleWhere( (server) => server['tag'] == 'app-dns-proxy', )['address'], - '8.8.8.8', + 'tcp://8.8.8.8', ); expect( (json['routing']['rules'] as List).single['ruleTag'], @@ -312,7 +312,7 @@ void main() { servers.singleWhere( (server) => server['tag'] == 'app-dns-proxy', )['address'], - '8.8.8.8', + 'tcp://8.8.8.8', ); }, ); diff --git a/test/service/shared/xray/remnawave_test.dart b/test/service/shared/xray/remnawave_test.dart index df2330b0e..e1bd7ead8 100644 --- a/test/service/shared/xray/remnawave_test.dart +++ b/test/service/shared/xray/remnawave_test.dart @@ -99,6 +99,7 @@ void main() { 'sockopt': {'dialerProxy': 'app-vpn-dns-direct'}, }, 'settings': { + 'rewriteNetwork': 'tcp', 'rules': [ {'action': 'hijack', 'qType': '1,28'}, {'action': 'direct'}, diff --git a/test/service/shared/xray/vpn_config_test.dart b/test/service/shared/xray/vpn_config_test.dart index 806a9d35e..4f83a0e71 100644 --- a/test/service/shared/xray/vpn_config_test.dart +++ b/test/service/shared/xray/vpn_config_test.dart @@ -36,6 +36,10 @@ void main() { final before = jsonEncode(source); final result = VpnConfigAdapter.adapt(source); expect(result['outbounds'].first, source['outbounds'][0]); + final dns = (result['outbounds'] as List).singleWhere( + (outbound) => outbound['protocol'] == 'dns', + ); + expect(dns['settings']['rewriteNetwork'], 'tcp'); expect(result['inbounds'].single['tag'], 'tunIn'); expect(result['routing']['rules'].first, { 'type': 'field', diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 83996b9c0..93954e041 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -9,10 +9,8 @@ #include #include #include -#include #include #include -#include void RegisterPlugins(flutter::PluginRegistry* registry) { AppLinksPluginCApiRegisterWithRegistrar( @@ -21,12 +19,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FileSelectorWindows")); PermissionHandlerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); - ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( - registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); SharePlusWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); - WindowManagerPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("WindowManagerPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index d0dd82715..672bfe304 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -6,10 +6,8 @@ list(APPEND FLUTTER_PLUGIN_LIST app_links file_selector_windows permission_handler_windows - screen_retriever_windows share_plus url_launcher_windows - window_manager ) list(APPEND FLUTTER_FFI_PLUGIN_LIST