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
This commit is contained in:
chen08209
2026-09-25 03:56:56 +08:00
parent 2a2522bbc6
commit 1e97716636
126 changed files with 6148 additions and 723 deletions
+7 -3
View File
@@ -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
+5 -1
View File
@@ -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`.
+42
View File
@@ -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`.
@@ -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)
@@ -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,
@@ -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"
@@ -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()
},
@@ -22,7 +22,7 @@ internal class SuspendModule(
get() = service.getSystemService<PowerManager>()?.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())
}
}
+3 -3
View File
@@ -6,12 +6,12 @@
android:viewportHeight="240"
tools:ignore="VectorRaster">
<path
android:pathData="M48.1,80.89L168.44,11.41c11.08,-6.4 25.24,-2.6 31.64,8.48 0,0 0,0 0,0h0c6.4,11.08 2.6,25.24 -8.48,31.64 0,0 0,0 0,0l-120.34,69.48c-11.08,6.4 -25.24,2.6 -31.64,-8.48 0,0 0,0 0,0h0c-6.4,-11.08 -2.6,-25.24 8.48,-31.64 0,0 0,0 0,0Z"
android:pathData="M48.1,80.89L168.44,11.41A23.16,23.16 0,0 1,191.6,51.53L71.26,121.01A23.16,23.16 0,0 1,48.1,80.89Z"
android:fillColor="#FFFFFF"/>
<path
android:pathData="M78.98,134.37l60.18,-34.74c11.07,-6.39 25.23,-2.59 31.63,8.48h0c6.4,11.07 2.61,25.24 -8.47,31.64l-60.18,34.74c-11.08,6.4 -25.24,2.6 -31.64,-8.48 0,0 0,0 0,0h0c-6.4,-11.08 -2.6,-25.24 8.48,-31.64h0Z"
android:pathData="M78.98,134.37L139.16,99.63A23.16,23.16 0,0 1,162.32,139.75L102.14,174.49A23.16,23.16 0,0 1,78.98,134.37Z"
android:fillColor="#FFFFFF"/>
<path
android:pathData="M109.86,187.86h0c11.08,-6.4 25.24,-2.6 31.64,8.48 0,0 0,0 0,0h0c6.4,11.08 2.6,25.24 -8.48,31.64 0,0 0,0 0,0h0c-11.08,6.4 -25.24,2.6 -31.64,-8.48 0,0 0,0 0,0h0c-6.4,-11.08 -2.6,-25.24 8.48,-31.64 0,0 0,0 0,0Z"
android:pathData="M98.28,207.92A23.16,23.16 0,1 1,144.6,207.92A23.16,23.16 0,1 1,98.28,207.92Z"
android:fillColor="#FFFFFF"/>
</vector>
@@ -6,12 +6,12 @@
android:viewportHeight="240"
tools:ignore="VectorRaster">
<path
android:pathData="M48.1,80.89L168.44,11.41c11.08,-6.4 25.24,-2.6 31.64,8.48 0,0 0,0 0,0h0c6.4,11.08 2.6,25.24 -8.48,31.64 0,0 0,0 0,0l-120.34,69.48c-11.08,6.4 -25.24,2.6 -31.64,-8.48 0,0 0,0 0,0h0c-6.4,-11.08 -2.6,-25.24 8.48,-31.64 0,0 0,0 0,0Z"
android:pathData="M48.1,80.89L168.44,11.41A23.16,23.16 0,0 1,191.6,51.53L71.26,121.01A23.16,23.16 0,0 1,48.1,80.89Z"
android:fillColor="#6666FB"/>
<path
android:pathData="M78.98,134.37l60.18,-34.74c11.07,-6.39 25.23,-2.59 31.63,8.48h0c6.4,11.07 2.61,25.24 -8.47,31.64l-60.18,34.74c-11.08,6.4 -25.24,2.6 -31.64,-8.48 0,0 0,0 0,0h0c-6.4,-11.08 -2.6,-25.24 8.48,-31.64h0Z"
android:pathData="M78.98,134.37L139.16,99.63A23.16,23.16 0,0 1,162.32,139.75L102.14,174.49A23.16,23.16 0,0 1,78.98,134.37Z"
android:fillColor="#336AB6"/>
<path
android:pathData="M109.86,187.86h0c11.08,-6.4 25.24,-2.6 31.64,8.48 0,0 0,0 0,0h0c6.4,11.08 2.6,25.24 -8.48,31.64 0,0 0,0 0,0h0c-11.08,6.4 -25.24,2.6 -31.64,-8.48 0,0 0,0 0,0h0c-6.4,-11.08 -2.6,-25.24 8.48,-31.64 0,0 0,0 0,0Z"
android:pathData="M98.28,207.92A23.16,23.16 0,1 1,144.6,207.92A23.16,23.16 0,1 1,98.28,207.92Z"
android:fillColor="#5CA8E9"/>
</vector>
Binary file not shown.

After

Width:  |  Height:  |  Size: 570 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 698 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 851 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1012 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 323 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 400 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Logo glyph: two bars and a dot on a 30° lattice, pitch 61.76, radius 23.16.
The Android notification drawables ic.xml and ic_service.xml carry the same geometry as fill paths. -->
<svg width="240" height="240" viewBox="0 0 240 240" xmlns="http://www.w3.org/2000/svg">
<title>glyph</title>
<g stroke-width="46.32" stroke-linecap="round">
<line x1="59.68" y1="100.95" x2="180.02" y2="31.47" stroke="#6666FB"/>
<line x1="90.56" y1="154.43" x2="150.74" y2="119.69" stroke="#336AB6"/>
</g>
<circle cx="121.44" cy="207.92" r="23.16" fill="#5CA8E9"/>
</svg>

After

Width:  |  Height:  |  Size: 617 B

+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Glyph: Material Symbols "bug_report" (Rounded, filled), Apache License 2.0 -->
<svg width="108" height="108" viewBox="100 -854 760 760" xmlns="http://www.w3.org/2000/svg">
<title>status_4</title>
<defs>
<linearGradient id="bug-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#F59E0B"/>
<stop offset="100%" stop-color="#EA580C"/>
</linearGradient>
</defs>
<path fill="url(#bug-grad)" d="M480-120q-65.37 0-121.18-31Q303-182 276-240h-86q-12.75 0-21.37-8.68-8.63-8.67-8.63-21.5 0-12.82 8.63-21.32 8.62-8.5 21.37-8.5h62q-7-26-7-52.67V-406h-56q-12.75 0-21.37-8.68-8.63-8.67-8.63-21.5 0-12.82 8.63-21.32 8.62-8.5 21.37-8.5h56q0-29 .5-57.5T254-580h-64q-12.75 0-21.37-8.68-8.63-8.67-8.63-21.5 0-12.82 8.63-21.32 8.62-8.5 21.37-8.5h90q14-28 37-49t51-35l-57-56q-8-8-8-19.79 0-11.78 8.42-20.35 7.72-7.86 19.65-7.86t19.93 8l74 74q27.67-10 56.34-10Q510-756 538-746l74-74q8-8 19.79-8 11.78 0 20.35 8.42 7.86 7.72 7.86 19.65T652-780l-56 56q28 14 49.4 35.67Q666.8-666.67 683-640h88q12.75 0 21.38 8.68 8.62 8.67 8.62 21.5 0 12.82-8.62 21.32-8.63 8.5-21.38 8.5h-65q9 28 8.5 56.67-.5 28.66-.5 57.33h57q12.75 0 21.38 8.68 8.62 8.67 8.62 21.5 0 12.82-8.62 21.32-8.63 8.5-21.38 8.5h-57q0 27 .5 53.5T708-300h63q12.75 0 21.38 8.68 8.62 8.67 8.62 21.5 0 12.82-8.62 21.32-8.63 8.5-21.38 8.5h-86q-26 59-82.45 89.5Q546.11-120 480-120Zm-50-200h100q12.75 0 21.38-8.68 8.62-8.67 8.62-21.5 0-12.82-8.62-21.32-8.63-8.5-21.38-8.5H430q-12.75 0-21.37 8.68-8.63 8.67-8.63 21.5 0 12.82 8.63 21.32 8.62 8.5 21.37 8.5Zm0-173h100q12.75 0 21.38-8.68 8.62-8.67 8.62-21.5 0-12.82-8.62-21.32-8.63-8.5-21.38-8.5H430q-12.75 0-21.37 8.68-8.63 8.67-8.63 21.5 0 12.82 8.63 21.32 8.62 8.5 21.37 8.5Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+1 -1
View File
@@ -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';
+22
View File
@@ -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();
+13
View File
@@ -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<dynamic>? 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<T> handleWatch<T>({
required Function function,
required void Function() onStart,
+1 -1
View File
@@ -47,4 +47,4 @@ class AutoLaunch {
}
}
final autoLaunch = system.isDesktop ? AutoLaunch() : null;
final autoLaunch = system.isDesktop && !safeModeBuild ? AutoLaunch() : null;
+1 -1
View File
@@ -81,7 +81,7 @@ class Permissions {
}
Future<void> checkLocationPermissions(ProviderReader read) async {
if (!_supportsLocationPermissions()) {
if (!_supportsLocationPermissions() || read(safeModeProvider)) {
return;
}
final res = await WifiSsidManager.instance.checkPermission();
-57
View File
@@ -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;
+106 -46
View File
@@ -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<TrayMenuItem> _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<void> _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<String, int> 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);
},
),
],
);
}
}
+75 -47
View File
@@ -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<void> _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<WindowProps?> captureNormalGeometry(WindowProps current) async {
final states = await Future.wait<bool>([
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<WindowEffect> _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<bool> 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<void> 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<void> toggle() => _visibility.toggle();
Future<void> _showWindow() async {
render?.resume();
await windowManager.show();
await windowManager.focus();
await desktopWindow.show();
await desktopWindow.focus();
}
Future<void> _hideWindow() async {
render?.pause();
await windowManager.hide();
await desktopWindow.hide();
}
Future<bool> _isWindowVisible() async {
final value = await windowManager.isVisible();
final value = await desktopWindow.isVisible();
commonPrint.log('window visible check: $value');
return value;
}
@override
Future<void> close() async {
await windowManager.close();
await desktopWindow.close();
}
@override
-1
View File
@@ -261,7 +261,6 @@ enum FunctionTag {
updateDelay,
vpnTip,
autoLaunch,
renderPause,
updatePageIndex,
pageChange,
proxiesTabChange,
+9 -4
View File
@@ -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<AndroidManager>
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<AndroidManager>
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;
}
+115 -114
View File
@@ -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<AppStateManager>
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<AppStateManager>
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<AppStateManager>
});
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<AppStateManager>
} else {
await core.startListener();
}
ref.read(checkIpNumProvider.notifier).add();
});
}
});
@@ -71,17 +78,26 @@ class _AppStateManagerState extends ConsumerState<AppStateManager>
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<void> 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<AppStateManager>
@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<NavigationItem> 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<NavigationRail>() !=
focusContext?.findAncestorWidgetOfExactType<NavigationSidebar>() !=
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,
);
},
),
),
+3 -1
View File
@@ -64,7 +64,9 @@ class _ConnectivityManagerState extends ConsumerState<ConnectivityManager> {
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;
}
+13 -9
View File
@@ -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),
),
),
),
],
+35 -3
View File
@@ -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<TrayManager> {
StreamSubscription<TrayEvent>? _subscription;
bool _isUpdating = false;
bool _hasPendingUpdate = false;
@override
void initState() {
@@ -29,15 +32,20 @@ class _TrayManagerState extends ConsumerState<TrayManager> {
_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<TrayManager> {
}
}
/// 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<void> _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<void>? operation) {
if (operation == null) {
return;
@@ -72,7 +104,7 @@ class _TrayManagerState extends ConsumerState<TrayManager> {
case TrayMenuRequested():
_reportFailure(Tray.instance.openMenu());
case TrayMenuItemSelected():
render?.active();
break;
}
}
+74 -139
View File
@@ -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<WindowManager>
with WindowListener {
Timer? _windowGeometryTimer;
int _windowGeometryRevision = 0;
int _windowBlurRevision = 0;
@override
Widget build(BuildContext context) {
@@ -45,7 +46,38 @@ class _WindowContainerState extends ConsumerState<WindowManager>
});
}
});
windowManager.addListener(this);
ref.listenManual(windowBlurRequestProvider, (prev, next) {
if (prev != next) {
unawaited(_applyWindowBlur(next));
}
}, fireImmediately: true);
desktopWindow.addListener(this);
}
Future<void> _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<WindowManager>
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<WindowManager>
}
@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<WindowManager>
_invalidateWindowGeometryCapture();
super.onWindowMaximize();
if (system.isWindows) {
unawaited(windowManager.setWindowCornerPreference(round: false));
unawaited(desktopWindow.setRoundedCorners(false));
}
}
@@ -149,7 +161,7 @@ class _WindowContainerState extends ConsumerState<WindowManager>
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<WindowManager>
_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<WindowManager>
@override
void dispose() {
_invalidateWindowGeometryCapture();
windowManager.removeListener(this);
desktopWindow.removeListener(this);
super.dispose();
}
}
@@ -294,7 +304,7 @@ class WindowCaptionState {
class WindowCaptionController extends ValueNotifier<WindowCaptionState>
with WindowListener {
WindowCaptionController() : super(const WindowCaptionState()) {
windowManager.addListener(this);
desktopWindow.addListener(this);
unawaited(_syncFromWindow());
}
@@ -302,9 +312,9 @@ class WindowCaptionController extends ValueNotifier<WindowCaptionState>
Future<void> _syncFromWindow() async {
final states = await Future.wait<bool>([
windowManager.isAlwaysOnTop(),
windowManager.isMaximized(),
windowManager.isFullScreen(),
desktopWindow.isAlwaysOnTop(),
desktopWindow.isMaximized(),
desktopWindow.isFullScreen(),
]);
_set(
WindowCaptionState(
@@ -345,25 +355,25 @@ class WindowCaptionController extends ValueNotifier<WindowCaptionState>
}
Future<void> 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<void> 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<WindowHeader> {
@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<WindowHeader> {
: 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),
),
);
}
}
+1 -1
View File
@@ -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;
});
+58 -33
View File
@@ -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<HotAction, HotKeyAction> _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<String, Map<String, int>> 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 = <String, Map<String, int>>{};
for (final group in groups) {
final testUrl = group.testUrl.takeFirstValid([defaultTestUrl]);
final groupDelays = <String, int>{};
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,
+4 -4
View File
@@ -13,7 +13,7 @@
#include <tray/tray_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
#include <wifi_ssid/wifi_ssid_plugin.h>
#include <window_manager/window_manager_plugin.h>
#include <window/window_plugin.h>
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);
}
+1 -1
View File
@@ -10,7 +10,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
tray
url_launcher_linux
wifi_ssid
window_manager
window
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
+2 -2
View File
@@ -5,7 +5,7 @@
#include <gdk/gdkx.h>
#endif
#include <window_manager/window_manager_plugin.h>
#include <window/window_plugin.h>
#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);
}
@@ -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"))
}
+3 -3
View File
@@ -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
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
import Cocoa
import FlutterMacOS
import window_manager
import window
import LaunchAtLogin
class MainFlutterWindow: NSWindow {
+11
View File
@@ -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
+22 -8
View File
@@ -54,25 +54,39 @@ abstract final class TrayCodec {
sink[id] = item;
return switch (item) {
TrayMenuSeparator() => <String, Object?>{'id': id, 'type': 'separator'},
TrayMenuAction(:final label, :final enabled) => <String, Object?>{
'id': id,
'type': 'action',
'label': label,
'enabled': enabled,
},
TrayMenuCheckbox(:final label, :final enabled, :final checked) =>
TrayMenuAction(:final label, :final detail, :final enabled) =>
<String, Object?>{
'id': id,
'type': 'action',
'label': label,
if (detail != null && detail.isNotEmpty) 'detail': detail,
'enabled': enabled,
},
TrayMenuCheckbox(
:final label,
:final detail,
:final enabled,
:final checked,
) =>
<String, Object?>{
'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,
) =>
<String, Object?>{
'id': id,
'type': 'submenu',
'label': label,
if (detail != null && detail.isNotEmpty) 'detail': detail,
'enabled': enabled,
'items': _encodeItems(items, sink, allocator),
},
+6
View File
@@ -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<TrayMenuItem> items;
final String? detail;
final bool enabled;
}
+7 -4
View File
@@ -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;
@@ -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)
}
@@ -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()
}
}
@@ -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()
}
}
@@ -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) {
@@ -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
}
}
}
@@ -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'),
+38
View File
@@ -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<Map<String, Object?>>();
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()]));
@@ -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,
+6 -1
View File
@@ -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)) {
+4 -4
View File
@@ -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'
}
@@ -36,6 +36,8 @@ class WifiSsidPlugin : FlutterPlugin, MethodCallHandler, ActivityAware {
private var wifiNetworkCallback: ConnectivityManager.NetworkCallback? = null
private val wifiInfoByNetwork = ConcurrentHashMap<Network, WifiInfo>()
private val pendingSsidResults = mutableListOf<Result>()
@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)
@@ -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 {
@@ -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',
+4
View File
@@ -0,0 +1,4 @@
.dart_tool/
.packages
build/
pubspec.lock
+25
View File
@@ -0,0 +1,25 @@
MIT License
Copyright (c) 2025 FlClash
Copyright (c) 2022-present LiJianying <lijy91@foxmail.com>
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.
+194
View File
@@ -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<String, Object?>`; `-` 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:)`.
+10
View File
@@ -0,0 +1,10 @@
analyzer:
exclude:
- build/**
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
include: ../../lint_options.yaml
+261
View File
@@ -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<WindowListener> _listeners =
ObserverList<WindowListener>();
List<WindowListener> get listeners => List<WindowListener>.of(_listeners);
bool get hasListeners => _listeners.isNotEmpty;
void addListener(WindowListener listener) {
_listeners.add(listener);
}
void removeListener(WindowListener listener) {
_listeners.remove(listener);
}
Future<void> _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<T> _invoke<T>(String method, [Map<String, Object?>? arguments]) async {
final result = await _channel.invokeMethod<T>(method, arguments);
if (result == null) {
throw PlatformException(
code: 'bad_result',
message: '$method answered null',
);
}
return result;
}
Future<void> _call(String method, [Map<String, Object?>? arguments]) {
return _channel.invokeMethod<void>(method, arguments);
}
Future<void> ensureInitialized() => _call('ensureInitialized');
Future<void> show({bool inactive = false}) {
return _call('show', {'inactive': inactive});
}
Future<void> hide() => _call('hide');
Future<bool> isVisible() => _invoke<bool>('isVisible');
Future<void> focus() => _call('focus');
Future<void> close() => _call('close');
Future<void> setPreventClose(bool value) {
return _call('setPreventClose', {'value': value});
}
Future<bool> isMaximized() => _invoke<bool>('isMaximized');
Future<void> maximize() => _call('maximize');
Future<void> unmaximize() => _call('unmaximize');
Future<bool> isMinimized() => _invoke<bool>('isMinimized');
Future<void> minimize() => _call('minimize');
Future<void> restore() => _call('restore');
Future<bool> isFullScreen() => _invoke<bool>('isFullScreen');
Future<void> setFullScreen(bool value) {
return _call('setFullScreen', {'value': value});
}
Future<Rect> getBounds() async {
final data = await _invoke<Map<Object?, Object?>>('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<void> setBounds({Offset? position, Size? size}) {
final arguments = <String, Object?>{
'x': position?.dx,
'y': position?.dy,
'width': size?.width,
'height': size?.height,
}..removeWhere((_, value) => value == null);
return _call('setBounds', arguments);
}
Future<Size> getSize() async => (await getBounds()).size;
Future<void> setSize(Size size) => setBounds(size: size);
Future<Offset> getPosition() async => (await getBounds()).topLeft;
Future<bool> isPositionSupported() => _invoke<bool>('isPositionSupported');
Future<void> setPosition(Offset position) => setBounds(position: position);
Future<void> setAlignment(Alignment alignment) async {
final position = await calcWindowPosition(await getSize(), alignment);
await setPosition(position);
}
Future<void> center() => setAlignment(Alignment.center);
Future<void> setMinimumSize(Size size) {
return _call('setMinimumSize', {
'width': size.width,
'height': size.height,
});
}
Future<bool> isAlwaysOnTop() => _invoke<bool>('isAlwaysOnTop');
Future<void> setAlwaysOnTop(bool value) {
return _call('setAlwaysOnTop', {'value': value});
}
Future<void> setTitleBarStyle(
TitleBarStyle style, {
bool windowButtonVisibility = true,
}) {
return _call('setTitleBarStyle', {
'style': style.name,
'windowButtonVisibility': windowButtonVisibility,
});
}
Future<void> setSkipTaskbar(bool value) {
return _call('setSkipTaskbar', {'value': value});
}
Future<void> setRoundedCorners(bool value) {
return _call('setRoundedCorners', {'value': value});
}
Future<bool> isEffectSupported(WindowEffect effect) {
return _invoke<bool>('isEffectSupported', {'effect': effect.name});
}
/// [brightness] picks the light or dark material; null follows the system.
Future<void> setEffect(
WindowEffect effect, {
Color? tint,
Brightness? brightness,
}) {
return _call('setEffect', {
'effect': effect.name,
'tint': tint?.toARGB32(),
'brightness': brightness?.name,
});
}
Future<void> 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<Offset> 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,
);
}
@@ -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 }
@@ -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 }
+30
View File
@@ -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;
}
}
@@ -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) {}
}
+5
View File
@@ -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';
+27
View File
@@ -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
)
@@ -0,0 +1,29 @@
#ifndef FLUTTER_PLUGIN_WINDOW_PLUGIN_H_
#define FLUTTER_PLUGIN_WINDOW_PLUGIN_H_
#include <flutter_linux/flutter_linux.h>
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_
+141
View File
@@ -0,0 +1,141 @@
#include "window_drag.h"
#include <gtk/gtk.h>
#include <cstring>
#include "window_plugin_internal.h"
static void collect_child(GtkWidget* widget, gpointer client_data) {
GList** children = static_cast<GList**>(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<gpointer*>(&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<GdkEventButton*>(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<GdkEvent*>(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<GdkEventButton*>(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<gpointer*>(&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));
}
+24
View File
@@ -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_
+204
View File
@@ -0,0 +1,204 @@
#include "window_geometry.h"
#include <gtk/gtk.h>
#include <cstring>
#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);
}
+34
View File
@@ -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_
+663
View File
@@ -0,0 +1,663 @@
#include "include/window/window_plugin.h"
#include <gtk/gtk.h>
#ifdef GDK_WINDOWING_X11
#include <gdk/gdkx.h>
#endif
#include <cstdint>
#include <cstring>
#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<double>(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<gint>(x), static_cast<gint>(y));
}
double width, height;
if (arg_double(args, "width", &width) && arg_double(args, "height", &height)) {
gint w = static_cast<gint>(width);
gint h = static_cast<gint>(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<GdkWindowHints>(0);
if (width >= 0 && height >= 0) {
hints.min_width = static_cast<gint>(width);
hints.min_height = static_cast<gint>(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");
}
}
@@ -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_
+74
View File
@@ -0,0 +1,74 @@
#include "window_style.h"
#include <gtk/gtk.h>
#include <cstring>
#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);
}
}
+20
View File
@@ -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_
+25
View File
@@ -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"
)
]
)
@@ -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
}
}
@@ -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)
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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)
}
}
+27
View File
@@ -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
@@ -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<String> log = <String>[];
@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<MethodCall> calls;
Object? Function(MethodCall call)? responder;
Future<void> emit(Object? name) {
return TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.handlePlatformMessage(
channel.name,
codec.encodeMethodCall(MethodCall('onEvent', {'name': name})),
(_) {},
);
}
setUp(() {
calls = <MethodCall>[];
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<PlatformException>().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'});
});
}
@@ -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<String, Object?> 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),
);
},
);
}
+38
View File
@@ -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
)
@@ -0,0 +1,29 @@
#ifndef FLUTTER_PLUGIN_WINDOW_PLUGIN_C_API_H_
#define FLUTTER_PLUGIN_WINDOW_PLUGIN_C_API_H_
#include <flutter_plugin_registrar.h>
#include <windows.h>
#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_
@@ -0,0 +1,19 @@
#ifndef FLUTTER_PLUGIN_WINDOW_PLUGIN_RUNNER_H_
#define FLUTTER_PLUGIN_WINDOW_PLUGIN_RUNNER_H_
#include <flutter/flutter_view_controller.h>
#include <windows.h>
// 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_
+105
View File
@@ -0,0 +1,105 @@
#include "running_instance.h"
#include <cstdint>
#include <sstream>
#include <string>
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<DWORD>(path.size()));
if (length == 0) {
return {};
}
if (length < path.size()) {
path.resize(length);
break;
}
path.resize(path.size() * 2);
}
::CharLowerBuffW(path.data(), static_cast<DWORD>(path.size()));
return path;
}
struct RunningWindowSearch {
std::wstring executable;
HWND found = nullptr;
};
BOOL CALLBACK MatchRunningWindow(HWND hwnd, LPARAM lparam) {
auto* search = reinterpret_cast<RunningWindowSearch*>(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<DWORD>(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<uint64_t>(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<LPARAM>(&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
+19
View File
@@ -0,0 +1,19 @@
#ifndef FLUTTER_PLUGIN_WINDOW_RUNNING_INSTANCE_H_
#define FLUTTER_PLUGIN_WINDOW_RUNNING_INSTANCE_H_
#include <windows.h>
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_
@@ -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<double>(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<double> x,
std::optional<double> y,
std::optional<double> width,
std::optional<double> 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<int>(*x * scale);
iy = static_cast<int>(*y * scale);
} else {
flags |= SWP_NOMOVE;
}
if (width.has_value() && height.has_value()) {
iw = static_cast<int>(*width * scale);
ih = static_cast<int>(*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<LONG>(width),
static_cast<LONG>(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<LRESULT> 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<DWORD>(::GetWindowLongPtrW(hwnd, GWL_STYLE)), FALSE,
static_cast<DWORD>(::GetWindowLongPtrW(hwnd, GWL_EXSTYLE)),
::GetDpiForWindow(hwnd));
RECT* rect = &reinterpret_cast<NCCALCSIZE_PARAMS*>(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<MINMAXINFO*>(lparam);
const double scale = DpiScale();
if (minimum_size_.x != 0)
info->ptMinTrackSize.x = static_cast<LONG>(minimum_size_.x * scale);
if (minimum_size_.y != 0)
info->ptMinTrackSize.y = static_cast<LONG>(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<const WINDOWPOS*>(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<HBRUSH>(::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<short>(LOWORD(lparam)),
static_cast<short>(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

Some files were not shown because too many files have changed in this diff Show More