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
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
After Width: | Height: | Size: 570 B |
|
After Width: | Height: | Size: 698 B |
|
After Width: | Height: | Size: 851 B |
|
After Width: | Height: | Size: 1012 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 323 B |
|
After Width: | Height: | Size: 400 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 618 B |
|
After Width: | Height: | Size: 8.6 KiB |
@@ -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 |
@@ -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 |
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
|
||||
@@ -47,4 +47,4 @@ class AutoLaunch {
|
||||
}
|
||||
}
|
||||
|
||||
final autoLaunch = system.isDesktop ? AutoLaunch() : null;
|
||||
final autoLaunch = system.isDesktop && !safeModeBuild ? AutoLaunch() : null;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -261,7 +261,6 @@ enum FunctionTag {
|
||||
updateDelay,
|
||||
vpnTip,
|
||||
autoLaunch,
|
||||
renderPause,
|
||||
updatePageIndex,
|
||||
pageChange,
|
||||
proxiesTabChange,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
||||
tray
|
||||
url_launcher_linux
|
||||
wifi_ssid
|
||||
window_manager
|
||||
window
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
import Cocoa
|
||||
import FlutterMacOS
|
||||
import window_manager
|
||||
import window
|
||||
import LaunchAtLogin
|
||||
|
||||
class MainFlutterWindow: NSWindow {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.dart_tool/
|
||||
.packages
|
||||
build/
|
||||
pubspec.lock
|
||||
@@ -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.
|
||||
@@ -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:)`.
|
||||
@@ -0,0 +1,10 @@
|
||||
analyzer:
|
||||
exclude:
|
||||
- build/**
|
||||
- android/**
|
||||
- ios/**
|
||||
- web/**
|
||||
- windows/**
|
||||
- macos/**
|
||||
- linux/**
|
||||
include: ../../lint_options.yaml
|
||||
@@ -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 }
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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_
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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_
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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_
|
||||
@@ -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_
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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_
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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_
|
||||
@@ -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
|
||||
@@ -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
|
||||