From 1e9771663665f3e237541afe6292238ee45e956d Mon Sep 17 00:00:00 2001 From: chen08209 Date: Fri, 25 Sep 2026 03:56:56 +0800 Subject: [PATCH] feat(platform): move desktop windows to plugins/window and rework the tray plugins/window replaces the window_manager fork on every desktop. Changelog: Tray hotkeys, node delays and a delay test --- .agents/architecture.md | 10 +- .agents/commands.md | 6 +- .agents/flutter-upgrade.md | 42 ++ .../com/follow/clash/plugins/AppPlugin.kt | 5 +- .../main/java/com/follow/clash/common/Ext.kt | 29 +- .../service/modules/NetworkObserveModule.kt | 18 +- .../service/modules/NotificationModule.kt | 43 +- .../clash/service/modules/SuspendModule.kt | 4 +- android/service/src/main/res/drawable/ic.xml | 6 +- .../src/main/res/drawable/ic_service.xml | 6 +- assets/images/tray/macos/2.0x/status_1.png | Bin 0 -> 570 bytes assets/images/tray/macos/2.0x/status_4.png | Bin 0 -> 698 bytes assets/images/tray/macos/3.0x/status_1.png | Bin 0 -> 851 bytes assets/images/tray/macos/3.0x/status_4.png | Bin 0 -> 1012 bytes assets/images/tray/macos/4.0x/status_1.png | Bin 0 -> 1130 bytes assets/images/tray/macos/4.0x/status_4.png | Bin 0 -> 1333 bytes assets/images/tray/macos/status_1.png | Bin 0 -> 323 bytes assets/images/tray/macos/status_4.png | Bin 0 -> 400 bytes assets/images/tray/unix/2.0x/status_4.png | Bin 0 -> 1261 bytes assets/images/tray/unix/3.0x/status_4.png | Bin 0 -> 1877 bytes assets/images/tray/unix/4.0x/status_4.png | Bin 0 -> 2622 bytes assets/images/tray/unix/status_4.png | Bin 0 -> 618 bytes assets/images/tray/windows/status_4.ico | Bin 0 -> 8800 bytes assets_source/images/icon/glyph.svg | 11 + assets_source/images/icon/status_4.svg | 12 + lib/common/common.dart | 2 +- lib/common/feature.dart | 22 + lib/common/function.dart | 13 + lib/common/launch.dart | 2 +- lib/common/permission.dart | 2 +- lib/common/render.dart | 57 -- lib/common/tray.dart | 152 ++-- lib/common/window.dart | 122 ++-- lib/enum/enum.dart | 1 - lib/manager/android_manager.dart | 13 +- lib/manager/app_manager.dart | 229 +++--- lib/manager/connectivity_manager.dart | 4 +- lib/manager/status_manager.dart | 22 +- lib/manager/tray_manager.dart | 38 +- lib/manager/window_manager.dart | 213 ++---- lib/providers/actions/theme.dart | 2 +- lib/providers/state/system.dart | 91 ++- linux/flutter/generated_plugin_registrant.cc | 8 +- linux/flutter/generated_plugins.cmake | 2 +- linux/runner/my_application.cc | 4 +- macos/Flutter/GeneratedPluginRegistrant.swift | 4 +- macos/Runner/AppDelegate.swift | 6 +- macos/Runner/MainFlutterWindow.swift | 2 +- plugins/tray/README.md | 11 + plugins/tray/lib/src/tray_codec.dart | 30 +- plugins/tray/lib/src/tray_menu.dart | 6 + plugins/tray/linux/tray_plugin.cc | 11 +- .../macos/tray/Sources/tray/TrayMenu.swift | 63 +- .../tray/Sources/tray/TrayMenuItemView.swift | 181 +++++ .../macos/tray/Sources/tray/TrayPlugin.swift | 31 +- .../tray/Sources/tray/TrayStatusItem.swift | 18 +- .../tray/Sources/tray/TrayTitleView.swift | 71 +- .../test/macos_title_view_source_test.dart | 14 +- plugins/tray/test/tray_codec_test.dart | 38 + .../tray/test/windows_menu_source_test.dart | 5 + plugins/tray/windows/tray_plugin.cpp | 7 +- plugins/wifi_ssid/android/build.gradle | 8 +- .../follow/clash/wifi_ssid/WifiSsidPlugin.kt | 7 + .../Sources/wifi_ssid/WifiSsidPlugin.swift | 44 +- .../test/macos_ssid_source_test.dart | 9 + plugins/window/.gitignore | 4 + plugins/window/LICENSE | 25 + plugins/window/README.md | 194 +++++ plugins/window/analysis_options.yaml | 10 + plugins/window/lib/src/desktop_window.dart | 261 +++++++ plugins/window/lib/src/title_bar_style.dart | 3 + plugins/window/lib/src/window_effect.dart | 4 + plugins/window/lib/src/window_event.dart | 30 + plugins/window/lib/src/window_listener.dart | 33 + plugins/window/lib/window.dart | 5 + plugins/window/linux/CMakeLists.txt | 27 + .../linux/include/window/window_plugin.h | 29 + plugins/window/linux/window_drag.cc | 141 ++++ plugins/window/linux/window_drag.h | 24 + plugins/window/linux/window_geometry.cc | 204 ++++++ plugins/window/linux/window_geometry.h | 34 + plugins/window/linux/window_plugin.cc | 663 ++++++++++++++++++ plugins/window/linux/window_plugin_internal.h | 34 + plugins/window/linux/window_style.cc | 74 ++ plugins/window/linux/window_style.h | 20 + plugins/window/macos/window/Package.swift | 25 + .../window/Sources/window/Arguments.swift | 64 ++ .../Sources/window/WindowController.swift | 278 ++++++++ .../Sources/window/WindowEffectView.swift | 96 +++ .../window/Sources/window/WindowPlugin.swift | 193 +++++ .../window/Sources/window/WindowStyle.swift | 53 ++ plugins/window/pubspec.yaml | 27 + plugins/window/test/desktop_window_test.dart | 207 ++++++ plugins/window/test/window_position_test.dart | 106 +++ plugins/window/windows/CMakeLists.txt | 38 + .../include/window/window_plugin_c_api.h | 29 + .../include/window/window_plugin_runner.h | 19 + plugins/window/windows/running_instance.cpp | 105 +++ plugins/window/windows/running_instance.h | 19 + plugins/window/windows/window_controller.cpp | 426 +++++++++++ plugins/window/windows/window_controller.h | 108 +++ plugins/window/windows/window_plugin.cpp | 394 +++++++++++ plugins/window/windows/window_plugin.h | 84 +++ .../window/windows/window_plugin_c_api.cpp | 21 + plugins/window/windows/window_style.cpp | 258 +++++++ plugins/window/windows/window_style.h | 61 ++ pubspec.lock | 12 +- pubspec.yaml | 11 +- test/common/run_after_frame_test.dart | 42 ++ test/common/tray_lifecycle_test.dart | 1 + test/common/tray_menu_test.dart | 143 ++++ test/common/tray_test.dart | 53 +- test/common/window_test.dart | 91 ++- test/lint/platform_layering_test.dart | 2 +- test/manager/app_env_manager_test.dart | 41 ++ test/manager/connectivity_manager_test.dart | 22 + test/manager/status_manager_test.dart | 6 +- test/manager/tray_manager_test.dart | 42 +- test/manager/window_header_test.dart | 8 +- test/manager/window_manager_test.dart | 127 +++- test/providers/permission_test.dart | 27 + tool/generate_status_icons.dart | 38 +- .../flutter/generated_plugin_registrant.cc | 6 +- windows/flutter/generated_plugins.cmake | 2 +- windows/runner/flutter_window.cpp | 4 + windows/runner/main.cpp | 6 +- 126 files changed, 6148 insertions(+), 723 deletions(-) create mode 100644 assets/images/tray/macos/2.0x/status_1.png create mode 100644 assets/images/tray/macos/2.0x/status_4.png create mode 100644 assets/images/tray/macos/3.0x/status_1.png create mode 100644 assets/images/tray/macos/3.0x/status_4.png create mode 100644 assets/images/tray/macos/4.0x/status_1.png create mode 100644 assets/images/tray/macos/4.0x/status_4.png create mode 100644 assets/images/tray/macos/status_1.png create mode 100644 assets/images/tray/macos/status_4.png create mode 100644 assets/images/tray/unix/2.0x/status_4.png create mode 100644 assets/images/tray/unix/3.0x/status_4.png create mode 100644 assets/images/tray/unix/4.0x/status_4.png create mode 100644 assets/images/tray/unix/status_4.png create mode 100644 assets/images/tray/windows/status_4.ico create mode 100644 assets_source/images/icon/glyph.svg create mode 100644 assets_source/images/icon/status_4.svg create mode 100644 lib/common/feature.dart delete mode 100644 lib/common/render.dart create mode 100644 plugins/tray/macos/tray/Sources/tray/TrayMenuItemView.swift create mode 100644 plugins/window/.gitignore create mode 100644 plugins/window/LICENSE create mode 100644 plugins/window/README.md create mode 100644 plugins/window/analysis_options.yaml create mode 100644 plugins/window/lib/src/desktop_window.dart create mode 100644 plugins/window/lib/src/title_bar_style.dart create mode 100644 plugins/window/lib/src/window_effect.dart create mode 100644 plugins/window/lib/src/window_event.dart create mode 100644 plugins/window/lib/src/window_listener.dart create mode 100644 plugins/window/lib/window.dart create mode 100644 plugins/window/linux/CMakeLists.txt create mode 100644 plugins/window/linux/include/window/window_plugin.h create mode 100644 plugins/window/linux/window_drag.cc create mode 100644 plugins/window/linux/window_drag.h create mode 100644 plugins/window/linux/window_geometry.cc create mode 100644 plugins/window/linux/window_geometry.h create mode 100644 plugins/window/linux/window_plugin.cc create mode 100644 plugins/window/linux/window_plugin_internal.h create mode 100644 plugins/window/linux/window_style.cc create mode 100644 plugins/window/linux/window_style.h create mode 100644 plugins/window/macos/window/Package.swift create mode 100644 plugins/window/macos/window/Sources/window/Arguments.swift create mode 100644 plugins/window/macos/window/Sources/window/WindowController.swift create mode 100644 plugins/window/macos/window/Sources/window/WindowEffectView.swift create mode 100644 plugins/window/macos/window/Sources/window/WindowPlugin.swift create mode 100644 plugins/window/macos/window/Sources/window/WindowStyle.swift create mode 100644 plugins/window/pubspec.yaml create mode 100644 plugins/window/test/desktop_window_test.dart create mode 100644 plugins/window/test/window_position_test.dart create mode 100644 plugins/window/windows/CMakeLists.txt create mode 100644 plugins/window/windows/include/window/window_plugin_c_api.h create mode 100644 plugins/window/windows/include/window/window_plugin_runner.h create mode 100644 plugins/window/windows/running_instance.cpp create mode 100644 plugins/window/windows/running_instance.h create mode 100644 plugins/window/windows/window_controller.cpp create mode 100644 plugins/window/windows/window_controller.h create mode 100644 plugins/window/windows/window_plugin.cpp create mode 100644 plugins/window/windows/window_plugin.h create mode 100644 plugins/window/windows/window_plugin_c_api.cpp create mode 100644 plugins/window/windows/window_style.cpp create mode 100644 plugins/window/windows/window_style.h create mode 100644 test/common/run_after_frame_test.dart create mode 100644 test/manager/app_env_manager_test.dart diff --git a/.agents/architecture.md b/.agents/architecture.md index f3209c53..0d4c3688 100644 --- a/.agents/architecture.md +++ b/.agents/architecture.md @@ -848,9 +848,13 @@ table. `lib/common/window.dart` and `lib/manager/window_manager.dart` are the on - Position and size changes surface as one `geometry-changed` event that each platform coalesces natively (150 ms after the last `WM_WINDOWPOSCHANGED`, did-move/did-resize, or configure-event); `WindowManager` only debounces the capture on top of it. -- Windows: with `titleBarStyle: hidden`, `WM_NCCALCSIZE` lets `DefWindowProc` compute the client rect and then puts - the top edge back (plus the frame height when maximized, so the taskbar stays uncovered; plus 1 px otherwise, which - Windows 11 needs to draw the top border and Windows 10 needs to avoid a white line). Fullscreen is emulated by the +- Windows: with `titleBarStyle: hidden`, `WM_NCCALCSIZE` takes the frame insets from `AdjustWindowRectExForDpi` and + keeps the client rect at the top edge (plus the frame height when maximized, so the taskbar stays uncovered; plus + 1 px on Windows 11, which draws the top border only into a non-client strip; none on Windows 10, where DWM draws + the whole caption over any strip shorter than one). Windows 10 gets its top border the Windows Terminal way + instead: the DWM frame is extended over the caption height, the plugin places the Flutter view 1 px lower and + paints that row black, which DWM treats as alpha 0, and hit-tests it as `HTTOP`; see `plugins/window/README.md`. + The plugin therefore owns the view placement on `WM_SIZE`. Fullscreen is emulated by the plugin, which raises `enter-full-screen` and `leave-full-screen` itself and mutes `WM_SIZE` while it is on. The find-running-window IPC (`WindowPluginFindRunningWindow`/`WindowPluginActivateWindow`) registers a window message from the executable path diff --git a/.agents/commands.md b/.agents/commands.md index e1f1201a..1a41ca5f 100644 --- a/.agents/commands.md +++ b/.agents/commands.md @@ -128,7 +128,11 @@ dart run tool/generate_status_icons.dart It writes the tray PNGs with Flutter `2.0x/`–`4.0x/` resolution variants to `assets/images/tray/unix/`, multi-size tray `.ico` files to `assets/images/tray/windows/`, and `windows/runner/resources/app_icon.ico` -from `assets/images/icon.png`. `pubspec.yaml` declares the two tray directories with `platforms:` so each +from `assets/images/icon.png`. macOS draws its menu bar icon as a template, so it skips the ringed color +icons, whose logo shrinks to a small glyph once only alpha shows: `status_1` renders the bare logo from +`assets_source/images/icon/glyph.svg` and `status_4` reuses `status_4.svg`, both recolored to black into +`assets/images/tray/macos/`; the sources keep their colors. `glyph.svg` is also the geometry of the Android notification drawables +`android/service/src/main/res/drawable/ic.xml` and `ic_service.xml`; change them together. `pubspec.yaml` declares the three tray directories with `platforms:` so each build only bundles the format its tray loads; a new status icon needs a source SVG and an entry in the script's `statusIconNames`, nothing in `pubspec.yaml`. diff --git a/.agents/flutter-upgrade.md b/.agents/flutter-upgrade.md index dfe4cb75..1a3e321c 100644 --- a/.agents/flutter-upgrade.md +++ b/.agents/flutter-upgrade.md @@ -29,3 +29,45 @@ listed in the SDK's `CHANGELOG.md`. changelog lists #191538). Before dropping the step, build the Intel DMG without it and scroll the proxies and dashboard pages on an Intel Mac with its integrated GPU driving the built-in display; an external display can switch to the discrete GPU and hide the flicker. + +## Windows view visibility forwarded by the runner + +- **Bug:** the Windows embedder's `WindowsLifecycleManager` counts the Flutter view as a window of its own and keeps + it visible until the view is sized to zero. Hiding the top-level window never resizes the view, and the `window` + plugin keeps it sized while minimized, so the app was never told `hidden`: animations kept rendering in the tray + and `appVisibleProvider` stayed true. +- **Workaround:** `WindowPluginForwardVisibility` in the `window` plugin's + `windows/include/window/window_plugin_runner.h` hands the top-level window's `WM_SHOWWINDOW` and `WM_SIZE` to + `ProcessExternalWindowMessage` for the view's HWND, so the view shows and hides with its window. + `FlutterWindow::MessageHandler` in `windows/runner/flutter_window.cpp` calls it before `HandleTopLevelWindowProc`; + the Flutter runner template has no such call, so keep it when regenerating the runner. +- **Remove when:** the engine hides the view together with its top-level window. Check with the proxy running and + the window focused: hiding it to the tray and minimizing it must each log `AppLifecycleState.hidden`. + +## macOS activation resumes a hidden window + +- **Bug:** `handleWillBecomeActive` in the macOS engine's `FlutterEngine.mm` resumes when any window in + `NSApp.windows` is visible, and the tray's `NSStatusBarWindow` always is. Activating the app while its window was + minimized or hidden, such as Cmd-Tab back to a minimized window, reported `resumed`, and the next deactivation + `inactive`, so frames ran until the window was shown again. +- **Workaround:** the `window` plugin observes will-become-active and will-resign-active and, once the engine has + answered, sends `AppLifecycleState.hidden` on `flutter/lifecycle` while its window is not visible + (`applicationActivityChanged` in `WindowPlugin.swift`). A window brought back without another activation change + would then wait on an occlusion notification macOS does not always post, keeping its last frame, so `show` and a + deminiaturize hand the state back (`windowBecameVisible`). +- **Remove when:** the engine ignores status item windows on activation. Check without the workaround: minimize the + window, switch to another app, then Cmd-Tab back without restoring it; the log must stay at + `AppLifecycleState.hidden`. + +## Linux minimize on Wayland reads as inactive + +- **Bug:** the Linux engine reports `hidden` for `GDK_WINDOW_STATE_ICONIFIED`, but xdg-shell has no minimized state, + so GTK 3 never sets it on Wayland. A minimized window reported only its focus loss, as `inactive`, and kept + rendering at the display rate (GNOME 46). +- **Workaround:** the `window` plugin's Linux `minimize` records the time, and a focus loss within 500 ms of it sends + `AppLifecycleState.hidden`. Its `window-state-event` handler is connected after the engine's, so that message + follows the engine's `inactive`; the compositor focuses the window when it restores it, which the engine reports as + `resumed`. A minimize the compositor starts itself (Super+H, the dock) is not covered. +- **Remove when:** the Linux engine reports minimized windows on Wayland, which needs GTK 4 or xdg-shell's + `suspended` state. Check in a GNOME Wayland session without the workaround: minimizing from the caption button + must log `AppLifecycleState.hidden`. diff --git a/android/app/src/main/kotlin/com/follow/clash/plugins/AppPlugin.kt b/android/app/src/main/kotlin/com/follow/clash/plugins/AppPlugin.kt index 910a0e46..b99646a5 100644 --- a/android/app/src/main/kotlin/com/follow/clash/plugins/AppPlugin.kt +++ b/android/app/src/main/kotlin/com/follow/clash/plugins/AppPlugin.kt @@ -272,12 +272,13 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware @Suppress("DEPRECATION") private fun updateExcludeFromRecents(value: Boolean?) { + val taskId = activity?.taskId ?: return val am = getSystemService(GlobalState.application, ActivityManager::class.java) val task = am?.appTasks?.firstOrNull { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - it.taskInfo.taskId == activity?.taskId + it.taskInfo?.taskId == taskId } else { - it.taskInfo.id == activity?.taskId + it.taskInfo?.id == taskId } } task?.setExcludeFromRecents(value ?: false) diff --git a/android/common/src/main/java/com/follow/clash/common/Ext.kt b/android/common/src/main/java/com/follow/clash/common/Ext.kt index 40e29906..18d92680 100644 --- a/android/common/src/main/java/com/follow/clash/common/Ext.kt +++ b/android/common/src/main/java/com/follow/clash/common/Ext.kt @@ -59,19 +59,24 @@ val Intent.toPendingIntent: PendingIntent PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, ) -fun Service.startForeground(notification: Notification) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val manager = getSystemService(NotificationManager::class.java) - var channel = manager?.getNotificationChannel(GlobalState.NOTIFICATION_CHANNEL) - if (channel == null) { - channel = NotificationChannel( - GlobalState.NOTIFICATION_CHANNEL, - getString(R.string.service_channel_name), - NotificationManager.IMPORTANCE_LOW, - ) - manager?.createNotificationChannel(channel) - } +fun Context.ensureNotificationChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return } + val manager = getSystemService(NotificationManager::class.java) ?: return + if (manager.getNotificationChannel(GlobalState.NOTIFICATION_CHANNEL) != null) { + return + } + manager.createNotificationChannel( + NotificationChannel( + GlobalState.NOTIFICATION_CHANNEL, + getString(R.string.service_channel_name), + NotificationManager.IMPORTANCE_LOW, + ), + ) +} + +fun Service.startForeground(notification: Notification) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { startForeground( GlobalState.NOTIFICATION_ID, diff --git a/android/service/src/main/java/com/follow/clash/service/modules/NetworkObserveModule.kt b/android/service/src/main/java/com/follow/clash/service/modules/NetworkObserveModule.kt index c9079121..08dfacc2 100644 --- a/android/service/src/main/java/com/follow/clash/service/modules/NetworkObserveModule.kt +++ b/android/service/src/main/java/com/follow/clash/service/modules/NetworkObserveModule.kt @@ -47,13 +47,13 @@ internal class NetworkObserveModule(private val service: Service) : ServiceModul private val callback = object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) { networkInfos[network] = NetworkInfo() - updateDns() + scheduleUpdateDns() } override fun onLosing(network: Network, maxMsToLive: Int) { val info = networkInfos[network] ?: return info.losingUntilMillis = System.currentTimeMillis() + maxMsToLive - updateDns() + scheduleUpdateDns() if (maxMsToLive > 0) { mainHandler.postDelayed({ if (networkInfos.containsKey(network)) { @@ -65,15 +65,24 @@ internal class NetworkObserveModule(private val service: Service) : ServiceModul override fun onLost(network: Network) { networkInfos.remove(network) - updateDns() + scheduleUpdateDns() } override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) { networkInfos[network]?.dnsList = linkProperties.dnsServers - updateDns() + scheduleUpdateDns() } } + private val updateDnsTask = Runnable(::updateDns) + + // A switch arrives as a burst, and a network that just came up has no DNS + // until its link properties follow; the Core flushes its cache per list. + private fun scheduleUpdateDns() { + mainHandler.removeCallbacks(updateDnsTask) + mainHandler.postDelayed(updateDnsTask, DNS_UPDATE_DELAY_MILLIS) + } + override fun start() { updateDns() connectivity?.registerNetworkCallback(request, callback) @@ -126,6 +135,7 @@ internal class NetworkObserveModule(private val service: Service) : ServiceModul } private const val DNS_PORT = 53 +private const val DNS_UPDATE_DELAY_MILLIS = 200L private fun InetAddress.asSocketAddressText(port: Int): String = when (this) { is Inet6Address -> "[$hostAddress]:$port" diff --git a/android/service/src/main/java/com/follow/clash/service/modules/NotificationModule.kt b/android/service/src/main/java/com/follow/clash/service/modules/NotificationModule.kt index df084d74..0ff86edb 100644 --- a/android/service/src/main/java/com/follow/clash/service/modules/NotificationModule.kt +++ b/android/service/src/main/java/com/follow/clash/service/modules/NotificationModule.kt @@ -11,6 +11,7 @@ import androidx.core.content.getSystemService import com.follow.clash.common.Components import com.follow.clash.common.GlobalState import com.follow.clash.common.QuickAction +import com.follow.clash.common.ensureNotificationChannel import com.follow.clash.common.quickIntent import com.follow.clash.common.receiveBroadcastFlow import com.follow.clash.common.startForeground @@ -22,9 +23,9 @@ import com.follow.clash.service.models.NotificationParams import com.follow.clash.service.models.getSpeedTrafficText import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart @@ -50,31 +51,31 @@ internal class NotificationModule( private val scope: CoroutineScope, ) : ServiceModule { override fun start() { + service.ensureNotificationChannel() update(ServiceConfig.notificationParams.value.extended) scope.launch { - val screenFlow = service.receiveBroadcastFlow { + service.receiveBroadcastFlow { addAction(Intent.ACTION_SCREEN_ON) addAction(Intent.ACTION_SCREEN_OFF) }.map { intent -> intent.action == Intent.ACTION_SCREEN_ON }.onStart { emit(isScreenOn()) + }.distinctUntilChanged().collectLatest { screenOn -> + if (!screenOn) return@collectLatest + combine( + flow { + while (true) { + delay(1_000) + emit(Unit) + } + }, + ServiceConfig.notificationParams, + ) { _, params -> + params.extended + }.distinctUntilChanged() + .collect(::update) } - - combine( - flow { - while (true) { - delay(1_000) - emit(Unit) - } - }, - ServiceConfig.notificationParams, - screenFlow, - ) { _, params, screenOn -> - params.takeIf { screenOn }?.extended - }.filterNotNull() - .distinctUntilChanged() - .collect(::update) } } @@ -102,6 +103,8 @@ internal class NotificationModule( } } + private val stopIntent by lazy { QuickAction.STOP.quickIntent.toPendingIntent } + private fun update(params: ExtendedNotificationParams) { service.startForeground( with(notificationBuilder) { @@ -109,11 +112,7 @@ internal class NotificationModule( setContentText(params.contentText) clearActions() if (params.showStopAction) { - addAction( - 0, - params.stopText, - QuickAction.STOP.quickIntent.toPendingIntent, - ) + addAction(0, params.stopText, stopIntent) } build() }, diff --git a/android/service/src/main/java/com/follow/clash/service/modules/SuspendModule.kt b/android/service/src/main/java/com/follow/clash/service/modules/SuspendModule.kt index 12f6930f..3efaddf7 100644 --- a/android/service/src/main/java/com/follow/clash/service/modules/SuspendModule.kt +++ b/android/service/src/main/java/com/follow/clash/service/modules/SuspendModule.kt @@ -22,7 +22,7 @@ internal class SuspendModule( get() = service.getSystemService()?.isDeviceIdleMode ?: true private fun updateSuspension(screenOn: Boolean) { - Core.suspended(!screenOn && isDeviceIdle) + Core.suspended(!screenOn && isDeviceIdle, screenOn) } override fun start() { @@ -42,6 +42,6 @@ internal class SuspendModule( } override fun stop() { - Core.suspended(false) + Core.suspended(false, isScreenOn()) } } diff --git a/android/service/src/main/res/drawable/ic.xml b/android/service/src/main/res/drawable/ic.xml index 9746595d..fccdee1f 100644 --- a/android/service/src/main/res/drawable/ic.xml +++ b/android/service/src/main/res/drawable/ic.xml @@ -6,12 +6,12 @@ android:viewportHeight="240" tools:ignore="VectorRaster"> diff --git a/android/service/src/main/res/drawable/ic_service.xml b/android/service/src/main/res/drawable/ic_service.xml index 6864a7f2..c1b20061 100644 --- a/android/service/src/main/res/drawable/ic_service.xml +++ b/android/service/src/main/res/drawable/ic_service.xml @@ -6,12 +6,12 @@ android:viewportHeight="240" tools:ignore="VectorRaster"> diff --git a/assets/images/tray/macos/2.0x/status_1.png b/assets/images/tray/macos/2.0x/status_1.png new file mode 100644 index 0000000000000000000000000000000000000000..8e739d11d2b7931e6974d54c6241d4a8927cbc3a GIT binary patch literal 570 zcmV-A0>%A_P)N@Xx#k(TqffVoF$%BBjA{vNL6+q=r~ivXH`( zWKuR3R{oT%{K?MBWaUp;pzKEejDc1c17G_Jh_nhp*ThyA`&9Whz@vsFX+xCAnr=umJrN4Y>Nq5iZl2X2>DU(wen0hyFk@$5Po#+#=9O<-fEpj~Ys)3~Vd$v5Fan?6rE1;LdT1K-#9 zwEehb{$g4cX`v6}Y8{u|E>tAzV{#ry+Ga%lLT9rZFLFY%GaEe>$wxA@n7+o^B0whs zAwO{%i;BR&%bbuZZeXA&NSgOKFyoR!TuQFDfqcNhvLI;&%$=nvToAFChHek~a2#9l zQF6Ze919aGJ(6?S5SDe>Vm+Q|eANp(-sDNEZS4$pb_27a1tx7{3mvr50cb^=Yb!e# zcfnbVZ(7gLXg5G4Th->h&|@9Ue2int797HA$s)g3hy5S<3lxt;_u^3ac>n+a07*qo IM6N<$f^eGyHUIzs literal 0 HcmV?d00001 diff --git a/assets/images/tray/macos/2.0x/status_4.png b/assets/images/tray/macos/2.0x/status_4.png new file mode 100644 index 0000000000000000000000000000000000000000..ffbd1136405c92184e26d3a5a5ebf281320881ac GIT binary patch literal 698 zcmV;r0!96aP)`6j2<<@lQ5x5{(*yA~7*BQ9&eNSdIxjcbZ;cXRAQJC-O&JzgM;#CBy z5u0%khp+{8F)-WmhAFEZ10iQWL-n}6s$mLM0yW}&sNE1VC*1c5^;P<)SVu8x`if+g zF`UX9<_N}T023}J^MdKXk6FS@;xD#lIjf1(ucF7)*lNOxS^S`|hA|XsPvQYiU?MuX z-RxIkre#wceF1j_K{#I^1uay*k9bwv=L(1)9Glh#|995jKhGDLVUKbv}z&zC;pYq=4P zp(bo7)!T4)jv#tM5HE$|FfFLp3BC}MG>Ge_=aYIIm;O=TY6iQ}C4KLEQ*3)PY}=9y z)AGKxeTh)ZQVuB<86R zJPEb?a3OADn}x|klh_ysR1}=x^9|FF#c@DoHAl+n9Gzc`d45_P$Et{DIlv4E?sd7S z6YpjLGbFfj=c*3D+fgmdb93-VLs_+g3*YMsKn8I}{B=7~RR>f_1?&{$tZu9<^?$(& gJj5fxt>XXIKOhmHkSJz_IsgCw07*qoM6N<$f=>}QRb?E;@sE=^45^D|(4rI!DTD|T+~mRtq83TnLTMAU zX;V<*LP9WFi95wMksuU=3~fqR)uKiULq$J*rl>)96vuE^80xJX zc&RmY6&S@{T$OF8yDsD5c4U_0HB4LBPeTTT^e)IaKE@r@OwHlT zMd zN)z2I!<)F2Bd~1U!eQ}cwZazslq0Z8oe){E6&}N9;`d*dpj=-{Y z8Sjaup%u2{j~szz>kdA_L`oCgJd3FufmP}|5wBX|K^)S5J5i;6!z(FGWbwKB4E>8e zVt3psFQ~`RU3`i)Y329lsKHH5iSe1mIJV(AtZd3e7T>FG=sb4zIqybHx_|p}U8IbM z8gbIY*UB535nGF~jPtD$Ir3)C=YN#o$<+Hf0(xr}U*PeK^Nipv#dG9`O&RL23V#+5 zaz>1kEVkZzzsy~~C_;6kY{p-G1Y8n#VkF~p>%<+bnj@3Upc6QNvm(=-#s}h#_A(~M z*i44lGBw_qo02TUE-@Ppxdlewq?JFP6=LnFGe=I!9HF5DKjh!lZ+*E@a8exNlpHh9(nz{jbRs0n%%hWlGXvbWIO``dJK{OrCi5!`v d@vy|O@ISn*%hb@iPsjiO002ovPDHLkV1iuOn128O literal 0 HcmV?d00001 diff --git a/assets/images/tray/macos/3.0x/status_4.png b/assets/images/tray/macos/3.0x/status_4.png new file mode 100644 index 0000000000000000000000000000000000000000..4bd31a13d9658888889387f48ce8212b60c7426e GIT binary patch literal 1012 zcmVcp4QJQg3^MV?u2ze{Xh~bDi;yCBHSZ8zQ+3Pubt^K_0qvl)x zi@o-L|M&m*tY`h#v(|bzLX>sbh)&IMPr!K1F_YPduds-{OUYDa1kWwS`~{OW#LVXY zr3AY6v1;N*@NyyM8-oU0Qz4Nb3!l%%PJ@XX!46zkX#Zh=Sd|&Yn-0IigY;airxx)g zPO35AEXMJAB&jlS#UitksFRyXIh`@QDlYCWyoT@a7v^yQQ@9VWM1Hp_M0FX(OL~*c zwQq1r9SKz_9?R*L{KblKKEUtQ)2WB^@rmla)wP3qt7Ge2FI?feM0IUm7@tGoTFmuH zu&UkBnwllf!)y(S_p>=%*^(w5yw|Xx<@PN$43cD#qwydv5uVz4{5JA>axd*B4YE{nf;WnLe42R#Dwh-uEPI`7mE#LE8k6e$Jhk6HIP&e z+cC53^8!8;mP-S3jp2`_&8f-8s$LlvX86MMcvq-gG1lTT+#r3=2ph@py)?u3@(kZ(a>iKBMc5)NK6eT$=NMzS z3O8lMT}XAz5wf;RDUTB-fo|GSNG5q-|#};sF zim<6wkZi8KT@(>32%n{>UuAqzY>17qZ9<{ApwNE0jCrIX4hmaz%bCX3DnVnLqHWu= zsmbP)`&_=qKIQY+sPOEUvp=6aq^YbXYB`lFrNEVj!G_jcB`=qoT-B~=9 z5jXkdG)LHP8NNBkNGE3a9!ho0xfP2CeJimQp9|};zgPJ0N!*hWH?~t%8a$PcGJGe5 z)0euLPIZjkvuki0t`I6$m*cd!D_g=Y{Iu-z9CqUg<#)!v+->;tRjxfy6cH;PjV$~c zX0q526Sk{`MC#_9Vnb|@WFlzc3f}9b^62APnHP#?PNPt!mP# z+}VPlu5E8g6T&U}LIWa-w#VU9PAeQEtX!KC^{Mb8+E5tx-|0-9wj0N{sH2n{h1G3U zL4OD<_$Jqh*{m0iOb^HxG=ql?@zzUWov_G!L>T;s^a`29`@$i^W(~YeTK6ljWlRV? i+7WtZW@Yyyl=2T2#lucXBm%qu0000+ARh+8~J&_vRdP(msdY2(5D)}}2%wK6rGnsYp?IdN2b_Bm&-z4qQU z-{k)=IXP?pf4@0_ris@uFNv}UEWj_)7_$MFq>&YeVSJ@{V}8YLLRnHPZda@^yRiaC zq!JXHwTd%mwb1&gEd$skzA;}4Ps2_ej63m3TMnD?92P~=mz$a58nYdDOpUuQ25}>{ zb{MB2Y%Nz}UDVAMZ&pm=N!e|PQQ;TU?YIxek(iHHcvlw;={_Eq?4_+FMg*mKJ94<_}3xyVI zCuR!kneE~m)0`T&cI8QnD-~bIy^u^zL{7yj#TwHTR@kQ`(iDXuJS5%D>CI_m#bFlS zlg60eaa|f&@mQgFVV>xAyXe#b5H8M6g9s7Qyf2nxh z=N+&A4a;S>Gd{zTvJ$KUi-k7mo<@BxoY1IzYa-QxYecB(hlH0&zE5=BX^N&QOZEeC zk#I}4o`P$HnyXP>TGilc;g;LmB)%ROl$KcqxJULr+k_hSdtz{v?l&RFab`Jbmct;v z*4tuoRVkU4#Hij&kxAiA#H5r}5cYGXXHW^ZSy=}4Tab=K&7up+GU%s1zqd7N78T|s zz0W~AWp8coSG=P)gPtfS&GJ~F`}aFGsp0-t43-Exqn*szh||kTunNq>i}=&OjoE=m wm3*kYD%CvUEc8MgCY+{SFZ8LWnWo144W?=YzY9wkX#fBK07*qoM6N<$f>a_OT>t<8 literal 0 HcmV?d00001 diff --git a/assets/images/tray/macos/4.0x/status_4.png b/assets/images/tray/macos/4.0x/status_4.png new file mode 100644 index 0000000000000000000000000000000000000000..5c7dcd9c4597f127dc60235d8f81fcb7bb6c2bd5 GIT binary patch literal 1333 zcmV-515QXudidtxcs%=@v%h^i zWAlggviDhgec%6E`@5{Q_F)1O$b#Mio_RWM#Zqj+U;#G9q6gRDD%7yGV0)Rc6uX6n z*6=eH6=-iN-WX$V1a}r_FEd_tps{uNRWT$@#oJBnZO5bn?B#+D4sCD|Bqfnk6Fj(x zsUsI$r+K66B%P6ijaKB;ur>!9h2jCpjo*eP8J;~AZ?$;-Cd|n2{L*l@{7I^WoFXT) z`|x1Gz29&NzQZzHgn5{YqXZKFz$bVY`|vteCOq#0tinI}G`&2mkv!ttG0@_EU54>X zi~H|mdRZMR0=EhKlXgVJNa1r%PmvEF#U^3HSOi`%4qvM}X5ne!6)D1d#(L4&CG{yc zEW+1{C1$Mb5zb@d0#{;}^2n*neylE`f0=NxaC$GBoVtwQrowv}i9XeMPNVF_r3Lpj zH74T=)ss_~KXF2#Jx+;xRZmKzY{;Wyt&SSgaTm@Lw*RfLTsSLr8$J|PxFieR>Vf~_Tn{Rp6djs;A$KhX>UjftPMx%r^Yej-F=U+`AmyG2WM^5N~uSW zg&7+9Tn4+(X0W?B#$fCmG&@oce#)Smr7$6CI*27GOKSJR47$rR=+-jmrp5}{mfbbd z4Ned4$!N1LrlYZ|SU=v2)U9Ix-)ldzJvdEB0%XBbY)-2*Inr1j)f2Nsj%p?f=gE2__Ac5gxaN~ zm87r5y~7c`IJeo2Boek1*Osou4`~%%P`&GyC2NyNXtxjQ7hbob*^; z@^Ne@+wFr^2`QRpwLjBhjaGo^c(}>+z4#pKMJznetA6Cml66u}H;cQV(Pc}2Ga{0f z`FXARJ1lW>vx`XtZ5CFk{|kokQCg)d@R#aczi8Qo8OzTxNo2;7OryGjC!> z1h1K2T5k%(iFi!5h+tg?@gmMFwFf0(ity3zTA?7bQ&P6!Ra`4wx+pWMUJj2Fim~Pk r;naHd?7*mO#g`6E8BbsW;{^W!ueC3$%fk6|00000NkvXXu0mjfNv?h6 literal 0 HcmV?d00001 diff --git a/assets/images/tray/macos/status_1.png b/assets/images/tray/macos/status_1.png new file mode 100644 index 0000000000000000000000000000000000000000..3083384d497feb1fdad380c469a7f8552941e413 GIT binary patch literal 323 zcmV-J0lfZ+P)z+F2)9?I#XHfM_VHMAK|HpYF*bav?+@o0) zS;I{L`oDHrppCN#kl`4O6fl8Z^di6=Hd4qCHt`w(J_4TX%P=l}xyB);Q$Uf~2ylr- zjAI8!SWZDr+y>wgols>btPA`}i6)O>C7?^Vid6Z4fpV9m8v!z0W4T6`E+J5DxX@d%;626Sg!-^`RUX7UmH{J;S^ut|D;Yu(85A$(>D$d VQ%c%DxlaH9002ovPDHLkV1lY2i1h#f literal 0 HcmV?d00001 diff --git a/assets/images/tray/macos/status_4.png b/assets/images/tray/macos/status_4.png new file mode 100644 index 0000000000000000000000000000000000000000..935fb6e8c1497d3499fa9cf2fe69606de25f2c61 GIT binary patch literal 400 zcmV;B0dM|^P)lXaQZ*0nPeS@`V@&blJ%}3l?UKoen z6P>5fANsL~>rAaWhaFsMoX>Mz1kLR8UEvhFxJpeaz-7>!rN|%A)Kz>AwGwgbd10JU zoc@3;Xc&yRYHE`*PJ&Uh8C8O2iP!I_7WzH(;X`U#4k!49Lu|={ufrGF-(R%J111^_ udch}bCrtJ+o)VNA7>?j$=#Owv^x}U$NOKfkZOdl>0000r|?f8{egO_lzI>{=%ELr zhyH*NnbeEmLw{tcAqyq8GStdNBocb*CF`w7g)mYf!VC*GD$`6?cV~9)%MXX()%~wzSuRs8mcY2lu#vGQ+s=C+|q8ouMG|zPk14%Xj zn1i`?PXi1RmjWj}zY~qbK;C=q(-`{$;=u;-W|-#r+a2E?nHX}nB-Ke2GVfjYDGLX& zIHAHeWN!AFd!`o_y;b?>KLb_Y@42-Wn4gi!L<-xGxu$25@f~Z|WOr6hCIXT$-k#n) zi1o;Et_diM=uhH>7?|0)BH~JeXJy2ZMGL*nCXc-isIs?bSr9m9+q*toI5d~+buf`( zw&IR~UEUdJNa~)k>Um@iA%XM5vvyZZ`+zf5L1d8{<`L=hcP7;|~uj#KQt( z1Lu*r;xJW3>PC9VNR9}HsSCVZe9e8XAXCKF83hHAHCFwRMIewhHd43oW^&lwh&5{v z2rauN5>bDq}^N0~6Gt*D(jGhTJUzSb4(x~uT-!2{;say!t zjjS?!6dxw@Vs1z2i!jrPtqXpX)uAvL1jdOj`^X2WRN9Nn8A*$yY$Yv?%cZoL7x^0% zy>;YqigG6q3tJL+=4*Y#cZjP^)in>XR+{PZA+4a;zt79nSBeKJY<|A;i10$@EWSO#V`GA>*33vhJciubMgjNnGrzp2K* XC_644diT`T00000NkvXXu0mjfOs7$X literal 0 HcmV?d00001 diff --git a/assets/images/tray/unix/3.0x/status_4.png b/assets/images/tray/unix/3.0x/status_4.png new file mode 100644 index 0000000000000000000000000000000000000000..9b7a45859ea664ad25fc24d3e620c599e8d1660e GIT binary patch literal 1877 zcmV-b2demqP)w`xdjd)ej2_rnZVoDyhb%T117^ zQt^Y8Vo5Xw3neHtQJcDzR$5}?Ra(r?ftu!|J9Bd9i(e!6mYyeSsKf0PY3B*c%%Pl1qxuup|9t)mm3UYx*`Asi0ef@xGI!MchuQ* zw1O+}8-*XaX zR+C>3Mf85V5EjC%qYqyCYhG<_S2KN|b;~LplVe+Dzf}dkB5~Lpw_&^=@m54ZVp?$C zq+g-ngGje`8mc2;0Ggn?{Fc6FgxwiSO=6Ku^hK{FUYEkH!xNWZYgbeL;t_4vAeo&; zs38=|zT~ye7U(K>LQ>liD^Hf*>l{BrY-|eE45)t2tV}zGCocZH9bIijY-zIpbI$SG z8dSAqfg&+y#GE}lx3{#b9c{TIwz$7<8`yJ*Vk5z|ebo%A$+*ex8+^3ke zH##_t==Fk%k9$B}8MigWCx*XMK9N;p=2Vn0ho%s{S38pe8$w;R(+Y}8HatDQXK*;H z&P2rixsOt*xAcgK&8ntZ{V`{q;D$y(Eup+3Y4!Iwy$Zo>onsuBTqx`;#jbCRJU+J& z9Hz)U)dD%0aoO+4&zs%R>>4l1i-#IXcNyaQnI$$^^#xy=rW zgteJNIQBG*WXfJ?>6qwvBdDljq{pye9#USMf$;fcOao3j_P3ig7+ z689va0Gw?b*}wF>kKRP_Xw(Anfw;h?g5epNodOruJnSL{6%;@p5%zh6`J}i^n}E)O zIe43SLJ5ux?OopPVRmZno25(0b~H@iNS}!fzIweM(1Ej&G>@hUGLgvnhRt!(drumJ z-g zb*(fftux2R5noKTk+zQWNAm|$P>|HrqY-EWwhOjXy~n6KDjBizp0tr%$1LWaj9`mT z4BTvCctfot1XYhTt)WsQ+3-4p4LQAQ_WpsL(YPtJbE8v>FU1)W&#|R_efLUsAZpy} zRMB#d&jUCIv=C-r9(`)*M^TQnv*KNup=dS71FDIQ7LhbL-5uvhRz+v$>q>4DK|ZPH z4b{MgUpDA3ag4+sQ&+IZR>FedeKAAt1O}shY5}Quf$)WD?5G)&y%wG0jx#O$+RgHN zY&zAO)I$N7)9$$k`u|ica4J+`EzMqGIV`RexCN&NQlSzl4uNB~<6sW{U@aD}^}!JE z>{QNcW*?QKJ0f~Lj$7l4{}+!+X>(2>-TY=XMXNJMO@So*3t_Nne?d}N`lP;nbRP&Ax#Yl@A#W?tDWDk$Ned)XLlSJ0mv45bP z-90?W%G!M<-7hhg@u;+5=#T>UY>P)$_CbchK0ZOqpQ5x|>5fV)VLwVE?AgBn9A_}63^gVMQWghqL z<%gMjd+)jXoO{lhb4NdPeL3gub@pEW^}l&A{IZKGav7$@JExe_&t#BrbNU0Zk|$@UW^sPtg!Ts5*+l^y_;e{03Lq<4AY zuSwdeif*PSe=Pk3@Z}kM3asqAt+XX68%|OU0cb^Tt|JHFRB_C+`K{SAlC%ReU6yB%80ddT$6O!69 z)On~p6OfmB37@&XFrlf-77QOOChbSt2m;7&O|KGVgNK}IuR032D7|=e+DJfImHh%A znrm{P%>)4yHf63=(FdreUVD71UDZ0AZWH#4Rh41y>S=y0^LnKI0X-jH}QQ4!AjT1|} zx)|hCuTuz@9#dK=$oZ;tH2?!J^}x80`gNcHhoVdb zW0F&^+l%r+gJY(?eVGFDC#SPxLvF;r@@XYVAIB{oNIj3rXCnvBB*_WUW&k-+Fud76 zRXk7bV*-y@hgkmop7Uanga7jypi7jTco-HRb$c(tB36PN$L$U>2LR*dY7N)ABoN5@_h z*xA&6NDR)D^hdP`F$vOgIp+o8J4^PI8PRv+*aR>}-V4rbK^ROXj?GvEiGv#on5_pP zpNV60x~{p<&rT~09ZplE3nlTgSf#_Q0jrjU%qpsS`RKazDjg1QTZeIu{2;pq9_(ddF_hySXb2=|Y;*5`ly$KqrmI$12BUgTOVHKZm=1-} zX-EtxsN-C4=!W!3_5L|H6HeKABln>HDLCJW;@FSV%LO)|bov4d)3pP1-m=;YXcTg0 zi$R#w3M+>mD&B9Q!A?+NB(qpueZ3Y8{D#Y1k<)$*6|Kl8hqe{|f=8^G#B}JAmgL~M zZ;B+>?vzl*J^se+$F+39rnD=TUC!k(ZJ0T)^_Q3to zPk_XmC{|^5C)Wuq4;8#Ew#%Zi-aBX2gQ8`X8aaD%cNdp?a#x@X-ghJqtT?~y`b~s z*qpAfC)e8cBgglTa$=ojjJF=-nxmn!kaw1BxN7v)zSE4DMaukrsO01Kx z@pd(anKwo{kN4Dv2VlS}#3;xPRej50dY#7D79*y&JRC9d2^IY=(@P}LCxXiOpcZ@M z>}tf`I2;{Lzlw5ROL8na@3npBg{r^x<)Md57esUsXhG1}`qX8>`C_EUvVV0srtY9- zlH~Z*i$@Ni>_hb_pkdEVRVD@b!{BL!U&*k2N!FL3(K|Dbh|#NSz(P3Nc5*^v2yV-Z zL1Fj&9mOkb+10)tv_5l|QQa3ESVG59k|ZZ2235YWU}y6?nKg@5jGod249|VL$nn^T zhQVJKR1d&WJq?T44^VYFe^ezO)=;A4#MSvd@1yg?vngZTm#~PHpx(8G!;b1Ql*4lg z+S^vtSP~YWXR*JaBG)YVS^jM-5}3vGaoob|*+oVwzT38@!UDm05}kRk`(MNozn*P$ z8KRA!q}R=a9GvliuHsuk^P@yg+K;ZyJfO;TK@QDDpl#IYQ$yQJm&bN$MiMzl?kdAf ze?i!q96a|;@ytgTEuNJfm9?>)@kk;BW$!K?6!cr@%|nlQwNP-bh*;7XcN)tkKpzv4je`#t_q7}QoDfu%cdnwlFPptog)30K82E5V za`r1chhewVJ@3~uyBu>Ob4rj`^4DhvCo$&;&PPzwhe&Q_nRN`~jDdNtT;cVTwwLy| gYU_lYkpEBlFEI6o{vhWN?*IS*07*qoM6N<$f{LgM0{{R3 literal 0 HcmV?d00001 diff --git a/assets/images/tray/unix/status_4.png b/assets/images/tray/unix/status_4.png new file mode 100644 index 0000000000000000000000000000000000000000..a79b5160cb82d5d6f5e2970ac09290ea5060b57f GIT binary patch literal 618 zcmV-w0+s!VP)H_Gdgktr|P?cZK4luTqsW*X)gFvM>p2jK_6LdD)=*U|whW z9{@=`9+ERBw^wriW@!{j+JkdJZo|E!$!uQJSq73@k6eFoox=@D;2>vBsdkrX3XU4} zuCt`}0>|272R`i+1jmT+hoOLU*(#tPA9Vw zfC!%zY)Q`dd_JX+*1n>kB{u9!F{81#E^#d*7%|stHXz6b&24JSscfn`-5e>+NazP! z4h)Yr)ObkeF@j)I=G zGdYiQ`2Vv6IG@b*JP4597cv4^z20?PAzE-}S5Ca?ZaoTis_$|0&9>3WgL>|)y;blN za(kDDn6qq(2J?_oN7?o3YUQW`M+jif$Lgcx!~bgk0V8XG5vEh5<^TWy07*qoM6N<$ Ef~NZzivR!s literal 0 HcmV?d00001 diff --git a/assets/images/tray/windows/status_4.ico b/assets/images/tray/windows/status_4.ico new file mode 100644 index 0000000000000000000000000000000000000000..58343d5407301d0318597084999e46cd059635bd GIT binary patch literal 8800 zcmai&Wl$X57On?(mmzpaumHg=IDw#p1q<#H+}(p)fFMJ#;O_1+KnR1o3@$+jce#Au zxj$~zJyoZ=x^}Hv-FtUcKkvKtUH||B01beT4|o}A0n&&7fI9#HASV7#y#Ernz1(DE z|A`}z0DuA%0DzYEKd~Y*0KkR;05CEA-}hf~UN`^%2giTn5mW%6lMnz97ynN@gAM>h zzXAXvRg`3KFv&4rmco&fl~Q{dU%miyfQIL@WJpXg$bQC4MOmHVB zC7~`5YnLQu^;wm8Ab^pbWT|~};H^SWk5~?!wgg1t8=V?5Onq4zh!*YU5+$WFty5uC z^BD~oZZFyNo{lLPztwDF%ucf_wClSn2vjR-5%6xU;c%yo zorB}SVWr#2?(}0`$44+- ztW=t82ul@2-WHuQ9yPEAHfM{sZ0v-i{hnnENpQM+#b07f*R9hVvXgfb{SzL)=)cLp z!j$GL_oGa;x5{T{@sXKcgoFKpmqk!@*c2f*r7wRD z<=Vw$y6iP+OZz@v9z)G_Zkqc9jg9<$3TK)r4w2K6SKV;fSiTagHPEx4sRdZLBkiXD z#A~te)=?nat-fJGFoh;-tyKl-Vqj31e+ml-)!@g?tr3}T;SW~2vhr`4`0GTNZJi5M zo-iWc{@8Vfds|nQ5u1dj7?V8$PDq->VaQ|CV}H*p_QZb7u4N)Lk3PY)^uh%|PFhK- zQqm~+zd-bYEr9r6h+-qDUO4>!Awtal4@9v)+HBM%ULdkOhLzuQFi=QinXc!l_VkX* zv(!{ih>2rioz5`vq3W^ngc)_CSs_cO>2n$ ziGWx!XjE-;Yi(xhG+idELqn8E-N2Z|xR5qTCYEIzt1QkIkBQ;Dz_d`4IJx$nDboZY zD{Eg$&xW))viU$TpRSsJi}`~GAriC^D_q(u3Ue_J6$Klr#-U>JnSvKZ@$XbVj1N(vBTWl}E}0@#jJZ@WwcH~$Fdo|C_) zRAi&VHhOmr*bwj)#Jj>U7mT!;pBMVmL?nq?E;zG9(R5giaD*k7RfzZ2^LC6(bX{am>DGw zOSWPZ-|CzDv(;-=HWSdwJ+M%RdFuH;NPjV80NKB!uX%{v{5R5(ME)UNHZnO`A%aqa zIIQdQ40rlo*4}XpQ4*QV06slMlf52ORH8H=(Xv(TA4wnq4?ZUXDDPYI?_n8AUn4Uf z+ScEa#1rCt!E0*BhJSkp$r9x}v^H?*pgUxMRr;Bm7;ZlGQU3kK-cqe+k`N&Xc6RLuqM~E z8u^__*l2x3`59eAF+Jse?Q> zuIHRVJ;k-mkyQE}r}~tRAv>DU6jwUZCEHuqbIJ6%IcaJe)Gy?5g1b>Nsd4#pGkClD z_G1OuG8{9*2SgkwdB=A}qliGXuk2e*VA};MR!t>)A8(x0JW=Yv6|?*WrVNepSCAtC zkLwu`&WT!__*?3msb$hXKhy6kB$sl|jZh@+Jp^ydkN2h*YE5D1npW$s*6&ME00n)j zpf~IF5lwG2yxVpS*KKS_mJ(Ff-MgI5XD9X456*Htt5s1-Clo@ZL z_}WL{Q!hQAsS*6{hDp7P1C^w0y}ckL+pnTR{#qjFnqPvXuM;xr7ksktJlJq~UeS7U zKBqxM2$!H>s*Nb$>UXHM-F)sOQz_OH-sX0hWI2P$+& z8!8zXi?z=12o9704I!D`Q~C@au+xjyEVr-N0CtbUysNF#8oBRj-XULZ_ey7=O@Fq; zA)e$<>Y4PrA}#AE4|+x_TPxqPP~8QQUnmxR>$2aqy{7ft!AMLB)W9RP2uWjHZV|f- zuwvO^`Q>3>!tQ59LuAPrhUpq9BCpNj(2SGQ?&j6=uKszrvkO6$0wfpK%e?+SbV&P> zr~OxlF5cc3|4oO;2><9%JG4Jl*NW2ZpNdn90djYD`NH1+)#jJMc+=O{C?wJ~q?ZyW z!NH^ii)btx(YbuB_4x*p>^USAlkeU|Y=lU~MyRPyc%oc_mLk+`Ya$m$Y!U6;pR(~5 z$`^*9qnenHecc>BPrE|reyf7#h5{qQlZe5m+Y$j~f)o&^*RSXDca*XIl4pDMK{ybxS{Q#@ zUelAPQeQOD;n04+q2_9vJS&n1v>3o7&{|5<&oS+oO>1*u5aBu=CKw z&JYz7-pxkt!qs?5tBN( zH^@vwd4&X~4Dw6T>At|yPi-0hgm1~Pe?V8ycn=Vajwd3*Jlg3!UbE3&LjcwmGJC3@ z7mCUvqsbsFFM2IQA7bHS?c_h|1eJ*?1Di>myb?PT@=w<;1?~}cMI`_R%)%2bz(NZ8 zh)N@-`vgdjO*jwU5OLjJvI3G5RwMNMg&g16VHd(e~gCf1_mOZ*P3m;5bc1bqH z!A6Hg&kJNjNaN={Pdrm{GdJGY|9n33v5;GnQ+TpVgYO6QoO84sL8lNs0#d zJ2f}LX|OJP=rD52>e=p#BF!=0@JT63qk^bqbn8*TrsqbhS$|NR1R@y}v%=h~kD4n+ z;OGOuFlAUai@Fd`ls1X^$M-Eu=?%vC{3f6)8)8b@F2*$RE8TK46;QDCKsQ-jblw}y z$-5yL=PG7bnLre5uU%T$_qC8bISw~GMY}PQw3(cbyhOFAPX2mj4G$XSg9|jWs~g=? z*6t~63Wik=u`{e33DIUf;vgon(s%2+kKNp{DC1YJG2v1fv1J-~C{iAG z!V%*uWc=nPzSNY~%NDN;-byT|dkU|@LyuX$4%QT+{rF~W^=p>o3KWp>ZQ?>jhb)6! zx{-xMSpGi1$$<|^nGKL*$?BzM}|Kv;fTcAn#%(p=*v9^ zhI?4}#88x^5diNJ!K}NpWu+87nwxq@*n3nie;LcT)R?qZnmh!YxV)4P?tnxbpN^?) ziyHoim%JRJ08IaSNz7BT-GB9xe~&=gp(ANF5$fh-g8^e;n5(75Tu0h#u!Se{Ye_=I zH*_Q7V#sR#b)xG2)ljHx;+a|@c)*)^SXW{^oJldan1cv0gb=fJEoB{rou=H?!+p2B z&}SupW~Y|>019@Q?grv=?}D!d$KrRrp2xg)Lv4JuGloQTC$YYL`eJlI4zzfV+bnY@ zZylpgr426ZTybkm#L^PA+_yUKX)6EG8KYXUJG(NUJ3THAhTOob^CScv-4K{)u-au+#&?7^U=exGdc+IaKDcXmn)9m86wnC ze0B-xzCuc(#igYEG_+uuIjk1F35n6$$co()biO-Dgto{!Zl_MQ@9=gARgves%rE@i zjW$d@)RABvIZ>Zyw~(jm=#i5&r%O<90^CH&9D6pP7xsy6K>E*jEVLixCy&IrdH8SO zQwLC8?u*!C8capEg1}KvqP^o{aIq%=1}-G_w#>G3cUjiRB&bC1ZSCg7FtG`{Xw^Ht z-!87D#3igDd!o-ys|tRvnK6uTzuA4ljw+cq;d6OafqBgyXZiADU5En)r63|+8x!5$M{TiXgMX< z=1Wf&k(8Dcf!8PeI2Eq5+_ZSs=Yer;!Q<#flZ}R?TtB=xQvmWG&(eT4bDO!Es8<&! zqT-6u`vJ7iER+#wQJhf63lPHbp5;3#Nsaq2=<_7q`p$yMS`Dlr5^FmQtemT6BLaZf z?H>*O4)O9Ri0P zE9@*5;~54-ijq;w@}-i*qF3s%YVpcU^NbnQk|`g`W`w+ZX=N01EoP0NoLU_LUK z0nKm=-Cx@T`GNtFEfHv@eK;!nfO`X?C4^wf3~^W=`Ga49V#1Sq)|W|+-t+d=gaVU3 zjas4ih#uH5@KG^|(BzQhQmYGNkI%QS@cp!L3vW?OJu~sZlm?MD`mDq=UFa}d)8rj5u}bM|A#;JX+Go|Ii?tPBP`Ee z_uy(&aalVRMf=0kmvK*)0sSnO&2J6caq_OSCr>=CIc_#rO)Rlap!jFN(R03f76AzM zKTPzc^911d*F?1tZ^QqaiJ}Jovmvtz+6L>|Qd+(W%XXNhps|><3}<4J`bvV{v6f5x zI-iHBr9^=EEw0v&Xu7p9!W`+`NG6>EWPa=7pveeHWvNmt4GdKkpU;>Q>rtg3>6unc z0!ika?>txDc#Q|JTf_IWZ7Y4yW9VXwxNoBQk?(!S^ZDpQ(Jk<4uEk7=w;)|mE4}jZ+ zUk9wRj|lUz+L(yfK|^}A6dUw*7nw#@S~%3-&9Oza9^`iN7vp1tKW5?qerKXY1B{Jbgp z_jcnE9MZ85>=LVA(%ZkbJ4sc`f^qqKT61zLPX>GI35sS?k?Z+WFLb)02i8Kg0;zUx z&`r?P0&q|KfxYJKf_N+YN&0AjX z96rj{orYl${bIqDQRO0W2dUnA%8hXrN1_zSWrPrf^ym+e?$<|$#|Rc zSVqRp##YT)(i~KLy=Z}Xc3IejfzS6a!`Oruz1e!-bMLYfhgFQHeR$};+y$HOed{GV z8YTWR;fy{p!W>qoJpdt2YvizG$2}8isG?xGyKd7(At0W|anIj3;I~-(JQ(>sfv;6X z{}gYf_uh2Y={+B5dy2#%o`rjO&oz1e*HL|}8*hQaVDsxIiycxVaOk90kHRm>*N#-N zZ#iZIm2(d~&ogH>%0{46rsDD3>IP>Hq;h5V zsZWB%-~zYV&fIYEHiWiJ4W_Pf-wMacE@t9NL(kUkIYH>VBr#UeDS53zkq_A4!sx{nhOReLvD;^Syy~-n#g|ZoKkLF zhN-;OyUDZ(f@@MF99;3t*{o`h5|8n6d&C3(oRTC2XF^ty0h~6Cu*%ulQ0RS^~K~b=Gk~@+x0Fl5B$_>g(1Zf`fJ@O(53- zy&P4Y)G!_W3G1cp(=imIv=vjuZoK>;4aB^5tm4V%N|4svZjS1BOj51P%Rh^j|T`dq|Q0 zUKMihDDgHPHh^sQ^;agl@Y{=ZENeq`P@UePM-kc>`#IO0sQPsjH0oBSc+|T70=ML0 zvF&hWb6)Tom9JR-Typ?~^>uq!*6N?xweXr%@jAP46y?grD?iuzww49i*EkCJK*nnz zCm7DoSKZLO{&}>HNTs6TvVK8GC~hE=cw*0+_jsB&5`=|PsO}>*glRBLVsF?NTIb|g zjvZ_mpZAA56{9IsK%r%{gNZM>_CB9%n}f)Q$|?*P-=*w<*EBCDfWygfFQ&Bh)#)6r zr*yXrMH{-W?M}jz|F4DRcwa;Mj=z>&)_>nedW7~)-L)`xeVZD~UpM8B@6bO?HU5mc ze>&SlHY)IZ9(0p2gsZZ2J(r7OU_K-iebd1I!{2+qCsY!+pj|_cn*LF+odqjz&Ii+7 z*Ju-vcvk8SELzoXHxe|-yna}P1Ryvr{84i${mbw_ikbLJUi{x;R%-3E^52RXdelF~ z%roQYXFv?G<*Pxlu|rr7?AVFYBYgV%O*k)kx_Z5QIVfG7gA*ljK=v)_=hzalQcyz# zC;H`&13bh~9b`70g4bkhyo*L`MH6R282paQ z-Eyo*Z$Z?4K(55}pts_-i}4aH55V+sxd*k%0T3Y*%dXaT=@MiVL$KHN;I9;SONjhW z?i2&`rw0YENBi`(LbI7!qQQp*N4Y{c&o1i-hi7%JU4kWWb;Pym-LeynpmJ94p%8p* zyojXeG*8}02U!**1~(SRWk!6+AG#wnV_e_<1~Lx|nmXe`A`Hd?fLRyMlOc->+XmS< zEvfbwl`E~HE8Ojk8)*Di}%<_J^=9|ovrGe@Uf)g zy;;Gie^Jxr(+A!xF`LP-PKUT>F1C>pbMd{btb+=@r;oOtzU~6T5z@VJTTNS{*(t~B zT(n(nmJLJxaeo2neN6(Z$ek8QVX(=jikqYSMItmK+m}Wo0~{lgI>%bR35UklZUOAk zmz&Vbp7>Xp7;u!^W+lQtyr5gw`xTMmQ2T_Sk_45m%K7$>^b+IK%=?eIoH?@g(LWG< z`(x1Xa>v@Cct6%Yp*~f+28@}f*FSc<5iTesOxCRH>gn#n+k2*d2-)vKEg%|hMeg^SvQapSpD8;`zJ_vsdBzpVHq_51TPJ}D>j%~9^`bnGsCx; znmC`KGTXJhZw34wn|6a{`$w}7T_6_=wpUf|Nit%04mA*Z?o`rE-Nt)|4b@bZ^xrE1 z`^bcPl>n>@QW$3icdZYjhvBUD&sg{rYz29JW2gsFPgJ+N>72I;QvhTVA~+_gE+eb< zq3W;j@BAM&(V6Zuk(&K8Du^22$**dK*uhb72i7C00Bf~rk9d$gtj3ht^bx9W!BJ~I zyL^^35{-P2)tJ389#f|GiVw*-WaU6Y1nME`7)(4PS{HYYal?LW`zkR~tRuG2w!D0c z5be))5+pUh>sG33-rD9Q|MXiX&8tsBFtEbBJBre-u|6DK2`UD~*d0>w6oE7hRV`ix z$viYk*~K87bN7GVi0cTvLOx82k z*2NT(zV&>;oKp+t-#}%jMcU_MENlu^fPp3t_ROopcS&;^6|5mi6#0!*IHaSLH&X7& zE&UBjI?g*-EpC^TiJ)};$D$w+BF10=B(303s+LPhH2ibAqMQKlC|50AcGp}eSCAdw zU9(ZOv611av6#(?1v+esm^vGV>kH8^FuP$$SA_P}utc~i0p3C(z3yt&Ga=CQE>ivC z1^vqfPWQ}^s9Gw%k6gHOkVbcEIa>``>>GTVn!`U5S5rAhj%&2@y6^lNIl6Y6p_M%o z+ekVHtX49%3O7-&9xLw-3_rGy7xytZJ^0h<&Qn3i?~P6T-`*eE;_t-0^e>Pq?G7a5 zFWW`#}9> z={zy~>2UqnJ^b9JP7peo&Awf9SABdJ&=G_@Zly(I7IpOKK};QaCY_42NqY32Qo_Bd zD>mhGc|<0H-61OP%=h3+_mKHJ&N+jqrKq0lH+^%i5ChCX+`zuk{>&)FV?U=Qg5z|R z7qy$0b7DFzS??yym)v~{v3(}gnjfe$9LhX|o+PcbMHT(LWnWIDNFS{>h|>)s`^=iQ zsi>EAgV1g8ofwy<9dg8FI_3%%$@^&`N{XehSMp)4`E#z+VI?VfAa@^3L5TZ`dbd5S zh*b%j&(^SHp@hxpjeCW+C*ajbOHq+F3RbPf+P#=z;ne`6V;>3hFX-D%mwN0AeW);k z)tSMNc%)ggwdl?RGeW1$@t}?P$MV-z1Bb`&d>gnBd}kaGZ_qykA1?5<1!hW=2O)6$ zRcMD?MuP73jq}G2$o(!@&W2S7<8vb$rXiYm6Y#I)tektlez)~>nisH{R&6QCmLqPH z&pK$-%!W4evDI+ixqOee4BVg$z~ul1UQhoG-tdzskL)BMhh^OJZiFQChXqOpL=k&F zZ3h+fZy!B5(Jgoco7+j&;qb!)j(X;9(SrupJJYue-$WvgcXeFQ4aln3_zT^A&R3xc zCk+UAsCkPW*+r}`P<4Ydo~IhP+x=OK#@UYO#&il;n=Mx=<^-@Mz|%{|Dv&nu`Dc literal 0 HcmV?d00001 diff --git a/assets_source/images/icon/glyph.svg b/assets_source/images/icon/glyph.svg new file mode 100644 index 00000000..0bd51307 --- /dev/null +++ b/assets_source/images/icon/glyph.svg @@ -0,0 +1,11 @@ + + + + glyph + + + + + + diff --git a/assets_source/images/icon/status_4.svg b/assets_source/images/icon/status_4.svg new file mode 100644 index 00000000..f84510bb --- /dev/null +++ b/assets_source/images/icon/status_4.svg @@ -0,0 +1,12 @@ + + + + status_4 + + + + + + + + diff --git a/lib/common/common.dart b/lib/common/common.dart index cfd9ba10..7a49c0a2 100644 --- a/lib/common/common.dart +++ b/lib/common/common.dart @@ -9,6 +9,7 @@ export 'converter.dart'; export 'datetime.dart'; export 'dialog.dart'; export 'exception.dart'; +export 'feature.dart'; export 'file.dart'; export 'fixed.dart'; export 'function.dart'; @@ -37,7 +38,6 @@ export 'print.dart'; export 'protocol.dart'; export 'provider_reader.dart'; export 'proxy.dart'; -export 'render.dart'; export 'request.dart'; export 'scroll.dart'; export 'shape.dart'; diff --git a/lib/common/feature.dart b/lib/common/feature.dart new file mode 100644 index 00000000..386d099e --- /dev/null +++ b/lib/common/feature.dart @@ -0,0 +1,22 @@ +/// Features whose code stays but that ship switched off. A disabled feature +/// hides its setting and ignores the stored value, so switching it back on +/// restores what the user had chosen. +class Feature { + const Feature({ + this.sidebarBlur = const bool.fromEnvironment('FEATURE_SIDEBAR_BLUR'), + this.customProviders = const bool.fromEnvironment( + 'FEATURE_CUSTOM_PROVIDERS', + ), + this.customProxies = const bool.fromEnvironment('FEATURE_CUSTOM_PROXIES'), + }); + + final bool sidebarBlur; + + /// App-level proxy and rule providers, and profiles offered as providers. + final bool customProviders; + + /// The proxies a custom overwrite keeps in place of the profile's own. + final bool customProxies; +} + +Feature feature = const Feature(); diff --git a/lib/common/function.dart b/lib/common/function.dart index 7725c9f2..b70a5e61 100644 --- a/lib/common/function.dart +++ b/lib/common/function.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:fl_clash/common/common.dart'; import 'package:fl_clash/enum/enum.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/scheduler.dart'; void _applyAndReport(dynamic tag, Function func, List? args) { void report(Object error, StackTrace stackTrace) { @@ -130,6 +131,18 @@ final debouncer = Debouncer(); final throttler = Throttler(); +/// Frames stop while the window is hidden, so a post-frame callback alone +/// would hold tray and hotkey work back until the window is shown again. +void runAfterFrame(VoidCallback callback) { + final binding = SchedulerBinding.instance; + if (!binding.framesEnabled) { + Timer.run(callback); + return; + } + binding.addPostFrameCallback((_) => callback()); + binding.scheduleFrame(); +} + FutureOr handleWatch({ required Function function, required void Function() onStart, diff --git a/lib/common/launch.dart b/lib/common/launch.dart index 99f8f55a..1d5bbc5c 100644 --- a/lib/common/launch.dart +++ b/lib/common/launch.dart @@ -47,4 +47,4 @@ class AutoLaunch { } } -final autoLaunch = system.isDesktop ? AutoLaunch() : null; +final autoLaunch = system.isDesktop && !safeModeBuild ? AutoLaunch() : null; diff --git a/lib/common/permission.dart b/lib/common/permission.dart index a5e5e0a0..fb6b0adb 100644 --- a/lib/common/permission.dart +++ b/lib/common/permission.dart @@ -81,7 +81,7 @@ class Permissions { } Future checkLocationPermissions(ProviderReader read) async { - if (!_supportsLocationPermissions()) { + if (!_supportsLocationPermissions() || read(safeModeProvider)) { return; } final res = await WifiSsidManager.instance.checkPermission(); diff --git a/lib/common/render.dart b/lib/common/render.dart deleted file mode 100644 index 68f757bc..00000000 --- a/lib/common/render.dart +++ /dev/null @@ -1,57 +0,0 @@ -import 'package:fl_clash/common/common.dart'; -import 'package:fl_clash/enum/enum.dart'; -import 'package:flutter/scheduler.dart'; - -class Render { - static Render? _instance; - bool _isPaused = false; - final _dispatcher = SchedulerBinding.instance.platformDispatcher; - FrameCallback? _beginFrame; - VoidCallback? _drawFrame; - - Render._internal(); - - factory Render() { - _instance ??= Render._internal(); - return _instance!; - } - - void active() { - resume(); - pause(); - } - - void pause() { - throttler.call( - FunctionTag.renderPause, - _pause, - duration: const Duration(seconds: 5), - ); - } - - void resume() { - throttler.cancel(FunctionTag.renderPause); - _resume(); - } - - void _pause() async { - if (_isPaused) return; - _isPaused = true; - _beginFrame = _dispatcher.onBeginFrame; - _drawFrame = _dispatcher.onDrawFrame; - _dispatcher.onBeginFrame = null; - _dispatcher.onDrawFrame = null; - commonPrint.log('pause'); - } - - void _resume() { - if (!_isPaused) return; - _isPaused = false; - _dispatcher.onBeginFrame = _beginFrame; - _dispatcher.onDrawFrame = _drawFrame; - _dispatcher.scheduleFrame(); - commonPrint.log('resume'); - } -} - -final Render? render = system.isDesktop ? Render() : null; diff --git a/lib/common/tray.dart b/lib/common/tray.dart index f8b2bfc8..a371404f 100644 --- a/lib/common/tray.dart +++ b/lib/common/tray.dart @@ -2,13 +2,13 @@ import 'package:fl_clash/enum/enum.dart'; import 'package:fl_clash/models/models.dart'; import 'package:fl_clash/providers/providers.dart'; import 'package:flutter/foundation.dart' show visibleForTesting; -import 'package:flutter/services.dart'; import 'package:tray/tray.dart'; import 'app_localizations.dart'; import 'l10n_labels.dart'; import 'app_ports.dart'; import 'constant.dart'; +import 'keyboard.dart'; import 'provider_reader.dart'; import 'system.dart'; import 'window.dart'; @@ -44,14 +44,22 @@ class AppTray implements TrayPort { } String get _trayIconDir { - return isWindows ? 'assets/images/tray/windows' : 'assets/images/tray/unix'; + if (isWindows) { + return 'assets/images/tray/windows'; + } + return isMacOS ? 'assets/images/tray/macos' : 'assets/images/tray/unix'; } - String getTrayIcon({required bool isStart, required bool tunEnable}) { - final status = switch ((isMacOS || !isStart, tunEnable)) { - (true, _) => 1, - (false, false) => 2, - (false, true) => 3, + String getTrayIcon({ + required bool isStart, + required bool tunEnable, + required bool safeMode, + }) { + final status = switch ((safeMode, isMacOS || !isStart, tunEnable)) { + (true, _, _) => 4, + (false, true, _) => 1, + (false, false, false) => 2, + (false, false, true) => 3, }; return '$_trayIconDir/status_$status.$_trayIconSuffix'; } @@ -77,10 +85,14 @@ class AppTray implements TrayPort { getTrayIcon( isStart: trayState.isStart, tunEnable: trayState.tunEnable, + safeMode: trayState.safeMode, ), isTemplate: isMacOS, + size: isMacOS ? 18 : 16, ), - toolTip: appName, + toolTip: trayState.safeMode + ? currentAppLocalizations.safeModeAppTitle(appName) + : appName, menu: _buildMenu(trayState: trayState, read: read), ), ); @@ -105,19 +117,30 @@ class AppTray implements TrayPort { final systemAction = read(systemActionProvider.notifier); final setupAction = read(setupActionProvider.notifier); final appLocalizations = currentAppLocalizations; + String? shortcut(HotAction action) => _shortcut(trayState, action); + final showItem = TrayMenuAction( + label: appLocalizations.show, + detail: shortcut(HotAction.view), + onSelected: () { + window?.show(); + }, + ); + final exitItem = TrayMenuAction( + label: appLocalizations.exit, + detail: shortcut(HotAction.exit), + onSelected: () { + systemAction.handleExit(); + }, + ); return [ - TrayMenuAction( - label: appLocalizations.show, - onSelected: () { - window?.show(); - }, - ), + showItem, TrayMenuCheckbox( label: trayState.isStart ? appLocalizations.stop : appLocalizations.start, checked: false, + detail: shortcut(HotAction.start), onSelected: commonAction.toggleRunning, ), if (isMacOS) @@ -131,6 +154,11 @@ class AppTray implements TrayPort { TrayMenuCheckbox( label: mode.label, checked: mode == trayState.mode, + detail: shortcut(switch (mode) { + Mode.rule => HotAction.ruleMode, + Mode.global => HotAction.globalMode, + Mode.direct => HotAction.directMode, + }), onSelected: () { setupAction.changeMode(mode); }, @@ -141,11 +169,13 @@ class AppTray implements TrayPort { TrayMenuCheckbox( label: appLocalizations.tun, checked: trayState.tunEnable, + detail: shortcut(HotAction.tun), onSelected: systemAction.updateTun, ), TrayMenuCheckbox( label: appLocalizations.systemProxy, checked: trayState.systemProxy, + detail: shortcut(HotAction.proxy), onSelected: systemAction.updateSystemProxy, ), const TrayMenuSeparator(), @@ -157,20 +187,33 @@ class AppTray implements TrayPort { ), TrayMenuAction( label: appLocalizations.copyEnvVar, - onSelected: () { - _copyEnv(trayState.port); - }, + detail: shortcut(HotAction.copyEnv), + onSelected: systemAction.copyProxyEnv, ), const TrayMenuSeparator(), - TrayMenuAction( - label: appLocalizations.exit, - onSelected: () { - systemAction.handleExit(); - }, - ), + exitItem, ]; } + String? _shortcut(TrayState trayState, HotAction action) { + final hotKey = trayState.hotKeys[action]; + final key = hotKey?.key; + if (hotKey == null || key == null) { + return null; + } + return ShortcutLabels( + isMacOS: isMacOS, + isWindows: isWindows, + ).text(hotKey.modifiers, key); + } + + String? _delayText(int? delay) { + if (delay == null) { + return null; + } + return delay > 0 ? '$delay' : currentAppLocalizations.timeout; + } + List _buildGroupMenu({ required TrayState trayState, required ProviderReader read, @@ -178,36 +221,53 @@ class AppTray implements TrayPort { if (trayState.groups.isEmpty) { return const []; } + final delays = read(trayDelaysProvider); + final proxiesAction = read(proxiesActionProvider.notifier); return [ for (final group in trayState.groups) - TrayMenuSubmenu( - label: group.name, - items: [ - for (final proxy in group.all) - TrayMenuCheckbox( - label: proxy.name, - checked: - read(selectedProxyNameProvider(group.name)) == proxy.name, - onSelected: () { - read( - proxiesActionProvider.notifier, - ).changeProxy(groupName: group.name, proxyName: proxy.name); - }, - ), - ], + _buildGroupSubmenu( + group, + selectedName: read(selectedProxyNameProvider(group.name)), + delays: delays[group.name] ?? const {}, + onSelected: (proxyName) { + proxiesAction.changeProxy( + groupName: group.name, + proxyName: proxyName, + ); + }, ), + TrayMenuAction( + label: HotAction.delayTest.label, + detail: _shortcut(trayState, HotAction.delayTest), + onSelected: () { + proxiesAction.delayTestGroups(trayState.groups); + }, + ), const TrayMenuSeparator(), ]; } - Future _copyEnv(int port) async { - final url = 'http://127.0.0.1:$port'; - - final cmdline = isWindows - ? 'set \$env:all_proxy=$url' - : 'export all_proxy=$url'; - - await Clipboard.setData(ClipboardData(text: cmdline)); + TrayMenuSubmenu _buildGroupSubmenu( + Group group, { + required String? selectedName, + required Map delays, + required void Function(String proxyName) onSelected, + }) { + return TrayMenuSubmenu( + label: group.name, + detail: _delayText(delays[selectedName]), + items: [ + for (final proxy in group.all) + TrayMenuCheckbox( + label: proxy.name, + checked: selectedName == proxy.name, + detail: _delayText(delays[proxy.name]), + onSelected: () { + onSelected(proxy.name); + }, + ), + ], + ); } } diff --git a/lib/common/window.dart b/lib/common/window.dart index d0e1a84c..8e82dfe4 100755 --- a/lib/common/window.dart +++ b/lib/common/window.dart @@ -6,17 +6,18 @@ import 'package:fl_clash/enum/enum.dart'; import 'package:fl_clash/models/config.dart'; import 'package:material_ui/material_ui.dart'; import 'package:screen_retriever/screen_retriever.dart'; -import 'package:window_manager/window_manager.dart'; +import 'package:window/window.dart'; class Window implements WindowPort { static Window? _instance; bool _supportsPosition = false; + WindowEffect? _blurEffect; late final WindowVisibilityController _visibility = WindowVisibilityController( showWindow: _showWindow, hideWindow: _hideWindow, isWindowVisible: _isWindowVisible, - setSkipTaskbar: (skip) => windowManager.setSkipTaskbar(skip), + setSkipTaskbar: (skip) => desktopWindow.setSkipTaskbar(skip), dockSettleDuration: system.isMacOS ? const Duration(seconds: 1) : Duration.zero, @@ -35,37 +36,28 @@ class Window implements WindowPort { commonPrint.log('another instance owns the data directory, exiting'); exit(0); } - if (system.isWindows) { - for (final scheme in protocolSchemes) { - protocol.register(scheme); + if (!safeModeBuild) { + if (system.isWindows) { + for (final scheme in protocolSchemes) { + protocol.register(scheme); + } + } + if (system.isLinux) { + unawaited(protocol.registerLinux(protocolSchemes)); } } - if (system.isLinux) { - unawaited(protocol.registerLinux(protocolSchemes)); - } - await windowManager.ensureInitialized(); + await desktopWindow.ensureInitialized(); _supportsPosition = !system.isMacOS; if (system.isLinux) { - _supportsPosition = await windowManager.isPositionSupported(); + _supportsPosition = await desktopWindow.isPositionSupported(); } - final WindowOptions windowOptions = WindowOptions( - size: props.size, - minimumSize: const Size(380, 400), - ); if (!system.isMacOS || version > 10) { - await windowManager.setTitleBarStyle(TitleBarStyle.hidden); + await desktopWindow.setTitleBarStyle(TitleBarStyle.hidden); } - await windowManager.setMaximizable(true); - // On Linux the compositor only honors positioning after the window is shown; - // elsewhere position it pre-show to avoid a visible jump. - if (!system.isLinux) { - await _windowPosition(props); - } - await windowManager.waitUntilReadyToShow(windowOptions); - if (system.isLinux) { - await _windowPosition(props); - } - await windowManager.setPreventClose(true); + await desktopWindow.setSize(props.size); + await desktopWindow.setMinimumSize(const Size(380, 400)); + await _windowPosition(props); + await desktopWindow.setPreventClose(true); } Future _windowPosition(WindowProps props) async { @@ -73,7 +65,7 @@ class Window implements WindowPort { final left = props.left; final top = props.top; if (left == null || top == null) { - await windowManager.setAlignment(Alignment.center); + await desktopWindow.setAlignment(Alignment.center); } else { final size = props.size; final right = left + size.width; @@ -94,9 +86,9 @@ class Window implements WindowPort { displayBounds.contains(Offset(right, bottom)); }); if (isPositionValid) { - await windowManager.setPosition(Offset(left, top)); + await desktopWindow.setPosition(Offset(left, top)); } else { - await windowManager.setAlignment(Alignment.center); + await desktopWindow.setAlignment(Alignment.center); } } } @@ -105,15 +97,15 @@ class Window implements WindowPort { @override Future captureNormalGeometry(WindowProps current) async { final states = await Future.wait([ - windowManager.isMaximized(), - windowManager.isFullScreen(), - windowManager.isMinimized(), + desktopWindow.isMaximized(), + desktopWindow.isFullScreen(), + desktopWindow.isMinimized(), ]); if (states.any((state) => state)) { return null; } - final bounds = await windowManager.getBounds(); + final bounds = await desktopWindow.getBounds(); if (!bounds.width.isFinite || !bounds.height.isFinite || bounds.width <= 0 || @@ -130,19 +122,57 @@ class Window implements WindowPort { ); } + /// A probe that finds no effect is not cached, so a failure at startup + /// does not pin the toggle to off for the rest of the session. + Future _resolveBlurEffect() async { + final cached = _blurEffect; + if (cached != null) { + return cached; + } + final candidates = system.isMacOS + ? const [WindowEffect.blur] + : const [WindowEffect.acrylic, WindowEffect.blur]; + for (final effect in candidates) { + if (await desktopWindow.isEffectSupported(effect)) { + return _blurEffect = effect; + } + } + return WindowEffect.none; + } + + /// Only Windows 10's accent blur uses the tint; it draws nothing without one. + @override + Future setBlur({ + required bool enabled, + required Brightness brightness, + required Color tint, + }) async { + if (system.isLinux) { + return false; + } + final effect = enabled ? await _resolveBlurEffect() : WindowEffect.none; + await desktopWindow.setEffect( + effect, + tint: system.isWindows + ? tint.withValues(alpha: kSidebarBlurOpacity) + : null, + brightness: brightness, + ); + return effect != WindowEffect.none; + } + /// Every desktop runner leaves the window hidden until [init] reveals it, so /// a failure before that point would leave the error screen with no window. Future showInitFailure() async { try { - await windowManager.ensureInitialized(); - if (await windowManager.isVisible()) { + await desktopWindow.ensureInitialized(); + if (await desktopWindow.isVisible()) { return; } - await windowManager.waitUntilReadyToShow( - const WindowOptions(size: Size(680, 580), center: true), - ); - await windowManager.show(); - await windowManager.focus(); + await desktopWindow.setSize(const Size(680, 580)); + await desktopWindow.center(); + await desktopWindow.show(); + await desktopWindow.focus(); } catch (e) { commonPrint.log( 'show init failure window failed ${e.toString()}', @@ -161,25 +191,23 @@ class Window implements WindowPort { Future toggle() => _visibility.toggle(); Future _showWindow() async { - render?.resume(); - await windowManager.show(); - await windowManager.focus(); + await desktopWindow.show(); + await desktopWindow.focus(); } Future _hideWindow() async { - render?.pause(); - await windowManager.hide(); + await desktopWindow.hide(); } Future _isWindowVisible() async { - final value = await windowManager.isVisible(); + final value = await desktopWindow.isVisible(); commonPrint.log('window visible check: $value'); return value; } @override Future close() async { - await windowManager.close(); + await desktopWindow.close(); } @override diff --git a/lib/enum/enum.dart b/lib/enum/enum.dart index b62ea31b..6b1706f8 100644 --- a/lib/enum/enum.dart +++ b/lib/enum/enum.dart @@ -261,7 +261,6 @@ enum FunctionTag { updateDelay, vpnTip, autoLaunch, - renderPause, updatePageIndex, pageChange, proxiesTabChange, diff --git a/lib/manager/android_manager.dart b/lib/manager/android_manager.dart index bcbed285..849c609a 100644 --- a/lib/manager/android_manager.dart +++ b/lib/manager/android_manager.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:fl_clash/common/common.dart'; import 'package:fl_clash/core/core.dart'; import 'package:fl_clash/enum/enum.dart'; @@ -31,7 +29,7 @@ class _AndroidContainerState extends ConsumerState app?.updateExcludeFromRecents(next); }, fireImmediately: true); ref.listenManual(loadedLocaleProvider, (prev, next) { - if (prev != null && prev != next) { + if (prev != null && prev != next && !safeModeBuild) { app?.initShortcuts(); } }); @@ -49,15 +47,22 @@ class _AndroidContainerState extends ConsumerState app?.onPackagesChanged = _reloadPackages; } + /// A reload reads every installed app's label, and a round of store updates + /// reports each app on its own. void _reloadPackages() { if (ref.read(packagesProvider).isEmpty) { return; } - unawaited(ref.read(systemActionProvider.notifier).getPackages()); + debouncer.call(FunctionTag.reloadPackages, () async { + if (mounted) { + await ref.read(systemActionProvider.notifier).getPackages(); + } + }, duration: const Duration(seconds: 2)); } @override void dispose() { + debouncer.cancel(FunctionTag.reloadPackages); if (app?.onPackagesChanged == _reloadPackages) { app?.onPackagesChanged = null; } diff --git a/lib/manager/app_manager.dart b/lib/manager/app_manager.dart index 9f40a69b..0f1bb8d8 100644 --- a/lib/manager/app_manager.dart +++ b/lib/manager/app_manager.dart @@ -4,11 +4,12 @@ import 'package:fl_clash/common/common.dart'; import 'package:fl_clash/common/permission.dart'; import 'package:fl_clash/common/system_dns.dart'; import 'package:fl_clash/enum/enum.dart'; -import 'package:fl_clash/manager/window_manager.dart'; import 'package:fl_clash/models/models.dart'; import 'package:fl_clash/providers/providers.dart'; import 'package:fl_clash/state.dart'; import 'package:fl_clash/widgets/animated_visibility.dart'; +import 'package:fl_clash/widgets/icon.dart'; +import 'package:fl_clash/widgets/sidebar.dart'; import 'package:flutter/foundation.dart'; import 'package:material_ui/material_ui.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -28,9 +29,11 @@ class _AppStateManagerState extends ConsumerState void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); - ref.listenManual(checkIpProvider, (prev, next) { - if (prev != next && next.isInit && next.containsDetection) { - ref.read(networkDetectionProvider.notifier).startCheck(); + // A desktop build started to the tray mounts while hidden, and a paused + // render never runs a post-frame callback. + scheduleMicrotask(() { + if (mounted) { + _updateVisible(WidgetsBinding.instance.lifecycleState); } }); ref.listenManual(configProvider, (prev, next) { @@ -38,6 +41,11 @@ class _AppStateManagerState extends ConsumerState ref.read(storeActionProvider.notifier).savePreferencesDebounce(); } }); + ref.listenManual(groupsProvider, (prev, next) { + if (prev != next) { + unawaited(precacheTargetIcons(next.map((group) => group.icon))); + } + }, fireImmediately: true); ref.listenManual(needUpdateGroupsProvider, (prev, next) { if (prev != next) { ref.read(proxiesActionProvider.notifier).updateGroupsDebounce(); @@ -45,7 +53,7 @@ class _AppStateManagerState extends ConsumerState }); ref.listenManual(suspendProvider, (prev, next) { final isStart = ref.read(isStartProvider); - if (prev != next && isStart) { + if (prev != next && isStart && !ref.read(safeModeProvider)) { debouncer.call(FunctionTag.suspend, () async { final core = ref.read(coreHandlerProvider); if (next == true) { @@ -53,7 +61,6 @@ class _AppStateManagerState extends ConsumerState } else { await core.startListener(); } - ref.read(checkIpNumProvider.notifier).add(); }); } }); @@ -71,17 +78,26 @@ class _AppStateManagerState extends ConsumerState super.dispose(); } + void _updateVisible(AppLifecycleState? state) { + ref.read(appVisibleProvider.notifier).value = switch (state) { + null || AppLifecycleState.resumed || AppLifecycleState.inactive => true, + AppLifecycleState.hidden || + AppLifecycleState.paused || + AppLifecycleState.detached => false, + }; + } + @override Future didChangeAppLifecycleState(AppLifecycleState state) async { commonPrint.log('$state'); + _updateVisible(state); if (state == AppLifecycleState.resumed) { permissions.check(ref.read); - render?.resume(); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) { return; } - ref.read(setupActionProvider.notifier).tryCheckIp(); + ref.read(routeTrackerProvider.notifier).markResumed(); }); } } @@ -93,86 +109,81 @@ class _AppStateManagerState extends ConsumerState @override Widget build(BuildContext context) { - return Listener( - onPointerHover: (_) { - render?.resume(); - }, - child: widget.child, - ); + return widget.child; } } -class AppEnvManager extends StatelessWidget { +class AppEnvManager extends ConsumerWidget { final Widget child; const AppEnvManager({super.key, required this.child}); + String? _bannerMessage({required bool safeMode}) { + if (safeMode) { + return 'SAFE MODE'; + } + if (!globalState.isPre) { + return null; + } + return kDebugMode ? 'DEBUG' : globalState.appEnv.toUpperCase(); + } + @override - Widget build(BuildContext context) { - if (kDebugMode) { - if (globalState.isPre) { - return Banner( - message: 'DEBUG', - location: BannerLocation.topEnd, - child: child, - ); - } + Widget build(BuildContext context, WidgetRef ref) { + final message = _bannerMessage(safeMode: ref.watch(safeModeProvider)); + if (message == null) { + return child; } - if (globalState.isPre) { - return Banner( - message: globalState.appEnv.toUpperCase(), - location: BannerLocation.topEnd, - child: child, - ); - } - return child; + return Banner( + message: message, + location: BannerLocation.topEnd, + child: child, + ); } } -class _SidebarRail extends StatelessWidget { - const _SidebarRail({ +class _Sidebar extends ConsumerWidget { + const _Sidebar({ required this.items, required this.currentIndex, - required this.showLabel, + required this.viewMode, required this.onSelected, }); final List items; final int currentIndex; - final bool showLabel; + final ViewMode viewMode; final void Function(int index) onSelected; @override - Widget build(BuildContext context) { - final labelStyle = context.textTheme.labelLarge!.copyWith( - color: context.colorScheme.onSurface, + Widget build(BuildContext context, WidgetRef ref) { + final canExpand = canExpandSidebar(viewMode); + final sidebarExpanded = ref.watch( + appSettingProvider.select((state) => state.sidebarExpanded), ); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: NavigationRail( - scrollable: true, - minExtendedWidth: 200, - backgroundColor: Colors.transparent, - selectedLabelTextStyle: labelStyle, - unselectedLabelTextStyle: labelStyle, - destinations: [ - for (final item in items) - NavigationRailDestination( - icon: item.icon, - label: Text(item.label.label), - ), - ], - onDestinationSelected: onSelected, - extended: false, - selectedIndex: currentIndex, - labelType: showLabel - ? NavigationRailLabelType.all - : NavigationRailLabelType.none, - ), - ), + final version = ref.watch(versionProvider); + return NavigationSidebar( + destinations: [ + for (final item in items) + SidebarDestination(glyph: item.glyph, label: item.label.label), ], + selectedIndex: currentIndex, + expanded: canExpand && sidebarExpanded, + onSelected: onSelected, + onToggle: canExpand + ? () { + ref + .read(appSettingProvider.notifier) + .update( + (state) => + state.copyWith(sidebarExpanded: !state.sidebarExpanded), + ); + } + : null, + windowControls: windowControlsOverSidebar( + isMacOS: system.isMacOS, + version: version, + ), ); } } @@ -183,10 +194,15 @@ class AppSidebarContainer extends ConsumerWidget { const AppSidebarContainer({super.key, required this.child}); Widget _buildBackground({ - required BuildContext context, + required Color color, + required Color edge, required Widget child, }) { - return Material(color: context.colorScheme.surfaceContainer, child: child); + return Material( + color: color, + shape: BorderDirectional(end: BorderSide(color: edge)), + child: child, + ); } void _updateSideBarWidth(WidgetRef ref, double contentWidth) { @@ -199,8 +215,9 @@ class AppSidebarContainer extends ConsumerWidget { void _handleToPage(WidgetRef ref, PageLabel pageLabel) { final focusNode = FocusManager.instance.primaryFocus; + final focusContext = focusNode?.context; final preserveNavigationFocus = - focusNode?.context?.findAncestorWidgetOfExactType() != + focusContext?.findAncestorWidgetOfExactType() != null; ref.read(currentPageLabelProvider.notifier).toPage(pageLabel); if (!preserveNavigationFocus || focusNode == null) { @@ -217,58 +234,37 @@ class AppSidebarContainer extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final navigationState = ref.watch(navigationStateProvider); final navigationItems = navigationState.navigationItems; - final isMobileView = navigationState.viewMode == ViewMode.mobile; + final viewMode = navigationState.viewMode; final currentIndex = navigationState.currentIndex; - final showLabel = ref.watch(appSettingProvider).showLabel; + final blur = ref.watch(windowBlurProvider); + final colorScheme = context.colorScheme; + final surfaceContainer = colorScheme.surfaceContainer; + void onSelected(int index) { + _handleToPage(ref, navigationItems[index].label); + } + + final hasSidebar = viewMode != ViewMode.mobile; + final isLtr = Directionality.of(context) == TextDirection.ltr; return Container( - color: context.colorScheme.surfaceContainer, + color: blur ? Colors.transparent : surfaceContainer, child: Row( children: [ AnimatedVisibility.sidebar( - visible: !isMobileView, + visible: hasSidebar, child: _buildBackground( - context: context, - child: SafeArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - if (system.isMacOS) const SizedBox(height: 22), - const SizedBox(height: 10), - if (!system.isMacOS) ...[ - const ClipRect(child: AppIcon()), - const SizedBox(height: 12), - ], - Expanded( - child: ScrollConfiguration( - behavior: const HiddenBarScrollBehavior(), - child: _SidebarRail( - items: navigationItems, - currentIndex: currentIndex, - showLabel: showLabel, - onSelected: (index) { - _handleToPage(ref, navigationItems[index].label); - }, - ), - ), - ), - const SizedBox(height: 16), - IconButton( - tooltip: context.appLocalizations.toggleLabel, - onPressed: () { - ref - .read(appSettingProvider.notifier) - .update( - (state) => - state.copyWith(showLabel: !state.showLabel), - ); - }, - icon: Icon( - Icons.menu, - color: context.colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 16), - ], + color: blur + ? surfaceContainer.withValues(alpha: kSidebarBlurOpacity) + : surfaceContainer, + edge: colorScheme.outlineVariant, + child: MediaQuery.removePadding( + context: context, + removeLeft: !isLtr, + removeRight: isLtr, + child: _Sidebar( + items: navigationItems, + currentIndex: currentIndex, + viewMode: viewMode, + onSelected: onSelected, ), ), ), @@ -279,7 +275,12 @@ class AppSidebarContainer extends ConsumerWidget { child: LayoutBuilder( builder: (_, constraints) { _updateSideBarWidth(ref, constraints.maxWidth); - return child; + return MediaQuery.removePadding( + context: context, + removeLeft: hasSidebar && isLtr, + removeRight: hasSidebar && !isLtr, + child: child, + ); }, ), ), diff --git a/lib/manager/connectivity_manager.dart b/lib/manager/connectivity_manager.dart index bd68f5f5..11720902 100644 --- a/lib/manager/connectivity_manager.dart +++ b/lib/manager/connectivity_manager.dart @@ -64,7 +64,9 @@ class _ConnectivityManagerState extends ConsumerState { final requestId = ++_ssidRequestId; // The SSID costs a blocking platform call and a location permission on // Android and macOS, and nothing reads it until a network is excluded. - if (!_onWifi || ref.read(excludeSSIDsProvider).isEmpty) { + if (!_onWifi || + ref.read(safeModeProvider) || + ref.read(excludeSSIDsProvider).isEmpty) { _publishSsid(requestId, null); return; } diff --git a/lib/manager/status_manager.dart b/lib/manager/status_manager.dart index be3c2815..abe80eff 100644 --- a/lib/manager/status_manager.dart +++ b/lib/manager/status_manager.dart @@ -4,9 +4,11 @@ import 'dart:collection'; import 'package:collection/collection.dart'; import 'package:fl_clash/common/common.dart'; import 'package:fl_clash/enum/enum.dart'; +import 'package:fl_clash/icons/icons.dart'; import 'package:fl_clash/models/models.dart'; import 'package:fl_clash/providers/app.dart'; import 'package:fl_clash/providers/state.dart'; +import 'package:fl_clash/widgets/navigation_dock.dart'; import 'package:fl_clash/widgets/theme.dart'; import 'package:material_ui/material_ui.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -471,12 +473,12 @@ class _MessageContent extends StatelessWidget { Widget build(BuildContext context) { final actionState = message.actionState; final level = message.level; - final icon = level.icon; + final glyph = level.glyph; return Row( mainAxisSize: MainAxisSize.min, children: [ - if (icon != null) ...[ - Icon(icon, size: 20, color: level.iconColor(context)), + if (glyph != null) ...[ + GlyphIcon(glyph, size: 20, color: level.iconColor(context)), const SizedBox(width: 12), ], Flexible( @@ -492,12 +494,14 @@ class _MessageContent extends StatelessWidget { if (actionState != null) ...[ const SizedBox(width: 16), CommonMinFilledButtonTheme( - child: FilledButton.tonal( - onPressed: () { - onDismiss(message.id); - actionState.action(); - }, - child: Text(actionState.actionText), + child: ElasticButton( + child: FilledButton.tonal( + onPressed: () { + onDismiss(message.id); + actionState.action(); + }, + child: Text(actionState.actionText), + ), ), ), ], diff --git a/lib/manager/tray_manager.dart b/lib/manager/tray_manager.dart index 27318b33..57094292 100755 --- a/lib/manager/tray_manager.dart +++ b/lib/manager/tray_manager.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:collection/collection.dart'; import 'package:fl_clash/common/common.dart'; import 'package:fl_clash/common/tray.dart'; import 'package:fl_clash/common/window.dart'; @@ -22,6 +23,8 @@ class TrayManager extends ConsumerStatefulWidget { class _TrayManagerState extends ConsumerState { StreamSubscription? _subscription; + bool _isUpdating = false; + bool _hasPendingUpdate = false; @override void initState() { @@ -29,15 +32,20 @@ class _TrayManagerState extends ConsumerState { _subscription = Tray.instance.events.listen(_handleTrayEvent); ref.listenManual(trayStateProvider, (prev, next) { if (prev != next) { - _reportFailure(ref.read(systemActionProvider.notifier).updateTray()); + _requestUpdate(); } }); ref.listenManual(loadedLocaleProvider, (prev, next) { if (prev != null && prev != next) { - _reportFailure(ref.read(systemActionProvider.notifier).updateTray()); + _requestUpdate(); } }); if (system.isMacOS) { + ref.listenManual(trayDelaysProvider, (prev, next) { + if (!const DeepCollectionEquality().equals(prev, next)) { + _requestUpdate(); + } + }); ref.listenManual(trayTitleStateProvider, (prev, next) { if (prev != next) { _reportFailure( @@ -51,6 +59,30 @@ class _TrayManagerState extends ConsumerState { } } + /// A delay test changes the menu per proxy, so updates in flight coalesce. + void _requestUpdate() { + if (_isUpdating) { + _hasPendingUpdate = true; + return; + } + _isUpdating = true; + _reportFailure(_drainUpdates()); + } + + Future _drainUpdates() async { + try { + do { + _hasPendingUpdate = false; + await ref.read(systemActionProvider.notifier).updateTray(); + } while (_hasPendingUpdate && mounted); + } finally { + _isUpdating = false; + if (_hasPendingUpdate && mounted) { + _requestUpdate(); + } + } + } + void _reportFailure(Future? operation) { if (operation == null) { return; @@ -72,7 +104,7 @@ class _TrayManagerState extends ConsumerState { case TrayMenuRequested(): _reportFailure(Tray.instance.openMenu()); case TrayMenuItemSelected(): - render?.active(); + break; } } diff --git a/lib/manager/window_manager.dart b/lib/manager/window_manager.dart index abd5b326..92315a88 100644 --- a/lib/manager/window_manager.dart +++ b/lib/manager/window_manager.dart @@ -1,15 +1,15 @@ import 'dart:async'; -import 'dart:ui' show ClipOp; import 'package:fl_clash/common/common.dart'; import 'package:fl_clash/common/launch.dart'; import 'package:fl_clash/enum/enum.dart'; +import 'package:fl_clash/icons/icons.dart'; import 'package:fl_clash/models/config.dart'; import 'package:fl_clash/providers/providers.dart'; import 'package:flutter/foundation.dart' show ValueListenable; import 'package:material_ui/material_ui.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:window_manager/window_manager.dart'; +import 'package:window/window.dart'; const _windowGeometryDelay = Duration(milliseconds: 120); @@ -26,6 +26,7 @@ class _WindowContainerState extends ConsumerState with WindowListener { Timer? _windowGeometryTimer; int _windowGeometryRevision = 0; + int _windowBlurRevision = 0; @override Widget build(BuildContext context) { @@ -45,7 +46,38 @@ class _WindowContainerState extends ConsumerState }); } }); - windowManager.addListener(this); + ref.listenManual(windowBlurRequestProvider, (prev, next) { + if (prev != next) { + unawaited(_applyWindowBlur(next)); + } + }, fireImmediately: true); + desktopWindow.addListener(this); + } + + Future _applyWindowBlur(WindowBlurRequest request) async { + final port = windowPort; + if (port == null) { + return; + } + final revision = ++_windowBlurRevision; + bool active; + try { + active = await port.setBlur( + enabled: request.enabled, + brightness: request.brightness, + tint: request.tint, + ); + } catch (error) { + commonPrint.log( + 'Window blur failed: ${compactError(error)}', + logLevel: LogLevel.warning, + ); + active = false; + } + if (!mounted || revision != _windowBlurRevision) { + return; + } + ref.read(windowBlurProvider.notifier).value = active; } @override @@ -58,11 +90,9 @@ class _WindowContainerState extends ConsumerState void onWindowFocus() { super.onWindowFocus(); commonPrint.log('focus'); - render?.resume(); } - /// Another launch, or a Dock reopen, asked for the window; showing it from - /// here keeps the render loop running before it becomes visible. + /// Another launch, or a Dock reopen, asked for the window. @override void onWindowActivate() { super.onWindowActivate(); @@ -113,26 +143,8 @@ class _WindowContainerState extends ConsumerState } @override - void onWindowMove() { - super.onWindowMove(); - _scheduleWindowGeometryCapture(); - } - - @override - void onWindowMoved() { - super.onWindowMoved(); - _scheduleWindowGeometryCapture(); - } - - @override - void onWindowResize() { - super.onWindowResize(); - _scheduleWindowGeometryCapture(); - } - - @override - void onWindowResized() { - super.onWindowResized(); + void onWindowGeometryChanged() { + super.onWindowGeometryChanged(); _scheduleWindowGeometryCapture(); } @@ -141,7 +153,7 @@ class _WindowContainerState extends ConsumerState _invalidateWindowGeometryCapture(); super.onWindowMaximize(); if (system.isWindows) { - unawaited(windowManager.setWindowCornerPreference(round: false)); + unawaited(desktopWindow.setRoundedCorners(false)); } } @@ -149,7 +161,7 @@ class _WindowContainerState extends ConsumerState void onWindowUnmaximize() { super.onWindowUnmaximize(); if (system.isWindows) { - unawaited(windowManager.setWindowCornerPreference(round: true)); + unawaited(desktopWindow.setRoundedCorners(true)); } _scheduleWindowGeometryCapture(); } @@ -171,14 +183,12 @@ class _WindowContainerState extends ConsumerState _invalidateWindowGeometryCapture(); ref.read(storeActionProvider.notifier).savePreferencesDebounce(); commonPrint.log('minimize'); - render?.pause(); super.onWindowMinimize(); } @override void onWindowRestore() { commonPrint.log('restore'); - render?.resume(); super.onWindowRestore(); _scheduleWindowGeometryCapture(); } @@ -186,7 +196,7 @@ class _WindowContainerState extends ConsumerState @override void dispose() { _invalidateWindowGeometryCapture(); - windowManager.removeListener(this); + desktopWindow.removeListener(this); super.dispose(); } } @@ -294,7 +304,7 @@ class WindowCaptionState { class WindowCaptionController extends ValueNotifier with WindowListener { WindowCaptionController() : super(const WindowCaptionState()) { - windowManager.addListener(this); + desktopWindow.addListener(this); unawaited(_syncFromWindow()); } @@ -302,9 +312,9 @@ class WindowCaptionController extends ValueNotifier Future _syncFromWindow() async { final states = await Future.wait([ - windowManager.isAlwaysOnTop(), - windowManager.isMaximized(), - windowManager.isFullScreen(), + desktopWindow.isAlwaysOnTop(), + desktopWindow.isMaximized(), + desktopWindow.isFullScreen(), ]); _set( WindowCaptionState( @@ -345,25 +355,25 @@ class WindowCaptionController extends ValueNotifier } Future toggleMaximized() async { - if (await windowManager.isFullScreen()) { - await windowManager.setFullScreen(false); - } else if (await windowManager.isMaximized()) { - await windowManager.unmaximize(); + if (await desktopWindow.isFullScreen()) { + await desktopWindow.setFullScreen(false); + } else if (await desktopWindow.isMaximized()) { + await desktopWindow.unmaximize(); } else { - await windowManager.maximize(); + await desktopWindow.maximize(); } } Future togglePin() async { - final isPinned = await windowManager.isAlwaysOnTop(); - await windowManager.setAlwaysOnTop(!isPinned); - _set(value.copyWith(isPinned: await windowManager.isAlwaysOnTop())); + final isPinned = await desktopWindow.isAlwaysOnTop(); + await desktopWindow.setAlwaysOnTop(!isPinned); + _set(value.copyWith(isPinned: await desktopWindow.isAlwaysOnTop())); } @override void dispose() { _disposed = true; - windowManager.removeListener(this); + desktopWindow.removeListener(this); super.dispose(); } } @@ -386,9 +396,12 @@ class _WindowHeaderState extends ConsumerState { @override Widget build(BuildContext context) { + final translucent = + ref.watch(windowBlurProvider) && !ref.watch(isMobileViewProvider); return WindowHeaderBar( height: kHeaderHeight, - onDragStart: windowManager.startDragging, + translucent: translucent, + onDragStart: desktopWindow.startDragging, onDoubleTap: caption.toggleMaximized, title: system.isMacOS ? const Text(appName) : null, actions: system.isMacOS @@ -396,7 +409,7 @@ class _WindowHeaderState extends ConsumerState { : WindowHeaderActions( state: caption, onPin: caption.togglePin, - onMinimize: windowManager.minimize, + onMinimize: desktopWindow.minimize, onMaximize: caption.toggleMaximized, onClose: () { ref.read(systemActionProvider.notifier).handleClose(); @@ -412,6 +425,7 @@ class WindowHeaderBar extends StatelessWidget { required this.height, required this.onDragStart, required this.onDoubleTap, + this.translucent = false, this.title, this.actions, }); @@ -419,15 +433,18 @@ class WindowHeaderBar extends StatelessWidget { final double height; final VoidCallback onDragStart; final VoidCallback onDoubleTap; + final bool translucent; final Widget? title; final Widget? actions; @override Widget build(BuildContext context) { + final colorScheme = context.colorScheme; return Material( - shape: Border( - bottom: BorderSide(color: context.colorScheme.outlineVariant), - ), + color: translucent ? Colors.transparent : null, + shape: translucent + ? null + : Border(bottom: BorderSide(color: colorScheme.outlineVariant)), child: SizedBox( height: height, child: Stack( @@ -440,7 +457,11 @@ class WindowHeaderBar extends StatelessWidget { }, onDoubleTap: onDoubleTap, child: ColoredBox( - color: context.colorScheme.surfaceContainerHighest, + color: translucent + ? colorScheme.surfaceContainer.withValues( + alpha: kSidebarBlurOpacity, + ) + : colorScheme.surfaceContainerHighest, ), ), ), @@ -529,8 +550,9 @@ class WindowHeaderActions extends StatelessWidget { iconSize: WidgetStatePropertyAll(pinIconSize), ), onPressed: onPin, - icon: Icon( - state.isPinned ? Icons.push_pin : Icons.push_pin_outlined, + icon: AnimatedGlyph( + glyph: AppGlyphs.pin, + filled: state.isPinned, ), ), ), @@ -574,90 +596,3 @@ class WindowHeaderActions extends StatelessWidget { ); } } - -enum CaptionGlyph { minimize, maximize, restore, close } - -/// Painted rather than taken from an icon font so every caption button keeps -/// the same one pixel stroke weight on every platform. -class CaptionIcon extends StatelessWidget { - const CaptionIcon(this.glyph, {super.key}); - - final CaptionGlyph glyph; - - @override - Widget build(BuildContext context) { - final color = IconTheme.of(context).color ?? context.colorScheme.onSurface; - return CustomPaint( - size: const Size.square(captionGlyphSize), - painter: _CaptionGlyphPainter(glyph: glyph, color: color), - ); - } -} - -class _CaptionGlyphPainter extends CustomPainter { - const _CaptionGlyphPainter({required this.glyph, required this.color}); - - final CaptionGlyph glyph; - final Color color; - - @override - void paint(Canvas canvas, Size size) { - final paint = Paint() - ..color = color - ..style = PaintingStyle.stroke - ..strokeWidth = 1 - ..strokeJoin = StrokeJoin.round; - const corner = Radius.circular(1); - // Every coordinate sits on a half pixel so a one pixel stroke covers a - // single device pixel at 100% scaling. - final box = (Offset.zero & size).deflate(0.5); - switch (glyph) { - case CaptionGlyph.minimize: - final y = (size.height / 2).floorToDouble() + 0.5; - canvas.drawLine(Offset(0, y), Offset(size.width, y), paint); - case CaptionGlyph.maximize: - canvas.drawRRect(RRect.fromRectAndRadius(box, corner), paint); - case CaptionGlyph.restore: - const offset = 2.0; - final front = Rect.fromLTRB( - box.left, - box.top + offset, - box.right - offset, - box.bottom, - ); - final back = front.shift(const Offset(offset, -offset)); - canvas.drawRRect(RRect.fromRectAndRadius(front, corner), paint); - canvas.save(); - canvas.clipRect(front.inflate(0.5), clipOp: ClipOp.difference); - canvas.drawRRect(RRect.fromRectAndRadius(back, corner), paint); - canvas.restore(); - case CaptionGlyph.close: - paint.strokeCap = StrokeCap.round; - canvas.drawLine(box.topLeft, box.bottomRight, paint); - canvas.drawLine(box.topRight, box.bottomLeft, paint); - } - } - - @override - bool shouldRepaint(_CaptionGlyphPainter oldDelegate) => - glyph != oldDelegate.glyph || color != oldDelegate.color; -} - -class AppIcon extends StatelessWidget { - const AppIcon({super.key}); - - @override - Widget build(BuildContext context) { - return Container( - decoration: ShapeDecoration( - color: context.colorScheme.surfaceContainerHighest, - shape: AppShape.md, - ), - padding: const EdgeInsets.all(8), - child: Transform.translate( - offset: const Offset(0, -1), - child: Image.asset('assets/images/icon.png', width: 34, height: 34), - ), - ); - } -} diff --git a/lib/providers/actions/theme.dart b/lib/providers/actions/theme.dart index 6b48260e..3d9866b7 100644 --- a/lib/providers/actions/theme.dart +++ b/lib/providers/actions/theme.dart @@ -6,7 +6,7 @@ class ThemeAction extends _$ThemeAction { void build() {} void updateBrightness() { - WidgetsBinding.instance.addPostFrameCallback((_) { + runAfterFrame(() { ref.read(systemBrightnessProvider.notifier).value = WidgetsBinding.instance.platformDispatcher.platformBrightness; }); diff --git a/lib/providers/state/system.dart b/lib/providers/state/system.dart index f945133b..a1827c07 100644 --- a/lib/providers/state/system.dart +++ b/lib/providers/state/system.dart @@ -10,20 +10,9 @@ UpdateParams updateParams(Ref ref) { ); return ref.watch( patchClashConfigProvider.select( - (state) => UpdateParams( - tun: state.tun.getRealTun(routeMode), + (state) => state.toUpdateParams( + routeMode: routeMode, authentication: authentication.credentials, - allowLan: state.allowLan, - findProcessMode: state.findProcessMode, - mode: state.mode, - logLevel: state.logLevel, - ipv6: state.ipv6, - tcpConcurrent: state.tcpConcurrent, - externalController: state.externalController, - unifiedDelay: state.unifiedDelay, - mixedPort: state.mixedPort, - geoAutoUpdate: state.geoAutoUpdate, - geoUpdateInterval: state.geoUpdateInterval, ), ), ); @@ -52,6 +41,8 @@ TrayState trayState(Ref ref) { ); final groups = ref.watch(currentGroupsStateProvider).value; final selectedMap = ref.watch(selectedMapProvider); + final safeMode = ref.watch(safeModeProvider); + final hotKeys = _trayHotKeys(ref); return TrayState( mode: clashConfig.mode, @@ -63,9 +54,58 @@ TrayState trayState(Ref ref) { groups: groups, selectedMap: selectedMap, showTrayTitle: appSetting.showTrayTitle, + safeMode: safeMode, + hotKeys: hotKeys, ); } +Map _trayHotKeys(Ref ref) { + final failures = ref.watch(hotKeyFailuresProvider); + return { + for (final hotKeyAction in ref.watch(hotKeyActionsProvider)) + if (isValidHotKey(hotKeyAction.modifiers, hotKeyAction.key) && + !failures.containsKey(hotKeyAction.action)) + hotKeyAction.action: hotKeyAction, + }; +} + +/// Measured delays of the proxies the tray lists, by group and then proxy +/// name. Resolved like a proxy card, so a nested group shows its selection. +@riverpod +Map> trayDelays(Ref ref) { + final delayMap = ref.watch(delayDataSourceProvider); + if (delayMap.isEmpty) { + return const {}; + } + final groups = ref.watch(currentGroupsStateProvider).value; + final allGroups = ref.watch(groupsProvider); + final selectedMap = ref.watch(selectedMapProvider); + final defaultTestUrl = ref.watch( + appSettingProvider.select((state) => state.testUrl), + ); + final delays = >{}; + for (final group in groups) { + final testUrl = group.testUrl.takeFirstValid([defaultTestUrl]); + final groupDelays = {}; + for (final proxy in group.all) { + final delay = computeProxyDelayState( + proxyName: proxy.name, + testUrl: testUrl, + groups: allGroups, + selectedMap: selectedMap, + delayMap: delayMap, + ).delay; + if (delay != 0) { + groupDelays[proxy.name] = delay; + } + } + if (groupDelays.isNotEmpty) { + delays[group.name] = groupDelays; + } + } + return delays; +} + @riverpod TrayTitleState trayTitleState(Ref ref) { final showTrayTitle = ref.watch( @@ -108,29 +148,12 @@ HotKeyAction getHotKeyAction(Ref ref, HotAction hotAction) { ); } -@riverpod -({bool isInit, int checkIpNum, bool containsDetection}) checkIp(Ref ref) { - final isInit = ref.watch(initProvider); - final checkIpNum = ref.watch(checkIpNumProvider); - final containsDetection = ref.watch( - dashboardStateProvider.select( - (state) => - state.dashboardWidgets.contains(DashboardWidget.networkDetection), - ), - ); - return ( - isInit: isInit, - checkIpNum: checkIpNum, - containsDetection: containsDetection, - ); -} - @riverpod bool shouldPatchSystemDns(Ref ref) { final autoSetSystemDns = ref.watch( networkSettingProvider.select((state) => state.autoSetSystemDns), ); - if (!autoSetSystemDns) { + if (!autoSetSystemDns || ref.watch(safeModeProvider)) { return false; } final isStart = ref.watch(runTimeProvider.select((state) => state != null)); @@ -183,6 +206,7 @@ SharedState sharedState(Ref ref) { ), ); final vpnSetting = ref.watch(vpnSettingProvider); + final safeMode = ref.watch(safeModeProvider); final currentProfileName = currentProfile.label; final selectedMap = currentProfile.selectedMap; final onlyStatisticsProxy = appSetting.onlyStatisticsProxy; @@ -200,12 +224,13 @@ SharedState sharedState(Ref ref) { startTip: currentAppLocalizations.startVpn, setupParams: SetupParams(selectedMap: selectedMap, testUrl: testUrl), vpnOptions: VpnOptions( - enable: vpnSetting.enable, + enable: vpnSetting.enable && !safeMode, stack: stack, // VpnService.setHttpProxy cannot carry credentials, so an authenticated // mixed port must not be declared as the system HTTP proxy; traffic // still flows through TUN. - systemProxy: vpnSetting.systemProxy && !networkSetting.authenticated, + systemProxy: + vpnSetting.systemProxy && !networkSetting.authenticated && !safeMode, port: port, ipv6: vpnSetting.ipv6, dnsHijacking: vpnSetting.dnsHijacking, diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index fc694d05..022493c4 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -13,7 +13,7 @@ #include #include #include -#include +#include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) dynamic_color_registrar = @@ -37,7 +37,7 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) wifi_ssid_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "WifiSsidPlugin"); wifi_ssid_plugin_register_with_registrar(wifi_ssid_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); + g_autoptr(FlPluginRegistrar) window_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "WindowPlugin"); + window_plugin_register_with_registrar(window_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 7c33f22c..00d1371e 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -10,7 +10,7 @@ list(APPEND FLUTTER_PLUGIN_LIST tray url_launcher_linux wifi_ssid - window_manager + window ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index df91902c..270e1d6e 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -5,7 +5,7 @@ #include #endif -#include +#include #include "flutter/generated_plugin_registrant.h" @@ -102,7 +102,7 @@ static void my_application_before_emit(GApplication* application, GVariant* plat static void my_application_after_emit(GApplication* application, GVariant* platform_data) { MyApplication* self = MY_APPLICATION(application); if (self->had_window_before_emit) { - window_manager_plugin_activate(); + window_plugin_activate(); } G_APPLICATION_CLASS(my_application_parent_class)->after_emit(application, platform_data); } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index b7d37842..3f0d7378 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -19,7 +19,7 @@ import sqflite_darwin import tray import url_launcher_macos import wifi_ssid -import window_manager +import window func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) @@ -36,5 +36,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { TrayPlugin.register(with: registry.registrar(forPlugin: "TrayPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WifiSsidPlugin.register(with: registry.registrar(forPlugin: "WifiSsidPlugin")) - WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) + WindowPlugin.register(with: registry.registrar(forPlugin: "WindowPlugin")) } diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift index 40ff3f6e..d84e8890 100755 --- a/macos/Runner/AppDelegate.swift +++ b/macos/Runner/AppDelegate.swift @@ -1,6 +1,6 @@ import Cocoa import FlutterMacOS -import window_manager +import window @main class AppDelegate: FlutterAppDelegate { @@ -10,7 +10,7 @@ class AppDelegate: FlutterAppDelegate { } override func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { - WindowManagerPlugin.instance?.handleShouldTerminate() + WindowPlugin.instance?.handleShouldTerminate() return .terminateCancel } @@ -19,7 +19,7 @@ class AppDelegate: FlutterAppDelegate { } override func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { - WindowManagerPlugin.instance?.handleReopen() + WindowPlugin.instance?.handleReopen() return false } } diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift index aee339ec..adbceb33 100755 --- a/macos/Runner/MainFlutterWindow.swift +++ b/macos/Runner/MainFlutterWindow.swift @@ -1,6 +1,6 @@ import Cocoa import FlutterMacOS -import window_manager +import window import LaunchAtLogin class MainFlutterWindow: NSWindow { diff --git a/plugins/tray/README.md b/plugins/tray/README.md index 663c8127..47cd9d2e 100644 --- a/plugins/tray/README.md +++ b/plugins/tray/README.md @@ -41,10 +41,21 @@ indicator scale it; Windows loads the path as-is, so point it at a multi-size `. Menu item ids are assigned by pre-order position, so an unchanged menu serializes identically across rebuilds and click dispatch stays stable while a menu is open. +Actions, checkboxes and submenus take an optional `detail`, secondary text shown after the label, such as +a shortcut or a measured latency: + +| macOS | Windows | Linux | +| --- | --- | --- | +| right-aligned column in the color of a native key equivalent | the menu's accelerator column | appended in parentheses | + +AppIndicator menus travel over dbusmenu, which carries no column for free text, hence the Linux fallback. + ## Events `Tray.instance.events` is a broadcast stream of `TrayIconActivated`, `TrayMenuRequested` and `TrayMenuItemSelected`. Per-item `onSelected` callbacks fire before the corresponding stream event. +On Windows a left click is `TrayIconActivated` and a right click `TrayMenuRequested`; macOS opens the +menu from either button, as status items do there, so it only sends `TrayMenuRequested`. ## Capabilities diff --git a/plugins/tray/lib/src/tray_codec.dart b/plugins/tray/lib/src/tray_codec.dart index ca58b4d4..dd200611 100644 --- a/plugins/tray/lib/src/tray_codec.dart +++ b/plugins/tray/lib/src/tray_codec.dart @@ -54,25 +54,39 @@ abstract final class TrayCodec { sink[id] = item; return switch (item) { TrayMenuSeparator() => {'id': id, 'type': 'separator'}, - TrayMenuAction(:final label, :final enabled) => { - 'id': id, - 'type': 'action', - 'label': label, - 'enabled': enabled, - }, - TrayMenuCheckbox(:final label, :final enabled, :final checked) => + TrayMenuAction(:final label, :final detail, :final enabled) => + { + 'id': id, + 'type': 'action', + 'label': label, + if (detail != null && detail.isNotEmpty) 'detail': detail, + 'enabled': enabled, + }, + TrayMenuCheckbox( + :final label, + :final detail, + :final enabled, + :final checked, + ) => { 'id': id, 'type': 'checkbox', 'label': label, + if (detail != null && detail.isNotEmpty) 'detail': detail, 'enabled': enabled, 'checked': checked, }, - TrayMenuSubmenu(:final label, :final enabled, :final items) => + TrayMenuSubmenu( + :final label, + :final detail, + :final enabled, + :final items, + ) => { 'id': id, 'type': 'submenu', 'label': label, + if (detail != null && detail.isNotEmpty) 'detail': detail, 'enabled': enabled, 'items': _encodeItems(items, sink, allocator), }, diff --git a/plugins/tray/lib/src/tray_menu.dart b/plugins/tray/lib/src/tray_menu.dart index 3dceabd8..efdd40f5 100644 --- a/plugins/tray/lib/src/tray_menu.dart +++ b/plugins/tray/lib/src/tray_menu.dart @@ -7,11 +7,13 @@ sealed class TrayMenuItem { final class TrayMenuAction extends TrayMenuItem { const TrayMenuAction({ required this.label, + this.detail, this.enabled = true, this.onSelected, }); final String label; + final String? detail; final bool enabled; final TrayMenuItemSelectedCallback? onSelected; } @@ -20,12 +22,14 @@ final class TrayMenuCheckbox extends TrayMenuItem { const TrayMenuCheckbox({ required this.label, required this.checked, + this.detail, this.enabled = true, this.onSelected, }); final String label; final bool checked; + final String? detail; final bool enabled; final TrayMenuItemSelectedCallback? onSelected; } @@ -34,11 +38,13 @@ final class TrayMenuSubmenu extends TrayMenuItem { const TrayMenuSubmenu({ required this.label, required this.items, + this.detail, this.enabled = true, }); final String label; final List items; + final String? detail; final bool enabled; } diff --git a/plugins/tray/linux/tray_plugin.cc b/plugins/tray/linux/tray_plugin.cc index c98d4358..415fd11c 100644 --- a/plugins/tray/linux/tray_plugin.cc +++ b/plugins/tray/linux/tray_plugin.cc @@ -83,10 +83,13 @@ static GtkWidget* build_menu(FlValue* items) { continue; } - const char* label = string_value(entry, "label"); - if (label == nullptr) { - label = ""; - } + const char* raw_label = string_value(entry, "label"); + const char* detail = string_value(entry, "detail"); + g_autofree gchar* label = + detail != nullptr && detail[0] != '\0' + ? g_strdup_printf("%s (%s)", raw_label == nullptr ? "" : raw_label, + detail) + : g_strdup(raw_label == nullptr ? "" : raw_label); GtkWidget* item; bool dispatches = true; diff --git a/plugins/tray/macos/tray/Sources/tray/TrayMenu.swift b/plugins/tray/macos/tray/Sources/tray/TrayMenu.swift index 0bd20329..2a24bfc8 100644 --- a/plugins/tray/macos/tray/Sources/tray/TrayMenu.swift +++ b/plugins/tray/macos/tray/Sources/tray/TrayMenu.swift @@ -1,14 +1,20 @@ import AppKit -final class TrayMenu: NSMenu { +final class TrayMenu: NSMenu, NSMenuDelegate { private let onSelect: (Int) -> Void + private let onClose: (() -> Void)? + private var contents: [TrayMenuItemView.Content?] = [] - init(items: [[String: Any]], onSelect: @escaping (Int) -> Void) { + init(items: [[String: Any]], onSelect: @escaping (Int) -> Void, onClose: (() -> Void)? = nil) { self.onSelect = onSelect + self.onClose = onClose super.init(title: "") autoenablesItems = false + delegate = self for entry in items { - addItem(makeItem(entry)) + let item = makeItem(entry) + addItem(item) + contents.append(item.isSeparatorItem ? nil : TrayMenu.content(entry, item)) } } @@ -16,6 +22,13 @@ final class TrayMenu: NSMenu { fatalError("init(coder:) has not been implemented") } + private static func detail(_ entry: [String: Any]) -> String? { + guard let detail = entry["detail"] as? String, !detail.isEmpty else { + return nil + } + return detail + } + private func makeItem(_ entry: [String: Any]) -> NSMenuItem { let type = entry["type"] as? String ?? "" if type == "separator" { @@ -26,23 +39,51 @@ final class TrayMenu: NSMenu { item.title = entry["label"] as? String ?? "" item.tag = entry["id"] as? Int ?? 0 item.isEnabled = entry["enabled"] as? Bool ?? true + item.state = type == "checkbox" && entry["checked"] as? Bool == true ? .on : .off - switch type { - case "checkbox": - item.state = (entry["checked"] as? Bool ?? false) ? .on : .off - item.target = self - item.action = #selector(didSelectItem(_:)) - case "submenu": + if type == "submenu" { let children = entry["items"] as? [[String: Any]] ?? [] setSubmenu(TrayMenu(items: children, onSelect: onSelect), for: item) - default: + } else { item.target = self item.action = #selector(didSelectItem(_:)) } - return item } + private static func content(_ entry: [String: Any], _ item: NSMenuItem) -> TrayMenuItemView.Content { + TrayMenuItemView.Content( + label: item.title, + detail: detail(entry), + checked: item.state == .on, + enabled: item.isEnabled, + hasSubmenu: item.hasSubmenu + ) + } + + // A delay test rebuilds the menu per result; measuring every row then costs ~20x. + func menuNeedsUpdate(_ menu: NSMenu) { + guard !contents.isEmpty else { + return + } + for (item, content) in zip(items, contents) { + if let content = content { + item.view = TrayMenuItemView(content: content) + } + } + contents = [] + } + + // AppKit does not redraw an item's view when its highlight changes. + func menu(_ menu: NSMenu, willHighlight item: NSMenuItem?) { + highlightedItem?.view?.needsDisplay = true + item?.view?.needsDisplay = true + } + + func menuDidClose(_ menu: NSMenu) { + onClose?() + } + @objc private func didSelectItem(_ sender: NSMenuItem) { onSelect(sender.tag) } diff --git a/plugins/tray/macos/tray/Sources/tray/TrayMenuItemView.swift b/plugins/tray/macos/tray/Sources/tray/TrayMenuItemView.swift new file mode 100644 index 00000000..f2fe074b --- /dev/null +++ b/plugins/tray/macos/tray/Sources/tray/TrayMenuItemView.swift @@ -0,0 +1,181 @@ +import AppKit + +// A native item leaves a fixed gap before its submenu arrow, so rows are drawn +// here to keep a detail next to the arrow. The metrics copy the native menu so +// these rows line up with the separators the menu still draws. +final class TrayMenuItemView: NSView { + struct Content { + let label: String + let detail: String? + let checked: Bool + let enabled: Bool + let hasSubmenu: Bool + } + + private struct Metrics { + let height: CGFloat + let stateX: CGFloat + let titleX: CGFloat + let trailing: CGFloat + let highlightInset: CGFloat + let highlightRadius: CGFloat + let font: NSFont + + static let current: Metrics = { + if #available(macOS 26, *) { + return Metrics( + height: 24, + stateX: 12, + titleX: 30, + trailing: 23, + highlightInset: 5, + highlightRadius: 8, + font: .menuFont(ofSize: 13) + ) + } + return Metrics( + height: 22, + stateX: 9, + titleX: 21, + trailing: 14, + highlightInset: 5, + highlightRadius: 4, + font: .menuFont(ofSize: 0) + ) + }() + } + + private static let metrics = Metrics.current + private static let detailGap: CGFloat = 20 + private static let arrowGap: CGFloat = 6 + + private static let detailFont = NSFont.monospacedDigitSystemFont( + ofSize: metrics.font.pointSize, + weight: .regular + ) + + private static let checkmark = symbol("checkmark", weight: .medium) + private static let arrow = symbol("chevron.right", weight: .semibold) + + private let content: Content + + init(content: Content) { + self.content = content + let metrics = TrayMenuItemView.metrics + var width = metrics.titleX + TrayMenuItemView.width(content.label) + metrics.trailing + if let detail = content.detail { + width += TrayMenuItemView.detailGap + TrayMenuItemView.width(detail, font: TrayMenuItemView.detailFont) + } + if content.hasSubmenu { + width += TrayMenuItemView.arrowGap + TrayMenuItemView.arrow.size.width + } + super.init(frame: NSRect(x: 0, y: 0, width: ceil(width), height: metrics.height)) + autoresizingMask = [.width] + setAccessibilityElement(true) + setAccessibilityRole(.menuItem) + setAccessibilityLabel([content.label, content.detail].compactMap { $0 }.joined(separator: ", ")) + setAccessibilityEnabled(content.enabled) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var isFlipped: Bool { + true + } + + private var isHighlighted: Bool { + content.enabled && enclosingMenuItem?.isHighlighted == true + } + + override func draw(_ dirtyRect: NSRect) { + let metrics = TrayMenuItemView.metrics + let highlighted = isHighlighted + if highlighted { + NSColor.controlAccentColor.setFill() + NSBezierPath( + roundedRect: bounds.insetBy(dx: metrics.highlightInset, dy: 0), + xRadius: metrics.highlightRadius, + yRadius: metrics.highlightRadius + ).fill() + } + + let primary: NSColor = highlighted + ? .selectedMenuItemTextColor + : content.enabled ? .labelColor : .tertiaryLabelColor + if content.checked { + drawSymbol(TrayMenuItemView.checkmark, x: metrics.stateX, color: primary) + } + + var trailingX = bounds.width - metrics.trailing + if content.hasSubmenu { + let arrowWidth = TrayMenuItemView.arrow.size.width + drawSymbol(TrayMenuItemView.arrow, x: trailingX - arrowWidth, color: primary) + trailingX -= arrowWidth + TrayMenuItemView.arrowGap + } + + if let detail = content.detail { + let color: NSColor = highlighted + ? .selectedMenuItemTextColor + : content.enabled ? .secondaryLabelColor : .tertiaryLabelColor + let detailWidth = TrayMenuItemView.width(detail, font: TrayMenuItemView.detailFont) + drawText(detail, x: trailingX - detailWidth, width: detailWidth, color: color, font: TrayMenuItemView.detailFont) + trailingX -= detailWidth + TrayMenuItemView.detailGap + } + + drawText(content.label, x: metrics.titleX, width: trailingX - metrics.titleX, color: primary, font: metrics.font) + } + + override func mouseUp(with event: NSEvent) { + guard content.enabled, !content.hasSubmenu, let item = enclosingMenuItem, + let action = item.action else { + return + } + var root = item.menu + while let parent = root?.supermenu { + root = parent + } + root?.cancelTracking() + NSApp.sendAction(action, to: item.target, from: item) + } + + private func drawText(_ text: String, x: CGFloat, width: CGFloat, color: NSColor, font: NSFont) { + let style = NSMutableParagraphStyle() + style.lineBreakMode = .byTruncatingTail + let height = ceil(font.ascender - font.descender) + let rect = NSRect(x: x, y: (bounds.height - height) / 2, width: max(width, 0), height: height) + (text as NSString).draw( + with: rect, + options: [.usesLineFragmentOrigin, .truncatesLastVisibleLine], + attributes: [.font: font, .foregroundColor: color, .paragraphStyle: style] + ) + } + + private func drawSymbol(_ image: NSImage, x: CGFloat, color: NSColor) { + let size = image.size + let rect = NSRect( + x: x, + y: ((bounds.height - size.height) / 2).rounded(), + width: size.width, + height: size.height + ) + let tinted = NSImage(size: size, flipped: false) { bounds in + image.draw(in: bounds) + color.setFill() + bounds.fill(using: .sourceAtop) + return true + } + tinted.draw(in: rect, from: .zero, operation: .sourceOver, fraction: 1, respectFlipped: true, hints: nil) + } + + private static func width(_ text: String, font: NSFont = metrics.font) -> CGFloat { + ceil((text as NSString).size(withAttributes: [.font: font]).width) + } + + private static func symbol(_ name: String, weight: NSFont.Weight) -> NSImage { + let configuration = NSImage.SymbolConfiguration(pointSize: metrics.font.pointSize, weight: weight) + return NSImage(systemSymbolName: name, accessibilityDescription: nil)? + .withSymbolConfiguration(configuration) ?? NSImage() + } +} diff --git a/plugins/tray/macos/tray/Sources/tray/TrayPlugin.swift b/plugins/tray/macos/tray/Sources/tray/TrayPlugin.swift index f43c547c..a8036f31 100644 --- a/plugins/tray/macos/tray/Sources/tray/TrayPlugin.swift +++ b/plugins/tray/macos/tray/Sources/tray/TrayPlugin.swift @@ -1,7 +1,7 @@ import Cocoa import FlutterMacOS -public class TrayPlugin: NSObject, FlutterPlugin, NSMenuDelegate { +public class TrayPlugin: NSObject, FlutterPlugin { private var channel: FlutterMethodChannel! private var statusItem: TrayStatusItem? private var menu: TrayMenu? @@ -49,11 +49,15 @@ public class TrayPlugin: NSObject, FlutterPlugin, NSMenuDelegate { item.setTitle(arguments["title"] as? String ?? "") if let items = arguments["menu"] as? [[String: Any]] { - let built = TrayMenu(items: items) { [weak self] id in - self?.channel.invokeMethod("onMenuItemSelected", arguments: ["id": id]) - } - built.delegate = self - menu = built + menu = TrayMenu( + items: items, + onSelect: { [weak self] id in + self?.channel.invokeMethod("onMenuItemSelected", arguments: ["id": id]) + }, + onClose: { [weak self] in + self?.statusItem?.closeMenu() + } + ) } return true @@ -82,14 +86,9 @@ public class TrayPlugin: NSObject, FlutterPlugin, NSMenuDelegate { } private func makeStatusItem() -> TrayStatusItem? { - guard let item = TrayStatusItem( - onActivate: { [weak self] in - self?.channel.invokeMethod("onIconActivated", arguments: nil) - }, - onMenuRequested: { [weak self] in - self?.channel.invokeMethod("onMenuRequested", arguments: nil) - } - ) else { + guard let item = TrayStatusItem(onMenuRequested: { [weak self] in + self?.channel.invokeMethod("onMenuRequested", arguments: nil) + }) else { return nil } statusItem = item @@ -115,8 +114,4 @@ public class TrayPlugin: NSObject, FlutterPlugin, NSMenuDelegate { image.isTemplate = icon["isTemplate"] as? Bool ?? false return image } - - public func menuDidClose(_ menu: NSMenu) { - statusItem?.closeMenu() - } } diff --git a/plugins/tray/macos/tray/Sources/tray/TrayStatusItem.swift b/plugins/tray/macos/tray/Sources/tray/TrayStatusItem.swift index 1842fe9d..ddb7ba47 100644 --- a/plugins/tray/macos/tray/Sources/tray/TrayStatusItem.swift +++ b/plugins/tray/macos/tray/Sources/tray/TrayStatusItem.swift @@ -5,12 +5,9 @@ final class TrayStatusItem { private let contentView: TrayContentView - init?(onActivate: @escaping () -> Void, onMenuRequested: @escaping () -> Void) { + init?(onMenuRequested: @escaping () -> Void) { statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - contentView = TrayContentView( - onActivate: onActivate, - onMenuRequested: onMenuRequested - ) + contentView = TrayContentView(onMenuRequested: onMenuRequested) guard let button = statusItem.button else { NSStatusBar.system.removeStatusItem(statusItem) @@ -59,7 +56,6 @@ final class TrayStatusItem { } private final class TrayContentView: NSView { - private let onActivate: () -> Void private let onMenuRequested: () -> Void private let imageView: NSImageView = { @@ -80,8 +76,7 @@ private final class TrayContentView: NSView { return stack }() - init(onActivate: @escaping () -> Void, onMenuRequested: @escaping () -> Void) { - self.onActivate = onActivate + init(onMenuRequested: @escaping () -> Void) { self.onMenuRequested = onMenuRequested super.init(frame: .zero) @@ -142,12 +137,7 @@ private final class TrayContentView: NSView { } override func mouseDown(with event: NSEvent) { - (superview as? NSButton)?.highlight(true) - onActivate() - } - - override func mouseUp(with event: NSEvent) { - (superview as? NSButton)?.highlight(false) + onMenuRequested() } override func rightMouseDown(with event: NSEvent) { diff --git a/plugins/tray/macos/tray/Sources/tray/TrayTitleView.swift b/plugins/tray/macos/tray/Sources/tray/TrayTitleView.swift index 35648c0f..3cc4d999 100644 --- a/plugins/tray/macos/tray/Sources/tray/TrayTitleView.swift +++ b/plugins/tray/macos/tray/Sources/tray/TrayTitleView.swift @@ -3,24 +3,19 @@ import AppKit final class TrayTitleView: NSView { static let width: CGFloat = 42 - private var attributedTitle: NSAttributedString? { + private static let font = NSFont.systemFont(ofSize: 8.75) + private static let lineHeight: CGFloat = 9 + + private var lines: [NSAttributedString] = [] { didSet { needsDisplay = true } } private var titleWidth: CGFloat = TrayTitleView.width - private let attributes: [NSAttributedString.Key: Any] = { - let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.maximumLineHeight = 9 - paragraphStyle.minimumLineHeight = 9 - paragraphStyle.alignment = .right - paragraphStyle.lineBreakMode = .byClipping - return [ - .paragraphStyle: paragraphStyle, - .font: NSFont.systemFont(ofSize: 8.75), - .foregroundColor: NSColor.labelColor, - ] - }() + private let attributes: [NSAttributedString.Key: Any] = [ + .font: TrayTitleView.font, + .foregroundColor: NSColor.labelColor, + ] override var isFlipped: Bool { true @@ -34,14 +29,13 @@ final class TrayTitleView: NSView { let wasHidden = isHidden let previousWidth = titleWidth - if title.isEmpty { - attributedTitle = nil - titleWidth = TrayTitleView.width - } else { - let text = NSAttributedString(string: title, attributes: attributes) - attributedTitle = text - titleWidth = max(TrayTitleView.width, ceil(measure(text).width)) - } + lines = title.isEmpty + ? [] + : title.components(separatedBy: "\n").map { + NSAttributedString(string: $0, attributes: attributes) + } + let textWidth = lines.map { ceil($0.size().width) }.max() ?? 0 + titleWidth = max(TrayTitleView.width, textWidth) isHidden = title.isEmpty let widthChanged = titleWidth != previousWidth @@ -51,27 +45,24 @@ final class TrayTitleView: NSView { return wasHidden != isHidden || widthChanged } - private func measure(_ text: NSAttributedString) -> NSSize { - text.boundingRect( - with: NSSize( - width: CGFloat.greatestFiniteMagnitude, - height: CGFloat.greatestFiniteMagnitude - ), - options: [.usesLineFragmentOrigin, .usesFontLeading] - ).size - } - + // Line boxes carry descender space, so centring them leaves the digits high. override func draw(_ dirtyRect: NSRect) { - guard let attributedTitle = attributedTitle else { + guard !lines.isEmpty else { return } - let textBounds = attributedTitle.boundingRect( - with: NSSize(width: bounds.width, height: CGFloat.greatestFiniteMagnitude), - options: [.usesLineFragmentOrigin, .usesFontLeading] - ) - let y = max((bounds.height - textBounds.height) / 2, 0) - attributedTitle.draw( - in: NSRect(x: 0, y: y, width: bounds.width, height: ceil(textBounds.height)) - ) + let capHeight = TrayTitleView.font.capHeight + let lineHeight = TrayTitleView.lineHeight + let blockHeight = capHeight + lineHeight * CGFloat(lines.count - 1) + let scale = window?.backingScaleFactor ?? 2 + var baseline = ((bounds.height - blockHeight) / 2 + capHeight) * scale + baseline = baseline.rounded() / scale + for line in lines { + let width = line.size().width + line.draw( + with: NSRect(x: bounds.width - width, y: baseline, width: width, height: 0), + options: [] + ) + baseline += lineHeight + } } } diff --git a/plugins/tray/test/macos_title_view_source_test.dart b/plugins/tray/test/macos_title_view_source_test.dart index 3f8388b5..9725cea9 100644 --- a/plugins/tray/test/macos_title_view_source_test.dart +++ b/plugins/tray/test/macos_title_view_source_test.dart @@ -32,21 +32,15 @@ void main() { expect(titleViewSource, isNot(contains('NSTextField'))); }); - test('macOS tray title text is drawn centered', () { + test('macOS tray title centres its cap-height block, right-aligned', () { expect(titleViewSource, contains('override var isFlipped: Bool')); - expect(titleViewSource, contains('.alignment = .right')); - expect( - titleViewSource, - contains('(bounds.height - textBounds.height) / 2'), - ); + expect(titleViewSource, contains('(bounds.height - blockHeight) / 2')); + expect(titleViewSource, contains('x: bounds.width - width')); }); test('macOS tray title widens instead of clipping long speeds', () { expect(titleViewSource, contains('invalidateIntrinsicContentSize')); - expect( - titleViewSource, - contains('max(TrayTitleView.width, ceil(measure(text).width))'), - ); + expect(titleViewSource, contains('max(TrayTitleView.width, textWidth)')); expect( statusItemSource, contains('greaterThanOrEqualToConstant: TrayTitleView.width'), diff --git a/plugins/tray/test/tray_codec_test.dart b/plugins/tray/test/tray_codec_test.dart index e9a04914..8457a75e 100644 --- a/plugins/tray/test/tray_codec_test.dart +++ b/plugins/tray/test/tray_codec_test.dart @@ -94,6 +94,44 @@ void main() { ); }); + test('detail is serialized only when it has text', () { + final encoded = TrayCodec.encode( + _spec( + menu: const [ + TrayMenuAction(label: 'a', detail: '⌃⌥S'), + TrayMenuCheckbox(label: 'b', checked: true, detail: '88 ms'), + TrayMenuSubmenu(label: 'c', detail: 'HK', items: []), + TrayMenuAction(label: 'd'), + TrayMenuAction(label: 'e', detail: ''), + ], + ), + ); + + final items = encoded.menu.cast>(); + expect(items.map((item) => item['detail']), [ + '⌃⌥S', + '88 ms', + 'HK', + null, + null, + ]); + expect(items[3].containsKey('detail'), isFalse); + expect(items[4].containsKey('detail'), isFalse); + }); + + test('signature tracks the detail', () { + String signatureWith(String? detail) { + return TrayCodec.encode( + _spec( + menu: [TrayMenuAction(label: 'a', detail: detail)], + ), + ).signature; + } + + expect(signatureWith('88 ms'), isNot(signatureWith('90 ms'))); + expect(signatureWith(null), isNot(signatureWith('88 ms'))); + }); + test('separators serialize without label or state', () { final encoded = TrayCodec.encode(_spec(menu: const [TrayMenuSeparator()])); diff --git a/plugins/tray/test/windows_menu_source_test.dart b/plugins/tray/test/windows_menu_source_test.dart index cf564253..e5bc2f2e 100644 --- a/plugins/tray/test/windows_menu_source_test.dart +++ b/plugins/tray/test/windows_menu_source_test.dart @@ -26,6 +26,11 @@ void main() { expect(pluginSource, isNot(contains('WM_COMMAND'))); }); + test('windows puts the detail in the accelerator column', () { + expect(pluginSource, contains('StringAt(*entry, "detail")')); + expect(pluginSource, contains("text += L'\\t';")); + }); + test('windows show reports a failed icon load', () { expect( pluginSource, diff --git a/plugins/tray/windows/tray_plugin.cpp b/plugins/tray/windows/tray_plugin.cpp index 290ca693..c71ef370 100644 --- a/plugins/tray/windows/tray_plugin.cpp +++ b/plugins/tray/windows/tray_plugin.cpp @@ -132,7 +132,12 @@ void TrayPlugin::RebuildMenu(HMENU menu, const flutter::EncodableList& items) { } const std::string* label = StringAt(*entry, "label"); - const std::wstring text = Utf16FromUtf8(label == nullptr ? "" : *label); + std::wstring text = Utf16FromUtf8(label == nullptr ? "" : *label); + const std::string* detail = StringAt(*entry, "detail"); + if (detail != nullptr && !detail->empty()) { + text += L'\t'; + text += Utf16FromUtf8(*detail); + } UINT flags = MF_STRING; if (!BoolAt(*entry, "enabled", true)) { diff --git a/plugins/wifi_ssid/android/build.gradle b/plugins/wifi_ssid/android/build.gradle index be3279b2..5691631e 100644 --- a/plugins/wifi_ssid/android/build.gradle +++ b/plugins/wifi_ssid/android/build.gradle @@ -8,7 +8,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:8.12.2' + classpath 'com.android.tools.build:gradle:9.4.1' } } @@ -23,7 +23,7 @@ apply plugin: 'com.android.library' android { namespace 'com.follow.clash.wifi_ssid' - compileSdk 36 + compileSdk 37 compileOptions { sourceCompatibility JavaVersion.VERSION_17 @@ -31,7 +31,7 @@ android { } defaultConfig { - minSdk 21 + minSdk 24 } } @@ -42,5 +42,5 @@ kotlin { } dependencies { - implementation 'androidx.core:core-ktx:1.15.0' + implementation 'androidx.core:core-ktx:1.19.1' } diff --git a/plugins/wifi_ssid/android/src/main/kotlin/com/follow/clash/wifi_ssid/WifiSsidPlugin.kt b/plugins/wifi_ssid/android/src/main/kotlin/com/follow/clash/wifi_ssid/WifiSsidPlugin.kt index 0a4953b9..91ee8a34 100644 --- a/plugins/wifi_ssid/android/src/main/kotlin/com/follow/clash/wifi_ssid/WifiSsidPlugin.kt +++ b/plugins/wifi_ssid/android/src/main/kotlin/com/follow/clash/wifi_ssid/WifiSsidPlugin.kt @@ -36,6 +36,8 @@ class WifiSsidPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { private var wifiNetworkCallback: ConnectivityManager.NetworkCallback? = null private val wifiInfoByNetwork = ConcurrentHashMap() private val pendingSsidResults = mutableListOf() + @Volatile + private var hasPendingSsid = false private val mainHandler = Handler(Looper.getMainLooper()) private var ssidTimeout: Runnable? = null @@ -255,6 +257,7 @@ class WifiSsidPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { return } pendingSsidResults.add(result) + hasPendingSsid = true if (!registerWifiNetworkCallback()) { completePendingSsidResults(ssid = null) return @@ -314,6 +317,9 @@ class WifiSsidPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { } else { wifiInfoByNetwork[network] = wifiInfo } + if (!hasPendingSsid) { + return + } currentSsid()?.let { ssid -> mainHandler.post { completePendingSsidResults(ssid = ssid) @@ -356,6 +362,7 @@ class WifiSsidPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { } val results = pendingSsidResults.toList() pendingSsidResults.clear() + hasPendingSsid = false results.forEach { result -> if (errorMessage == null) { result.success(ssid) diff --git a/plugins/wifi_ssid/macos/wifi_ssid/Sources/wifi_ssid/WifiSsidPlugin.swift b/plugins/wifi_ssid/macos/wifi_ssid/Sources/wifi_ssid/WifiSsidPlugin.swift index f23fc933..652f61c8 100644 --- a/plugins/wifi_ssid/macos/wifi_ssid/Sources/wifi_ssid/WifiSsidPlugin.swift +++ b/plugins/wifi_ssid/macos/wifi_ssid/Sources/wifi_ssid/WifiSsidPlugin.swift @@ -5,10 +5,16 @@ import FlutterMacOS public class WifiSsidPlugin: NSObject, FlutterPlugin, CLLocationManagerDelegate { - private let locationManager = CLLocationManager() - private let wifiClient = CWWiFiClient.shared() + // Both talk to system daemons (locationd, wifid) on creation, so they are + // not built until Dart asks for the SSID or the permission. + private lazy var locationManager: CLLocationManager = { + let manager = CLLocationManager() + manager.delegate = self + return manager + }() + private lazy var wifiClient = CWWiFiClient.shared() private let ssidQueue = DispatchQueue(label: "com.follow.clash.wifi_ssid") - private var pendingPermissionResult: FlutterResult? + private var pendingPermissionResults: [FlutterResult] = [] private enum Method { static let getSsid = "getSsid" @@ -16,10 +22,6 @@ public class WifiSsidPlugin: NSObject, FlutterPlugin, CLLocationManagerDelegate static let requestPermission = "requestPermission" } - private enum ErrorCode { - static let inProgress = "IN_PROGRESS" - } - public static func register(with registrar: FlutterPluginRegistrar) { let channel = FlutterMethodChannel( name: "wifi_ssid", binaryMessenger: registrar.messenger @@ -28,11 +30,6 @@ public class WifiSsidPlugin: NSObject, FlutterPlugin, CLLocationManagerDelegate registrar.addMethodCallDelegate(instance, channel: channel) } - override init() { - super.init() - locationManager.delegate = self - } - public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { switch call.method { case Method.getSsid: @@ -67,24 +64,19 @@ public class WifiSsidPlugin: NSObject, FlutterPlugin, CLLocationManagerDelegate result(permission.rawValue) return } - if pendingPermissionResult != nil { - result( - FlutterError( - code: ErrorCode.inProgress, - message: "A permission request is already active", - details: nil - ) - ) - return - } - pendingPermissionResult = result + pendingPermissionResults.append(result) locationManager.requestWhenInUseAuthorization() } public func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { - guard let result = pendingPermissionResult else { return } - pendingPermissionResult = nil - result(mapAuthStatus(manager.authorizationStatus).rawValue) + // A new manager reports its status once, and the lazy one is created + // by the request itself, so that report can land while the prompt is up. + guard manager.authorizationStatus != .notDetermined, + !pendingPermissionResults.isEmpty else { return } + let results = pendingPermissionResults + pendingPermissionResults.removeAll() + let permission = mapAuthStatus(manager.authorizationStatus).rawValue + results.forEach { $0(permission) } } private func mapAuthStatus(_ status: CLAuthorizationStatus) -> WifiSsidPermission { diff --git a/plugins/wifi_ssid/test/macos_ssid_source_test.dart b/plugins/wifi_ssid/test/macos_ssid_source_test.dart index ea3eceb7..ccc1ef12 100644 --- a/plugins/wifi_ssid/test/macos_ssid_source_test.dart +++ b/plugins/wifi_ssid/test/macos_ssid_source_test.dart @@ -51,6 +51,15 @@ void main() { ); }); + test('macOS lets a second permission request wait for the same answer', () { + expect(pluginSource, contains('pendingPermissionResults.append(result)')); + expect( + pluginSource, + isNot(contains('IN_PROGRESS')), + reason: 'a prompt left unanswered would refuse every later request', + ); + }); + test('macOS reports the location permission as granted below macOS 14', () { final checkPermissionIndex = pluginSource.indexOf( 'private func checkPermission', diff --git a/plugins/window/.gitignore b/plugins/window/.gitignore new file mode 100644 index 00000000..b4bd9333 --- /dev/null +++ b/plugins/window/.gitignore @@ -0,0 +1,4 @@ +.dart_tool/ +.packages +build/ +pubspec.lock diff --git a/plugins/window/LICENSE b/plugins/window/LICENSE new file mode 100644 index 00000000..a47e6a3c --- /dev/null +++ b/plugins/window/LICENSE @@ -0,0 +1,25 @@ +MIT License + +Copyright (c) 2025 FlClash +Copyright (c) 2022-present LiJianying + +Portions of the platform implementations derive from the window_manager +Flutter plugin (https://github.com/leanflutter/window_manager). + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/window/README.md b/plugins/window/README.md new file mode 100644 index 00000000..13f3adf1 --- /dev/null +++ b/plugins/window/README.md @@ -0,0 +1,194 @@ +# window + +Desktop window control for FlClash on Linux, macOS and Windows. Written for +FlClash; it replaced the `window_manager` fork, and its platform code derives +from that plugin (see `LICENSE`). + +## Dart API + +`desktopWindow` is the singleton `DesktopWindow`. Every call is a method on the +`window` method channel; events arrive on the same channel as `onEvent` with a +`name` argument and are delivered to `WindowListener`s as `WindowEvent`s. + +Coordinates and sizes cross the channel in logical pixels on every platform. +Windows converts with the window's own DPI. + +Every native handler validates its arguments and answers a missing or mistyped +argument with a `PlatformException` whose code is `bad_args`. A call the +platform cannot honor answers with code `unsupported`. Native code never +crashes on channel input. + +## Method channel contract + +Arguments are a `Map`; `-` means no arguments. Void methods +return `null`. + +| Method | Arguments | Returns | Notes | +| --- | --- | --- | --- | +| `ensureInitialized` | - | null | Binds the plugin to the Flutter view's window and leaves it hidden. Every other method requires it. | +| `show` | `inactive: bool` | null | `inactive` shows without taking focus. | +| `hide` | - | null | | +| `isVisible` | - | bool | | +| `focus` | - | null | | +| `close` | - | null | Runs the close path; with `preventClose` set only the `close` event fires. | +| `setPreventClose` | `value: bool` | null | | +| `isMaximized` | - | bool | | +| `maximize` | - | null | | +| `unmaximize` | - | null | | +| `isMinimized` | - | bool | | +| `minimize` | - | null | | +| `restore` | - | null | | +| `isFullScreen` | - | bool | | +| `setFullScreen` | `value: bool` | null | | +| `getBounds` | - | `{x, y, width, height}` doubles | Logical pixels, top-left origin on every platform. | +| `setBounds` | `x?, y?, width?, height?: double` | null | Absent `x`/`y` keeps the position, absent size keeps the size. Never changes z-order or focus. | +| `isPositionSupported` | - | bool | false on Wayland, where the compositor owns placement. | +| `setMinimumSize` | `width, height: double` | null | | +| `isAlwaysOnTop` / `setAlwaysOnTop` | `value: bool` | bool / null | | +| `setTitleBarStyle` | `style: "normal" \| "hidden", windowButtonVisibility: bool` | null | Hidden keeps the system frame, border and resize edges; only the caption goes. | +| `setSkipTaskbar` | `value: bool` | null | macOS switches the activation policy. | +| `setRoundedCorners` | `value: bool` | null | Windows 11 corner preference; no-op elsewhere. | +| `isEffectSupported` | `effect: String` | bool | See effects below. False for anything `setEffect` would silently not draw. | +| `setEffect` | `effect: String, tint: int?` ARGB`, brightness: "light" \| "dark" \| null` | null | `unsupported` error when `isEffectSupported` is false. `brightness` picks the light or dark material; null follows the system. | +| `startDragging` | - | null | Begins a window move from the current pointer press. | + +### Events + +`onEvent` carries `name`, one of: + +`close`, `focus`, `blur`, `show`, `hide`, `maximize`, `unmaximize`, +`minimize`, `restore`, `geometry-changed`, `enter-full-screen`, +`leave-full-screen`, `should-terminate`, `activate`. + +- `geometry-changed` fires once the window's position or size has settled, + whether the user or `setBounds` changed it. Each platform coalesces the raw + notifications for 150 ms (Windows `WM_WINDOWPOSCHANGED`, macOS did-move and + did-resize, Linux configure-event) and reports the final geometry. It is + not suppressed while maximized or full screen; Dart decides what to keep. +- `show` and `hide` follow the window's visibility changes: Windows reports + `WM_SHOWWINDOW`, Linux the GTK `show`/`hide` signals, and macOS a `show` or + `hide` call that changed `isVisible`. +- `close` fires when the user or `close` asks to close; with `preventClose` + the window stays open and Dart decides. +- `enter-full-screen` and `leave-full-screen` are raised by the platform's + full-screen transition; on Windows the plugin emulates full screen and + raises them itself from `setFullScreen`. +- `should-terminate` (macOS) is forwarded by the application delegate from + `applicationShouldTerminate`. +- `activate` is raised by the runner when another launch, or a Dock reopen, + asks the running instance to show its window. Nothing is shown natively. + +### Window style + +Each platform keeps one style record and reapplies it as a whole whenever any +part changes, so the settings never fight each other: + +- `titleBarStyle`, `windowButtonVisibility` +- `roundedCorners` (Windows) +- `effect`, `effectTint`, `effectBrightness` (Windows, macOS) + +On Windows that record owns `DwmExtendFrameIntoClientArea`, the DWM window +attributes (including immersive dark mode, which the system backdrops and the +frame border follow) and `SetWindowCompositionAttribute`; on macOS it owns +`isOpaque`, `backgroundColor`, `titlebarAppearsTransparent` and the effect +view, whose `appearance` is the requested brightness; on Linux the header bar +and decoration state. + +### Effects + +`effect` is one of `none`, `transparent`, `blur`, `acrylic`, `mica`. Linux +supports only `none`: no desktop-neutral blur protocol exists for GTK 3, and a +transparent window without blur is not a useful effect. + +| Effect | Windows | macOS | +| --- | --- | --- | +| `none` | default frame | opaque window | +| `transparent` | transparent gradient accent | clear window | +| `blur` | `ACCENT_ENABLE_BLURBEHIND` | `NSVisualEffectView` `.sidebar`, behind-window blending, follows the window's active state | +| `acrylic` | `ACCENT_ENABLE_ACRYLICBLURBEHIND` (Windows 10 1803+) | `NSVisualEffectView` `.fullScreenUI` | +| `mica` | `DWMSBT_MAINWINDOW` on Windows 11 | unsupported | + +The accent effects leave the DWM frame margins at zero: Windows fills a frame +extended into the client area with an opaque material that covers them. `mica` +extends the whole frame, which the system backdrop needs, and the Windows 10 +top border below extends the top. The accent effects go through +`SetWindowCompositionAttribute`, so `isEffectSupported` is false for them on a +user32 without it rather than reporting an effect that draws nothing. + +With `titleBarStyle: hidden` on Windows, `WM_NCCALCSIZE` takes the frame +insets from `AdjustWindowRectExForDpi` and keeps the client rect at the +window's top edge, plus the frame height when maximized so the taskbar stays +uncovered. Windows 11 draws the top border into a 1 px non-client strip left +there. Windows 10 draws the whole caption over any strip shorter than one +(Windows Terminal's `NonClientIslandWindow` records the same), so it gets no +strip. Instead, while no accent effect is on, `WindowStyle::Apply` extends the +DWM frame over the whole caption height and `WindowController` keeps a 1 px +row of client area above the Flutter view, paints it black in `WM_PAINT` +(alpha 0 to DWM, which shows the frame's border through it) and answers +`WM_NCHITTEST` with `HTTOP` for it. The plugin therefore positions the view +itself on `WM_SIZE` and after every frame change; the runner's own placement +only runs before `ensureInitialized`. The row is gone while maximized or full +screen, where there is no border to show, and while an accent effect is on. + +The Flutter content must paint a translucent background for anything but +`none` to be visible. + +On macOS the plugin follows macos_window_utils, which flutter_acrylic uses: +`ensureInitialized` installs a host view controller whose view is the +`NSVisualEffectView` and hosts the Flutter view controller as a child, so the +effect view is the Flutter view's superview for the life of the window. +Placing the effect view beneath the Flutter view as a sibling breaks input: +AppKit hands an unhandled `mouseDown` from a non-opaque view to the sibling +below it before the view controller, so Flutter never receives clicks. `none` +and `transparent` mask the material out with an empty `maskImage` and leave +the view inactive instead of swapping view controllers. + +## Lifecycle + +The engine reports `AppLifecycleState` from its own view of the window. Where +that view keeps an unseen window rendering, the plugin sends `hidden` on +`flutter/lifecycle` itself: + +- Linux: Wayland has no minimized state, so GTK never sets `ICONIFIED` after + `minimize` and the engine reports only the focus loss, as `inactive`. When + the window loses focus within 500 ms of `minimize`, the plugin sends + `hidden` from a `window-state-event` handler connected after the engine's. + A compositor that ignores the request, such as sway, leaves the focus where + it is, and a restore from the compositor focuses the window, which the + engine reports as `resumed`. A minimize the compositor starts itself, such + as Super+H, still reads as `inactive`. +- macOS: on activation the engine resumes if any window in `NSApp.windows` is + visible, and the status item's window always is. After every activation + change the plugin sends `hidden` while its own window is not visible. A + window that comes back without another activation change would then wait + for an occlusion notification, which macOS does not always post, so after + `show` or a deminiaturize the plugin sends `resumed` or `inactive` itself. + +On Windows the engine counts the Flutter view as a window of its own that hides +only once it is sized to zero. Hiding the top-level window never resizes the +view, and neither does minimizing it, because the plugin consumes that +`WM_SIZE`. The plugin has no handle to the engine, so the runner passes the +top-level window's `WM_SHOWWINDOW` and `WM_SIZE` to the engine for the view +through `WindowPluginForwardVisibility` (see Runner hooks). + +## Unused contract surface + +The application drives only `none`, `blur` and `acrylic`, always passes +`windowButtonVisibility: true`, and calls `setRoundedCorners` for Windows +alone. `transparent`, `mica`, hidden caption buttons and the macOS +`setRoundedCorners` no-op stay in the contract so every platform answers the +same table; they have no application caller today. + +## Runner hooks + +- Windows: `WindowPluginFindRunningWindow()` and + `WindowPluginActivateWindow(HWND)` from `window/window_plugin_c_api.h`, and + `WindowPluginForwardVisibility(controller, message, wparam, lparam)` from + `window/window_plugin_runner.h`, which the top-level window procedure calls + before `HandleTopLevelWindowProc`. That header is inline because it calls + `flutter::FlutterEngine`, which only the app's C++ wrapper implements; the + plugin DLL cannot use it. +- Linux: `window_plugin_activate()` from `window/window_plugin.h`. +- macOS: `WindowPlugin.instance?.handleShouldTerminate()`, + `WindowPlugin.instance?.handleReopen()`, and `NSWindow.hiddenWindowAtLaunch()` + for `MainFlutterWindow.order(_:relativeTo:)`. diff --git a/plugins/window/analysis_options.yaml b/plugins/window/analysis_options.yaml new file mode 100644 index 00000000..75188c33 --- /dev/null +++ b/plugins/window/analysis_options.yaml @@ -0,0 +1,10 @@ +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** +include: ../../lint_options.yaml diff --git a/plugins/window/lib/src/desktop_window.dart b/plugins/window/lib/src/desktop_window.dart new file mode 100644 index 00000000..bb8ebc91 --- /dev/null +++ b/plugins/window/lib/src/desktop_window.dart @@ -0,0 +1,261 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart' show Alignment, Brightness; +import 'package:screen_retriever/screen_retriever.dart'; +import 'package:window/src/title_bar_style.dart'; +import 'package:window/src/window_effect.dart'; +import 'package:window/src/window_event.dart'; +import 'package:window/src/window_listener.dart'; + +class DesktopWindow { + DesktopWindow._() { + _channel.setMethodCallHandler(_handleMethodCall); + } + + static final DesktopWindow instance = DesktopWindow._(); + + static const MethodChannel _channel = MethodChannel('window'); + + final ObserverList _listeners = + ObserverList(); + + List get listeners => List.of(_listeners); + + bool get hasListeners => _listeners.isNotEmpty; + + void addListener(WindowListener listener) { + _listeners.add(listener); + } + + void removeListener(WindowListener listener) { + _listeners.remove(listener); + } + + Future _handleMethodCall(MethodCall call) async { + if (call.method != 'onEvent') { + throw MissingPluginException('${call.method} is not an event'); + } + final arguments = call.arguments; + final event = WindowEvent.fromWireName( + arguments is Map ? arguments['name'] : null, + ); + if (event == null) { + return; + } + for (final listener in listeners) { + if (!_listeners.contains(listener)) { + continue; + } + listener.onWindowEvent(event); + _dispatch(listener, event); + } + } + + void _dispatch(WindowListener listener, WindowEvent event) { + switch (event) { + case WindowEvent.close: + listener.onWindowClose(); + case WindowEvent.focus: + listener.onWindowFocus(); + case WindowEvent.blur: + listener.onWindowBlur(); + case WindowEvent.show: + listener.onWindowShow(); + case WindowEvent.hide: + listener.onWindowHide(); + case WindowEvent.maximize: + listener.onWindowMaximize(); + case WindowEvent.unmaximize: + listener.onWindowUnmaximize(); + case WindowEvent.minimize: + listener.onWindowMinimize(); + case WindowEvent.restore: + listener.onWindowRestore(); + case WindowEvent.geometryChanged: + listener.onWindowGeometryChanged(); + case WindowEvent.enterFullScreen: + listener.onWindowEnterFullScreen(); + case WindowEvent.leaveFullScreen: + listener.onWindowLeaveFullScreen(); + case WindowEvent.shouldTerminate: + listener.onWindowShouldTerminate(); + case WindowEvent.activate: + listener.onWindowActivate(); + } + } + + Future _invoke(String method, [Map? arguments]) async { + final result = await _channel.invokeMethod(method, arguments); + if (result == null) { + throw PlatformException( + code: 'bad_result', + message: '$method answered null', + ); + } + return result; + } + + Future _call(String method, [Map? arguments]) { + return _channel.invokeMethod(method, arguments); + } + + Future ensureInitialized() => _call('ensureInitialized'); + + Future show({bool inactive = false}) { + return _call('show', {'inactive': inactive}); + } + + Future hide() => _call('hide'); + + Future isVisible() => _invoke('isVisible'); + + Future focus() => _call('focus'); + + Future close() => _call('close'); + + Future setPreventClose(bool value) { + return _call('setPreventClose', {'value': value}); + } + + Future isMaximized() => _invoke('isMaximized'); + + Future maximize() => _call('maximize'); + + Future unmaximize() => _call('unmaximize'); + + Future isMinimized() => _invoke('isMinimized'); + + Future minimize() => _call('minimize'); + + Future restore() => _call('restore'); + + Future isFullScreen() => _invoke('isFullScreen'); + + Future setFullScreen(bool value) { + return _call('setFullScreen', {'value': value}); + } + + Future getBounds() async { + final data = await _invoke>('getBounds'); + return Rect.fromLTWH( + (data['x'] as num).toDouble(), + (data['y'] as num).toDouble(), + (data['width'] as num).toDouble(), + (data['height'] as num).toDouble(), + ); + } + + Future setBounds({Offset? position, Size? size}) { + final arguments = { + 'x': position?.dx, + 'y': position?.dy, + 'width': size?.width, + 'height': size?.height, + }..removeWhere((_, value) => value == null); + return _call('setBounds', arguments); + } + + Future getSize() async => (await getBounds()).size; + + Future setSize(Size size) => setBounds(size: size); + + Future getPosition() async => (await getBounds()).topLeft; + + Future isPositionSupported() => _invoke('isPositionSupported'); + + Future setPosition(Offset position) => setBounds(position: position); + + Future setAlignment(Alignment alignment) async { + final position = await calcWindowPosition(await getSize(), alignment); + await setPosition(position); + } + + Future center() => setAlignment(Alignment.center); + + Future setMinimumSize(Size size) { + return _call('setMinimumSize', { + 'width': size.width, + 'height': size.height, + }); + } + + Future isAlwaysOnTop() => _invoke('isAlwaysOnTop'); + + Future setAlwaysOnTop(bool value) { + return _call('setAlwaysOnTop', {'value': value}); + } + + Future setTitleBarStyle( + TitleBarStyle style, { + bool windowButtonVisibility = true, + }) { + return _call('setTitleBarStyle', { + 'style': style.name, + 'windowButtonVisibility': windowButtonVisibility, + }); + } + + Future setSkipTaskbar(bool value) { + return _call('setSkipTaskbar', {'value': value}); + } + + Future setRoundedCorners(bool value) { + return _call('setRoundedCorners', {'value': value}); + } + + Future isEffectSupported(WindowEffect effect) { + return _invoke('isEffectSupported', {'effect': effect.name}); + } + + /// [brightness] picks the light or dark material; null follows the system. + Future setEffect( + WindowEffect effect, { + Color? tint, + Brightness? brightness, + }) { + return _call('setEffect', { + 'effect': effect.name, + 'tint': tint?.toARGB32(), + 'brightness': brightness?.name, + }); + } + + Future startDragging() => _call('startDragging'); +} + +final DesktopWindow desktopWindow = DesktopWindow.instance; + +/// Positions [windowSize] inside the work area of the display under the +/// cursor, falling back to the primary display. +@visibleForTesting +Future calcWindowPosition(Size windowSize, Alignment alignment) async { + final primaryDisplay = await screenRetriever.getPrimaryDisplay(); + final allDisplays = await screenRetriever.getAllDisplays(); + final cursorScreenPoint = await screenRetriever.getCursorScreenPoint(); + + final currentDisplay = allDisplays.firstWhere((display) { + final visiblePosition = display.visiblePosition; + if (visiblePosition == null) { + return false; + } + return Rect.fromLTWH( + visiblePosition.dx, + visiblePosition.dy, + display.size.width, + display.size.height, + ).contains(cursorScreenPoint); + }, orElse: () => primaryDisplay); + + final visibleSize = currentDisplay.visibleSize ?? currentDisplay.size; + final visibleStart = currentDisplay.visiblePosition ?? Offset.zero; + final slack = Offset( + visibleSize.width - windowSize.width, + visibleSize.height - windowSize.height, + ); + return Offset( + visibleStart.dx + slack.dx / 2 + alignment.x * slack.dx / 2, + visibleStart.dy + slack.dy / 2 + alignment.y * slack.dy / 2, + ); +} diff --git a/plugins/window/lib/src/title_bar_style.dart b/plugins/window/lib/src/title_bar_style.dart new file mode 100644 index 00000000..3c5ff5f6 --- /dev/null +++ b/plugins/window/lib/src/title_bar_style.dart @@ -0,0 +1,3 @@ +/// Whether the system caption is drawn. `hidden` keeps the frame, border and +/// resize edges and removes only the caption bar. +enum TitleBarStyle { normal, hidden } diff --git a/plugins/window/lib/src/window_effect.dart b/plugins/window/lib/src/window_effect.dart new file mode 100644 index 00000000..8e55b7f2 --- /dev/null +++ b/plugins/window/lib/src/window_effect.dart @@ -0,0 +1,4 @@ +/// Backdrop drawn behind the Flutter content. Anything but [none] needs the +/// content to paint a translucent background to be visible, and +/// `DesktopWindow.isEffectSupported` tells whether the platform can draw it. +enum WindowEffect { none, transparent, blur, acrylic, mica } diff --git a/plugins/window/lib/src/window_event.dart b/plugins/window/lib/src/window_event.dart new file mode 100644 index 00000000..67489b45 --- /dev/null +++ b/plugins/window/lib/src/window_event.dart @@ -0,0 +1,30 @@ +enum WindowEvent { + close('close'), + focus('focus'), + blur('blur'), + show('show'), + hide('hide'), + maximize('maximize'), + unmaximize('unmaximize'), + minimize('minimize'), + restore('restore'), + geometryChanged('geometry-changed'), + enterFullScreen('enter-full-screen'), + leaveFullScreen('leave-full-screen'), + shouldTerminate('should-terminate'), + activate('activate'); + + const WindowEvent(this.wireName); + + /// The name the platform side sends on the method channel. + final String wireName; + + static WindowEvent? fromWireName(Object? name) { + for (final event in values) { + if (event.wireName == name) { + return event; + } + } + return null; + } +} diff --git a/plugins/window/lib/src/window_listener.dart b/plugins/window/lib/src/window_listener.dart new file mode 100644 index 00000000..4b990194 --- /dev/null +++ b/plugins/window/lib/src/window_listener.dart @@ -0,0 +1,33 @@ +import 'package:window/src/window_event.dart'; + +abstract mixin class WindowListener { + void onWindowClose() {} + + void onWindowFocus() {} + + void onWindowBlur() {} + + void onWindowShow() {} + + void onWindowHide() {} + + void onWindowMaximize() {} + + void onWindowUnmaximize() {} + + void onWindowMinimize() {} + + void onWindowRestore() {} + + void onWindowGeometryChanged() {} + + void onWindowEnterFullScreen() {} + + void onWindowLeaveFullScreen() {} + + void onWindowShouldTerminate() {} + + void onWindowActivate() {} + + void onWindowEvent(WindowEvent event) {} +} diff --git a/plugins/window/lib/window.dart b/plugins/window/lib/window.dart new file mode 100644 index 00000000..dadd0cae --- /dev/null +++ b/plugins/window/lib/window.dart @@ -0,0 +1,5 @@ +export 'src/desktop_window.dart'; +export 'src/title_bar_style.dart'; +export 'src/window_effect.dart'; +export 'src/window_event.dart'; +export 'src/window_listener.dart'; diff --git a/plugins/window/linux/CMakeLists.txt b/plugins/window/linux/CMakeLists.txt new file mode 100644 index 00000000..4f3fb6ae --- /dev/null +++ b/plugins/window/linux/CMakeLists.txt @@ -0,0 +1,27 @@ +cmake_minimum_required(VERSION 3.10) +set(PROJECT_NAME "window") +project(${PROJECT_NAME} LANGUAGES CXX) + +set(PLUGIN_NAME "window_plugin") + +find_package(PkgConfig REQUIRED) + +add_library(${PLUGIN_NAME} SHARED + "window_plugin.cc" + "window_geometry.cc" + "window_style.cc" + "window_drag.cc" +) +apply_standard_settings(${PLUGIN_NAME}) +set_target_properties(${PLUGIN_NAME} PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) +target_include_directories(${PLUGIN_NAME} INTERFACE + "${CMAKE_CURRENT_SOURCE_DIR}/include") +target_link_libraries(${PLUGIN_NAME} PRIVATE flutter) +target_link_libraries(${PLUGIN_NAME} PRIVATE PkgConfig::GTK) + +set(window_bundled_libraries + "" + PARENT_SCOPE +) diff --git a/plugins/window/linux/include/window/window_plugin.h b/plugins/window/linux/include/window/window_plugin.h new file mode 100644 index 00000000..8ef03d09 --- /dev/null +++ b/plugins/window/linux/include/window/window_plugin.h @@ -0,0 +1,29 @@ +#ifndef FLUTTER_PLUGIN_WINDOW_PLUGIN_H_ +#define FLUTTER_PLUGIN_WINDOW_PLUGIN_H_ + +#include + +G_BEGIN_DECLS + +#ifdef FLUTTER_PLUGIN_IMPL +#define FLUTTER_PLUGIN_EXPORT __attribute__((visibility("default"))) +#else +#define FLUTTER_PLUGIN_EXPORT +#endif + +typedef struct _WindowPlugin WindowPlugin; +typedef struct { + GObjectClass parent_class; +} WindowPluginClass; + +FLUTTER_PLUGIN_EXPORT GType window_plugin_get_type(); + +FLUTTER_PLUGIN_EXPORT void window_plugin_register_with_registrar( + FlPluginRegistrar* registrar); + +// Reports "activate" to Dart, targeting the most recently registered plugin. +FLUTTER_PLUGIN_EXPORT void window_plugin_activate(); + +G_END_DECLS + +#endif // FLUTTER_PLUGIN_WINDOW_PLUGIN_H_ diff --git a/plugins/window/linux/window_drag.cc b/plugins/window/linux/window_drag.cc new file mode 100644 index 00000000..b75a6b84 --- /dev/null +++ b/plugins/window/linux/window_drag.cc @@ -0,0 +1,141 @@ +#include "window_drag.h" + +#include + +#include + +#include "window_plugin_internal.h" + +static void collect_child(GtkWidget* widget, gpointer client_data) { + GList** children = static_cast(client_data); + *children = g_list_prepend(*children, widget); +} + +static void find_event_box(WindowPlugin* self, GtkWidget* widget) { + g_autoptr(GList) children = nullptr; + gtk_container_forall(GTK_CONTAINER(widget), collect_child, &children); + GtkWidget* event_box = nullptr; + for (GList* l = children; l != nullptr; l = l->next) { + GtkWidget* child = GTK_WIDGET(l->data); + if (GTK_IS_EVENT_BOX(child)) { + event_box = child; + } + } + if (event_box != nullptr) { + self->drag.event_box = event_box; + g_object_add_weak_pointer(G_OBJECT(event_box), + reinterpret_cast(&self->drag.event_box)); + } +} + +static void emit_button_release(WindowPlugin* self) { + if (self->drag.event_box == nullptr) { + return; + } + GdkWindow* window = gtk_widget_get_window(self->drag.event_box); + if (window == nullptr) { + return; + } + GdkEventButton* event = + reinterpret_cast(gdk_event_new(GDK_BUTTON_RELEASE)); + event->window = GDK_WINDOW(g_object_ref(window)); + event->send_event = TRUE; + event->x = self->drag.event_button.x; + event->y = self->drag.event_button.y; + event->button = self->drag.event_button.button; + event->time = self->drag.event_button.time; + + gboolean result; + g_signal_emit_by_name(self->drag.event_box, "button-release-event", event, + &result); + gdk_event_free(reinterpret_cast(event)); +} + +static gboolean on_event_after(GtkWidget*, + GdkEvent* event, + gpointer data) { + WindowPlugin* self = WINDOW_PLUGIN(data); + if (event->type == GDK_ENTER_NOTIFY && self->drag.is_dragging) { + self->drag.is_dragging = false; + emit_button_release(self); + } + return FALSE; +} + +// A global hook, since the button-press event is consumed by Flutter's own +// view before it reaches any per-window signal handler. +static gboolean on_mouse_press(GSignalInvocationHint*, + guint, + const GValue* param_values, + gpointer data) { + WindowPlugin* self = WINDOW_PLUGIN(data); + GObject* emitter = G_OBJECT(g_value_get_object(param_values + 0)); + FlView* view = fl_plugin_registrar_get_view(self->registrar); + if (view == nullptr || (emitter != G_OBJECT(view) && + emitter != G_OBJECT(self->drag.event_box))) { + return TRUE; + } + GdkEventButton* event = + static_cast(g_value_get_boxed(param_values + 1)); + memcpy(&self->drag.event_button, event, sizeof(self->drag.event_button)); + return TRUE; +} + +void window_drag_init(WindowDragState* state) { + memset(state, 0, sizeof(*state)); +} + +void window_drag_connect(WindowPlugin* self) { + GtkWindow* window = window_plugin_get_window(self); + if (window != nullptr) { + self->drag.event_after_handler_id = + g_signal_connect(window, "event-after", G_CALLBACK(on_event_after), self); + find_event_box(self, GTK_WIDGET(fl_plugin_registrar_get_view(self->registrar))); + } + + self->drag.mouse_press_hook_id = g_signal_add_emission_hook( + g_signal_lookup("button-press-event", GTK_TYPE_WIDGET), 0, + on_mouse_press, self, nullptr); +} + +void window_drag_dispose(WindowPlugin* self) { + if (self->drag.mouse_press_hook_id != 0) { + g_signal_remove_emission_hook( + g_signal_lookup("button-press-event", GTK_TYPE_WIDGET), + self->drag.mouse_press_hook_id); + self->drag.mouse_press_hook_id = 0; + } + + if (self->drag.event_box != nullptr) { + g_object_remove_weak_pointer( + G_OBJECT(self->drag.event_box), + reinterpret_cast(&self->drag.event_box)); + self->drag.event_box = nullptr; + } + + GtkWindow* window = window_plugin_get_window(self); + if (window != nullptr && self->drag.event_after_handler_id != 0) { + g_signal_handler_disconnect(window, self->drag.event_after_handler_id); + } + self->drag.event_after_handler_id = 0; +} + +FlMethodResponse* window_drag_start(WindowPlugin* self) { + GtkWindow* window = window_plugin_get_window(self); + if (window == nullptr) { + return FL_METHOD_RESPONSE(fl_method_error_response_new( + "not_ready", "Window is not available", nullptr)); + } + + GdkDisplay* display = gdk_screen_get_display(gtk_window_get_screen(window)); + GdkDevice* device = gdk_seat_get_pointer(gdk_display_get_default_seat(display)); + + gint root_x, root_y; + gdk_device_get_position(device, nullptr, &root_x, &root_y); + gtk_window_begin_move_drag(window, GDK_BUTTON_PRIMARY, root_x, root_y, + self->drag.event_button.time); + self->drag.is_dragging = true; + + g_autoptr(FlValue) result = fl_value_new_null(); + return FL_METHOD_RESPONSE(fl_method_success_response_new(result)); +} diff --git a/plugins/window/linux/window_drag.h b/plugins/window/linux/window_drag.h new file mode 100644 index 00000000..d1bde8c7 --- /dev/null +++ b/plugins/window/linux/window_drag.h @@ -0,0 +1,24 @@ +#ifndef WINDOW_DRAG_H_ +#define WINDOW_DRAG_H_ + +#include "include/window/window_plugin.h" + +G_BEGIN_DECLS + +typedef struct { + GdkEventButton event_button; + gulong mouse_press_hook_id; + gulong event_after_handler_id; + GtkWidget* event_box; + bool is_dragging; +} WindowDragState; + +void window_drag_init(WindowDragState* state); +void window_drag_dispose(WindowPlugin* self); +void window_drag_connect(WindowPlugin* self); + +FlMethodResponse* window_drag_start(WindowPlugin* self); + +G_END_DECLS + +#endif // WINDOW_DRAG_H_ diff --git a/plugins/window/linux/window_geometry.cc b/plugins/window/linux/window_geometry.cc new file mode 100644 index 00000000..6fe5d27d --- /dev/null +++ b/plugins/window/linux/window_geometry.cc @@ -0,0 +1,204 @@ +#include "window_geometry.h" + +#include + +#include + +#include "window_plugin_internal.h" + +// "geometry-changed" waits for the configure-event stream to go quiet. +static const guint kSettleMilliseconds = 150; +static const int kMaxRestoreCorrections = 5; + +static gboolean is_geometry_owned_by_wm(WindowPlugin* self) { + GtkWindow* window = window_plugin_get_window(self); + if (window == nullptr) { + return true; + } + if (gtk_window_is_maximized(window)) { + return true; + } + + GdkWindow* gdk_window = window_plugin_get_gdk_window(self); + if (gdk_window == nullptr) { + return false; + } + + return gdk_window_get_state(gdk_window) & GDK_WINDOW_STATE_FULLSCREEN; +} + +void window_geometry_init(WindowGeometryState* state) { + memset(state, 0, sizeof(*state)); +} + +void window_geometry_dispose(WindowGeometryState* state) { + if (state->settle_timeout_id != 0) { + g_source_remove(state->settle_timeout_id); + state->settle_timeout_id = 0; + } + if (state->restore_timeout_id != 0) { + g_source_remove(state->restore_timeout_id); + state->restore_timeout_id = 0; + } +} + +static void apply_saved_geometry(WindowPlugin* self) { + GtkWindow* window = window_plugin_get_window(self); + if (window == nullptr) { + return; + } + gtk_window_move(window, self->geometry.saved_x, self->geometry.saved_y); + gtk_window_resize(window, self->geometry.saved_width, + self->geometry.saved_height); +} + +void window_geometry_on_hide(WindowPlugin* self) { + GtkWindow* window = window_plugin_get_window(self); + if (window == nullptr) { + return; + } + + // GTK drops an unmapped window's placement; reapplied on the next map. + self->geometry.has_saved_geometry = !is_geometry_owned_by_wm(self); + if (self->geometry.has_saved_geometry) { + gtk_window_get_position(window, &self->geometry.saved_x, + &self->geometry.saved_y); + gtk_window_get_size(window, &self->geometry.saved_width, + &self->geometry.saved_height); + } + + gtk_widget_hide(GTK_WIDGET(window)); + if (self->geometry.has_saved_geometry) { + apply_saved_geometry(self); + } +} + +static gboolean on_restore_settled(gpointer data) { + WindowPlugin* self = WINDOW_PLUGIN(data); + self->geometry.restore_timeout_id = 0; + if (self->geometry.has_saved_geometry) { + self->geometry.has_saved_geometry = false; + apply_saved_geometry(self); + } + return G_SOURCE_REMOVE; +} + +static bool is_saved_position_on_monitor(WindowPlugin* self) { + GtkWindow* window = window_plugin_get_window(self); + if (window == nullptr) { + return false; + } + GdkDisplay* display = gdk_screen_get_display(gtk_window_get_screen(window)); + GdkRectangle saved = {self->geometry.saved_x, self->geometry.saved_y, + self->geometry.saved_width, + self->geometry.saved_height}; + int n_monitors = gdk_display_get_n_monitors(display); + for (int i = 0; i < n_monitors; i++) { + GdkRectangle workarea; + gdk_monitor_get_workarea(gdk_display_get_monitor(display, i), &workarea); + if (gdk_rectangle_intersect(&saved, &workarea, nullptr)) { + return true; + } + } + return false; +} + +static void center_on_primary_monitor(WindowPlugin* self, GtkWindow* window) { + GdkDisplay* display = gdk_screen_get_display(gtk_window_get_screen(window)); + GdkMonitor* monitor = gdk_display_get_primary_monitor(display); + if (monitor == nullptr) { + monitor = gdk_display_get_monitor(display, 0); + } + gint width = self->geometry.saved_width; + gint height = self->geometry.saved_height; + gtk_window_resize(window, width, height); + if (monitor == nullptr) { + return; + } + GdkRectangle workarea; + gdk_monitor_get_workarea(monitor, &workarea); + gtk_window_move(window, workarea.x + MAX(0, (workarea.width - width) / 2), + workarea.y + MAX(0, (workarea.height - height) / 2)); +} + +// The window manager may place the window after this handler restored the +// geometry, so the restore stays open until the placement settles. +void window_geometry_on_map_event(WindowPlugin* self) { + if (!self->geometry.has_saved_geometry) { + return; + } + if (!is_saved_position_on_monitor(self)) { + self->geometry.has_saved_geometry = false; + GtkWindow* window = window_plugin_get_window(self); + if (window != nullptr) { + center_on_primary_monitor(self, window); + } + return; + } + self->geometry.restore_corrections = 0; + apply_saved_geometry(self); + if (self->geometry.restore_timeout_id != 0) { + g_source_remove(self->geometry.restore_timeout_id); + } + self->geometry.restore_timeout_id = + g_timeout_add(kSettleMilliseconds, on_restore_settled, self); +} + +static void correct_restored_placement(WindowPlugin* self) { + if (!self->geometry.has_saved_geometry || + self->geometry.restore_timeout_id == 0 || + self->geometry.restore_corrections >= kMaxRestoreCorrections) { + return; + } + GtkWindow* window = window_plugin_get_window(self); + if (window == nullptr) { + return; + } + gint x, y; + gtk_window_get_position(window, &x, &y); + if (x == self->geometry.saved_x && y == self->geometry.saved_y) { + return; + } + self->geometry.restore_corrections++; + apply_saved_geometry(self); +} + +static gboolean on_settle_timeout(gpointer data) { + WindowPlugin* self = WINDOW_PLUGIN(data); + self->geometry.settle_timeout_id = 0; + + GtkWindow* window = window_plugin_get_window(self); + if (window == nullptr) { + return G_SOURCE_REMOVE; + } + + gint x, y, width, height; + gtk_window_get_position(window, &x, &y); + gtk_window_get_size(window, &width, &height); + + bool changed = self->geometry.has_settled_geometry && + (x != self->geometry.settled_x || + y != self->geometry.settled_y || + width != self->geometry.settled_width || + height != self->geometry.settled_height); + + self->geometry.settled_x = x; + self->geometry.settled_y = y; + self->geometry.settled_width = width; + self->geometry.settled_height = height; + self->geometry.has_settled_geometry = true; + + if (changed) { + window_plugin_emit_event(self, "geometry-changed"); + } + return G_SOURCE_REMOVE; +} + +void window_geometry_on_configure_event(WindowPlugin* self) { + correct_restored_placement(self); + if (self->geometry.settle_timeout_id != 0) { + g_source_remove(self->geometry.settle_timeout_id); + } + self->geometry.settle_timeout_id = + g_timeout_add(kSettleMilliseconds, on_settle_timeout, self); +} diff --git a/plugins/window/linux/window_geometry.h b/plugins/window/linux/window_geometry.h new file mode 100644 index 00000000..4aa3a50d --- /dev/null +++ b/plugins/window/linux/window_geometry.h @@ -0,0 +1,34 @@ +#ifndef WINDOW_GEOMETRY_H_ +#define WINDOW_GEOMETRY_H_ + +#include "include/window/window_plugin.h" + +G_BEGIN_DECLS + +typedef struct { + bool has_saved_geometry; + gint saved_x; + gint saved_y; + gint saved_width; + gint saved_height; + guint restore_timeout_id; + int restore_corrections; + + bool has_settled_geometry; + gint settled_x; + gint settled_y; + gint settled_width; + gint settled_height; + guint settle_timeout_id; +} WindowGeometryState; + +void window_geometry_init(WindowGeometryState* state); +void window_geometry_dispose(WindowGeometryState* state); + +void window_geometry_on_hide(WindowPlugin* self); +void window_geometry_on_map_event(WindowPlugin* self); +void window_geometry_on_configure_event(WindowPlugin* self); + +G_END_DECLS + +#endif // WINDOW_GEOMETRY_H_ diff --git a/plugins/window/linux/window_plugin.cc b/plugins/window/linux/window_plugin.cc new file mode 100644 index 00000000..d4193141 --- /dev/null +++ b/plugins/window/linux/window_plugin.cc @@ -0,0 +1,663 @@ +#include "include/window/window_plugin.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include +#include + +#include "window_plugin_internal.h" + +G_DEFINE_TYPE(WindowPlugin, window_plugin, g_object_get_type()) + +static WindowPlugin* activate_target = nullptr; + +static constexpr gint64 kMinimizeFocusLossWindow = + 500 * G_TIME_SPAN_MILLISECOND; + +GtkWindow* window_plugin_get_window(WindowPlugin* self) { + FlView* view = fl_plugin_registrar_get_view(self->registrar); + if (view == nullptr) { + return nullptr; + } + return GTK_WINDOW(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +GdkWindow* window_plugin_get_gdk_window(WindowPlugin* self) { + GtkWindow* window = window_plugin_get_window(self); + if (window == nullptr) { + return nullptr; + } + return gtk_widget_get_window(GTK_WIDGET(window)); +} + +void window_plugin_emit_event(WindowPlugin* self, const char* name) { + g_autoptr(FlValue) args = fl_value_new_map(); + fl_value_set_string_take(args, "name", fl_value_new_string(name)); + fl_method_channel_invoke_method(self->channel, "onEvent", args, nullptr, + nullptr, nullptr); +} + +static FlMethodResponse* success_response(FlValue* value) { + return FL_METHOD_RESPONSE(fl_method_success_response_new(value)); +} + +static FlMethodResponse* success_null() { + g_autoptr(FlValue) value = fl_value_new_null(); + return success_response(value); +} + +static FlMethodResponse* success_bool(bool value) { + g_autoptr(FlValue) value_out = fl_value_new_bool(value); + return success_response(value_out); +} + +static FlMethodResponse* not_ready_response() { + return FL_METHOD_RESPONSE(fl_method_error_response_new( + "not_ready", "Window is not available", nullptr)); +} + +static FlMethodResponse* bad_args_response(const char* method, + const char* key) { + g_autofree gchar* message = g_strdup_printf("%s requires '%s'", method, key); + return FL_METHOD_RESPONSE( + fl_method_error_response_new("bad_args", message, nullptr)); +} + +static FlMethodResponse* unsupported_response(const char* message) { + return FL_METHOD_RESPONSE( + fl_method_error_response_new("unsupported", message, nullptr)); +} + +static bool arg_bool(FlValue* args, const char* key, bool* out) { + if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) { + return false; + } + FlValue* value = fl_value_lookup_string(args, key); + if (value == nullptr || fl_value_get_type(value) != FL_VALUE_TYPE_BOOL) { + return false; + } + *out = fl_value_get_bool(value); + return true; +} + +static bool arg_double(FlValue* args, const char* key, double* out) { + if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) { + return false; + } + FlValue* value = fl_value_lookup_string(args, key); + if (value == nullptr) { + return false; + } + if (fl_value_get_type(value) == FL_VALUE_TYPE_FLOAT) { + *out = fl_value_get_float(value); + return true; + } + if (fl_value_get_type(value) == FL_VALUE_TYPE_INT) { + *out = static_cast(fl_value_get_int(value)); + return true; + } + return false; +} + +static bool arg_string(FlValue* args, const char* key, const char** out) { + if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) { + return false; + } + FlValue* value = fl_value_lookup_string(args, key); + if (value == nullptr || fl_value_get_type(value) != FL_VALUE_TYPE_STRING) { + return false; + } + *out = fl_value_get_string(value); + return true; +} + +static FlMethodResponse* handle_ensure_initialized(WindowPlugin*, + FlValue*) { + return success_null(); +} + +static FlMethodResponse* handle_show(WindowPlugin* self, FlValue* args) { + bool inactive; + if (!arg_bool(args, "inactive", &inactive)) { + return bad_args_response("show", "inactive"); + } + GtkWindow* window = window_plugin_get_window(self); + if (window == nullptr) { + return not_ready_response(); + } + if (inactive) { + gtk_window_set_focus_on_map(window, FALSE); + gtk_widget_show(GTK_WIDGET(window)); + gtk_window_set_focus_on_map(window, TRUE); + } else { + gtk_widget_show(GTK_WIDGET(window)); + } + return success_null(); +} + +static FlMethodResponse* handle_hide(WindowPlugin* self, FlValue*) { + window_geometry_on_hide(self); + return success_null(); +} + +static FlMethodResponse* handle_is_visible(WindowPlugin* self, FlValue*) { + GtkWindow* window = window_plugin_get_window(self); + return success_bool(window != nullptr && + gtk_widget_is_visible(GTK_WIDGET(window))); +} + +static FlMethodResponse* handle_focus(WindowPlugin* self, FlValue*) { + GtkWindow* window = window_plugin_get_window(self); + if (window == nullptr) { + return not_ready_response(); + } + gtk_window_present(window); + return success_null(); +} + +static FlMethodResponse* handle_close(WindowPlugin* self, FlValue*) { + GtkWindow* window = window_plugin_get_window(self); + if (window == nullptr) { + return not_ready_response(); + } + gtk_window_close(window); + return success_null(); +} + +static FlMethodResponse* handle_set_prevent_close(WindowPlugin* self, + FlValue* args) { + bool value; + if (!arg_bool(args, "value", &value)) { + return bad_args_response("setPreventClose", "value"); + } + self->prevent_close = value; + return success_null(); +} + +static FlMethodResponse* handle_is_maximized(WindowPlugin* self, + FlValue*) { + GtkWindow* window = window_plugin_get_window(self); + return success_bool(window != nullptr && gtk_window_is_maximized(window)); +} + +static FlMethodResponse* handle_maximize(WindowPlugin* self, FlValue*) { + GtkWindow* window = window_plugin_get_window(self); + if (window == nullptr) { + return not_ready_response(); + } + gtk_window_maximize(window); + return success_null(); +} + +static FlMethodResponse* handle_unmaximize(WindowPlugin* self, FlValue*) { + GtkWindow* window = window_plugin_get_window(self); + if (window == nullptr) { + return not_ready_response(); + } + gtk_window_unmaximize(window); + return success_null(); +} + +static FlMethodResponse* handle_is_minimized(WindowPlugin* self, + FlValue*) { + GdkWindow* gdk_window = window_plugin_get_gdk_window(self); + return success_bool(gdk_window != nullptr && + (gdk_window_get_state(gdk_window) & + GDK_WINDOW_STATE_ICONIFIED)); +} + +static FlMethodResponse* handle_minimize(WindowPlugin* self, FlValue*) { + GtkWindow* window = window_plugin_get_window(self); + if (window == nullptr) { + return not_ready_response(); + } + gtk_window_iconify(window); + self->minimize_requested_at = g_get_monotonic_time(); + return success_null(); +} + +static FlMethodResponse* handle_restore(WindowPlugin* self, FlValue*) { + GtkWindow* window = window_plugin_get_window(self); + if (window == nullptr) { + return not_ready_response(); + } + gtk_window_deiconify(window); + gtk_window_present(window); + return success_null(); +} + +static FlMethodResponse* handle_is_full_screen(WindowPlugin* self, + FlValue*) { + GdkWindow* gdk_window = window_plugin_get_gdk_window(self); + return success_bool(gdk_window != nullptr && + (gdk_window_get_state(gdk_window) & + GDK_WINDOW_STATE_FULLSCREEN)); +} + +static FlMethodResponse* handle_set_full_screen(WindowPlugin* self, + FlValue* args) { + bool value; + if (!arg_bool(args, "value", &value)) { + return bad_args_response("setFullScreen", "value"); + } + GtkWindow* window = window_plugin_get_window(self); + if (window == nullptr) { + return not_ready_response(); + } + if (value) { + gtk_window_fullscreen(window); + } else { + gtk_window_unfullscreen(window); + } + return success_null(); +} + +static FlMethodResponse* handle_get_bounds(WindowPlugin* self, FlValue*) { + GtkWindow* window = window_plugin_get_window(self); + if (window == nullptr) { + return not_ready_response(); + } + + gint x, y, width, height; + gtk_window_get_position(window, &x, &y); + gtk_window_get_size(window, &width, &height); + + g_autoptr(FlValue) result = fl_value_new_map(); + fl_value_set_string_take(result, "x", fl_value_new_float(x)); + fl_value_set_string_take(result, "y", fl_value_new_float(y)); + fl_value_set_string_take(result, "width", fl_value_new_float(width)); + fl_value_set_string_take(result, "height", fl_value_new_float(height)); + return success_response(result); +} + +static FlMethodResponse* handle_set_bounds(WindowPlugin* self, FlValue* args) { + GtkWindow* window = window_plugin_get_window(self); + if (window == nullptr) { + return not_ready_response(); + } + + double x, y; + if (arg_double(args, "x", &x) && arg_double(args, "y", &y)) { + gtk_window_move(window, static_cast(x), static_cast(y)); + } + + double width, height; + if (arg_double(args, "width", &width) && arg_double(args, "height", &height)) { + gint w = static_cast(width); + gint h = static_cast(height); + if (gtk_widget_get_mapped(GTK_WIDGET(window))) { + gtk_window_resize(window, w, h); + } else { + // A resize request loses to the default-size hint when an unmapped + // window is mapped, so the default size is set instead. + gtk_window_set_default_size(window, w, h); + } + } + + return success_null(); +} + +static FlMethodResponse* handle_is_position_supported([[maybe_unused]] WindowPlugin* self, + FlValue*) { + bool supported = false; +#ifdef GDK_WINDOWING_X11 + GtkWindow* window = window_plugin_get_window(self); + if (window != nullptr) { + GdkScreen* screen = gtk_window_get_screen(window); + supported = screen != nullptr && GDK_IS_X11_SCREEN(screen); + } +#endif + return success_bool(supported); +} + +static FlMethodResponse* handle_set_minimum_size(WindowPlugin* self, + FlValue* args) { + double width, height; + if (!arg_double(args, "width", &width)) { + return bad_args_response("setMinimumSize", "width"); + } + if (!arg_double(args, "height", &height)) { + return bad_args_response("setMinimumSize", "height"); + } + + GdkWindow* gdk_window = window_plugin_get_gdk_window(self); + if (gdk_window == nullptr) { + return not_ready_response(); + } + + GdkGeometry hints; + memset(&hints, 0, sizeof(hints)); + GdkWindowHints mask = static_cast(0); + if (width >= 0 && height >= 0) { + hints.min_width = static_cast(width); + hints.min_height = static_cast(height); + mask = GDK_HINT_MIN_SIZE; + } + gdk_window_set_geometry_hints(gdk_window, &hints, mask); + return success_null(); +} + +static FlMethodResponse* handle_is_always_on_top(WindowPlugin* self, + FlValue*) { + return success_bool(self->always_on_top); +} + +static FlMethodResponse* handle_set_always_on_top(WindowPlugin* self, + FlValue* args) { + bool value; + if (!arg_bool(args, "value", &value)) { + return bad_args_response("setAlwaysOnTop", "value"); + } + GtkWindow* window = window_plugin_get_window(self); + if (window == nullptr) { + return not_ready_response(); + } + gtk_window_set_keep_above(window, value); + self->always_on_top = value; + return success_null(); +} + +static FlMethodResponse* handle_set_title_bar_style(WindowPlugin* self, + FlValue* args) { + const char* style; + if (!arg_string(args, "style", &style)) { + return bad_args_response("setTitleBarStyle", "style"); + } + bool window_button_visibility; + if (!arg_bool(args, "windowButtonVisibility", &window_button_visibility)) { + return bad_args_response("setTitleBarStyle", "windowButtonVisibility"); + } + + g_free(self->style.title_bar_style); + self->style.title_bar_style = g_strdup(style); + self->style.window_button_visibility = window_button_visibility; + window_style_apply(self); + return success_null(); +} + +static FlMethodResponse* handle_set_skip_taskbar(WindowPlugin* self, + FlValue* args) { + bool value; + if (!arg_bool(args, "value", &value)) { + return bad_args_response("setSkipTaskbar", "value"); + } + GtkWindow* window = window_plugin_get_window(self); + if (window == nullptr) { + return not_ready_response(); + } + gtk_window_set_skip_taskbar_hint(window, value); + return success_null(); +} + +static FlMethodResponse* handle_set_rounded_corners(WindowPlugin*, + FlValue* args) { + bool value; + if (!arg_bool(args, "value", &value)) { + return bad_args_response("setRoundedCorners", "value"); + } + return success_null(); +} + +static FlMethodResponse* handle_is_effect_supported(WindowPlugin*, + FlValue* args) { + const char* effect; + if (!arg_string(args, "effect", &effect)) { + return bad_args_response("isEffectSupported", "effect"); + } + return success_bool(g_strcmp0(effect, "none") == 0); +} + +static FlMethodResponse* handle_set_effect(WindowPlugin*, FlValue* args) { + const char* effect; + if (!arg_string(args, "effect", &effect)) { + return bad_args_response("setEffect", "effect"); + } + if (g_strcmp0(effect, "none") != 0) { + return unsupported_response("Linux supports only the \"none\" effect"); + } + return success_null(); +} + +static FlMethodResponse* handle_start_dragging(WindowPlugin* self, + FlValue*) { + return window_drag_start(self); +} + +typedef FlMethodResponse* (*MethodHandler)(WindowPlugin*, FlValue*); + +typedef struct { + const char* name; + MethodHandler handler; +} MethodEntry; + +static const MethodEntry kMethods[] = { + {"ensureInitialized", handle_ensure_initialized}, + {"show", handle_show}, + {"hide", handle_hide}, + {"isVisible", handle_is_visible}, + {"focus", handle_focus}, + {"close", handle_close}, + {"setPreventClose", handle_set_prevent_close}, + {"isMaximized", handle_is_maximized}, + {"maximize", handle_maximize}, + {"unmaximize", handle_unmaximize}, + {"isMinimized", handle_is_minimized}, + {"minimize", handle_minimize}, + {"restore", handle_restore}, + {"isFullScreen", handle_is_full_screen}, + {"setFullScreen", handle_set_full_screen}, + {"getBounds", handle_get_bounds}, + {"setBounds", handle_set_bounds}, + {"isPositionSupported", handle_is_position_supported}, + {"setMinimumSize", handle_set_minimum_size}, + {"isAlwaysOnTop", handle_is_always_on_top}, + {"setAlwaysOnTop", handle_set_always_on_top}, + {"setTitleBarStyle", handle_set_title_bar_style}, + {"setSkipTaskbar", handle_set_skip_taskbar}, + {"setRoundedCorners", handle_set_rounded_corners}, + {"isEffectSupported", handle_is_effect_supported}, + {"setEffect", handle_set_effect}, + {"startDragging", handle_start_dragging}, +}; + +static void window_plugin_handle_method_call(WindowPlugin* self, + FlMethodCall* method_call) { + const gchar* method = fl_method_call_get_name(method_call); + FlValue* args = fl_method_call_get_args(method_call); + + g_autoptr(FlMethodResponse) response = nullptr; + for (const MethodEntry& entry : kMethods) { + if (g_strcmp0(method, entry.name) == 0) { + response = entry.handler(self, args); + break; + } + } + if (response == nullptr) { + response = FL_METHOD_RESPONSE(fl_method_not_implemented_response_new()); + } + + fl_method_call_respond(method_call, response, nullptr); +} + +static void method_call_cb(FlMethodChannel*, + FlMethodCall* method_call, + gpointer user_data) { + window_plugin_handle_method_call(WINDOW_PLUGIN(user_data), method_call); +} + +static gboolean on_window_close(GtkWidget*, GdkEvent*, + gpointer data) { + WindowPlugin* self = WINDOW_PLUGIN(data); + window_plugin_emit_event(self, "close"); + return self->prevent_close; +} + +static gboolean on_window_focus(GtkWidget*, GdkEvent*, + gpointer data) { + window_plugin_emit_event(WINDOW_PLUGIN(data), "focus"); + return FALSE; +} + +static gboolean on_window_blur(GtkWidget*, GdkEvent*, + gpointer data) { + window_plugin_emit_event(WINDOW_PLUGIN(data), "blur"); + return FALSE; +} + +static void on_window_show(GtkWidget*, gpointer data) { + window_plugin_emit_event(WINDOW_PLUGIN(data), "show"); +} + +static void on_window_hide(GtkWidget*, gpointer data) { + window_plugin_emit_event(WINDOW_PLUGIN(data), "hide"); +} + +static gboolean on_window_map_event(GtkWidget*, GdkEvent*, + gpointer data) { + window_geometry_on_map_event(WINDOW_PLUGIN(data)); + return FALSE; +} + +static gboolean on_window_configure_event(GtkWidget*, GdkEvent*, + gpointer data) { + window_geometry_on_configure_event(WINDOW_PLUGIN(data)); + return FALSE; +} + +static void send_lifecycle_hidden(WindowPlugin* self) { + g_autoptr(FlStringCodec) codec = fl_string_codec_new(); + g_autoptr(FlValue) state = fl_value_new_string("AppLifecycleState.hidden"); + g_autoptr(GBytes) message = fl_message_codec_encode_message( + FL_MESSAGE_CODEC(codec), state, nullptr); + fl_binary_messenger_send_on_channel( + fl_plugin_registrar_get_messenger(self->registrar), "flutter/lifecycle", + message, nullptr, nullptr, nullptr); +} + +static gboolean on_window_state_event(GtkWidget*, + GdkEventWindowState* event, + gpointer data) { + WindowPlugin* self = WINDOW_PLUGIN(data); + if (self->minimize_requested_at != 0 && + (event->changed_mask & GDK_WINDOW_STATE_FOCUSED)) { + gint64 elapsed = g_get_monotonic_time() - self->minimize_requested_at; + self->minimize_requested_at = 0; + if (!(event->new_window_state & GDK_WINDOW_STATE_FOCUSED) && + elapsed < kMinimizeFocusLossWindow) { + send_lifecycle_hidden(self); + } + } + if (event->changed_mask & GDK_WINDOW_STATE_MAXIMIZED) { + window_plugin_emit_event( + self, (event->new_window_state & GDK_WINDOW_STATE_MAXIMIZED) + ? "maximize" + : "unmaximize"); + } + if (event->changed_mask & GDK_WINDOW_STATE_ICONIFIED) { + window_plugin_emit_event( + self, (event->new_window_state & GDK_WINDOW_STATE_ICONIFIED) + ? "minimize" + : "restore"); + } + if (event->changed_mask & GDK_WINDOW_STATE_FULLSCREEN) { + window_plugin_emit_event( + self, (event->new_window_state & GDK_WINDOW_STATE_FULLSCREEN) + ? "enter-full-screen" + : "leave-full-screen"); + } + return FALSE; +} + +static void connect_window_signals(WindowPlugin* self, GtkWindow* window) { + guint i = 0; + self->window_signal_ids[i++] = + g_signal_connect(window, "delete-event", G_CALLBACK(on_window_close), self); + self->window_signal_ids[i++] = g_signal_connect( + window, "focus-in-event", G_CALLBACK(on_window_focus), self); + self->window_signal_ids[i++] = g_signal_connect( + window, "focus-out-event", G_CALLBACK(on_window_blur), self); + self->window_signal_ids[i++] = + g_signal_connect(window, "show", G_CALLBACK(on_window_show), self); + self->window_signal_ids[i++] = + g_signal_connect(window, "hide", G_CALLBACK(on_window_hide), self); + self->window_signal_ids[i++] = g_signal_connect( + window, "map-event", G_CALLBACK(on_window_map_event), self); + self->window_signal_ids[i++] = g_signal_connect( + window, "configure-event", G_CALLBACK(on_window_configure_event), self); + // After the engine's lifecycle monitor, so a minimize reads as hidden. + self->window_signal_ids[i++] = g_signal_connect_after( + window, "window-state-event", G_CALLBACK(on_window_state_event), self); + self->window_signal_count = i; +} + +static void window_plugin_dispose(GObject* object) { + WindowPlugin* self = WINDOW_PLUGIN(object); + if (activate_target == self) { + activate_target = nullptr; + } + + GtkWindow* window = window_plugin_get_window(self); + if (window != nullptr) { + for (guint i = 0; i < self->window_signal_count; i++) { + g_signal_handler_disconnect(window, self->window_signal_ids[i]); + } + } + window_drag_dispose(self); + window_geometry_dispose(&self->geometry); + window_style_dispose(&self->style); + + g_clear_object(&self->registrar); + g_clear_object(&self->channel); + + G_OBJECT_CLASS(window_plugin_parent_class)->dispose(object); +} + +static void window_plugin_class_init(WindowPluginClass* klass) { + G_OBJECT_CLASS(klass)->dispose = window_plugin_dispose; +} + +static void window_plugin_init(WindowPlugin* self) { + window_geometry_init(&self->geometry); + window_style_init(&self->style); + window_drag_init(&self->drag); +} + +void window_plugin_register_with_registrar(FlPluginRegistrar* registrar) { + WindowPlugin* plugin = + WINDOW_PLUGIN(g_object_new(window_plugin_get_type(), nullptr)); + plugin->registrar = FL_PLUGIN_REGISTRAR(g_object_ref(registrar)); + + GtkWindow* window = window_plugin_get_window(plugin); + if (window != nullptr) { + // The engine's own delete-event handler on this widget consumes the + // signal before this plugin's runs (flutter/engine#40033), so it is + // disconnected first; matching no handler leaves this a no-op. + guint handler_id = g_signal_handler_find( + window, G_SIGNAL_MATCH_DATA, 0, 0, nullptr, nullptr, + fl_plugin_registrar_get_view(registrar)); + if (handler_id > 0) { + g_signal_handler_disconnect(window, handler_id); + } + connect_window_signals(plugin, window); + } + window_drag_connect(plugin); + + g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); + plugin->channel = fl_method_channel_new( + fl_plugin_registrar_get_messenger(registrar), "window", + FL_METHOD_CODEC(codec)); + fl_method_channel_set_method_call_handler( + plugin->channel, method_call_cb, g_object_ref(plugin), g_object_unref); + + activate_target = plugin; + g_object_unref(plugin); +} + +void window_plugin_activate() { + if (activate_target != nullptr) { + window_plugin_emit_event(activate_target, "activate"); + } +} diff --git a/plugins/window/linux/window_plugin_internal.h b/plugins/window/linux/window_plugin_internal.h new file mode 100644 index 00000000..7bf86090 --- /dev/null +++ b/plugins/window/linux/window_plugin_internal.h @@ -0,0 +1,34 @@ +#ifndef WINDOW_PLUGIN_INTERNAL_H_ +#define WINDOW_PLUGIN_INTERNAL_H_ + +#include "include/window/window_plugin.h" +#include "window_drag.h" +#include "window_geometry.h" +#include "window_style.h" + +G_BEGIN_DECLS + +#define WINDOW_PLUGIN(obj) \ + (G_TYPE_CHECK_INSTANCE_CAST((obj), window_plugin_get_type(), WindowPlugin)) + +struct _WindowPlugin { + GObject parent_instance; + FlPluginRegistrar* registrar; + FlMethodChannel* channel; + bool prevent_close; + bool always_on_top; + gint64 minimize_requested_at; + gulong window_signal_ids[8]; + guint window_signal_count; + WindowGeometryState geometry; + WindowStyle style; + WindowDragState drag; +}; + +GtkWindow* window_plugin_get_window(WindowPlugin* self); +GdkWindow* window_plugin_get_gdk_window(WindowPlugin* self); +void window_plugin_emit_event(WindowPlugin* self, const char* name); + +G_END_DECLS + +#endif // WINDOW_PLUGIN_INTERNAL_H_ diff --git a/plugins/window/linux/window_style.cc b/plugins/window/linux/window_style.cc new file mode 100644 index 00000000..78425e16 --- /dev/null +++ b/plugins/window/linux/window_style.cc @@ -0,0 +1,74 @@ +#include "window_style.h" + +#include + +#include + +#include "window_plugin_internal.h" + +static gboolean is_header_bar(GtkWidget* widget) { + return widget != nullptr && + (GTK_IS_HEADER_BAR(widget) || + g_str_has_suffix(G_OBJECT_TYPE_NAME(widget), "HeaderBar")); +} + +static GtkWidget* find_header_bar(GtkWidget* widget) { + if (is_header_bar(widget)) { + return widget; + } + + if (GTK_IS_CONTAINER(widget)) { + g_autoptr(GList) children = + gtk_container_get_children(GTK_CONTAINER(widget)); + for (GList* l = children; l != nullptr; l = l->next) { + GtkWidget* header_bar = find_header_bar(GTK_WIDGET(l->data)); + if (header_bar != nullptr) { + return header_bar; + } + } + } + + return nullptr; +} + +static GtkWidget* get_header_bar(GtkWindow* window) { + GtkWidget* titlebar = gtk_window_get_titlebar(window); + if (is_header_bar(titlebar)) { + return titlebar; + } + return find_header_bar(GTK_WIDGET(window)); +} + +void window_style_init(WindowStyle* style) { + memset(style, 0, sizeof(*style)); + style->window_button_visibility = true; +} + +void window_style_dispose(WindowStyle* style) { + g_free(style->title_bar_style); + style->title_bar_style = nullptr; +} + +void window_style_apply(WindowPlugin* self) { + GtkWindow* window = window_plugin_get_window(self); + if (window == nullptr) { + return; + } + + gboolean normal = g_strcmp0(self->style.title_bar_style, "hidden") != 0; + + GtkWidget* header_bar = get_header_bar(window); + if (header_bar != nullptr) { + gtk_widget_set_visible(header_bar, normal); + if (GTK_IS_HEADER_BAR(header_bar)) { + gtk_header_bar_set_show_close_button( + GTK_HEADER_BAR(header_bar), self->style.window_button_visibility); + } + } else if (gtk_window_get_title(window) != nullptr) { + gtk_window_set_decorated(window, normal); + } + + if (normal) { + gtk_window_set_decorated(window, true); + } +} diff --git a/plugins/window/linux/window_style.h b/plugins/window/linux/window_style.h new file mode 100644 index 00000000..44b5247d --- /dev/null +++ b/plugins/window/linux/window_style.h @@ -0,0 +1,20 @@ +#ifndef WINDOW_STYLE_H_ +#define WINDOW_STYLE_H_ + +#include "include/window/window_plugin.h" + +G_BEGIN_DECLS + +typedef struct { + gchar* title_bar_style; // owned; nullptr behaves as "normal" + bool window_button_visibility; +} WindowStyle; + +void window_style_init(WindowStyle* style); +void window_style_dispose(WindowStyle* style); + +void window_style_apply(WindowPlugin* self); + +G_END_DECLS + +#endif // WINDOW_STYLE_H_ diff --git a/plugins/window/macos/window/Package.swift b/plugins/window/macos/window/Package.swift new file mode 100644 index 00000000..7e48e56a --- /dev/null +++ b/plugins/window/macos/window/Package.swift @@ -0,0 +1,25 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "window", + platforms: [ + .macOS(.v11) + ], + products: [ + .library(name: "window", targets: ["window"]) + ], + dependencies: [ + .package(name: "FlutterFramework", path: "../FlutterFramework") + ], + targets: [ + .target( + name: "window", + dependencies: [ + .product(name: "FlutterFramework", package: "FlutterFramework") + ], + path: "Sources/window" + ) + ] +) diff --git a/plugins/window/macos/window/Sources/window/Arguments.swift b/plugins/window/macos/window/Sources/window/Arguments.swift new file mode 100644 index 00000000..b544312e --- /dev/null +++ b/plugins/window/macos/window/Sources/window/Arguments.swift @@ -0,0 +1,64 @@ +import Foundation + +struct ArgumentError: Error { + let key: String +} + +struct Arguments { + private let values: [String: Any] + + init(_ values: [String: Any]) { + self.values = values + } + + func bool(_ key: String) throws -> Bool { + guard let value = values[key] as? Bool else { + throw ArgumentError(key: key) + } + return value + } + + func double(_ key: String) throws -> Double { + guard let value = values[key] as? NSNumber else { + throw ArgumentError(key: key) + } + return value.doubleValue + } + + func string(_ key: String) throws -> String { + guard let value = values[key] as? String else { + throw ArgumentError(key: key) + } + return value + } + + func optionalDouble(_ key: String) throws -> Double? { + guard let raw = values[key], !(raw is NSNull) else { + return nil + } + guard let value = raw as? NSNumber else { + throw ArgumentError(key: key) + } + return value.doubleValue + } + + func optionalString(_ key: String) throws -> String? { + guard let raw = values[key], !(raw is NSNull) else { + return nil + } + guard let value = raw as? String else { + throw ArgumentError(key: key) + } + return value + } + + func optionalInt(_ key: String) throws -> Int? { + guard let raw = values[key], !(raw is NSNull) else { + return nil + } + guard let value = raw as? NSNumber else { + throw ArgumentError(key: key) + } + return value.intValue + } +} diff --git a/plugins/window/macos/window/Sources/window/WindowController.swift b/plugins/window/macos/window/Sources/window/WindowController.swift new file mode 100644 index 00000000..c56b4905 --- /dev/null +++ b/plugins/window/macos/window/Sources/window/WindowController.swift @@ -0,0 +1,278 @@ +import Cocoa + +final class WindowController: NSObject, NSWindowDelegate { + var onEvent: ((String) -> Void)? + var onBecameVisible: (() -> Void)? + + private var window: NSWindow? + private weak var previousDelegate: NSWindowDelegate? + private var preventClose = false + private var style = WindowStyle() + private var wasZoomed = false + private var geometrySettle: DispatchWorkItem? + + private static let geometrySettleDelay: DispatchTimeInterval = .milliseconds(150) + + func ensureInitialized(with window: NSWindow) { + guard self.window !== window else { return } + self.window = window + previousDelegate = window.delegate + window.delegate = self + wasZoomed = window.isZoomed + WindowEffectView.install(in: window) + } + + private func requireWindow() throws -> NSWindow { + guard let window else { + throw PluginError(code: "not_initialized", message: "ensureInitialized has not run") + } + return window + } + + func show(inactive: Bool) throws { + let window = try requireWindow() + let wasVisible = window.isVisible + if inactive { + window.orderFront(nil) + } else { + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + if !wasVisible && window.isVisible { + emit("show") + onBecameVisible?() + } + } + + func hide() throws { + let window = try requireWindow() + let wasVisible = window.isVisible + window.orderOut(nil) + if wasVisible && !window.isVisible { + emit("hide") + } + } + + func isVisible() throws -> Bool { + try requireWindow().isVisible + } + + func focus() throws { + let window = try requireWindow() + NSApp.activate(ignoringOtherApps: false) + window.makeKeyAndOrderFront(nil) + } + + func close() throws { + try requireWindow().performClose(nil) + } + + func setPreventClose(_ value: Bool) throws { + _ = try requireWindow() + preventClose = value + } + + func isMaximized() throws -> Bool { + try requireWindow().isZoomed + } + + func maximize() throws { + let window = try requireWindow() + if !window.isZoomed { + window.zoom(nil) + } + } + + func unmaximize() throws { + let window = try requireWindow() + if window.isZoomed { + window.zoom(nil) + } + } + + func isMinimized() throws -> Bool { + try requireWindow().isMiniaturized + } + + func minimize() throws { + try requireWindow().miniaturize(nil) + } + + func restore() throws { + try requireWindow().deminiaturize(nil) + } + + func isFullScreen() throws -> Bool { + try requireWindow().styleMask.contains(.fullScreen) + } + + func setFullScreen(_ value: Bool) throws { + let window = try requireWindow() + if window.styleMask.contains(.fullScreen) != value { + window.toggleFullScreen(nil) + } + } + + private var primaryScreenHeight: CGFloat { + NSScreen.screens.first?.frame.height ?? 0 + } + + private func topLeft(of frame: NSRect) -> CGPoint { + CGPoint(x: frame.origin.x, y: primaryScreenHeight - frame.origin.y - frame.size.height) + } + + func getBounds() throws -> [String: Double] { + let frame = try requireWindow().frame + let point = topLeft(of: frame) + return [ + "x": Double(point.x), + "y": Double(point.y), + "width": Double(frame.size.width), + "height": Double(frame.size.height) + ] + } + + func setBounds(x: Double?, y: Double?, width: Double?, height: Double?) throws { + let window = try requireWindow() + var frame = window.frame + if let width, let height { + // The frame origin is bottom-left, so keep the top edge fixed when + // only the size changes. + frame.origin.y += frame.size.height - CGFloat(height) + frame.size.width = CGFloat(width) + frame.size.height = CGFloat(height) + } + if let x, let y { + frame.origin = CGPoint(x: CGFloat(x), y: primaryScreenHeight - CGFloat(y) - frame.size.height) + } + window.setFrame(frame, display: true) + } + + func isPositionSupported() throws -> Bool { + _ = try requireWindow() + return true + } + + func setMinimumSize(width: Double, height: Double) throws { + let window = try requireWindow() + window.minSize = NSSize(width: width, height: height) + } + + func isAlwaysOnTop() throws -> Bool { + try requireWindow().level == .floating + } + + func setAlwaysOnTop(_ value: Bool) throws { + let window = try requireWindow() + window.level = value ? .floating : .normal + } + + func setTitleBarStyle(style styleName: String, windowButtonVisibility: Bool) throws { + let window = try requireWindow() + style.titleBarStyle = styleName == "hidden" ? .hidden : .normal + style.windowButtonVisibility = windowButtonVisibility + style.apply(to: window) + } + + func setSkipTaskbar(_ value: Bool) throws { + _ = try requireWindow() + NSApplication.shared.setActivationPolicy(value ? .accessory : .regular) + } + + func setRoundedCorners(_ value: Bool) throws { + _ = try requireWindow() + } + + func isEffectSupported(_ effect: WindowEffect) throws -> Bool { + _ = try requireWindow() + return WindowEffectView.isSupported(effect) + } + + func setEffect(_ effect: WindowEffect, tint: NSColor?, appearance: NSAppearance?) throws { + let window = try requireWindow() + style.effect = effect + style.effectTint = tint + style.effectAppearance = appearance + style.apply(to: window) + } + + func startDragging() throws { + let window = try requireWindow() + guard let event = window.currentEvent else { + return + } + // Deferred so the drag starts after the channel call that triggered it + // returns; performDrag ignores the event otherwise. + DispatchQueue.main.async { + window.performDrag(with: event) + } + } + + private func scheduleGeometryChanged() { + geometrySettle?.cancel() + let work = DispatchWorkItem { [weak self] in + self?.geometrySettle = nil + self?.emit("geometry-changed") + } + geometrySettle = work + DispatchQueue.main.asyncAfter(deadline: .now() + Self.geometrySettleDelay, execute: work) + } + + // MARK: - NSWindowDelegate + + func windowShouldClose(_ sender: NSWindow) -> Bool { + emit("close") + if preventClose { + return false + } + if let previousDelegate, previousDelegate.responds(to: #selector(NSWindowDelegate.windowShouldClose(_:))) { + return previousDelegate.windowShouldClose?(sender) ?? true + } + return true + } + + func windowDidResize(_ notification: Notification) { + scheduleGeometryChanged() + guard let window else { return } + // AppKit has no maximize/unmaximize delegate call of its own; infer the + // transition from the isZoomed change a resize produced. + let isZoomed = window.isZoomed + if isZoomed != wasZoomed { + wasZoomed = isZoomed + emit(isZoomed ? "maximize" : "unmaximize") + } + } + + func windowDidMove(_ notification: Notification) { + scheduleGeometryChanged() + } + + func windowDidBecomeMain(_ notification: Notification) { + emit("focus") + } + + func windowDidResignMain(_ notification: Notification) { + emit("blur") + } + + func windowDidMiniaturize(_ notification: Notification) { + emit("minimize") + } + + func windowDidDeminiaturize(_ notification: Notification) { + emit("restore") + onBecameVisible?() + } + + func windowDidEnterFullScreen(_ notification: Notification) { + emit("enter-full-screen") + } + + func windowDidExitFullScreen(_ notification: Notification) { + emit("leave-full-screen") + } + + private func emit(_ name: String) { + onEvent?(name) + } +} diff --git a/plugins/window/macos/window/Sources/window/WindowEffectView.swift b/plugins/window/macos/window/Sources/window/WindowEffectView.swift new file mode 100644 index 00000000..ced69623 --- /dev/null +++ b/plugins/window/macos/window/Sources/window/WindowEffectView.swift @@ -0,0 +1,96 @@ +import Cocoa +import FlutterMacOS + +enum WindowEffect: String { + case none + case transparent + case blur + case acrylic + case mica +} + +private final class EffectHostViewController: NSViewController { + let flutterViewController: FlutterViewController + let effectView = NSVisualEffectView() + + init(hosting flutterViewController: FlutterViewController) { + self.flutterViewController = flutterViewController + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + override func loadView() { + let flutterView = flutterViewController.view + effectView.frame = flutterView.frame + effectView.autoresizingMask = [.width, .height] + effectView.blendingMode = .behindWindow + flutterView.frame = effectView.bounds + flutterView.autoresizingMask = [.width, .height] + addChild(flutterViewController) + effectView.addSubview(flutterView) + view = effectView + } +} + +enum WindowEffectView { + static func isSupported(_ effect: WindowEffect) -> Bool { + effect != .mica + } + + static func appearance(brightness: String?) throws -> NSAppearance? { + switch brightness { + case nil: + return nil + case "light": + return NSAppearance(named: .aqua) + case "dark": + return NSAppearance(named: .darkAqua) + default: + throw ArgumentError(key: "brightness") + } + } + + static func install(in window: NSWindow) { + guard let flutterViewController = window.contentViewController as? FlutterViewController else { + return + } + let frame = window.frame + window.contentViewController = EffectHostViewController(hosting: flutterViewController) + window.setFrame(frame, display: true) + } + + static func apply(_ effect: WindowEffect, tint: NSColor?, appearance: NSAppearance?, to window: NSWindow) { + guard let host = window.contentViewController as? EffectHostViewController else { + return + } + let effectView = host.effectView + switch effect { + case .none, .transparent, .mica: + effectView.maskImage = NSImage() + effectView.state = .inactive + host.flutterViewController.backgroundColor = effect == .none ? .windowBackgroundColor : .clear + case .blur, .acrylic: + effectView.maskImage = nil + effectView.material = effect == .blur ? .sidebar : .fullScreenUI + effectView.state = .followsWindowActiveState + effectView.appearance = appearance + applyTint(tint, to: effectView) + host.flutterViewController.backgroundColor = .clear + } + } + + private static func applyTint(_ tint: NSColor?, to effectView: NSVisualEffectView) { + guard let tint else { + effectView.layer?.backgroundColor = nil + return + } + // NSVisualEffectView paints the system material itself, so a translucent + // layer color is the only way to tint it without replacing the material. + effectView.wantsLayer = true + effectView.layer?.backgroundColor = tint.cgColor + } +} diff --git a/plugins/window/macos/window/Sources/window/WindowPlugin.swift b/plugins/window/macos/window/Sources/window/WindowPlugin.swift new file mode 100644 index 00000000..fd22ac6f --- /dev/null +++ b/plugins/window/macos/window/Sources/window/WindowPlugin.swift @@ -0,0 +1,193 @@ +import Cocoa +import FlutterMacOS + +struct PluginError: Error { + let code: String + let message: String? +} + +public extension NSWindow { + private static var hiddenAtLaunchKey = false + + // MainFlutterWindow.order(_:relativeTo:) calls this on every order call; + // only the first one should hide the still-being-configured window. + private var hiddenAtLaunchConfigured: Bool { + get { objc_getAssociatedObject(self, &Self.hiddenAtLaunchKey) as? Bool ?? false } + set { objc_setAssociatedObject(self, &Self.hiddenAtLaunchKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + func hiddenWindowAtLaunch() { + if !hiddenAtLaunchConfigured { + setIsVisible(false) + hiddenAtLaunchConfigured = true + } + } +} + +public class WindowPlugin: NSObject, FlutterPlugin { + public static var instance: WindowPlugin? + + private var channel: FlutterMethodChannel? + private var lifecycle: FlutterBasicMessageChannel? + private var registrar: FlutterPluginRegistrar? + private let controller = WindowController() + private lazy var handlers: [String: (Arguments) throws -> Any?] = makeHandlers() + private var active = NSApp.isActive + private var reportedHidden = false + + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel(name: "window", binaryMessenger: registrar.messenger) + let plugin = WindowPlugin() + plugin.registrar = registrar + plugin.channel = channel + plugin.lifecycle = FlutterBasicMessageChannel( + name: "flutter/lifecycle", + binaryMessenger: registrar.messenger, + codec: FlutterStringCodec.sharedInstance() + ) + for name in [NSApplication.willBecomeActiveNotification, NSApplication.willResignActiveNotification] { + NotificationCenter.default.addObserver( + plugin, selector: #selector(applicationActivityChanged), name: name, object: nil + ) + } + WindowPlugin.instance = plugin + registrar.addMethodCallDelegate(plugin, channel: channel) + } + + public override init() { + super.init() + controller.onEvent = { [weak self] name in + self?.emit(name) + } + controller.onBecameVisible = { [weak self] in + self?.windowBecameVisible() + } + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + let arguments = Arguments((call.arguments as? [String: Any]) ?? [:]) + guard let handler = handlers[call.method] else { + result(FlutterMethodNotImplemented) + return + } + do { + result(try handler(arguments)) + } catch let error as ArgumentError { + result(FlutterError(code: "bad_args", message: "\(call.method): \(error.key)", details: nil)) + } catch let error as PluginError { + result(FlutterError(code: error.code, message: error.message, details: nil)) + } catch { + result(FlutterError(code: "unsupported", message: "\(error)", details: nil)) + } + } + + /// Forwarded from `applicationShouldTerminate` so Dart can run shutdown work first. + public func handleShouldTerminate() { + emit("should-terminate") + } + + /// Forwarded from `applicationShouldHandleReopen` (Dock reopen / second launch). + public func handleReopen() { + emit("activate") + } + + // The engine resumes on activation if any app window is visible, and the status item's always is. + @objc private func applicationActivityChanged(_ notification: Notification) { + active = notification.name == NSApplication.willBecomeActiveNotification + DispatchQueue.main.async { [weak self] in + guard let self, (try? self.controller.isVisible()) == false else { return } + self.reportedHidden = true + self.lifecycle?.sendMessage("AppLifecycleState.hidden") + } + } + + // Back without another activation change, the window would wait on an occlusion notification macOS may not post. + private func windowBecameVisible() { + guard reportedHidden else { return } + reportedHidden = false + lifecycle?.sendMessage(active ? "AppLifecycleState.resumed" : "AppLifecycleState.inactive") + } + + private func emit(_ name: String) { + channel?.invokeMethod("onEvent", arguments: ["name": name]) + } + + private func makeHandlers() -> [String: (Arguments) throws -> Any?] { + let controller = self.controller + + var handlers: [String: (Arguments) throws -> Any?] = [ + "show": { args in try controller.show(inactive: args.bool("inactive")); return nil }, + "hide": { _ in try controller.hide(); return nil }, + "isVisible": { _ in try controller.isVisible() }, + "focus": { _ in try controller.focus(); return nil }, + "close": { _ in try controller.close(); return nil }, + "setPreventClose": { args in try controller.setPreventClose(args.bool("value")); return nil }, + "isMaximized": { _ in try controller.isMaximized() }, + "maximize": { _ in try controller.maximize(); return nil }, + "unmaximize": { _ in try controller.unmaximize(); return nil }, + "isMinimized": { _ in try controller.isMinimized() }, + "minimize": { _ in try controller.minimize(); return nil }, + "restore": { _ in try controller.restore(); return nil }, + "isFullScreen": { _ in try controller.isFullScreen() }, + "setFullScreen": { args in try controller.setFullScreen(args.bool("value")); return nil }, + "getBounds": { _ in try controller.getBounds() }, + "setBounds": { args in + try controller.setBounds( + x: args.optionalDouble("x"), + y: args.optionalDouble("y"), + width: args.optionalDouble("width"), + height: args.optionalDouble("height") + ) + return nil + }, + "isPositionSupported": { _ in try controller.isPositionSupported() }, + "setMinimumSize": { args in + try controller.setMinimumSize(width: args.double("width"), height: args.double("height")) + return nil + }, + "isAlwaysOnTop": { _ in try controller.isAlwaysOnTop() }, + "setAlwaysOnTop": { args in try controller.setAlwaysOnTop(args.bool("value")); return nil }, + "setTitleBarStyle": { args in + try controller.setTitleBarStyle( + style: args.string("style"), + windowButtonVisibility: args.bool("windowButtonVisibility") + ) + return nil + }, + "setSkipTaskbar": { args in try controller.setSkipTaskbar(args.bool("value")); return nil }, + "setRoundedCorners": { args in + try controller.setRoundedCorners(args.bool("value")) + return nil + }, + "isEffectSupported": { args in + guard let effect = WindowEffect(rawValue: try args.string("effect")) else { + throw ArgumentError(key: "effect") + } + return try controller.isEffectSupported(effect) + }, + "setEffect": { args in + guard let effect = WindowEffect(rawValue: try args.string("effect")) else { + throw ArgumentError(key: "effect") + } + guard WindowEffectView.isSupported(effect) else { + throw PluginError(code: "unsupported", message: "effect \(effect.rawValue) is not supported") + } + let tint = try args.optionalInt("tint") + let appearance = try WindowEffectView.appearance(brightness: try args.optionalString("brightness")) + try controller.setEffect(effect, tint: tint.map { NSColor(argb: $0) }, appearance: appearance) + return nil + }, + "startDragging": { _ in try controller.startDragging(); return nil } + ] + + handlers["ensureInitialized"] = { [weak self] _ in + guard let self, let window = self.registrar?.view?.window else { + throw PluginError(code: "not_initialized", message: "no window for the Flutter view") + } + controller.ensureInitialized(with: window) + return nil + } + + return handlers + } +} diff --git a/plugins/window/macos/window/Sources/window/WindowStyle.swift b/plugins/window/macos/window/Sources/window/WindowStyle.swift new file mode 100644 index 00000000..74d1beb6 --- /dev/null +++ b/plugins/window/macos/window/Sources/window/WindowStyle.swift @@ -0,0 +1,53 @@ +import Cocoa + +enum TitleBarStyle { + case normal + case hidden +} + +extension NSColor { + convenience init(argb: Int) { + let alpha = CGFloat((argb >> 24) & 0xFF) / 255 + let red = CGFloat((argb >> 16) & 0xFF) / 255 + let green = CGFloat((argb >> 8) & 0xFF) / 255 + let blue = CGFloat(argb & 0xFF) / 255 + self.init(red: red, green: green, blue: blue, alpha: alpha) + } +} + +struct WindowStyle { + var titleBarStyle: TitleBarStyle = .normal + var windowButtonVisibility = true + var effect: WindowEffect = .none + var effectTint: NSColor? + var effectAppearance: NSAppearance? + + func apply(to window: NSWindow) { + switch titleBarStyle { + case .hidden: + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.styleMask.insert(.fullSizeContentView) + case .normal: + window.titleVisibility = .visible + window.titlebarAppearsTransparent = false + window.styleMask.remove(.fullSizeContentView) + } + + window.standardWindowButton(.closeButton)?.isHidden = !windowButtonVisibility + window.standardWindowButton(.miniaturizeButton)?.isHidden = !windowButtonVisibility + window.standardWindowButton(.zoomButton)?.isHidden = !windowButtonVisibility + + switch effect { + case .none: + window.isOpaque = true + window.backgroundColor = .windowBackgroundColor + case .transparent, .blur, .acrylic, .mica: + window.isOpaque = false + window.backgroundColor = .clear + } + window.hasShadow = true + + WindowEffectView.apply(effect, tint: effectTint, appearance: effectAppearance, to: window) + } +} diff --git a/plugins/window/pubspec.yaml b/plugins/window/pubspec.yaml new file mode 100644 index 00000000..0da3c781 --- /dev/null +++ b/plugins/window/pubspec.yaml @@ -0,0 +1,27 @@ +name: window +description: Desktop window control for FlClash on Linux, macOS and Windows. +version: 0.0.1 + +environment: + sdk: '>=3.8.0 <4.0.0' + flutter: '>=3.3.0' + +dependencies: + flutter: + sdk: flutter + screen_retriever: ^0.2.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + +flutter: + plugin: + platforms: + linux: + pluginClass: WindowPlugin + macos: + pluginClass: WindowPlugin + windows: + pluginClass: WindowPluginCApi diff --git a/plugins/window/test/desktop_window_test.dart b/plugins/window/test/desktop_window_test.dart new file mode 100644 index 00000000..79e53255 --- /dev/null +++ b/plugins/window/test/desktop_window_test.dart @@ -0,0 +1,207 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:window/window.dart'; + +class _RecordingListener with WindowListener { + final List log = []; + + @override + void onWindowEvent(WindowEvent event) => log.add('event:${event.name}'); + + @override + void onWindowClose() => log.add('close'); + + @override + void onWindowGeometryChanged() => log.add('geometryChanged'); + + @override + void onWindowShouldTerminate() => log.add('shouldTerminate'); + + @override + void onWindowActivate() => log.add('activate'); +} + +class _SelfRemovingListener with WindowListener { + int calls = 0; + + @override + void onWindowFocus() { + calls++; + desktopWindow.removeListener(this); + } +} + +void main() { + const channel = MethodChannel('window'); + const codec = StandardMethodCodec(); + + TestWidgetsFlutterBinding.ensureInitialized(); + + late List calls; + Object? Function(MethodCall call)? responder; + + Future emit(Object? name) { + return TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + channel.name, + codec.encodeMethodCall(MethodCall('onEvent', {'name': name})), + (_) {}, + ); + } + + setUp(() { + calls = []; + responder = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return responder?.call(call); + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + for (final listener in desktopWindow.listeners) { + desktopWindow.removeListener(listener); + } + }); + + test('every wire name maps back to its event', () { + for (final event in WindowEvent.values) { + expect(WindowEvent.fromWireName(event.wireName), event); + } + expect(WindowEvent.fromWireName('nope'), isNull); + expect(WindowEvent.fromWireName(null), isNull); + }); + + test( + 'events reach the generic and the dedicated callback in order', + () async { + final listener = _RecordingListener(); + desktopWindow.addListener(listener); + + await emit('close'); + await emit('geometry-changed'); + await emit('should-terminate'); + await emit('activate'); + + expect(listener.log, [ + 'event:close', + 'close', + 'event:geometryChanged', + 'geometryChanged', + 'event:shouldTerminate', + 'shouldTerminate', + 'event:activate', + 'activate', + ]); + }, + ); + + test('unknown and malformed events are ignored', () async { + final listener = _RecordingListener(); + desktopWindow.addListener(listener); + + await emit('resized'); + await emit(null); + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + channel.name, + codec.encodeMethodCall(const MethodCall('onEvent', 'close')), + (_) {}, + ); + + expect(listener.log, isEmpty); + }); + + test('a listener removed while dispatching is skipped', () async { + final first = _SelfRemovingListener(); + final second = _RecordingListener(); + desktopWindow.addListener(first); + desktopWindow.addListener(second); + + await emit('focus'); + await emit('focus'); + + expect(first.calls, 1); + expect(second.log, ['event:focus', 'event:focus']); + expect(desktopWindow.listeners, [second]); + }); + + test('effect tints cross the channel as ARGB integers', () async { + await desktopWindow.setEffect( + WindowEffect.acrylic, + tint: const Color(0xFF445566), + brightness: Brightness.dark, + ); + await desktopWindow.setEffect(WindowEffect.none); + + expect(calls.map((call) => call.method), ['setEffect', 'setEffect']); + expect(calls[0].arguments, { + 'effect': 'acrylic', + 'tint': 0xFF445566, + 'brightness': 'dark', + }); + expect(calls[1].arguments, { + 'effect': 'none', + 'tint': null, + 'brightness': null, + }); + }); + + test('bounds carry only the fields that were given', () async { + await desktopWindow.setPosition(const Offset(10, 20)); + await desktopWindow.setSize(const Size(300, 400)); + await desktopWindow.setMinimumSize(const Size(380, 400)); + + expect(calls[0].method, 'setBounds'); + expect(calls[0].arguments, {'x': 10.0, 'y': 20.0}); + expect(calls[1].arguments, {'width': 300.0, 'height': 400.0}); + expect(calls[2].method, 'setMinimumSize'); + expect(calls[2].arguments, {'width': 380.0, 'height': 400.0}); + }); + + test('getBounds decodes the platform rectangle', () async { + responder = (call) => call.method == 'getBounds' + ? {'x': 1, 'y': 2.5, 'width': 300, 'height': 400} + : null; + + expect( + await desktopWindow.getBounds(), + const Rect.fromLTWH(1, 2.5, 300, 400), + ); + expect(calls.single.arguments, isNull); + }); + + test('enum arguments use their names', () async { + await desktopWindow.setTitleBarStyle(TitleBarStyle.hidden); + await desktopWindow.setRoundedCorners(false); + await desktopWindow.maximize(); + + expect(calls[0].arguments, { + 'style': 'hidden', + 'windowButtonVisibility': true, + }); + expect(calls[1].arguments, {'value': false}); + expect(calls[2].method, 'maximize'); + expect(calls[2].arguments, isNull); + }); + + test('a query the platform answers with null is a PlatformException', () { + expect( + desktopWindow.isVisible(), + throwsA( + isA().having((e) => e.code, 'code', 'bad_result'), + ), + ); + }); + + test('capability queries pass the platform answer through', () async { + responder = (call) => call.method == 'isPositionSupported'; + + expect(await desktopWindow.isPositionSupported(), isTrue); + expect(await desktopWindow.isEffectSupported(WindowEffect.mica), isFalse); + expect(calls.last.arguments, {'effect': 'mica'}); + }); +} diff --git a/plugins/window/test/window_position_test.dart b/plugins/window/test/window_position_test.dart new file mode 100644 index 00000000..ce9df635 --- /dev/null +++ b/plugins/window/test/window_position_test.dart @@ -0,0 +1,106 @@ +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart' show Alignment; +import 'package:flutter_test/flutter_test.dart'; +import 'package:window/window.dart'; + +void main() { + const channel = MethodChannel('dev.leanflutter.plugins/screen_retriever'); + + TestWidgetsFlutterBinding.ensureInitialized(); + + Map display({ + required String id, + required Offset position, + required Size size, + Size? visibleSize, + Offset? visiblePosition, + }) { + return { + 'id': id, + 'name': id, + 'size': {'width': size.width, 'height': size.height}, + 'visibleSize': { + 'width': (visibleSize ?? size).width, + 'height': (visibleSize ?? size).height, + }, + 'visiblePosition': { + 'dx': (visiblePosition ?? position).dx, + 'dy': (visiblePosition ?? position).dy, + }, + 'scaleFactor': 1.0, + }; + } + + late Offset cursor; + final primary = display( + id: 'primary', + position: Offset.zero, + size: const Size(1000, 800), + visibleSize: const Size(1000, 760), + visiblePosition: const Offset(0, 40), + ); + final secondary = display( + id: 'secondary', + position: const Offset(1000, 0), + size: const Size(600, 400), + ); + + setUp(() { + cursor = Offset.zero; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + return switch (call.method) { + 'getPrimaryDisplay' => primary, + 'getAllDisplays' => { + 'displays': [primary, secondary], + }, + 'getCursorScreenPoint' => {'dx': cursor.dx, 'dy': cursor.dy}, + _ => null, + }; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + test( + 'centers inside the work area of the display under the cursor', + () async { + cursor = const Offset(100, 100); + + final position = await calcWindowPosition( + const Size(400, 300), + Alignment.center, + ); + + expect(position, const Offset(300, 270)); + }, + ); + + test('corner alignments hug the work area edges', () async { + cursor = const Offset(1200, 100); + + expect( + await calcWindowPosition(const Size(200, 100), Alignment.topLeft), + const Offset(1000, 0), + ); + expect( + await calcWindowPosition(const Size(200, 100), Alignment.bottomRight), + const Offset(1400, 300), + ); + }); + + test( + 'falls back to the primary display when the cursor is nowhere', + () async { + cursor = const Offset(-500, -500); + + expect( + await calcWindowPosition(const Size(1000, 760), Alignment.center), + const Offset(0, 40), + ); + }, + ); +} diff --git a/plugins/window/windows/CMakeLists.txt b/plugins/window/windows/CMakeLists.txt new file mode 100644 index 00000000..4aeb6a20 --- /dev/null +++ b/plugins/window/windows/CMakeLists.txt @@ -0,0 +1,38 @@ +cmake_minimum_required(VERSION 3.14) +set(PROJECT_NAME "window") +project(${PROJECT_NAME} LANGUAGES CXX) +cmake_policy(VERSION 3.14...3.25) + +set(PLUGIN_NAME "window_plugin") + +list(APPEND PLUGIN_SOURCES + "running_instance.cpp" + "running_instance.h" + "window_controller.cpp" + "window_controller.h" + "window_plugin.cpp" + "window_plugin.h" + "window_style.cpp" + "window_style.h" +) + +add_library(${PLUGIN_NAME} SHARED + "include/window/window_plugin_c_api.h" + "window_plugin_c_api.cpp" + ${PLUGIN_SOURCES} +) + +apply_standard_settings(${PLUGIN_NAME}) + +set_target_properties(${PLUGIN_NAME} PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) + +target_include_directories(${PLUGIN_NAME} INTERFACE + "${CMAKE_CURRENT_SOURCE_DIR}/include") +target_link_libraries(${PLUGIN_NAME} PRIVATE flutter flutter_wrapper_plugin dwmapi) + +set(window_bundled_libraries + "" + PARENT_SCOPE +) diff --git a/plugins/window/windows/include/window/window_plugin_c_api.h b/plugins/window/windows/include/window/window_plugin_c_api.h new file mode 100644 index 00000000..538a8723 --- /dev/null +++ b/plugins/window/windows/include/window/window_plugin_c_api.h @@ -0,0 +1,29 @@ +#ifndef FLUTTER_PLUGIN_WINDOW_PLUGIN_C_API_H_ +#define FLUTTER_PLUGIN_WINDOW_PLUGIN_C_API_H_ + +#include +#include + +#ifdef FLUTTER_PLUGIN_IMPL +#define FLUTTER_PLUGIN_EXPORT __declspec(dllexport) +#else +#define FLUTTER_PLUGIN_EXPORT __declspec(dllimport) +#endif + +#if defined(__cplusplus) +extern "C" { +#endif + +FLUTTER_PLUGIN_EXPORT void WindowPluginCApiRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar); + +// Another running instance's top-level window, or nullptr; safe pre-engine. +FLUTTER_PLUGIN_EXPORT HWND WindowPluginFindRunningWindow(); + +FLUTTER_PLUGIN_EXPORT void WindowPluginActivateWindow(HWND window); + +#if defined(__cplusplus) +} // extern "C" +#endif + +#endif // FLUTTER_PLUGIN_WINDOW_PLUGIN_C_API_H_ diff --git a/plugins/window/windows/include/window/window_plugin_runner.h b/plugins/window/windows/include/window/window_plugin_runner.h new file mode 100644 index 00000000..a6286718 --- /dev/null +++ b/plugins/window/windows/include/window/window_plugin_runner.h @@ -0,0 +1,19 @@ +#ifndef FLUTTER_PLUGIN_WINDOW_PLUGIN_RUNNER_H_ +#define FLUTTER_PLUGIN_WINDOW_PLUGIN_RUNNER_H_ + +#include +#include + +// The engine hides the view only once it is sized to zero, and hiding or +// minimizing the top-level window never resizes it. Call this before +// HandleTopLevelWindowProc, where the plugin consumes a minimize's WM_SIZE. +inline void WindowPluginForwardVisibility( + flutter::FlutterViewController& controller, UINT message, WPARAM wparam, + LPARAM lparam) { + if (message == WM_SHOWWINDOW || message == WM_SIZE) { + controller.engine()->ProcessExternalWindowMessage( + controller.view()->GetNativeWindow(), message, wparam, lparam); + } +} + +#endif // FLUTTER_PLUGIN_WINDOW_PLUGIN_RUNNER_H_ diff --git a/plugins/window/windows/running_instance.cpp b/plugins/window/windows/running_instance.cpp new file mode 100644 index 00000000..6c1d17c2 --- /dev/null +++ b/plugins/window/windows/running_instance.cpp @@ -0,0 +1,105 @@ +#include "running_instance.h" + +#include +#include +#include + +namespace window { + +namespace { + +constexpr wchar_t kRunnerWindowClass[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +// Lower-cased because GetModuleFileNameW keeps launch-time casing, and the +// same executable must always hash to one message. +std::wstring GetExecutablePath() { + std::wstring path(MAX_PATH, L'\0'); + for (;;) { + const DWORD length = ::GetModuleFileNameW( + nullptr, path.data(), static_cast(path.size())); + if (length == 0) { + return {}; + } + if (length < path.size()) { + path.resize(length); + break; + } + path.resize(path.size() * 2); + } + ::CharLowerBuffW(path.data(), static_cast(path.size())); + return path; +} + +struct RunningWindowSearch { + std::wstring executable; + HWND found = nullptr; +}; + +BOOL CALLBACK MatchRunningWindow(HWND hwnd, LPARAM lparam) { + auto* search = reinterpret_cast(lparam); + wchar_t window_class[64] = {}; + ::GetClassNameW(hwnd, window_class, 64); + if (_wcsicmp(window_class, kRunnerWindowClass) != 0) { + return TRUE; + } + DWORD pid = 0; + ::GetWindowThreadProcessId(hwnd, &pid); + if (pid == ::GetCurrentProcessId()) { + return TRUE; + } + HANDLE process = + ::OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid); + if (process == nullptr) { + return TRUE; + } + std::wstring executable(search->executable.size() + 1, L'\0'); + DWORD length = static_cast(executable.size()); + const BOOL queried = + ::QueryFullProcessImageNameW(process, 0, executable.data(), &length); + ::CloseHandle(process); + if (queried && + _wcsicmp(executable.c_str(), search->executable.c_str()) == 0) { + search->found = hwnd; + return FALSE; + } + return TRUE; +} + +} // namespace + +UINT GetActivateMessage() { + static const UINT message = [] { + uint64_t hash = 14695981039346656037ull; + for (wchar_t c : GetExecutablePath()) { + hash = (hash ^ static_cast(c)) * 1099511628211ull; + } + std::wostringstream name; + name << L"window_plugin.activate:" << std::hex << hash; + return ::RegisterWindowMessageW(name.str().c_str()); + }(); + return message; +} + +void AllowRelaunchMessagesThroughUipi(HWND root) { + ::ChangeWindowMessageFilterEx(root, GetActivateMessage(), MSGFLT_ALLOW, + nullptr); + ::ChangeWindowMessageFilterEx(root, WM_COPYDATA, MSGFLT_ALLOW, nullptr); +} + +HWND FindRunningWindow() { + RunningWindowSearch search{GetExecutablePath()}; + ::EnumWindows(MatchRunningWindow, reinterpret_cast(&search)); + return search.found; +} + +void ActivateWindow(HWND window) { + if (window == nullptr) { + return; + } + DWORD pid = 0; + ::GetWindowThreadProcessId(window, &pid); + ::AllowSetForegroundWindow(pid); + ::PostMessageW(window, GetActivateMessage(), 0, 0); +} + +} // namespace window diff --git a/plugins/window/windows/running_instance.h b/plugins/window/windows/running_instance.h new file mode 100644 index 00000000..adb135e1 --- /dev/null +++ b/plugins/window/windows/running_instance.h @@ -0,0 +1,19 @@ +#ifndef FLUTTER_PLUGIN_WINDOW_RUNNING_INSTANCE_H_ +#define FLUTTER_PLUGIN_WINDOW_RUNNING_INSTANCE_H_ + +#include + +namespace window { + +UINT GetActivateMessage(); + +// Lets a lower-integrity relaunch reach an elevated |root| with +// GetActivateMessage() and app_links' WM_COPYDATA; UIPI drops both otherwise. +void AllowRelaunchMessagesThroughUipi(HWND root); + +HWND FindRunningWindow(); +void ActivateWindow(HWND window); + +} // namespace window + +#endif // FLUTTER_PLUGIN_WINDOW_RUNNING_INSTANCE_H_ diff --git a/plugins/window/windows/window_controller.cpp b/plugins/window/windows/window_controller.cpp new file mode 100644 index 00000000..8dd2d72f --- /dev/null +++ b/plugins/window/windows/window_controller.cpp @@ -0,0 +1,426 @@ +#include "window_controller.h" + +#include "running_instance.h" + +namespace window { + +namespace { + +constexpr UINT_PTR kGeometryTimerId = 0x57494E47; // "WING" +constexpr UINT kGeometrySettleMilliseconds = 150; + +} // namespace + +WindowController::WindowController(EventEmitter emit_event) + : emit_event_(std::move(emit_event)) {} + +WindowController::~WindowController() { + if (hwnd_ != nullptr && ::IsWindow(hwnd_)) { + ::KillTimer(hwnd_, kGeometryTimerId); + } + if (taskbar_ != nullptr) { + taskbar_->Release(); + taskbar_ = nullptr; + } +} + +void WindowController::Initialize(HWND hwnd, HWND view) { + if (hwnd_ == hwnd) { + return; + } + hwnd_ = hwnd; + view_ = view; + GetTaskbarList(); + ::ShowWindow(hwnd_, SW_HIDE); +} + +double WindowController::DpiScale() const { + if (hwnd_ == nullptr) { + return 1.0; + } + return ::GetDpiForWindow(hwnd_) / static_cast(USER_DEFAULT_SCREEN_DPI); +} + +int WindowController::TopBorderHeight() const { + return style_ != nullptr ? style_->TopBorderHeight(hwnd_) : 0; +} + +void WindowController::PlaceView() const { + if (hwnd_ == nullptr || view_ == nullptr) { + return; + } + RECT client{}; + ::GetClientRect(hwnd_, &client); + const LONG border = TopBorderHeight(); + const LONG height = client.bottom > border ? client.bottom - border : 0; + ::MoveWindow(view_, 0, border, client.right, height, TRUE); +} + +ITaskbarList3* WindowController::GetTaskbarList() { + if (taskbar_ == nullptr) { + ::CoCreateInstance(CLSID_TaskbarList, nullptr, CLSCTX_INPROC_SERVER, + IID_PPV_ARGS(&taskbar_)); + if (taskbar_ != nullptr) { + taskbar_->HrInit(); + } + } + return taskbar_; +} + +void WindowController::Show(bool inactive) { + if (hwnd_ == nullptr) { + return; + } + ::ShowWindow(hwnd_, inactive ? SW_SHOWNOACTIVATE : SW_SHOW); + if (!inactive) { + ::SetForegroundWindow(hwnd_); + } +} + +void WindowController::Hide() { + if (hwnd_ != nullptr) { + ::ShowWindow(hwnd_, SW_HIDE); + } +} + +bool WindowController::IsVisible() const { + return hwnd_ != nullptr && ::IsWindowVisible(hwnd_) != FALSE; +} + +void WindowController::Focus() { + if (hwnd_ == nullptr) { + return; + } + if (IsMinimized()) { + Restore(); + } + ::SetWindowPos(hwnd_, HWND_TOP, 0, 0, 0, 0, SWP_NOSIZE | SWP_NOMOVE); + ::SetForegroundWindow(hwnd_); +} + +void WindowController::Close() { + if (hwnd_ != nullptr) { + ::PostMessageW(hwnd_, WM_SYSCOMMAND, SC_CLOSE, 0); + } +} + +bool WindowController::IsMaximized() const { + if (hwnd_ == nullptr) { + return false; + } + WINDOWPLACEMENT placement{sizeof(WINDOWPLACEMENT)}; + ::GetWindowPlacement(hwnd_, &placement); + return placement.showCmd == SW_MAXIMIZE; +} + +void WindowController::Maximize() { + if (hwnd_ != nullptr && !IsMaximized()) { + ::PostMessageW(hwnd_, WM_SYSCOMMAND, SC_MAXIMIZE, 0); + } +} + +void WindowController::Unmaximize() { + if (hwnd_ != nullptr && IsMaximized()) { + ::PostMessageW(hwnd_, WM_SYSCOMMAND, SC_RESTORE, 0); + } +} + +bool WindowController::IsMinimized() const { + if (hwnd_ == nullptr) { + return false; + } + WINDOWPLACEMENT placement{sizeof(WINDOWPLACEMENT)}; + ::GetWindowPlacement(hwnd_, &placement); + return placement.showCmd == SW_SHOWMINIMIZED; +} + +void WindowController::Minimize() { + if (hwnd_ == nullptr || is_fullscreen_ || IsMinimized()) { + return; + } + ::PostMessageW(hwnd_, WM_SYSCOMMAND, SC_MINIMIZE, 0); +} + +void WindowController::Restore() { + if (hwnd_ == nullptr) { + return; + } + WINDOWPLACEMENT placement{sizeof(WINDOWPLACEMENT)}; + ::GetWindowPlacement(hwnd_, &placement); + if (placement.showCmd != SW_NORMAL) { + ::PostMessageW(hwnd_, WM_SYSCOMMAND, SC_RESTORE, 0); + } +} + +void WindowController::SetFullScreen(bool value) { + if (hwnd_ == nullptr || value == is_fullscreen_) { + return; + } + + if (value) { + fullscreen_saved_.maximized = ::IsZoomed(hwnd_); + fullscreen_saved_.style = ::GetWindowLongW(hwnd_, GWL_STYLE); + ::GetWindowRect(hwnd_, &fullscreen_saved_.frame); + is_fullscreen_ = true; + last_state_ = WindowState::kNormal; + + MONITORINFO monitor_info{sizeof(MONITORINFO)}; + ::GetMonitorInfo(::MonitorFromWindow(hwnd_, MONITOR_DEFAULTTONEAREST), + &monitor_info); + ::SetWindowLongPtrW( + hwnd_, GWL_STYLE, + fullscreen_saved_.style & ~(WS_THICKFRAME | WS_MAXIMIZEBOX)); + ::SetWindowPos(hwnd_, HWND_TOP, monitor_info.rcMonitor.left, + monitor_info.rcMonitor.top, 0, 0, + SWP_NOSIZE | SWP_NOOWNERZORDER | SWP_FRAMECHANGED); + ::SetWindowPos(hwnd_, HWND_TOP, 0, 0, + monitor_info.rcMonitor.right - monitor_info.rcMonitor.left, + monitor_info.rcMonitor.bottom - monitor_info.rcMonitor.top, + SWP_NOMOVE | SWP_NOOWNERZORDER | SWP_FRAMECHANGED); + emit_event_("enter-full-screen"); + return; + } + + is_fullscreen_ = false; + ::SetWindowLongPtrW( + hwnd_, GWL_STYLE, + fullscreen_saved_.style | (WS_THICKFRAME | WS_MAXIMIZEBOX)); + if (::IsZoomed(hwnd_)) { + ::SetWindowPos(hwnd_, nullptr, 0, 0, 0, 0, + SWP_NOACTIVATE | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | + SWP_FRAMECHANGED); + if (fullscreen_saved_.maximized) { + ::PostMessageW(hwnd_, WM_SYSCOMMAND, SC_MAXIMIZE, 0); + } + } else { + ::SetWindowPos(hwnd_, nullptr, fullscreen_saved_.frame.left, + fullscreen_saved_.frame.top, + fullscreen_saved_.frame.right - fullscreen_saved_.frame.left, + fullscreen_saved_.frame.bottom - fullscreen_saved_.frame.top, + SWP_NOACTIVATE | SWP_NOZORDER); + } + if (style_ != nullptr) { + style_->Apply(hwnd_); + } + emit_event_("leave-full-screen"); +} + +WindowBounds WindowController::GetBounds() const { + WindowBounds bounds{}; + RECT rect{}; + if (hwnd_ != nullptr && ::GetWindowRect(hwnd_, &rect)) { + const double scale = DpiScale(); + bounds.x = rect.left / scale; + bounds.y = rect.top / scale; + bounds.width = (rect.right - rect.left) / scale; + bounds.height = (rect.bottom - rect.top) / scale; + } + return bounds; +} + +void WindowController::SetBounds(std::optional x, + std::optional y, + std::optional width, + std::optional height) { + if (hwnd_ == nullptr) { + return; + } + const double scale = DpiScale(); + int ix = 0; + int iy = 0; + int iw = 0; + int ih = 0; + UINT flags = SWP_NOZORDER | SWP_NOACTIVATE; + if (x.has_value() && y.has_value()) { + ix = static_cast(*x * scale); + iy = static_cast(*y * scale); + } else { + flags |= SWP_NOMOVE; + } + if (width.has_value() && height.has_value()) { + iw = static_cast(*width * scale); + ih = static_cast(*height * scale); + } else { + flags |= SWP_NOSIZE; + } + ::SetWindowPos(hwnd_, nullptr, ix, iy, iw, ih, flags); +} + +void WindowController::SetMinimumSize(double width, double height) { + minimum_size_ = (width >= 0 && height >= 0) + ? POINT{static_cast(width), + static_cast(height)} + : POINT{0, 0}; +} + +bool WindowController::IsAlwaysOnTop() const { + return hwnd_ != nullptr && + (::GetWindowLongW(hwnd_, GWL_EXSTYLE) & WS_EX_TOPMOST) != 0; +} + +void WindowController::SetAlwaysOnTop(bool value) { + if (hwnd_ != nullptr) { + ::SetWindowPos(hwnd_, value ? HWND_TOPMOST : HWND_NOTOPMOST, 0, 0, 0, 0, + SWP_NOMOVE | SWP_NOSIZE); + } +} + +void WindowController::SetSkipTaskbar(bool value) { + if (hwnd_ == nullptr) { + return; + } + ITaskbarList3* taskbar = GetTaskbarList(); + if (taskbar == nullptr) { + return; + } + if (value) { + taskbar->DeleteTab(hwnd_); + } else { + taskbar->AddTab(hwnd_); + } +} + +void WindowController::StartDragging() { + if (hwnd_ == nullptr) { + return; + } + ::ReleaseCapture(); + ::SendMessageW(hwnd_, WM_SYSCOMMAND, SC_MOVE | HTCAPTION, 0); +} + +std::optional WindowController::HandleWindowMessage( + HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam) { + if (wparam && message == WM_NCCALCSIZE) { + const bool title_bar_hidden = + style_ != nullptr && style_->title_bar_style == TitleBarStyle::kHidden; + if (!title_bar_hidden) { + return std::nullopt; + } + if (is_fullscreen_) { + return 0; + } + // The insets come from the window's styles rather than DefWindowProc, so + // they do not hinge on what it returns for the runner's window class. + // Maximized windows hang past the monitor edge by one frame width. + RECT frame{}; + ::AdjustWindowRectExForDpi( + &frame, static_cast(::GetWindowLongPtrW(hwnd, GWL_STYLE)), FALSE, + static_cast(::GetWindowLongPtrW(hwnd, GWL_EXSTYLE)), + ::GetDpiForWindow(hwnd)); + RECT* rect = &reinterpret_cast(lparam)->rgrc[0]; + rect->left -= frame.left; + rect->right -= frame.right; + rect->bottom -= frame.bottom; + // Windows 11 draws the top border only into a non-client strip. Windows + // 10 must get none: DWM draws the whole caption over a strip shorter than + // one (Windows Terminal, NonClientIslandWindow::_UpdateFrameMargins). + rect->top += + IsMaximized() ? frame.bottom : (IsWindows11OrGreater() ? 1 : 0); + return 0; + } else if (message == WM_GETMINMAXINFO) { + auto* info = reinterpret_cast(lparam); + const double scale = DpiScale(); + if (minimum_size_.x != 0) + info->ptMinTrackSize.x = static_cast(minimum_size_.x * scale); + if (minimum_size_.y != 0) + info->ptMinTrackSize.y = static_cast(minimum_size_.y * scale); + return 0; + } else if (message == WM_NCACTIVATE) { + emit_event_(wparam != 0 ? "focus" : "blur"); + if (style_ != nullptr && style_->title_bar_style == TitleBarStyle::kHidden) { + return TRUE; + } + } else if (message == WM_WINDOWPOSCHANGED) { + const auto* pos = reinterpret_cast(lparam); + if ((pos->flags & SWP_FRAMECHANGED) != 0) { + PlaceView(); + } + if ((pos->flags & SWP_NOMOVE) == 0 || (pos->flags & SWP_NOSIZE) == 0) { + ::SetTimer(hwnd, kGeometryTimerId, kGeometrySettleMilliseconds, nullptr); + } + } else if (message == WM_TIMER && wparam == kGeometryTimerId) { + ::KillTimer(hwnd, kGeometryTimerId); + emit_event_("geometry-changed"); + return 0; + } else if (message == WM_SIZE) { + if (!is_fullscreen_) { + if (wparam == SIZE_MAXIMIZED) { + emit_event_("maximize"); + last_state_ = WindowState::kMaximized; + } else if (wparam == SIZE_MINIMIZED) { + emit_event_("minimize"); + last_state_ = WindowState::kMinimized; + return 0; + } else if (wparam == SIZE_RESTORED) { + if (last_state_ == WindowState::kMaximized) { + emit_event_("unmaximize"); + last_state_ = WindowState::kNormal; + } else if (last_state_ == WindowState::kMinimized) { + emit_event_("restore"); + last_state_ = WindowState::kNormal; + } + } + } + if (view_ == nullptr) { + return std::nullopt; + } + PlaceView(); + return 0; + } else if (message == WM_PAINT) { + const LONG border = TopBorderHeight(); + if (border == 0) { + return std::nullopt; + } + PAINTSTRUCT paint{}; + const HDC dc = ::BeginPaint(hwnd, &paint); + RECT strip = paint.rcPaint; + if (strip.bottom > border) { + strip.bottom = border; + } + // DWM shows the extended frame where the client is painted black (alpha 0). + if (strip.top < strip.bottom) { + ::FillRect(dc, &strip, + static_cast(::GetStockObject(BLACK_BRUSH))); + } + ::EndPaint(hwnd, &paint); + return 0; + } else if (message == WM_NCHITTEST) { + const LONG border = TopBorderHeight(); + if (border == 0) { + return std::nullopt; + } + const LRESULT hit = ::DefWindowProcW(hwnd, message, wparam, lparam); + const POINT point{static_cast(LOWORD(lparam)), + static_cast(HIWORD(lparam))}; + RECT window{}; + ::GetWindowRect(hwnd, &window); + if (hit != HTCLIENT || point.y >= window.top + border) { + return hit; + } + RECT frame{}; + ::AdjustWindowRectExForDpi(&frame, WS_OVERLAPPEDWINDOW, FALSE, 0, + ::GetDpiForWindow(hwnd)); + const LONG corner = -frame.left * 2; + if (point.x < window.left + corner) { + return HTTOPLEFT; + } + if (point.x >= window.right - corner) { + return HTTOPRIGHT; + } + return HTTOP; + } else if (message == WM_CLOSE) { + emit_event_("close"); + if (is_prevent_close_) { + return -1; + } + } else if (message == WM_SHOWWINDOW) { + emit_event_(wparam == TRUE ? "show" : "hide"); + } else if (message != WM_NULL && message == GetActivateMessage()) { + emit_event_("activate"); + return 0; + } + + return std::nullopt; +} + +} // namespace window diff --git a/plugins/window/windows/window_controller.h b/plugins/window/windows/window_controller.h new file mode 100644 index 00000000..064d72c9 --- /dev/null +++ b/plugins/window/windows/window_controller.h @@ -0,0 +1,108 @@ +#ifndef FLUTTER_PLUGIN_WINDOW_CONTROLLER_H_ +#define FLUTTER_PLUGIN_WINDOW_CONTROLLER_H_ + +#include + +#include + +#include +#include +#include + +#include "window_style.h" + +namespace window { + +struct WindowBounds { + double x = 0; + double y = 0; + double width = 0; + double height = 0; +}; + +// Owns the HWND, the WM_* handling for it, the placement of the Flutter view +// inside it and the state that handling depends on. WindowStyle::Apply is the +// only thing touching DWM or composition attributes; this class only reads +// title_bar_style and TopBorderHeight from it. +class WindowController { + public: + using EventEmitter = std::function; + + explicit WindowController(EventEmitter emit_event); + ~WindowController(); + + WindowController(const WindowController&) = delete; + WindowController& operator=(const WindowController&) = delete; + + void Initialize(HWND hwnd, HWND view); + HWND GetWindow() const { return hwnd_; } + void SetStyle(const WindowStyle* style) { style_ = style; } + + std::optional HandleWindowMessage(HWND hwnd, UINT message, + WPARAM wparam, LPARAM lparam); + + void Show(bool inactive); + void Hide(); + bool IsVisible() const; + void Focus(); + void Close(); + void SetPreventClose(bool value) { is_prevent_close_ = value; } + + bool IsMaximized() const; + void Maximize(); + void Unmaximize(); + bool IsMinimized() const; + void Minimize(); + void Restore(); + + bool IsFullScreen() const { return is_fullscreen_; } + void SetFullScreen(bool value); + + WindowBounds GetBounds() const; + void SetBounds(std::optional x, std::optional y, + std::optional width, std::optional height); + void SetMinimumSize(double width, double height); + + bool IsAlwaysOnTop() const; + void SetAlwaysOnTop(bool value); + + void SetSkipTaskbar(bool value); + + void StartDragging(); + + private: + enum class WindowState { + kNormal, + kMaximized, + kMinimized, + }; + + struct FullScreenSavedState { + bool maximized = false; + LONG style = 0; + RECT frame{}; + }; + + double DpiScale() const; + ITaskbarList3* GetTaskbarList(); + int TopBorderHeight() const; + void PlaceView() const; + + HWND hwnd_ = nullptr; + HWND view_ = nullptr; + const WindowStyle* style_ = nullptr; + EventEmitter emit_event_; + + WindowState last_state_ = WindowState::kNormal; + bool is_prevent_close_ = false; + POINT minimum_size_{0, 0}; + + bool is_fullscreen_ = false; + FullScreenSavedState fullscreen_saved_; + + ITaskbarList3* taskbar_ = nullptr; +}; + +} // namespace window + +#endif // FLUTTER_PLUGIN_WINDOW_CONTROLLER_H_ diff --git a/plugins/window/windows/window_plugin.cpp b/plugins/window/windows/window_plugin.cpp new file mode 100644 index 00000000..fc80735e --- /dev/null +++ b/plugins/window/windows/window_plugin.cpp @@ -0,0 +1,394 @@ +#include "window_plugin.h" + +#include +#include + +#include "running_instance.h" + +namespace window { + +namespace { + +std::optional GetBool(const flutter::EncodableMap* args, + const char* key) { + if (args == nullptr) return std::nullopt; + const auto it = args->find(flutter::EncodableValue(key)); + if (it == args->end()) return std::nullopt; + const auto* value = std::get_if(&it->second); + return value == nullptr ? std::nullopt : std::optional(*value); +} + +std::optional GetDouble(const flutter::EncodableMap* args, + const char* key) { + if (args == nullptr) return std::nullopt; + const auto it = args->find(flutter::EncodableValue(key)); + if (it == args->end()) return std::nullopt; + const auto* value = std::get_if(&it->second); + return value == nullptr ? std::nullopt : std::optional(*value); +} + +std::optional GetInt(const flutter::EncodableMap* args, + const char* key) { + if (args == nullptr) return std::nullopt; + const auto it = args->find(flutter::EncodableValue(key)); + if (it == args->end()) return std::nullopt; + if (const auto* value32 = std::get_if(&it->second)) { + return static_cast(*value32); + } + if (const auto* value64 = std::get_if(&it->second)) { + return *value64; + } + return std::nullopt; +} + +std::optional GetString(const flutter::EncodableMap* args, + const char* key) { + if (args == nullptr) return std::nullopt; + const auto it = args->find(flutter::EncodableValue(key)); + if (it == args->end()) return std::nullopt; + const auto* value = std::get_if(&it->second); + return value == nullptr ? std::nullopt : std::optional(*value); +} + +} // namespace + +void WindowPlugin::RegisterWithRegistrar( + flutter::PluginRegistrarWindows* registrar) { + auto plugin = std::make_unique(registrar); + registrar->AddPlugin(std::move(plugin)); +} + +WindowPlugin::WindowPlugin(flutter::PluginRegistrarWindows* registrar) + : registrar_(registrar), + controller_([this](const std::string& name) { EmitEvent(name); }) { + controller_.SetStyle(&style_); + BuildHandlerTable(); + + channel_ = std::make_unique>( + registrar_->messenger(), "window", + &flutter::StandardMethodCodec::GetInstance()); + channel_->SetMethodCallHandler([this](const auto& call, auto result) { + HandleMethodCall(call, std::move(result)); + }); + + window_proc_id_ = registrar_->RegisterTopLevelWindowProcDelegate( + [this](HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam) { + return HandleWindowProc(hwnd, message, wparam, lparam); + }); +} + +WindowPlugin::~WindowPlugin() { + registrar_->UnregisterTopLevelWindowProcDelegate(window_proc_id_); +} + +void WindowPlugin::EmitEvent(const std::string& name) { + if (channel_ == nullptr) return; + flutter::EncodableMap args; + args[flutter::EncodableValue("name")] = flutter::EncodableValue(name); + channel_->InvokeMethod("onEvent", + std::make_unique(args)); +} + +std::optional WindowPlugin::HandleWindowProc(HWND hwnd, + UINT message, + WPARAM wparam, + LPARAM lparam) { + // Plugins register before the runner parents the Flutter view, so the + // top-level window is only known once its messages arrive here. + if (!relaunch_messages_allowed_) { + relaunch_messages_allowed_ = true; + AllowRelaunchMessagesThroughUipi(::GetAncestor(hwnd, GA_ROOT)); + } + return controller_.HandleWindowMessage(hwnd, message, wparam, lparam); +} + +void WindowPlugin::HandleMethodCall( + const flutter::MethodCall& call, + std::unique_ptr result) { + const auto it = handlers_.find(call.method_name()); + if (it == handlers_.end()) { + result->NotImplemented(); + return; + } + const auto* args = std::get_if(call.arguments()); + (this->*(it->second))(args, std::move(result)); +} + +void WindowPlugin::BuildHandlerTable() { + handlers_["ensureInitialized"] = &WindowPlugin::HandleEnsureInitialized; + handlers_["show"] = &WindowPlugin::HandleShow; + handlers_["hide"] = &WindowPlugin::HandleHide; + handlers_["isVisible"] = &WindowPlugin::HandleIsVisible; + handlers_["focus"] = &WindowPlugin::HandleFocus; + handlers_["close"] = &WindowPlugin::HandleClose; + handlers_["setPreventClose"] = &WindowPlugin::HandleSetPreventClose; + handlers_["isMaximized"] = &WindowPlugin::HandleIsMaximized; + handlers_["maximize"] = &WindowPlugin::HandleMaximize; + handlers_["unmaximize"] = &WindowPlugin::HandleUnmaximize; + handlers_["isMinimized"] = &WindowPlugin::HandleIsMinimized; + handlers_["minimize"] = &WindowPlugin::HandleMinimize; + handlers_["restore"] = &WindowPlugin::HandleRestore; + handlers_["isFullScreen"] = &WindowPlugin::HandleIsFullScreen; + handlers_["setFullScreen"] = &WindowPlugin::HandleSetFullScreen; + handlers_["getBounds"] = &WindowPlugin::HandleGetBounds; + handlers_["setBounds"] = &WindowPlugin::HandleSetBounds; + handlers_["isPositionSupported"] = &WindowPlugin::HandleIsPositionSupported; + handlers_["setMinimumSize"] = &WindowPlugin::HandleSetMinimumSize; + handlers_["isAlwaysOnTop"] = &WindowPlugin::HandleIsAlwaysOnTop; + handlers_["setAlwaysOnTop"] = &WindowPlugin::HandleSetAlwaysOnTop; + handlers_["setTitleBarStyle"] = &WindowPlugin::HandleSetTitleBarStyle; + handlers_["setSkipTaskbar"] = &WindowPlugin::HandleSetSkipTaskbar; + handlers_["setRoundedCorners"] = &WindowPlugin::HandleSetRoundedCorners; + handlers_["isEffectSupported"] = &WindowPlugin::HandleIsEffectSupported; + handlers_["setEffect"] = &WindowPlugin::HandleSetEffect; + handlers_["startDragging"] = &WindowPlugin::HandleStartDragging; +} + +void WindowPlugin::HandleEnsureInitialized(const flutter::EncodableMap*, + std::unique_ptr result) { + const HWND view = registrar_->GetView()->GetNativeWindow(); + controller_.Initialize(::GetAncestor(view, GA_ROOT), view); + result->Success(); +} + +void WindowPlugin::HandleShow(const flutter::EncodableMap* args, + std::unique_ptr result) { + const auto inactive = GetBool(args, "inactive"); + if (!inactive.has_value()) { + result->Error("bad_args", "show: inactive"); + return; + } + controller_.Show(*inactive); + result->Success(); +} + +void WindowPlugin::HandleHide(const flutter::EncodableMap*, + std::unique_ptr result) { + controller_.Hide(); + result->Success(); +} + +void WindowPlugin::HandleIsVisible(const flutter::EncodableMap*, + std::unique_ptr result) { + result->Success(flutter::EncodableValue(controller_.IsVisible())); +} + +void WindowPlugin::HandleFocus(const flutter::EncodableMap*, + std::unique_ptr result) { + controller_.Focus(); + result->Success(); +} + +void WindowPlugin::HandleClose(const flutter::EncodableMap*, + std::unique_ptr result) { + controller_.Close(); + result->Success(); +} + +void WindowPlugin::HandleSetPreventClose(const flutter::EncodableMap* args, + std::unique_ptr result) { + const auto value = GetBool(args, "value"); + if (!value.has_value()) { + result->Error("bad_args", "setPreventClose: value"); + return; + } + controller_.SetPreventClose(*value); + result->Success(); +} + +void WindowPlugin::HandleIsMaximized(const flutter::EncodableMap*, + std::unique_ptr result) { + result->Success(flutter::EncodableValue(controller_.IsMaximized())); +} + +void WindowPlugin::HandleMaximize(const flutter::EncodableMap*, + std::unique_ptr result) { + controller_.Maximize(); + result->Success(); +} + +void WindowPlugin::HandleUnmaximize(const flutter::EncodableMap*, + std::unique_ptr result) { + controller_.Unmaximize(); + result->Success(); +} + +void WindowPlugin::HandleIsMinimized(const flutter::EncodableMap*, + std::unique_ptr result) { + result->Success(flutter::EncodableValue(controller_.IsMinimized())); +} + +void WindowPlugin::HandleMinimize(const flutter::EncodableMap*, + std::unique_ptr result) { + controller_.Minimize(); + result->Success(); +} + +void WindowPlugin::HandleRestore(const flutter::EncodableMap*, + std::unique_ptr result) { + controller_.Restore(); + result->Success(); +} + +void WindowPlugin::HandleIsFullScreen(const flutter::EncodableMap*, + std::unique_ptr result) { + result->Success(flutter::EncodableValue(controller_.IsFullScreen())); +} + +void WindowPlugin::HandleSetFullScreen(const flutter::EncodableMap* args, + std::unique_ptr result) { + const auto value = GetBool(args, "value"); + if (!value.has_value()) { + result->Error("bad_args", "setFullScreen: value"); + return; + } + controller_.SetFullScreen(*value); + result->Success(); +} + +void WindowPlugin::HandleGetBounds(const flutter::EncodableMap*, + std::unique_ptr result) { + const WindowBounds bounds = controller_.GetBounds(); + flutter::EncodableMap value; + value[flutter::EncodableValue("x")] = flutter::EncodableValue(bounds.x); + value[flutter::EncodableValue("y")] = flutter::EncodableValue(bounds.y); + value[flutter::EncodableValue("width")] = + flutter::EncodableValue(bounds.width); + value[flutter::EncodableValue("height")] = + flutter::EncodableValue(bounds.height); + result->Success(flutter::EncodableValue(value)); +} + +void WindowPlugin::HandleSetBounds(const flutter::EncodableMap* args, + std::unique_ptr result) { + controller_.SetBounds(GetDouble(args, "x"), GetDouble(args, "y"), + GetDouble(args, "width"), GetDouble(args, "height")); + result->Success(); +} + +void WindowPlugin::HandleIsPositionSupported(const flutter::EncodableMap*, + std::unique_ptr result) { + result->Success(flutter::EncodableValue(true)); +} + +void WindowPlugin::HandleSetMinimumSize(const flutter::EncodableMap* args, + std::unique_ptr result) { + const auto width = GetDouble(args, "width"); + const auto height = GetDouble(args, "height"); + if (!width.has_value() || !height.has_value()) { + result->Error("bad_args", "setMinimumSize: width/height"); + return; + } + controller_.SetMinimumSize(*width, *height); + result->Success(); +} + +void WindowPlugin::HandleIsAlwaysOnTop(const flutter::EncodableMap*, + std::unique_ptr result) { + result->Success(flutter::EncodableValue(controller_.IsAlwaysOnTop())); +} + +void WindowPlugin::HandleSetAlwaysOnTop(const flutter::EncodableMap* args, + std::unique_ptr result) { + const auto value = GetBool(args, "value"); + if (!value.has_value()) { + result->Error("bad_args", "setAlwaysOnTop: value"); + return; + } + controller_.SetAlwaysOnTop(*value); + result->Success(); +} + +void WindowPlugin::HandleSetTitleBarStyle(const flutter::EncodableMap* args, + std::unique_ptr result) { + const auto style_name = GetString(args, "style"); + const auto button_visibility = GetBool(args, "windowButtonVisibility"); + if (!style_name.has_value() || !button_visibility.has_value()) { + result->Error("bad_args", + "setTitleBarStyle: style/windowButtonVisibility"); + return; + } + const auto style = ParseTitleBarStyle(*style_name); + if (!style.has_value()) { + result->Error("bad_args", "setTitleBarStyle: style"); + return; + } + style_.SetTitleBarStyle(*style, *button_visibility, controller_.GetWindow()); + result->Success(); +} + +void WindowPlugin::HandleSetSkipTaskbar(const flutter::EncodableMap* args, + std::unique_ptr result) { + const auto value = GetBool(args, "value"); + if (!value.has_value()) { + result->Error("bad_args", "setSkipTaskbar: value"); + return; + } + controller_.SetSkipTaskbar(*value); + result->Success(); +} + +void WindowPlugin::HandleSetRoundedCorners(const flutter::EncodableMap* args, + std::unique_ptr result) { + const auto value = GetBool(args, "value"); + if (!value.has_value()) { + result->Error("bad_args", "setRoundedCorners: value"); + return; + } + style_.SetRoundedCorners(*value, controller_.GetWindow()); + result->Success(); +} + +void WindowPlugin::HandleIsEffectSupported(const flutter::EncodableMap* args, + std::unique_ptr result) { + const auto effect_name = GetString(args, "effect"); + if (!effect_name.has_value()) { + result->Error("bad_args", "isEffectSupported: effect"); + return; + } + const auto effect = ParseEffect(*effect_name); + if (!effect.has_value()) { + result->Error("bad_args", "isEffectSupported: effect"); + return; + } + result->Success(flutter::EncodableValue(IsEffectSupported(*effect))); +} + +void WindowPlugin::HandleSetEffect(const flutter::EncodableMap* args, + std::unique_ptr result) { + const auto effect_name = GetString(args, "effect"); + if (!effect_name.has_value()) { + result->Error("bad_args", "setEffect: effect"); + return; + } + const auto effect = ParseEffect(*effect_name); + if (!effect.has_value()) { + result->Error("bad_args", "setEffect: effect"); + return; + } + if (!IsEffectSupported(*effect)) { + result->Error("unsupported", "setEffect: " + *effect_name); + return; + } + const uint32_t tint = + static_cast(GetInt(args, "tint").value_or(0)); + std::optional dark; + if (const auto brightness = GetString(args, "brightness"); + brightness.has_value()) { + if (*brightness != "light" && *brightness != "dark") { + result->Error("bad_args", "setEffect: brightness"); + return; + } + dark = *brightness == "dark"; + } + style_.SetEffect(*effect, tint, dark, controller_.GetWindow()); + result->Success(); +} + +void WindowPlugin::HandleStartDragging(const flutter::EncodableMap*, + std::unique_ptr result) { + controller_.StartDragging(); + result->Success(); +} + +} // namespace window diff --git a/plugins/window/windows/window_plugin.h b/plugins/window/windows/window_plugin.h new file mode 100644 index 00000000..dec157ca --- /dev/null +++ b/plugins/window/windows/window_plugin.h @@ -0,0 +1,84 @@ +#ifndef FLUTTER_PLUGIN_WINDOW_PLUGIN_H_ +#define FLUTTER_PLUGIN_WINDOW_PLUGIN_H_ + +#include + +#include +#include +#include + +#include +#include +#include +#include + +#include "window_controller.h" +#include "window_style.h" + +namespace window { + +class WindowPlugin : public flutter::Plugin { + public: + static void RegisterWithRegistrar(flutter::PluginRegistrarWindows* registrar); + + explicit WindowPlugin(flutter::PluginRegistrarWindows* registrar); + ~WindowPlugin() override; + + WindowPlugin(const WindowPlugin&) = delete; + WindowPlugin& operator=(const WindowPlugin&) = delete; + + private: + using Result = flutter::MethodResult; + using Handler = void (WindowPlugin::*)(const flutter::EncodableMap* args, + std::unique_ptr result); + + void HandleMethodCall( + const flutter::MethodCall& call, + std::unique_ptr result); + std::optional HandleWindowProc(HWND hwnd, UINT message, + WPARAM wparam, LPARAM lparam); + void EmitEvent(const std::string& name); + void BuildHandlerTable(); + + void HandleEnsureInitialized(const flutter::EncodableMap* args, std::unique_ptr result); + void HandleShow(const flutter::EncodableMap* args, std::unique_ptr result); + void HandleHide(const flutter::EncodableMap* args, std::unique_ptr result); + void HandleIsVisible(const flutter::EncodableMap* args, std::unique_ptr result); + void HandleFocus(const flutter::EncodableMap* args, std::unique_ptr result); + void HandleClose(const flutter::EncodableMap* args, std::unique_ptr result); + void HandleSetPreventClose(const flutter::EncodableMap* args, std::unique_ptr result); + void HandleIsMaximized(const flutter::EncodableMap* args, std::unique_ptr result); + void HandleMaximize(const flutter::EncodableMap* args, std::unique_ptr result); + void HandleUnmaximize(const flutter::EncodableMap* args, std::unique_ptr result); + void HandleIsMinimized(const flutter::EncodableMap* args, std::unique_ptr result); + void HandleMinimize(const flutter::EncodableMap* args, std::unique_ptr result); + void HandleRestore(const flutter::EncodableMap* args, std::unique_ptr result); + void HandleIsFullScreen(const flutter::EncodableMap* args, std::unique_ptr result); + void HandleSetFullScreen(const flutter::EncodableMap* args, std::unique_ptr result); + void HandleGetBounds(const flutter::EncodableMap* args, std::unique_ptr result); + void HandleSetBounds(const flutter::EncodableMap* args, std::unique_ptr result); + void HandleIsPositionSupported(const flutter::EncodableMap* args, std::unique_ptr result); + void HandleSetMinimumSize(const flutter::EncodableMap* args, std::unique_ptr result); + void HandleIsAlwaysOnTop(const flutter::EncodableMap* args, std::unique_ptr result); + void HandleSetAlwaysOnTop(const flutter::EncodableMap* args, std::unique_ptr result); + void HandleSetTitleBarStyle(const flutter::EncodableMap* args, std::unique_ptr result); + void HandleSetSkipTaskbar(const flutter::EncodableMap* args, std::unique_ptr result); + void HandleSetRoundedCorners(const flutter::EncodableMap* args, std::unique_ptr result); + void HandleIsEffectSupported(const flutter::EncodableMap* args, std::unique_ptr result); + void HandleSetEffect(const flutter::EncodableMap* args, std::unique_ptr result); + void HandleStartDragging(const flutter::EncodableMap* args, std::unique_ptr result); + + flutter::PluginRegistrarWindows* registrar_; + std::unique_ptr> channel_; + std::unordered_map handlers_; + + WindowStyle style_; + WindowController controller_; + + int window_proc_id_ = -1; + bool relaunch_messages_allowed_ = false; +}; + +} // namespace window + +#endif // FLUTTER_PLUGIN_WINDOW_PLUGIN_H_ diff --git a/plugins/window/windows/window_plugin_c_api.cpp b/plugins/window/windows/window_plugin_c_api.cpp new file mode 100644 index 00000000..d52883d6 --- /dev/null +++ b/plugins/window/windows/window_plugin_c_api.cpp @@ -0,0 +1,21 @@ +#include "include/window/window_plugin_c_api.h" + +#include + +#include "running_instance.h" +#include "window_plugin.h" + +void WindowPluginCApiRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar) { + window::WindowPlugin::RegisterWithRegistrar( + flutter::PluginRegistrarManager::GetInstance() + ->GetRegistrar(registrar)); +} + +HWND WindowPluginFindRunningWindow() { + return window::FindRunningWindow(); +} + +void WindowPluginActivateWindow(HWND window) { + window::ActivateWindow(window); +} diff --git a/plugins/window/windows/window_style.cpp b/plugins/window/windows/window_style.cpp new file mode 100644 index 00000000..9a3ae6fa --- /dev/null +++ b/plugins/window/windows/window_style.cpp @@ -0,0 +1,258 @@ +#include "window_style.h" + +#include + +namespace window { + +namespace { + +#ifndef DWMWA_WINDOW_CORNER_PREFERENCE +#define DWMWA_WINDOW_CORNER_PREFERENCE 33 +#endif +#ifndef DWMWA_SYSTEMBACKDROP_TYPE +#define DWMWA_SYSTEMBACKDROP_TYPE 38 +#endif +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif +// The pre-20H1 value of the same attribute. +#define DWMWA_USE_IMMERSIVE_DARK_MODE_BEFORE_20H1 19 + +enum WindowCornerPreference { + kDwmwcpDoNotRound = 1, + kDwmwcpRound = 2, +}; + +enum SystemBackdropType { + kDwmsbtNone = 1, + kDwmsbtMainWindow = 2, +}; + +enum AccentState { + kAccentDisabled = 0, + kAccentEnableTransparentGradient = 2, + kAccentEnableBlurBehind = 3, + kAccentEnableAcrylicBlurBehind = 4, +}; + +struct AccentPolicy { + int accent_state; + int accent_flags; + int gradient_color; + int animation_id; +}; + +struct WindowCompositionAttributeData { + int attribute; + PVOID data; + SIZE_T size_in_bytes; +}; + +using SetWindowCompositionAttributeFn = + BOOL(WINAPI*)(HWND, WindowCompositionAttributeData*); + +SetWindowCompositionAttributeFn GetSetWindowCompositionAttribute() { + static const SetWindowCompositionAttributeFn function = [] { + const HMODULE user32 = ::GetModuleHandleW(L"user32.dll"); + return user32 == nullptr + ? nullptr + : reinterpret_cast( + ::GetProcAddress(user32, + "SetWindowCompositionAttribute")); + }(); + return function; +} + +using RtlGetVersionFn = LONG(WINAPI*)(OSVERSIONINFOW*); + +int QueryBuildNumber() { + const HMODULE ntdll = ::GetModuleHandleW(L"ntdll.dll"); + if (ntdll == nullptr) { + return 0; + } + const auto rtl_get_version = reinterpret_cast( + ::GetProcAddress(ntdll, "RtlGetVersion")); + if (rtl_get_version == nullptr) { + return 0; + } + OSVERSIONINFOW info{}; + info.dwOSVersionInfoSize = sizeof(info); + if (rtl_get_version(&info) != 0) { + return 0; + } + return static_cast(info.dwBuildNumber); +} + +bool ExtendsTopFrame(const WindowStyle& style) { + return style.title_bar_style == TitleBarStyle::kHidden && + style.effect == Effect::kNone && !IsWindows11OrGreater(); +} + +// SetWindowCompositionAttribute takes its color as ABGR. +uint32_t ToAccentColor(uint32_t argb) { + const uint32_t a = (argb >> 24) & 0xFF; + const uint32_t r = (argb >> 16) & 0xFF; + const uint32_t g = (argb >> 8) & 0xFF; + const uint32_t b = argb & 0xFF; + return (a << 24) | (b << 16) | (g << 8) | r; +} + +} // namespace + +std::optional ParseTitleBarStyle(const std::string& value) { + if (value == "normal") return TitleBarStyle::kNormal; + if (value == "hidden") return TitleBarStyle::kHidden; + return std::nullopt; +} + +std::optional ParseEffect(const std::string& value) { + if (value == "none") return Effect::kNone; + if (value == "transparent") return Effect::kTransparent; + if (value == "blur") return Effect::kBlur; + if (value == "acrylic") return Effect::kAcrylic; + if (value == "mica") return Effect::kMica; + return std::nullopt; +} + +int WindowsBuildNumber() { + static const int build = QueryBuildNumber(); + return build; +} + +bool IsWindows11OrGreater() { + return WindowsBuildNumber() >= 22000; +} + +// The accent effects need the undocumented SetWindowCompositionAttribute, +// which Apply() skips when user32 lacks it; without it they draw nothing. +bool IsEffectSupported(Effect effect) { + const bool has_accent = GetSetWindowCompositionAttribute() != nullptr; + switch (effect) { + case Effect::kNone: + return true; + case Effect::kTransparent: + case Effect::kBlur: + return has_accent; + case Effect::kAcrylic: + return has_accent && WindowsBuildNumber() >= 17134; + case Effect::kMica: + return WindowsBuildNumber() >= 22000; + } + return false; +} + +void WindowStyle::SetTitleBarStyle(TitleBarStyle style, + bool button_visibility, HWND hwnd) { + title_bar_style = style; + window_button_visibility = button_visibility; + Apply(hwnd); +} + +void WindowStyle::SetRoundedCorners(bool value, HWND hwnd) { + rounded_corners = value; + Apply(hwnd); +} + +void WindowStyle::SetEffect(Effect value, uint32_t tint, + std::optional dark, HWND hwnd) { + effect = value; + effect_tint = tint; + dark_mode = dark; + Apply(hwnd); +} + +void WindowStyle::Apply(HWND hwnd) const { + if (hwnd == nullptr) { + return; + } + + int accent_state = kAccentDisabled; + uint32_t accent_color = 0; + int backdrop_type = kDwmsbtNone; + + switch (effect) { + case Effect::kNone: + break; + case Effect::kTransparent: + accent_state = kAccentEnableTransparentGradient; + accent_color = ToAccentColor(effect_tint); + break; + case Effect::kBlur: + accent_state = kAccentEnableBlurBehind; + accent_color = ToAccentColor(effect_tint); + break; + case Effect::kAcrylic: + accent_state = kAccentEnableAcrylicBlurBehind; + accent_color = ToAccentColor(effect_tint); + break; + case Effect::kMica: + backdrop_type = kDwmsbtMainWindow; + break; + } + + // Windows fills an extended frame opaque, which would cover an accent effect. + MARGINS margins{}; + if (effect == Effect::kMica) { + margins = {-1, -1, -1, -1}; + } else if (ExtendsTopFrame(*this)) { + RECT frame{}; + ::AdjustWindowRectExForDpi(&frame, WS_OVERLAPPEDWINDOW, FALSE, 0, + ::GetDpiForWindow(hwnd)); + margins.cyTopHeight = -frame.top; + } + ::DwmExtendFrameIntoClientArea(hwnd, &margins); + + if (rounded_corners.has_value()) { + const int corner_preference = + *rounded_corners ? kDwmwcpRound : kDwmwcpDoNotRound; + ::DwmSetWindowAttribute(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE, + &corner_preference, sizeof(corner_preference)); + } + + if (IsWindows11OrGreater()) { + ::DwmSetWindowAttribute(hwnd, DWMWA_SYSTEMBACKDROP_TYPE, &backdrop_type, + sizeof(backdrop_type)); + } + + // The system backdrops pick their light or dark material from this + // attribute, which also colors the frame border. + if (dark_mode.has_value() && WindowsBuildNumber() >= 17763) { + const BOOL dark = *dark_mode ? TRUE : FALSE; + const DWORD attribute = WindowsBuildNumber() >= 18985 + ? DWMWA_USE_IMMERSIVE_DARK_MODE + : DWMWA_USE_IMMERSIVE_DARK_MODE_BEFORE_20H1; + ::DwmSetWindowAttribute(hwnd, attribute, &dark, sizeof(dark)); + } + + // WS_SYSMENU also gates WS_MINIMIZEBOX/WS_MAXIMIZEBOX, so clearing it is + // what hides the caption buttons for a hidden title bar. + const bool hide_buttons = + title_bar_style == TitleBarStyle::kHidden && !window_button_visibility; + LONG_PTR style_bits = ::GetWindowLongPtrW(hwnd, GWL_STYLE); + style_bits = hide_buttons ? (style_bits & ~WS_SYSMENU) + : (style_bits | WS_SYSMENU); + ::SetWindowLongPtrW(hwnd, GWL_STYLE, style_bits); + + if (const auto set_composition_attribute = + GetSetWindowCompositionAttribute(); + set_composition_attribute != nullptr) { + AccentPolicy policy{accent_state, 2, static_cast(accent_color), 0}; + WindowCompositionAttributeData data{19, &policy, sizeof(policy)}; + set_composition_attribute(hwnd, &data); + } + + ::SetWindowPos(hwnd, nullptr, 0, 0, 0, 0, + SWP_NOZORDER | SWP_NOOWNERZORDER | SWP_NOMOVE | SWP_NOSIZE | + SWP_NOACTIVATE | SWP_FRAMECHANGED); +} + +int WindowStyle::TopBorderHeight(HWND hwnd) const { + if (hwnd == nullptr || !ExtendsTopFrame(*this) || ::IsZoomed(hwnd)) { + return 0; + } + const bool sizable = + (::GetWindowLongPtrW(hwnd, GWL_STYLE) & WS_THICKFRAME) != 0; + return sizable ? 1 : 0; +} + +} // namespace window diff --git a/plugins/window/windows/window_style.h b/plugins/window/windows/window_style.h new file mode 100644 index 00000000..8ce2ddbb --- /dev/null +++ b/plugins/window/windows/window_style.h @@ -0,0 +1,61 @@ +#ifndef FLUTTER_PLUGIN_WINDOW_STYLE_H_ +#define FLUTTER_PLUGIN_WINDOW_STYLE_H_ + +#include + +#include +#include +#include + +namespace window { + +enum class TitleBarStyle { + kNormal, + kHidden, +}; + +enum class Effect { + kNone, + kTransparent, + kBlur, + kAcrylic, + kMica, +}; + +std::optional ParseTitleBarStyle(const std::string& value); +std::optional ParseEffect(const std::string& value); + +int WindowsBuildNumber(); +bool IsWindows11OrGreater(); +bool IsEffectSupported(Effect effect); + +// Reapplied as a whole by Apply() on every change, so settings never fight. +struct WindowStyle { + TitleBarStyle title_bar_style = TitleBarStyle::kNormal; + bool window_button_visibility = true; + std::optional rounded_corners; + Effect effect = Effect::kNone; + uint32_t effect_tint = 0; // ARGB. + std::optional dark_mode; + + void SetTitleBarStyle(TitleBarStyle style, bool button_visibility, + HWND hwnd); + void SetRoundedCorners(bool value, HWND hwnd); + void SetEffect(Effect value, uint32_t tint, std::optional dark, + HWND hwnd); + + // Only place calling the Dwm*/SetWindowCompositionAttribute APIs. + void Apply(HWND hwnd) const; + + // Rows the controller keeps clear above the view and paints transparent. + // Windows 10 draws the top border only inside a frame extended over the + // whole caption (a shorter non-client strip makes DWM draw the caption), so + // Apply() extends it there; Windows 11 draws the border into the 1 px + // non-client strip WM_NCCALCSIZE leaves. Zero while an accent effect is on, + // maximized or full screen. + int TopBorderHeight(HWND hwnd) const; +}; + +} // namespace window + +#endif // FLUTTER_PLUGIN_WINDOW_STYLE_H_ diff --git a/pubspec.lock b/pubspec.lock index 877e924c..256420a6 100755 --- a/pubspec.lock +++ b/pubspec.lock @@ -1773,15 +1773,13 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" - window_manager: + window: dependency: "direct main" description: - path: "packages/window_manager" - ref: "v0.5.1-flclash.3" - resolved-ref: "4c7dcf9b6b0658a83ce77ad8c3e480952d190d3a" - url: "https://github.com/chen08209/window_manager" - source: git - version: "0.5.1" + path: "plugins/window" + relative: true + source: path + version: "0.0.1" windows_file_picker: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index a06e1be8..069f6b42 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -20,11 +20,8 @@ dependencies: path_provider: ^2.1.5 path: ^1.9.0 shared_preferences: ^2.5.3 - window_manager: - git: - url: https://github.com/chen08209/window_manager - ref: v0.5.1-flclash.3 - path: packages/window_manager + window: + path: plugins/window dynamic_color: ^2.1.0 proxy: path: plugins/proxy @@ -109,7 +106,9 @@ flutter: - assets/images/avatar/ - assets/images/empty/ - path: assets/images/tray/unix/ - platforms: [ macos, linux ] + platforms: [ linux ] + - path: assets/images/tray/macos/ + platforms: [ macos ] - path: assets/images/tray/windows/ platforms: [ windows ] fonts: diff --git a/test/common/run_after_frame_test.dart b/test/common/run_after_frame_test.dart new file mode 100644 index 00000000..8a978c4f --- /dev/null +++ b/test/common/run_after_frame_test.dart @@ -0,0 +1,42 @@ +import 'package:fl_clash/common/function.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('waits for a frame it schedules while frames are enabled', ( + tester, + ) async { + var calls = 0; + + runAfterFrame(() => calls++); + await tester.idle(); + + expect(calls, 0); + expect(tester.binding.hasScheduledFrame, isTrue); + + await tester.pump(); + + expect(calls, 1); + }); + + testWidgets('runs without a frame while the window is hidden', ( + tester, + ) async { + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden); + addTearDown( + () => tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.resumed, + ), + ); + var calls = 0; + + runAfterFrame(() => calls++); + + expect(calls, 0); + + await tester.idle(); + + expect(calls, 1); + expect(tester.binding.hasScheduledFrame, isFalse); + }); +} diff --git a/test/common/tray_lifecycle_test.dart b/test/common/tray_lifecycle_test.dart index ab2dd97b..d117cc3d 100644 --- a/test/common/tray_lifecycle_test.dart +++ b/test/common/tray_lifecycle_test.dart @@ -22,6 +22,7 @@ TrayState _trayState({bool isStart = false}) { groups: const [], selectedMap: const {}, showTrayTitle: false, + safeMode: false, ); } diff --git a/test/common/tray_menu_test.dart b/test/common/tray_menu_test.dart index 6c603c7b..6aab9da8 100644 --- a/test/common/tray_menu_test.dart +++ b/test/common/tray_menu_test.dart @@ -1,13 +1,16 @@ +import 'dart:convert'; import 'dart:io'; import 'package:fl_clash/common/app_localizations.dart'; import 'package:fl_clash/common/app_ports.dart'; import 'package:fl_clash/common/constant.dart'; +import 'package:fl_clash/common/l10n_labels.dart'; import 'package:fl_clash/common/tray.dart'; import 'package:fl_clash/enum/enum.dart'; import 'package:fl_clash/l10n/l10n.dart'; import 'package:fl_clash/models/models.dart'; import 'package:fl_clash/providers/action.dart'; +import 'package:fl_clash/providers/state.dart'; import 'package:fl_clash/state.dart'; import 'package:flutter/foundation.dart'; import 'package:material_ui/material_ui.dart'; @@ -20,6 +23,24 @@ import 'package:tray/tray.dart'; const _channel = MethodChannel('tray'); +const _proxyGroup = Group( + name: 'Proxy', + type: GroupType.Selector, + all: [ + Proxy(name: 'A', type: 'Direct'), + Proxy(name: 'B', type: 'Direct'), + Proxy(name: 'C', type: 'Direct'), + ], +); + +HotKeyAction _bind(HotAction action, PhysicalKeyboardKey key) { + return HotKeyAction( + action: action, + key: key.usbHidUsage, + modifiers: const {KeyboardModifier.control, KeyboardModifier.shift}, + ); +} + class _FakePathProvider extends PathProviderPlatform { _FakePathProvider(this.root); @@ -41,8 +62,10 @@ TrayState _trayState({ bool systemProxy = false, bool autoLaunch = false, bool showTrayTitle = false, + bool safeMode = false, Mode mode = Mode.rule, List groups = const [], + Map hotKeys = const {}, }) { return TrayState( mode: mode, @@ -54,6 +77,8 @@ TrayState _trayState({ groups: groups, selectedMap: const {}, showTrayTitle: showTrayTitle, + safeMode: safeMode, + hotKeys: hotKeys, ); } @@ -62,6 +87,10 @@ List> _items(MethodCall? call) { return menu?.cast>() ?? const []; } +Map _item(MethodCall? call, String label) { + return _items(call).firstWhere((item) => item['label'] == label); +} + List _labels(MethodCall? call) { return _items(call).map((item) => item['label']).whereType().toList(); } @@ -169,6 +198,35 @@ void main() { expect(labels, contains(l10n.systemProxy)); }); + group('safe mode', () { + test('keeps the full menu', () async { + await update(_trayState(isStart: true, safeMode: true)); + + final labels = _labels(showCall()); + final l10n = currentAppLocalizations; + expect(labels, contains(l10n.stop)); + expect(labels, contains(l10n.tun)); + expect(labels, contains(l10n.systemProxy)); + expect(labels, contains(l10n.copyEnvVar)); + }); + + test('names the mode in the tooltip and ships the safe icon', () async { + await update(_trayState(isStart: true, safeMode: true)); + + final arguments = showCall()!.arguments as Map; + final l10n = currentAppLocalizations; + expect(arguments['toolTip'], '$appName (${l10n.safeMode})'); + final reps = ((arguments['icon'] as Map)['reps'] as List).cast(); + final base = reps.firstWhere((rep) => rep['scale'] == 1.0); + expect( + base['bytes'], + base64Encode( + File('assets/images/tray/macos/status_4.png').readAsBytesSync(), + ), + ); + }); + }); + test('offers every outbound mode as a menu entry', () async { await update(_trayState(mode: Mode.global)); @@ -236,6 +294,74 @@ void main() { expect(children.map((item) => item['label']), contains('A')); }); + test('shows each bound hotkey dimmed beside its entry', () async { + await update( + _trayState( + isStart: true, + hotKeys: { + HotAction.view: _bind(HotAction.view, PhysicalKeyboardKey.keyV), + HotAction.globalMode: _bind( + HotAction.globalMode, + PhysicalKeyboardKey.keyG, + ), + HotAction.tun: _bind(HotAction.tun, PhysicalKeyboardKey.keyT), + }, + ), + ); + + final l10n = currentAppLocalizations; + final call = showCall(); + expect(_item(call, l10n.show)['detail'], '⌃⇧V'); + expect(_item(call, Mode.global.label)['detail'], '⌃⇧G'); + expect(_item(call, l10n.tun)['detail'], '⌃⇧T'); + expect(_item(call, l10n.exit), isNot(contains('detail'))); + expect(_item(call, Mode.rule.label), isNot(contains('detail'))); + }); + + test( + 'group submenus show the delay of the selection and of each proxy', + () async { + container.dispose(); + container = ProviderContainer( + overrides: [ + selectedProxyNameProvider('Proxy').overrideWithValue('A'), + trayDelaysProvider.overrideWithValue({ + 'Proxy': {'A': 120, 'B': -1}, + }), + ], + ); + await update(_trayState(groups: [_proxyGroup])); + + final submenu = _item(showCall(), 'Proxy'); + expect(submenu['detail'], '120'); + final children = (submenu['items'] as List).cast>(); + expect(children.map((item) => item['detail']), [ + '120', + currentAppLocalizations.timeout, + null, + ]); + }, + ); + + test('a delay test entry follows the groups', () async { + await update( + _trayState( + groups: [_proxyGroup], + hotKeys: { + HotAction.delayTest: _bind( + HotAction.delayTest, + PhysicalKeyboardKey.keyD, + ), + }, + ), + ); + + final items = _items(showCall()); + final index = items.indexWhere((item) => item['type'] == 'submenu'); + expect(items[index + 1]['label'], HotAction.delayTest.label); + expect(items[index + 1]['detail'], '⌃⇧D'); + }); + group('a platform that is not macOS', () { late AppTray windows; @@ -268,6 +394,23 @@ void main() { _labels(showCall()), isNot(contains(currentAppLocalizations.speedStatistics)), ); + expect(_labels(showCall()), isNot(contains(HotAction.delayTest.label))); + }); + + test('joins modifiers with + in the hotkey hints', () async { + await update( + _trayState( + hotKeys: { + HotAction.view: _bind(HotAction.view, PhysicalKeyboardKey.keyV), + }, + ), + on: windows, + ); + + expect( + _item(showCall(), currentAppLocalizations.show)['detail'], + 'Ctrl+Shift+V', + ); }); test('never pushes a tray title', () async { diff --git a/test/common/tray_test.dart b/test/common/tray_test.dart index 6c63c290..d1d8db99 100644 --- a/test/common/tray_test.dart +++ b/test/common/tray_test.dart @@ -9,30 +9,30 @@ void main() { test('windows loads ico files from the windows directory', () { expect( - windows.getTrayIcon(isStart: false, tunEnable: false), + windows.getTrayIcon(isStart: false, tunEnable: false, safeMode: false), 'assets/images/tray/windows/status_1.ico', ); expect( - windows.getTrayIcon(isStart: true, tunEnable: false), + windows.getTrayIcon(isStart: true, tunEnable: false, safeMode: false), 'assets/images/tray/windows/status_2.ico', ); expect( - windows.getTrayIcon(isStart: true, tunEnable: true), + windows.getTrayIcon(isStart: true, tunEnable: true, safeMode: false), 'assets/images/tray/windows/status_3.ico', ); }); test('linux loads png files from the unix directory', () { expect( - linux.getTrayIcon(isStart: false, tunEnable: false), + linux.getTrayIcon(isStart: false, tunEnable: false, safeMode: false), 'assets/images/tray/unix/status_1.png', ); expect( - linux.getTrayIcon(isStart: true, tunEnable: false), + linux.getTrayIcon(isStart: true, tunEnable: false, safeMode: false), 'assets/images/tray/unix/status_2.png', ); expect( - linux.getTrayIcon(isStart: true, tunEnable: true), + linux.getTrayIcon(isStart: true, tunEnable: true, safeMode: false), 'assets/images/tray/unix/status_3.png', ); }); @@ -44,8 +44,45 @@ void main() { (true, true), ]) { expect( - macOS.getTrayIcon(isStart: isStart, tunEnable: tunEnable), - 'assets/images/tray/unix/status_1.png', + macOS.getTrayIcon( + isStart: isStart, + tunEnable: tunEnable, + safeMode: false, + ), + 'assets/images/tray/macos/status_1.png', + ); + } + }); + + test('safe mode shows its own icon whatever the core is doing', () { + for (final (isStart, tunEnable) in [ + (false, false), + (true, false), + (true, true), + ]) { + expect( + windows.getTrayIcon( + isStart: isStart, + tunEnable: tunEnable, + safeMode: true, + ), + 'assets/images/tray/windows/status_4.ico', + ); + expect( + linux.getTrayIcon( + isStart: isStart, + tunEnable: tunEnable, + safeMode: true, + ), + 'assets/images/tray/unix/status_4.png', + ); + expect( + macOS.getTrayIcon( + isStart: isStart, + tunEnable: tunEnable, + safeMode: true, + ), + 'assets/images/tray/macos/status_4.png', ); } }); diff --git a/test/common/window_test.dart b/test/common/window_test.dart index 9f53a7e9..3ab77d6a 100644 --- a/test/common/window_test.dart +++ b/test/common/window_test.dart @@ -1,14 +1,18 @@ +import 'dart:io'; + +import 'package:fl_clash/common/constant.dart'; import 'package:fl_clash/common/window.dart'; import 'package:fl_clash/models/config.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; -const _windowChannel = MethodChannel('window_manager'); +const _windowChannel = MethodChannel('window'); void main() { TestWidgetsFlutterBinding.ensureInitialized(); late List calls; + late List methodCalls; late bool isVisible; late bool isMaximized; late bool isFullScreen; @@ -17,6 +21,7 @@ void main() { setUp(() { calls = []; + methodCalls = []; isVisible = true; isMaximized = false; isFullScreen = false; @@ -25,7 +30,9 @@ void main() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(_windowChannel, (call) async { calls.add(call.method); + methodCalls.add(call); return switch (call.method) { + 'isEffectSupported' => true, 'isVisible' => isVisible, 'isMaximized' => isMaximized, 'isFullScreen' => isFullScreen, @@ -68,6 +75,88 @@ void main() { expect(calls, containsAllInOrder(['hide', 'setSkipTaskbar'])); }); + // Runs before any probe succeeds: the singleton caches a supported effect. + test('an unsupported probe is retried the next time blur is on', () async { + const tint = Color(0xFF112233); + if (Platform.isLinux) { + return; + } + var supported = false; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_windowChannel, (call) async { + methodCalls.add(call); + return call.method == 'isEffectSupported' ? supported : null; + }); + + final first = await Window().setBlur( + enabled: true, + brightness: Brightness.dark, + tint: tint, + ); + supported = true; + final second = await Window().setBlur( + enabled: true, + brightness: Brightness.dark, + tint: tint, + ); + + expect(first, isFalse); + expect(second, isTrue); + expect( + methodCalls.where((call) => call.method == 'isEffectSupported'), + hasLength(Platform.isWindows ? 3 : 2), + ); + expect(methodCalls.last.arguments['effect'], isNot('none')); + }); + + test('blur respects the platform effect support and brightness', () async { + const tint = Color(0xFF112233); + final active = await Window().setBlur( + enabled: true, + brightness: Brightness.dark, + tint: tint, + ); + + if (Platform.isLinux) { + expect(active, isFalse); + expect(methodCalls, isEmpty); + return; + } + + expect(active, isTrue); + expect(calls.last, 'setEffect'); + expect(methodCalls.last.arguments, { + 'effect': Platform.isWindows ? 'acrylic' : 'blur', + 'tint': Platform.isWindows + ? tint.withValues(alpha: kSidebarBlurOpacity).toARGB32() + : null, + 'brightness': 'dark', + }); + }); + + test('blur off sets the effect back to none', () async { + const tint = Color(0xFF112233); + final active = await Window().setBlur( + enabled: false, + brightness: Brightness.light, + tint: tint, + ); + + expect(active, isFalse); + if (Platform.isLinux) { + expect(methodCalls, isEmpty); + return; + } + + expect(methodCalls.last.arguments, { + 'effect': 'none', + 'tint': Platform.isWindows + ? tint.withValues(alpha: kSidebarBlurOpacity).toARGB32() + : null, + 'brightness': 'light', + }); + }); + test('close asks the platform to close the window', () async { await Window().close(); diff --git a/test/lint/platform_layering_test.dart b/test/lint/platform_layering_test.dart index 54e0e55e..2aa893e0 100644 --- a/test/lint/platform_layering_test.dart +++ b/test/lint/platform_layering_test.dart @@ -5,7 +5,7 @@ import 'package:test/test.dart'; const _platformPackages = [ 'tray', - 'window_manager', + 'window', 'launch_at_startup', 'screen_retriever', ]; diff --git a/test/manager/app_env_manager_test.dart b/test/manager/app_env_manager_test.dart new file mode 100644 index 00000000..a9637ea3 --- /dev/null +++ b/test/manager/app_env_manager_test.dart @@ -0,0 +1,41 @@ +import 'package:fl_clash/manager/app_manager.dart'; +import 'package:fl_clash/providers/app.dart'; +import 'package:fl_clash/state.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() { + globalState.appEnv = 'stable'; + }); + + Future pump(WidgetTester tester, {required bool safeMode}) async { + await tester.pumpWidget( + ProviderScope( + overrides: [safeModeProvider.overrideWithValue(safeMode)], + child: const Directionality( + textDirection: TextDirection.ltr, + child: AppEnvManager(child: SizedBox(key: Key('child'))), + ), + ), + ); + } + + testWidgets('a stable build carries no banner', (tester) async { + await pump(tester, safeMode: false); + + expect(find.byKey(const Key('child')), findsOneWidget); + expect(find.byType(Banner), findsNothing); + }); + + testWidgets('a safe mode build is bannered on any channel', (tester) async { + await pump(tester, safeMode: true); + + final banner = tester.widget(find.byType(Banner)); + expect(banner.message, 'SAFE MODE'); + expect(find.byKey(const Key('child')), findsOneWidget); + }); +} diff --git a/test/manager/connectivity_manager_test.dart b/test/manager/connectivity_manager_test.dart index 18facf05..093a94c7 100644 --- a/test/manager/connectivity_manager_test.dart +++ b/test/manager/connectivity_manager_test.dart @@ -229,6 +229,28 @@ void main() { expect(currentSsid(), isNull); }); + testWidgets('a safe mode build never reads the SSID', (tester) async { + container.dispose(); + container = ProviderContainer( + overrides: [safeModeProvider.overrideWithValue(true)], + ); + globalState.container = container; + var reads = 0; + await pumpManager( + tester, + readSsid: () async { + reads++; + return 'Home'; + }, + ); + + connectivity.add([ConnectivityResult.wifi]); + await tester.pumpAndSettle(); + + expect(reads, 0); + expect(currentSsid(), isNull); + }); + testWidgets('forwards every result to the callback', (tester) async { final seen = >[]; await pumpManager( diff --git a/test/manager/status_manager_test.dart b/test/manager/status_manager_test.dart index b9e4bfe5..689007ef 100644 --- a/test/manager/status_manager_test.dart +++ b/test/manager/status_manager_test.dart @@ -1,10 +1,12 @@ import 'package:fl_clash/enum/enum.dart'; +import 'package:fl_clash/icons/icons.dart'; import 'package:fl_clash/manager/status_manager.dart'; import 'package:fl_clash/models/models.dart'; import 'package:fl_clash/providers/app.dart'; import 'package:material_ui/material_ui.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../helpers/glyph_finders.dart'; import '../helpers/test_app.dart'; Future _pumpStatusManager( @@ -278,7 +280,7 @@ void main() { state.message('boom', level: MessageLevel.error); await tester.pump(); - expect(find.byIcon(Icons.error_outline), findsOneWidget); + expect(find.byGlyph(AppGlyphs.error), findsOneWidget); await tester.pump(const Duration(seconds: 4)); await tester.pump(const Duration(milliseconds: 500)); @@ -297,7 +299,7 @@ void main() { await tester.pump(); expect(find.text('plain'), findsOneWidget); - expect(find.byType(Icon), findsNothing); + expect(find.byType(GlyphIcon), findsNothing); await tester.pumpWidget(const SizedBox.shrink()); }); diff --git a/test/manager/tray_manager_test.dart b/test/manager/tray_manager_test.dart index 92256812..fdc3950c 100644 --- a/test/manager/tray_manager_test.dart +++ b/test/manager/tray_manager_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:fl_clash/l10n/l10n.dart'; import 'package:fl_clash/manager/locale_manager.dart'; import 'package:fl_clash/manager/tray_manager.dart'; @@ -13,7 +15,7 @@ import 'package:tray/tray.dart'; import '../helpers/test_profiles.dart'; const _trayChannel = MethodChannel('tray'); -const _windowChannel = MethodChannel('window_manager'); +const _windowChannel = MethodChannel('window'); const _codec = StandardMethodCodec(); class _RecordingSystemAction extends SystemAction { @@ -34,6 +36,19 @@ class _FailingSystemAction extends SystemAction { } } +class _SlowFirstFailureSystemAction extends SystemAction { + static int updateTrayCount = 0; + static Completer firstUpdate = Completer(); + + @override + Future updateTray() async { + if (updateTrayCount++ == 0) { + await firstUpdate.future; + throw StateError('tray boom'); + } + } +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -186,6 +201,31 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('a change made during a failing update still reaches the tray', ( + tester, + ) async { + _SlowFirstFailureSystemAction.updateTrayCount = 0; + _SlowFirstFailureSystemAction.firstUpdate = Completer(); + await pumpTrayManager( + tester, + systemAction: _SlowFirstFailureSystemAction.new, + ); + + for (var i = 0; i < 2; i++) { + container + .read(patchClashConfigProvider.notifier) + .update((state) => state.copyWith(mixedPort: state.mixedPort + 1)); + await tester.pump(); + } + expect(_SlowFirstFailureSystemAction.updateTrayCount, 1); + + _SlowFirstFailureSystemAction.firstUpdate.complete(); + await tester.pumpAndSettle(); + + expect(_SlowFirstFailureSystemAction.updateTrayCount, 2); + expect(tester.takeException(), isNull); + }); + testWidgets('a language switch pushes the tray in the new language', ( tester, ) async { diff --git a/test/manager/window_header_test.dart b/test/manager/window_header_test.dart index 605effb3..fe41cb59 100644 --- a/test/manager/window_header_test.dart +++ b/test/manager/window_header_test.dart @@ -1,4 +1,5 @@ import 'package:fl_clash/common/common.dart'; +import 'package:fl_clash/icons/icons.dart'; import 'package:fl_clash/l10n/l10n.dart'; import 'package:fl_clash/manager/window_manager.dart'; import 'package:fl_clash/providers/providers.dart'; @@ -9,9 +10,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:material_ui/material_ui.dart'; +import '../helpers/glyph_finders.dart'; import '../helpers/test_app.dart'; -const _windowChannel = MethodChannel('window_manager'); +const _windowChannel = MethodChannel('window'); const _contentKey = Key('window-header-test-content'); @@ -32,7 +34,7 @@ Finder _glyph(CaptionGlyph glyph) => find.byWidgetPredicate( Finder _captionButton(Finder icon) => find.ancestor(of: icon, matching: find.byType(IconButton)); -Finder get _pinIcon => find.byIcon(Icons.push_pin_outlined); +Finder get _pinIcon => find.byGlyph(AppGlyphs.pin); List get _captionIcons => [ _glyph(CaptionGlyph.minimize), @@ -775,7 +777,7 @@ void main() { ); expect( container.read(overlayTopOffsetProvider), - kToolbarHeight + (shows ? kHeaderHeight : 0), + pageToolbarHeight + (shows ? kHeaderHeight : 0), reason: 'view size $viewSize', ); } diff --git a/test/manager/window_manager_test.dart b/test/manager/window_manager_test.dart index 15341116..8af6251c 100644 --- a/test/manager/window_manager_test.dart +++ b/test/manager/window_manager_test.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'package:fl_clash/common/app_localizations.dart'; import 'package:fl_clash/common/app_ports.dart'; +import 'package:fl_clash/common/feature.dart'; +import 'package:fl_clash/icons/icons.dart'; import 'package:fl_clash/l10n/l10n.dart'; import 'package:fl_clash/manager/window_manager.dart'; import 'package:fl_clash/models/config.dart'; @@ -11,13 +13,13 @@ import 'package:material_ui/material_ui.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:window_manager/window_manager.dart' - show WindowListener, windowManager; +import 'package:window/window.dart' show WindowListener, desktopWindow; +import '../helpers/glyph_finders.dart'; import '../helpers/test_app.dart'; import '../helpers/test_profiles.dart'; -const _windowChannel = MethodChannel('window_manager'); +const _windowChannel = MethodChannel('window'); class _RecordingSystemAction extends SystemAction { static final calls = []; @@ -33,12 +35,26 @@ class _RecordingSystemAction extends SystemAction { } } +typedef _BlurRequest = ({bool enabled, Brightness brightness, Color tint}); + class _RecordingWindowPort implements WindowPort { Rect bounds = const Rect.fromLTWH(0, 0, 1000, 800); int shows = 0; Completer? geometryGate; bool isNormal = true; bool supportsPosition = true; + bool supportsBlur = true; + final blurRequests = <_BlurRequest>[]; + + @override + Future setBlur({ + required bool enabled, + required Brightness brightness, + required Color tint, + }) async { + blurRequests.add((enabled: enabled, brightness: brightness, tint: tint)); + return enabled && supportsBlur; + } @override Future captureNormalGeometry(WindowProps current) async { @@ -108,7 +124,7 @@ void main() { .setMockMethodCallHandler(_windowChannel, (call) async { windowCalls.add(call); if (call.method == 'setAlwaysOnTop') { - isAlwaysOnTop = call.arguments['isAlwaysOnTop'] as bool; + isAlwaysOnTop = call.arguments['value'] as bool; } return switch (call.method) { 'isAlwaysOnTop' => isAlwaysOnTop, @@ -174,7 +190,7 @@ void main() { final listener = await pumpWindowManager(tester); window.bounds = const Rect.fromLTWH(120, 64, 1000, 800); - listener.onWindowMove(); + listener.onWindowGeometryChanged(); await settleWindowGeometry(tester); final setting = container.read(windowSettingProvider); @@ -186,7 +202,7 @@ void main() { final listener = await pumpWindowManager(tester); window.bounds = const Rect.fromLTWH(0, 0, 1280, 960); - listener.onWindowResize(); + listener.onWindowGeometryChanged(); await settleWindowGeometry(tester); final setting = container.read(windowSettingProvider); @@ -208,6 +224,73 @@ void main() { expect(tester.takeException(), isNull); }); + group('sidebar blur', () { + setUp(() => feature = const Feature(sidebarBlur: true)); + + tearDown(() => feature = const Feature()); + + testWidgets('a disabled feature drops the backdrop despite the setting', ( + tester, + ) async { + feature = const Feature(); + await pumpWindowManager(tester); + await tester.pumpAndSettle(); + + expect(container.read(themeSettingProvider).sidebarBlur, isTrue); + expect(window.blurRequests.single.enabled, isFalse); + expect(container.read(windowBlurProvider), isFalse); + }); + + testWidgets('is applied on start and mirrored into the provider', ( + tester, + ) async { + await pumpWindowManager(tester); + await tester.pumpAndSettle(); + + expect(window.blurRequests, hasLength(1)); + expect(window.blurRequests.single.enabled, isTrue); + expect(window.blurRequests.single.brightness, Brightness.dark); + expect(container.read(windowBlurProvider), isTrue); + }); + + testWidgets('a platform without a backdrop leaves the provider off', ( + tester, + ) async { + window.supportsBlur = false; + await pumpWindowManager(tester); + await tester.pumpAndSettle(); + + expect(container.read(windowBlurProvider), isFalse); + }); + + testWidgets('turning the setting off drops the backdrop', (tester) async { + await pumpWindowManager(tester); + await tester.pumpAndSettle(); + + container + .read(themeSettingProvider.notifier) + .update((state) => state.copyWith(sidebarBlur: false)); + await tester.pumpAndSettle(); + + expect(window.blurRequests.last.enabled, isFalse); + expect(container.read(windowBlurProvider), isFalse); + }); + + testWidgets('a theme mode change reapplies the effect', (tester) async { + await pumpWindowManager(tester); + await tester.pumpAndSettle(); + + container + .read(themeSettingProvider.notifier) + .update((state) => state.copyWith(themeMode: ThemeMode.light)); + await tester.pumpAndSettle(); + + expect(window.blurRequests.last.enabled, isTrue); + expect(window.blurRequests.last.brightness, Brightness.light); + expect(container.read(windowBlurProvider), isTrue); + }); + }); + testWidgets('an activate request shows the window through the port', ( tester, ) async { @@ -226,7 +309,7 @@ void main() { final gate = Completer(); window.geometryGate = gate; - listener.onWindowMove(); + listener.onWindowGeometryChanged(); await settleWindowGeometry(tester); await tester.pumpWidget(const SizedBox.shrink()); gate.complete(); @@ -244,7 +327,7 @@ void main() { final gate = Completer(); window.geometryGate = gate; - listener.onWindowResize(); + listener.onWindowGeometryChanged(); await settleWindowGeometry(tester); await tester.pumpWidget(const SizedBox.shrink()); gate.complete(); @@ -259,7 +342,7 @@ void main() { window.bounds = const Rect.fromLTWH(0, 0, 1920, 1080); window.isNormal = false; - listener.onWindowResize(); + listener.onWindowGeometryChanged(); await settleWindowGeometry(tester); expect(container.read(windowSettingProvider).width, isNot(1920)); @@ -278,7 +361,7 @@ void main() { window.bounds = const Rect.fromLTWH(0, 0, 1200, 900); window.supportsPosition = false; - listener.onWindowMove(); + listener.onWindowGeometryChanged(); await settleWindowGeometry(tester); expect( @@ -295,12 +378,12 @@ void main() { window.bounds = const Rect.fromLTWH(10, 10, 640, 480); window.geometryGate = firstGate; - listener.onWindowMove(); + listener.onWindowGeometryChanged(); await settleWindowGeometry(tester); window.bounds = const Rect.fromLTWH(80, 64, 1200, 900); window.geometryGate = null; - listener.onWindowMove(); + listener.onWindowGeometryChanged(); await settleWindowGeometry(tester); firstGate.complete(); @@ -330,12 +413,6 @@ void main() { }); }); - testWidgets('AppIcon renders the bundled application icon', (tester) async { - await tester.pumpWidget(const MaterialApp(home: AppIcon())); - - expect(find.byType(Image), findsOneWidget); - }); - group('WindowHeaderActions', () { late ValueNotifier caption; late List pressed; @@ -380,7 +457,7 @@ void main() { await pumpActions(tester); expect( - tooltipOf(tester, find.byIcon(Icons.push_pin_outlined)), + tooltipOf(tester, find.byGlyph(AppGlyphs.pin)), currentAppLocalizations.pinWindow, ); expect( @@ -409,7 +486,7 @@ void main() { await tester.pump(); expect( - tooltipOf(tester, find.byIcon(Icons.push_pin)), + tooltipOf(tester, find.byGlyph(AppGlyphs.pin)), currentAppLocalizations.unpinWindow, ); expect( @@ -439,7 +516,7 @@ void main() { testWidgets('each button reports its own press', (tester) async { await pumpActions(tester); - await tester.tap(find.byIcon(Icons.push_pin_outlined)); + await tester.tap(find.byGlyph(AppGlyphs.pin)); await tester.tap(glyph(CaptionGlyph.minimize)); await tester.tap(glyph(CaptionGlyph.maximize)); await tester.tap(glyph(CaptionGlyph.close)); @@ -455,7 +532,7 @@ void main() { .handlePlatformMessage( _windowChannel.name, _windowChannel.codec.encodeMethodCall( - MethodCall('onEvent', {'eventName': name}), + MethodCall('onEvent', {'name': name}), ), (_) {}, ); @@ -531,7 +608,7 @@ void main() { final leave = windowCalls.singleWhere( (call) => call.method == 'setFullScreen', ); - expect(leave.arguments, {'isFullScreen': false}); + expect(leave.arguments, {'value': false}); await emitWindowEvent('leave-full-screen'); @@ -570,11 +647,11 @@ void main() { testWidgets('disposing stops listening to the window', (tester) async { final caption = WindowCaptionController(); await tester.pump(); - expect(windowManager.listeners, contains(caption)); + expect(desktopWindow.listeners, contains(caption)); caption.dispose(); - expect(windowManager.listeners, isNot(contains(caption))); + expect(desktopWindow.listeners, isNot(contains(caption))); }); }); } diff --git a/test/providers/permission_test.dart b/test/providers/permission_test.dart index ab18e434..c00cb914 100644 --- a/test/providers/permission_test.dart +++ b/test/providers/permission_test.dart @@ -69,6 +69,33 @@ void main() { }, ); + test('a safe mode build neither checks nor requests location', () async { + container.dispose(); + container = ProviderContainer( + overrides: [ + excludeSSIDsProvider.overrideWithValue(const ['Office Wi-Fi']), + safeModeProvider.overrideWithValue(true), + ], + ); + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call.method); + return 0; + }); + + await Permissions.test( + supportsLocationPermissions: true, + ).checkLocationPermissions(container.read); + + expect(calls, isEmpty); + expect( + container.read(locationPermissionsProvider), + WifiSsidPermission.denied, + ); + expect(container.read(currentSSIDProvider), isNull); + }); + test('does not read SSID when permission remains denied', () async { final calls = []; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger diff --git a/tool/generate_status_icons.dart b/tool/generate_status_icons.dart index d65f9b3b..1b905a39 100644 --- a/tool/generate_status_icons.dart +++ b/tool/generate_status_icons.dart @@ -6,7 +6,12 @@ import 'src/icons/ico.dart'; const sourceDir = 'assets_source/images/icon'; const pngOutputDir = 'assets/images/tray/unix'; const icoOutputDir = 'assets/images/tray/windows'; -const statusIconNames = ['status_1', 'status_2', 'status_3']; +const macosOutputDir = 'assets/images/tray/macos'; +const statusIconNames = ['status_1', 'status_2', 'status_3', 'status_4']; +const macosStatusIconSources = { + 'status_1': '$sourceDir/glyph.svg', + 'status_4': '$sourceDir/status_4.svg', +}; const trayBaseSize = 18; const trayScales = [1, 2, 3, 4]; const appIconSource = 'assets/images/icon.png'; @@ -33,7 +38,7 @@ Future main() async { exitCode = 1; return; } - await _writeTrayVariants(renderer, source, name); + await _writeTrayVariants(renderer, source, name, pngOutputDir); await _writeIco( renderer, source, @@ -41,6 +46,21 @@ Future main() async { sizes: trayIcoSizes, ); } + for (final MapEntry(key: name, value: path) + in macosStatusIconSources.entries) { + final source = File(path); + if (!source.existsSync()) { + stderr.writeln('Missing source SVG: ${source.path}'); + exitCode = 1; + return; + } + await _writeTrayVariants( + renderer, + await renderer.monochrome(source), + name, + macosOutputDir, + ); + } final appIcon = await renderer.wrapRaster(File(appIconSource)); await _writeIco(renderer, appIcon, File(appIconOutput)); } finally { @@ -54,9 +74,10 @@ Future _writeTrayVariants( _Renderer renderer, File source, String name, + String outputDir, ) async { for (final scale in trayScales) { - final directory = scale == 1 ? pngOutputDir : '$pngOutputDir/$scale.0x'; + final directory = scale == 1 ? outputDir : '$outputDir/$scale.0x'; await Directory(directory).create(recursive: true); final output = File('$directory/$name.png'); await output.writeAsBytes( @@ -131,5 +152,16 @@ class _Renderer { return wrapper; } + Future monochrome(File source) async { + final copy = File('${tempDir.path}/mono-${_basename(source)}'); + await copy.writeAsString( + (await source.readAsString()).replaceAllMapped( + RegExp(r'\b(fill|stroke)="(?!none")[^"]*"'), + (match) => '${match[1]}="#000000"', + ), + ); + return copy; + } + String _basename(File file) => file.uri.pathSegments.last; } diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 118e87c1..f4443488 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -15,7 +15,7 @@ #include #include #include -#include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { AppLinksPluginCApiRegisterWithRegistrar( @@ -36,6 +36,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("UrlLauncherWindows")); WifiSsidPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("WifiSsidPluginCApi")); - WindowManagerPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("WindowManagerPlugin")); + WindowPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("WindowPluginCApi")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 6732f2ad..38ee26ea 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -12,7 +12,7 @@ list(APPEND FLUTTER_PLUGIN_LIST tray url_launcher_windows wifi_ssid - window_manager + window ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp index 65d2a562..2bfc9334 100644 --- a/windows/runner/flutter_window.cpp +++ b/windows/runner/flutter_window.cpp @@ -2,6 +2,8 @@ #include +#include + #include "flutter/generated_plugin_registrant.h" FlutterWindow::FlutterWindow(const flutter::DartProject& project) @@ -53,6 +55,8 @@ FlutterWindow::MessageHandler(HWND hwnd, UINT const message, LPARAM const lparam) noexcept { // Give Flutter, including plugins, an opportunity to handle window messages. if (flutter_controller_) { + WindowPluginForwardVisibility(*flutter_controller_, message, wparam, + lparam); std::optional result = flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, lparam); diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp index c159a173..deb46e0d 100644 --- a/windows/runner/main.cpp +++ b/windows/runner/main.cpp @@ -3,16 +3,16 @@ #include #include -#include +#include #include "flutter_window.h" #include "utils.h" int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, _In_ wchar_t *command_line, _In_ int show_command) { - if (HWND running = WindowManagerFindRunningWindow()) { + if (HWND running = WindowPluginFindRunningWindow()) { SendAppLink(running); - WindowManagerActivateWindow(running); + WindowPluginActivateWindow(running); return EXIT_SUCCESS; }