From 2aca44365a13d3f5e6693895aa4814c825d2b39b Mon Sep 17 00:00:00 2001 From: Brenno de Winter Date: Sat, 6 Jun 2026 21:25:34 +0200 Subject: [PATCH] Add dual-screen presenter mode (slide on beamer, notes on laptop) When a second display is connected (macOS), presenting now opens a borderless audience window on the beamer showing the slide, while the main window shows the presenter view (current/next slide, speaker notes, clock, controls) on the laptop. The two windows stay in sync over method channels: navigation, blank screen, audio-complete and beamer clicks are forwarded between them, and media plays only on the beamer to avoid double audio. Falls back to the existing single-window presenter when there is one display or the second window can't be created. - Vendors a fork of desktop_multi_window in third_party/ that re-adds the native macOS window geometry/fullscreen calls (coverScreen, setFrame, close) the published 0.3.0 dropped; wired via a path dependency. - Registers the app's plugins for sub-windows in MainFlutterWindow so video/image rendering works on the beamer. - Routes the multi_window dart entrypoint to a minimal AudienceWindowApp. Compiles (flutter analyze + macOS debug build) and all tests pass; runtime two-screen behaviour still needs verification on real hardware. Co-Authored-By: Claude Opus 4.8 --- lib/l10n/app_localizations.dart | 6 +- lib/main.dart | 15 +- lib/models/slide.dart | 3 +- lib/widgets/app_shell.dart | 2 +- lib/widgets/editors/code_editor.dart | 5 +- lib/widgets/presentation/audience_window.dart | 144 +++++++ .../presentation/fullscreen_presenter.dart | 198 +++++++++- linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 + macos/Podfile.lock | 6 + macos/Runner/MainFlutterWindow.swift | 7 + pubspec.lock | 7 + pubspec.yaml | 4 + third_party/desktop_multi_window/CHANGELOG.md | 25 ++ third_party/desktop_multi_window/LICENSE | 201 ++++++++++ third_party/desktop_multi_window/README.md | 303 ++++++++++++++ .../lib/desktop_multi_window.dart | 3 + .../lib/src/window_channel.dart | 219 +++++++++++ .../lib/src/window_configuration.dart | 43 ++ .../lib/src/window_controller.dart | 158 ++++++++ .../desktop_multi_window/linux/CMakeLists.txt | 27 ++ .../linux/desktop_multi_window_plugin.cc | 137 +++++++ .../desktop_multi_window_plugin_internal.h | 12 + .../linux/flutter_window.cc | 52 +++ .../linux/flutter_window.h | 44 +++ .../desktop_multi_window_plugin.h | 32 ++ .../linux/multi_window_manager.cc | 235 +++++++++++ .../linux/multi_window_manager.h | 47 +++ .../linux/window_channel_plugin.cc | 370 ++++++++++++++++++ .../linux/window_channel_plugin.h | 18 + .../linux/window_configuration.h | 42 ++ .../Classes/FlutterMultiWindowPlugin.swift | 171 ++++++++ .../macos/Classes/FlutterWindow.swift | 147 +++++++ .../macos/Classes/WindowChannel.swift | 281 +++++++++++++ .../macos/Classes/WindowConfiguration.swift | 51 +++ .../macos/desktop_multi_window.podspec | 22 ++ third_party/desktop_multi_window/pubspec.yaml | 28 ++ .../windows/CMakeLists.txt | 29 ++ .../windows/desktop_multi_window_plugin.cpp | 118 ++++++ .../windows/flutter_window.cc | 71 ++++ .../windows/flutter_window.h | 44 +++ .../windows/flutter_window_wrapper.h | 69 ++++ .../desktop_multi_window_plugin.h | 27 ++ .../windows/multi_window_manager.cc | 190 +++++++++ .../windows/multi_window_manager.h | 44 +++ .../windows/multi_window_plugin_internal.h | 12 + .../windows/win32_window.cpp | 301 ++++++++++++++ .../windows/win32_window.h | 102 +++++ .../windows/window_channel_plugin.cc | 347 ++++++++++++++++ .../windows/window_channel_plugin.h | 17 + .../windows/window_configuration.h | 34 ++ .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 54 files changed, 4466 insertions(+), 15 deletions(-) create mode 100644 lib/widgets/presentation/audience_window.dart create mode 100644 third_party/desktop_multi_window/CHANGELOG.md create mode 100644 third_party/desktop_multi_window/LICENSE create mode 100644 third_party/desktop_multi_window/README.md create mode 100644 third_party/desktop_multi_window/lib/desktop_multi_window.dart create mode 100644 third_party/desktop_multi_window/lib/src/window_channel.dart create mode 100644 third_party/desktop_multi_window/lib/src/window_configuration.dart create mode 100644 third_party/desktop_multi_window/lib/src/window_controller.dart create mode 100644 third_party/desktop_multi_window/linux/CMakeLists.txt create mode 100644 third_party/desktop_multi_window/linux/desktop_multi_window_plugin.cc create mode 100644 third_party/desktop_multi_window/linux/desktop_multi_window_plugin_internal.h create mode 100755 third_party/desktop_multi_window/linux/flutter_window.cc create mode 100755 third_party/desktop_multi_window/linux/flutter_window.h create mode 100644 third_party/desktop_multi_window/linux/include/desktop_multi_window/desktop_multi_window_plugin.h create mode 100755 third_party/desktop_multi_window/linux/multi_window_manager.cc create mode 100755 third_party/desktop_multi_window/linux/multi_window_manager.h create mode 100644 third_party/desktop_multi_window/linux/window_channel_plugin.cc create mode 100644 third_party/desktop_multi_window/linux/window_channel_plugin.h create mode 100644 third_party/desktop_multi_window/linux/window_configuration.h create mode 100644 third_party/desktop_multi_window/macos/Classes/FlutterMultiWindowPlugin.swift create mode 100644 third_party/desktop_multi_window/macos/Classes/FlutterWindow.swift create mode 100644 third_party/desktop_multi_window/macos/Classes/WindowChannel.swift create mode 100644 third_party/desktop_multi_window/macos/Classes/WindowConfiguration.swift create mode 100644 third_party/desktop_multi_window/macos/desktop_multi_window.podspec create mode 100644 third_party/desktop_multi_window/pubspec.yaml create mode 100644 third_party/desktop_multi_window/windows/CMakeLists.txt create mode 100644 third_party/desktop_multi_window/windows/desktop_multi_window_plugin.cpp create mode 100644 third_party/desktop_multi_window/windows/flutter_window.cc create mode 100644 third_party/desktop_multi_window/windows/flutter_window.h create mode 100644 third_party/desktop_multi_window/windows/flutter_window_wrapper.h create mode 100644 third_party/desktop_multi_window/windows/include/desktop_multi_window/desktop_multi_window_plugin.h create mode 100644 third_party/desktop_multi_window/windows/multi_window_manager.cc create mode 100644 third_party/desktop_multi_window/windows/multi_window_manager.h create mode 100644 third_party/desktop_multi_window/windows/multi_window_plugin_internal.h create mode 100644 third_party/desktop_multi_window/windows/win32_window.cpp create mode 100644 third_party/desktop_multi_window/windows/win32_window.h create mode 100644 third_party/desktop_multi_window/windows/window_channel_plugin.cc create mode 100644 third_party/desktop_multi_window/windows/window_channel_plugin.h create mode 100644 third_party/desktop_multi_window/windows/window_configuration.h diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 0f25d6e..5a4d81a 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1860,8 +1860,7 @@ const _dutchSourceStrings = { 'Vrije Markdown': 'Frije Markdown', 'Broncode': 'Boarnekoade', 'Programmeertaal': 'Programmeartaal', - 'Plak of typ hier je broncode...': - 'Plak of typ hjir dyn boarnekoade...', + 'Plak of typ hier je broncode...': 'Plak of typ hjir dyn boarnekoade...', 'Overgeslagen': 'Oerslein', 'Kopiëren': 'Kopiearje', 'Kopieer als afbeelding': 'Kopiearje as ôfbylding', @@ -2033,8 +2032,7 @@ const _dutchSourceStrings = { 'Vrije Markdown': 'Markdown liber', 'Broncode': 'Código fuente', 'Programmeertaal': 'Lenguahe di programashon', - 'Plak of typ hier je broncode...': - 'Pega òf tek bo código fuente akinan...', + 'Plak of typ hier je broncode...': 'Pega òf tek bo código fuente akinan...', 'Overgeslagen': 'Saltá', 'Kopiëren': 'Kopia', 'Kopieer als afbeelding': 'Kopia komo imágen', diff --git a/lib/main.dart b/lib/main.dart index 3fc5877..99d2522 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,13 +1,26 @@ +import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:window_manager/window_manager.dart'; import 'app.dart'; +import 'widgets/presentation/audience_window.dart'; -void main() async { +void main(List args) async { WidgetsFlutterBinding.ensureInitialized(); + // Secondary windows (e.g. the audience/beamer slide window) are launched by + // desktop_multi_window with these entrypoint arguments. They run a minimal app + // and must not touch the main window's window_manager setup. + if (args.isNotEmpty && args.first == 'multi_window') { + final raw = args.length >= 3 ? args[2] : ''; + final parsed = raw.isEmpty ? const {} : jsonDecode(raw); + final map = Map.from(parsed as Map); + runApp(AudienceWindowApp(args: map)); + return; + } + if (!kIsWeb && (Platform.isMacOS || Platform.isWindows || Platform.isLinux)) { await windowManager.ensureInitialized(); const options = WindowOptions( diff --git a/lib/models/slide.dart b/lib/models/slide.dart index 7d3fb9e..909848a 100644 --- a/lib/models/slide.dart +++ b/lib/models/slide.dart @@ -95,7 +95,8 @@ class Slide { final String quote; final String quoteAuthor; final String customMarkdown; - final String codeLanguage; // highlight.js language id for code slides ('' = plain) + final String + codeLanguage; // highlight.js language id for code slides ('' = plain) final String cssClass; final String notes; final double advanceDuration; // 0 = no auto-advance diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index 1c80f39..db14ffb 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -963,7 +963,7 @@ class _MainLayoutState extends ConsumerState<_MainLayout> { var initial = visible.indexWhere((i) => i >= editor.selectedIndex); if (initial < 0) initial = visible.length - 1; if (initial < 0) initial = 0; - FullscreenPresenter.show( + FullscreenPresenter.present( context, slides: slides, projectPath: deck.projectPath, diff --git a/lib/widgets/editors/code_editor.dart b/lib/widgets/editors/code_editor.dart index d7f86f2..0271a73 100644 --- a/lib/widgets/editors/code_editor.dart +++ b/lib/widgets/editors/code_editor.dart @@ -83,10 +83,7 @@ class _CodeEditorState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - EditorField( - label: 'Titel (optioneel)', - controller: _title, - ), + EditorField(label: 'Titel (optioneel)', controller: _title), const SizedBox(height: 16), Row( children: [ diff --git a/lib/widgets/presentation/audience_window.dart b/lib/widgets/presentation/audience_window.dart new file mode 100644 index 0000000..02858eb --- /dev/null +++ b/lib/widgets/presentation/audience_window.dart @@ -0,0 +1,144 @@ +import 'package:desktop_multi_window/desktop_multi_window.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../../models/deck.dart'; +import '../../models/settings.dart'; +import '../../models/slide.dart'; +import '../../services/markdown_service.dart'; +import '../../utils/url_launcher_util.dart'; +import '../slides/slide_preview.dart'; + +/// Channel the audience (beamer) window listens on for updates from the +/// presenter (laptop) window. +const audienceChannel = WindowMethodChannel( + 'ocideck/audience', + mode: ChannelMode.unidirectional, +); + +/// Channel the presenter window listens on; the audience window uses it to +/// forward navigation (clicks on the beamer) and audio-complete events. +const presenterChannel = WindowMethodChannel( + 'ocideck/presenter', + mode: ChannelMode.unidirectional, +); + +/// The app that runs inside the secondary (beamer) window. It only renders the +/// current slide fullscreen; the presenter window drives it via [audienceChannel]. +class AudienceWindowApp extends StatefulWidget { + final Map args; + + const AudienceWindowApp({super.key, required this.args}); + + @override + State createState() => _AudienceWindowAppState(); +} + +class _AudienceWindowAppState extends State { + List _slides = const []; + ThemeProfile _theme = const ThemeProfile(); + TlpLevel _tlp = TlpLevel.none; + String? _projectPath; + int _index = 0; + int _blank = 0; // 0 = none, 1 = black, 2 = white + + @override + void initState() { + super.initState(); + final markdown = widget.args['markdown'] as String? ?? ''; + _projectPath = widget.args['projectPath'] as String?; + _index = (widget.args['index'] as num?)?.toInt() ?? 0; + final deck = MarkdownService().parseDeck(markdown); + _slides = deck?.slides ?? const []; + _theme = deck?.themeProfile ?? const ThemeProfile(); + _tlp = deck?.tlp ?? TlpLevel.none; + audienceChannel.setMethodCallHandler(_onPresenterCall); + } + + @override + void dispose() { + audienceChannel.setMethodCallHandler(null); + super.dispose(); + } + + Future _onPresenterCall(MethodCall call) async { + switch (call.method) { + case 'update': + final m = Map.from(call.arguments as Map); + if (!mounted) return null; + setState(() { + _index = (m['index'] as num?)?.toInt() ?? _index; + _blank = (m['blank'] as num?)?.toInt() ?? 0; + }); + case 'close': + try { + final self = await WindowController.fromCurrentEngine(); + await self.close(); + } catch (_) {} + } + return null; + } + + void _send(String method) { + // Best-effort: the presenter may already be gone. + presenterChannel.invokeMethod(method).catchError((_) => null); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold(backgroundColor: Colors.black, body: _body()), + ); + } + + Widget _body() { + if (_slides.isEmpty) return const SizedBox.shrink(); + if (_blank != 0) { + return Container(color: _blank == 2 ? Colors.white : Colors.black); + } + final slide = _slides[_index.clamp(0, _slides.length - 1)]; + return GestureDetector( + onTap: () => _send('next'), + onSecondaryTap: () => _send('prev'), + child: SizedBox.expand(child: _canvas(slide)), + ); + } + + /// A 16:9 slide letterboxed to fit the screen, mirroring the presenter's view. + Widget _canvas(Slide slide) { + return LayoutBuilder( + builder: (_, constraints) { + final w = constraints.maxWidth; + final h = constraints.maxHeight; + const ratio = 16.0 / 9.0; + double slideW, slideH; + if (w / h > ratio) { + slideH = h; + slideW = h * ratio; + } else { + slideW = w; + slideH = w / ratio; + } + return Center( + child: SizedBox( + width: slideW, + height: slideH, + child: SlidePreviewWidget( + slide: slide, + projectPath: _projectPath, + themeProfile: _theme, + onLinkTap: openExternalUrl, + slideNumber: _index + 1, + slideCount: _slides.length, + tlp: _tlp, + enableMedia: true, + autoplayMedia: true, + // Audio finishing on the beamer drives the presenter's auto-advance. + onAudioComplete: () => _send('audioComplete'), + ), + ), + ); + }, + ); + } +} diff --git a/lib/widgets/presentation/fullscreen_presenter.dart b/lib/widgets/presentation/fullscreen_presenter.dart index c99cd78..2fdcbb2 100644 --- a/lib/widgets/presentation/fullscreen_presenter.dart +++ b/lib/widgets/presentation/fullscreen_presenter.dart @@ -1,5 +1,7 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; +import 'package:desktop_multi_window/desktop_multi_window.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:screen_retriever/screen_retriever.dart'; @@ -8,9 +10,11 @@ import 'package:window_manager/window_manager.dart'; import '../../models/deck.dart'; import '../../models/settings.dart'; import '../../models/slide.dart'; +import '../../services/markdown_service.dart'; import '../../utils/url_launcher_util.dart'; import '../../l10n/app_localizations.dart'; import '../slides/slide_preview.dart'; +import 'audience_window.dart'; /// Blanco-schermstand tijdens het presenteren (zoals B/W in PowerPoint). enum _Blank { none, black, white } @@ -22,6 +26,11 @@ class FullscreenPresenter extends StatefulWidget { final int initialIndex; final TlpLevel tlp; + /// When set, this presenter drives a separate audience (beamer) window: the + /// laptop shows the presenter view, the slide goes to [audienceWindow]. Null + /// for the classic single-screen mode. + final WindowController? audienceWindow; + const FullscreenPresenter({ super.key, required this.slides, @@ -29,8 +38,51 @@ class FullscreenPresenter extends StatefulWidget { required this.themeProfile, required this.initialIndex, this.tlp = TlpLevel.none, + this.audienceWindow, }); + /// Entry point used by the app: pick dual-screen mode when a second display is + /// available (macOS), otherwise the single-window presenter. Any failure to + /// open the second window falls back to single-window mode. + static Future present( + BuildContext context, { + required List slides, + required String? projectPath, + required ThemeProfile themeProfile, + required int initialIndex, + TlpLevel tlp = TlpLevel.none, + }) async { + var dual = false; + if (Platform.isMacOS) { + try { + final displays = await screenRetriever.getAllDisplays(); + dual = displays.length >= 2; + } catch (_) { + dual = false; + } + } + if (!context.mounted) return; + if (dual) { + await showDualScreen( + context, + slides: slides, + projectPath: projectPath, + themeProfile: themeProfile, + initialIndex: initialIndex, + tlp: tlp, + ); + } else { + await show( + context, + slides: slides, + projectPath: projectPath, + themeProfile: themeProfile, + initialIndex: initialIndex, + tlp: tlp, + ); + } + } + static Future show( BuildContext context, { required List slides, @@ -66,6 +118,88 @@ class FullscreenPresenter extends StatefulWidget { } } + /// Dual-screen mode: open a borderless audience window on the beamer showing + /// the slide, and run the presenter view (current/next/notes/timer) in the + /// main window on the laptop. The two windows stay in sync over method + /// channels. Falls back to [show] if the second window can't be created. + static Future showDualScreen( + BuildContext context, { + required List slides, + required String? projectPath, + required ThemeProfile themeProfile, + required int initialIndex, + TlpLevel tlp = TlpLevel.none, + }) async { + // A self-contained markdown deck is the payload for the audience window; it + // carries the slides, the style profile and the TLP level in one string. + final markdown = MarkdownService().generateDeck( + Deck( + title: 'Presentatie', + slides: slides, + projectPath: projectPath, + themeProfile: themeProfile, + tlp: tlp, + ), + ); + final argument = jsonEncode({ + 'markdown': markdown, + 'projectPath': projectPath, + 'index': initialIndex, + }); + + WindowController? audience; + try { + audience = await WindowController.create( + WindowConfiguration(arguments: argument, hiddenAtLaunch: true), + ); + await audience.coverScreen(external: true); + } catch (_) { + audience = null; + } + + if (audience == null) { + if (context.mounted) { + await show( + context, + slides: slides, + projectPath: projectPath, + themeProfile: themeProfile, + initialIndex: initialIndex, + tlp: tlp, + ); + } + return; + } + + final hadWakeLock = await _wakeLockEnabled(); + await _enableWakeLock(); + try { + if (context.mounted) { + await Navigator.push( + context, + PageRouteBuilder( + opaque: true, + pageBuilder: (context, anim, anim2) => FullscreenPresenter( + slides: slides, + projectPath: projectPath, + themeProfile: themeProfile, + initialIndex: initialIndex, + tlp: tlp, + audienceWindow: audience, + ), + transitionsBuilder: (context, animation, secondary, child) => + FadeTransition(opacity: animation, child: child), + transitionDuration: const Duration(milliseconds: 200), + ), + ); + } + } finally { + await _restoreWakeLock(hadWakeLock); + // Make sure the audience window is gone even if exit didn't close it. + audience.close().catchError((_) => null); + } + } + @override State createState() => _FullscreenPresenterState(); } @@ -150,12 +284,38 @@ class _FullscreenPresenterState extends State { List _displays = const []; int _displayIndex = 0; + /// True when this presenter drives a separate audience (beamer) window. + bool get _dual => widget.audienceWindow != null; + + /// Last (index, blank) pushed to the audience window, to avoid redundant sends. + int? _lastSentIndex; + int? _lastSentBlank; + @override void initState() { super.initState(); _index = widget.initialIndex; _startTime = DateTime.now(); _focusNode = FocusNode(); + if (_dual) { + // The laptop shows the presenter view; the slide lives on the beamer. + _presenterView = true; + // Navigation triggered on the beamer (clicks) and its audio-end events + // come back over this channel. + presenterChannel.setMethodCallHandler((call) async { + switch (call.method) { + case 'next': + _next(); + case 'prev': + _prev(); + case 'exit': + _exit(); + case 'audioComplete': + _onAudioCompleted(); + } + return null; + }); + } // Tik elke seconde, maar herbouw alleen in presenter view (klok/teller). _clockTimer = Timer.periodic(const Duration(seconds: 1), (_) { if (mounted && _presenterView) setState(() {}); @@ -174,9 +334,26 @@ class _FullscreenPresenterState extends State { _typedTimer?.cancel(); _gridScroll.dispose(); _focusNode.dispose(); + if (_dual) presenterChannel.setMethodCallHandler(null); super.dispose(); } + int get _blankCode => + _blank == _Blank.white ? 2 : (_blank == _Blank.black ? 1 : 0); + + /// Mirror the current index/blank state to the audience window when it changed. + void _syncAudience() { + final aw = widget.audienceWindow; + if (aw == null) return; + final blank = _blankCode; + if (_index == _lastSentIndex && blank == _lastSentBlank) return; + _lastSentIndex = _index; + _lastSentBlank = blank; + audienceChannel + .invokeMethod('update', {'index': _index, 'blank': blank}) + .catchError((_) => null); + } + /// Decode the current slide's images plus its neighbours into the image cache /// ahead of time. Because a precached [FileImage] resolves synchronously, the /// next slide paints its picture on the very first frame instead of flashing @@ -334,7 +511,15 @@ class _FullscreenPresenterState extends State { Future _exit() async { _advanceTimer?.cancel(); - await windowManager.setFullScreen(false); + final aw = widget.audienceWindow; + if (aw != null) { + // Dual mode: the main window was never put in full screen; just tear down + // the audience window. + audienceChannel.invokeMethod('close').catchError((_) => null); + aw.close().catchError((_) => null); + } else { + await windowManager.setFullScreen(false); + } if (mounted) Navigator.pop(context); } @@ -644,6 +829,9 @@ class _FullscreenPresenterState extends State { return const SizedBox.shrink(); } + // Keep the beamer window in step with whatever index/blank we now show. + _syncAudience(); + return Focus( focusNode: _focusNode, autofocus: true, @@ -849,9 +1037,11 @@ class _FullscreenPresenterState extends State { slideCount: widget.slides.length, tlp: widget.tlp, // Tijdens het presenteren speelt media en starten audio/video - // vanzelf; het audio-einde stuurt de auto-advance aan. - enableMedia: true, - autoplayMedia: true, + // vanzelf; het audio-einde stuurt de auto-advance aan. In dual- + // schermmodus speelt de media op het beamervenster, niet hier, + // anders zou het geluid dubbel klinken. + enableMedia: !_dual, + autoplayMedia: !_dual, onAudioComplete: _onAudioCompleted, ), ), diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 144271d..d908b43 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -7,6 +7,7 @@ #include "generated_plugin_registrant.h" #include +#include #include #include #include @@ -16,6 +17,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) desktop_drop_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "DesktopDropPlugin"); desktop_drop_plugin_register_with_registrar(desktop_drop_registrar); + g_autoptr(FlPluginRegistrar) desktop_multi_window_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "DesktopMultiWindowPlugin"); + desktop_multi_window_plugin_register_with_registrar(desktop_multi_window_registrar); g_autoptr(FlPluginRegistrar) pasteboard_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "PasteboardPlugin"); pasteboard_plugin_register_with_registrar(pasteboard_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index d05f858..003b3d3 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST desktop_drop + desktop_multi_window pasteboard screen_retriever_linux url_launcher_linux diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index e6410ee..a0adf12 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,6 +6,7 @@ import FlutterMacOS import Foundation import desktop_drop +import desktop_multi_window import file_picker import package_info_plus import pasteboard @@ -18,6 +19,7 @@ import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin")) + FlutterMultiWindowPlugin.register(with: registry.registrar(forPlugin: "FlutterMultiWindowPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PasteboardPlugin.register(with: registry.registrar(forPlugin: "PasteboardPlugin")) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 9e5f075..f7575bf 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -1,6 +1,8 @@ PODS: - desktop_drop (0.0.1): - FlutterMacOS + - desktop_multi_window (0.0.1): + - FlutterMacOS - file_picker (0.0.1): - FlutterMacOS - FlutterMacOS (1.0.0) @@ -25,6 +27,7 @@ PODS: DEPENDENCIES: - desktop_drop (from `Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos`) + - desktop_multi_window (from `Flutter/ephemeral/.symlinks/plugins/desktop_multi_window/macos`) - file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) @@ -39,6 +42,8 @@ DEPENDENCIES: EXTERNAL SOURCES: desktop_drop: :path: Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos + desktop_multi_window: + :path: Flutter/ephemeral/.symlinks/plugins/desktop_multi_window/macos file_picker: :path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos FlutterMacOS: @@ -62,6 +67,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: desktop_drop: 10a3e6a7fa9dbe350541f2574092fecfa345a07b + desktop_multi_window: 93667594ccc4b88d91a97972fd3b1b89667fa80a file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 package_info_plus: f0052d280d17aa382b932f399edf32507174e870 diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift index 3cc05eb..abe25af 100644 --- a/macos/Runner/MainFlutterWindow.swift +++ b/macos/Runner/MainFlutterWindow.swift @@ -1,5 +1,6 @@ import Cocoa import FlutterMacOS +import desktop_multi_window class MainFlutterWindow: NSWindow { override func awakeFromNib() { @@ -10,6 +11,12 @@ class MainFlutterWindow: NSWindow { RegisterGeneratedPlugins(registry: flutterViewController) + // Register the app's plugins in every sub-window (e.g. the audience/beamer + // window) too, so video_player, image loading, etc. work there as well. + FlutterMultiWindowPlugin.setOnWindowCreatedCallback { controller in + RegisterGeneratedPlugins(registry: controller) + } + super.awakeFromNib() } } diff --git a/pubspec.lock b/pubspec.lock index 8e04da7..da6b849 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -169,6 +169,13 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.1" + desktop_multi_window: + dependency: "direct main" + description: + path: "third_party/desktop_multi_window" + relative: true + source: path + version: "0.3.0" fake_async: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index f05b7b0..f22bb7c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -32,6 +32,10 @@ dependencies: flutter_math_fork: ^0.7.4 highlight: ^0.7.0 wakelock_plus: ^1.5.2 + # Vendored fork: adds native macOS window-geometry/fullscreen methods that + # the published 0.3.0 dropped, needed for the dual-screen presenter mode. + desktop_multi_window: + path: third_party/desktop_multi_window dev_dependencies: flutter_test: diff --git a/third_party/desktop_multi_window/CHANGELOG.md b/third_party/desktop_multi_window/CHANGELOG.md new file mode 100644 index 0000000..27bbd18 --- /dev/null +++ b/third_party/desktop_multi_window/CHANGELOG.md @@ -0,0 +1,25 @@ +## 0.3.0 + +* [BREAK CHANGE] rewritten, please refer to readme + +## 0.2.1 + +* bug fixed + +## 0.2.0 +* Added the ability to determine whether a created window will be resizable or not +([#101](https://github.com/MixinNetwork/flutter-plugins/issues/101) and [#130](https://github.com/MixinNetwork/flutter-plugins/pull/130)) + +## 0.1.0 + +* [BREAK CHANGE] upgrade min flutter version to 3.0.0 +* fix macOS memory leak issue. [#123](https://github.com/MixinNetwork/flutter-plugins/issues/123) + +## 0.0.2 + +* [Windows] fix free window_channel_ may cause crash. [#78](https://github.com/MixinNetwork/flutter-plugins/pull/78) +* add getAllSubWindowIds api. [#77](https://github.com/MixinNetwork/flutter-plugins/pull/77) + +## 0.0.1 + +* Initial release. support Linux, macOS, Windows. diff --git a/third_party/desktop_multi_window/LICENSE b/third_party/desktop_multi_window/LICENSE new file mode 100644 index 0000000..2832753 --- /dev/null +++ b/third_party/desktop_multi_window/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [2021] [Mixin] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/third_party/desktop_multi_window/README.md b/third_party/desktop_multi_window/README.md new file mode 100644 index 0000000..a0424aa --- /dev/null +++ b/third_party/desktop_multi_window/README.md @@ -0,0 +1,303 @@ +# desktop_multi_window + +[![Pub](https://img.shields.io/pub/v/desktop_multi_window.svg)](https://pub.dev/packages/desktop_multi_window) + +A Flutter plugin to create and manage multiple windows on desktop platforms. + +| | | +|---------|-----| +| Windows | ✅ | +| Linux | ✅ | +| macOS | ✅ | + +## Installation + +Add `desktop_multi_window` to your `pubspec.yaml`: + +```yaml +dependencies: + desktop_multi_window: ^latest_version +``` + +## Getting Started + +### 1. Initialize Multi-Window Support + +In your `main()` function, initialize multi-window support before running your app: + +```dart +import 'package:desktop_multi_window/desktop_multi_window.dart'; + +Future main(List args) async { + WidgetsFlutterBinding.ensureInitialized(); + + // Get the current window controller + final windowController = await WindowController.fromCurrentEngine(); + + // Parse window arguments to determine which window to show + final arguments = parseArguments(windowController.arguments); + + // Run different apps based on the window type + switch (arguments.type) { + case YourArgumentDefinitions.main: + runApp(const MainWindow()); + case YourArgumentDefinitions.sample: + runApp(const SampleWindow()); + // Add more window types as needed + } +} +``` + +### 2. Create New Windows + +Use `WindowController.create()` to create and manage new windows: + +```dart +// Create a new window +final controller = await WindowController.create( + WindowConfiguration( + hiddenAtLaunch: true, + arguments: 'YOUR_WINDOW_ARGUMENTS_HERE', + ), +); + +// Show the window (if hidden at launch) +await controller.show(); +``` + +### 3. Manage Existing Windows + +Get all window controllers and manage them: + +```dart +// Get all windows +final controllers = await WindowController.getAll(); + +// Find a specific window by business ID +for (var controller in controllers) { + final args = parseArguments(controller.arguments); + // Check window type + if (args.type == YourArgumentDefinitions.sample) { + await controller.center(); + await controller.show(); + return; + } +} + +// Listen to window changes +onWindowsChanged.listen((_) { + // Handle window changes +}); +``` + +### 4. Communication Between Windows + +Use `WindowMethodChannel` for bidirectional communication between windows: + +```dart +// In the target window, set up a method call handler +const channel = WindowMethodChannel('my_channel'); +channel.setMethodCallHandler((call) async { + switch (call.method) { + case 'play': + // Handle the method call + return 'success'; + default: + throw MissingPluginException('Not implemented: ${call.method}'); + } +}); + +// From another window, invoke methods +const channel = WindowMethodChannel('my_channel'); +final result = await channel.invokeMethod('play'); +``` + +### 5. Extend WindowController with Custom Methods + +Create an extension to add custom functionality: + +```dart +import 'package:desktop_multi_window/desktop_multi_window.dart'; +import 'package:window_manager/window_manager.dart'; + +extension WindowControllerExtension on WindowController { + Future doCustomInitialize() async { + return await setWindowMethodHandler((call) async { + switch (call.method) { + case 'window_center': + return await windowManager.center(); + case 'window_close': + return await windowManager.close(); + default: + throw MissingPluginException('Not implemented: ${call.method}'); + } + }); + } + + Future center() { + return invokeMethod('window_center'); + } + + Future close() { + return invokeMethod('window_close'); + } +} +``` + +And now, you can center or close the window in the other window: + +```dart +final controller = await WindowController.fromWindowId(other_window_id); + +// Center the window +await controller.center(); + +// Close the window +await controller.close(); +``` + +## Working with Plugins in Sub-Windows + +Each window created by this plugin has its own dedicated Flutter engine. Method channels cannot be shared between engines, so plugins must be manually registered for each new window. + +### Platform-Specific Plugin Registration + +#### Windows + +Edit `windows/runner/flutter_window.cpp`: + +1. Add the include at the top of the file: + +```diff + #include "flutter_window.h" + + #include + + #include "flutter/generated_plugin_registrant.h" ++#include "desktop_multi_window/desktop_multi_window_plugin.h" +``` + +2. Register the callback in the `OnCreate()` method: + +```diff + RegisterPlugins(flutter_controller_->engine()); ++ DesktopMultiWindowSetWindowCreatedCallback([](void *controller) { ++ auto *flutter_view_controller = ++ reinterpret_cast(controller); ++ auto *registry = flutter_view_controller->engine(); ++ RegisterPlugins(registry); ++ }); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); +``` + +The `RegisterPlugins` function will automatically register all plugins for each new window. + +#### macOS + +Edit `macos/Runner/MainFlutterWindow.swift`: + +1. Add the import at the top of the file: + +```diff + import Cocoa + import FlutterMacOS ++import desktop_multi_window +``` + +2. Register the callback in the `awakeFromNib()` method: + +```diff + RegisterGeneratedPlugins(registry: flutterViewController) + ++ FlutterMultiWindowPlugin.setOnWindowCreatedCallback { controller in ++ // Register the plugin which you want access from other isolate. ++ RegisterGeneratedPlugins(registry: controller) ++ } ++ + super.awakeFromNib() +``` + +The `RegisterGeneratedPlugins` function will automatically register all plugins for each new window. + +#### Linux + +Edit `linux/my_application.cc`: + +1. Add the include at the top of the file: + +```diff + #include "my_application.h" + + #include + #ifdef GDK_WINDOWING_X11 + #include + #endif + + #include "flutter/generated_plugin_registrant.h" + ++#include "desktop_multi_window/desktop_multi_window_plugin.h" +``` + +2. Register the callback in the `my_application_activate()` function: + +```diff + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + ++ desktop_multi_window_plugin_set_window_created_callback([](FlPluginRegistry* registry){ ++ fl_register_plugins(registry); ++ }); ++ + gtk_widget_grab_focus(GTK_WIDGET(view)); +``` + +The `fl_register_plugins` function will automatically register all plugins for each new window. + +## Integration with window_manager + +This plugin works great with [window_manager](https://pub.dev/packages/window_manager) to control window properties: + +by now, you should this fork version with a bit fix + +```yaml + window_manager: + git: + url: https://github.com/boyan01/window_manager.git + path: packages/window_manager + ref: 6fae92d21b4c80ce1b8f71c1190d7970cf722bd4 +``` + +```dart +import 'package:window_manager/window_manager.dart'; + +// Configure window options +WindowOptions windowOptions = const WindowOptions( + size: Size(800, 600), + center: true, + backgroundColor: Colors.transparent, + skipTaskbar: false, + titleBarStyle: TitleBarStyle.hidden, +); + +windowManager.waitUntilReadyToShow(windowOptions, () async { + await windowManager.show(); + await windowManager.focus(); +}); + +// Prevent window from closing immediately +windowManager.setPreventClose(true); +windowManager.addListener(this); // Must implement WindowListener +``` + +## Example + +Check out the [example](example) directory for a complete working application that demonstrates: +- Creating multiple window types +- Single instance vs multi-instance windows +- Communication between windows +- Custom window extensions +- Plugin registration for video playback +- Window lifecycle management + +## License + +MIT diff --git a/third_party/desktop_multi_window/lib/desktop_multi_window.dart b/third_party/desktop_multi_window/lib/desktop_multi_window.dart new file mode 100644 index 0000000..2ae30eb --- /dev/null +++ b/third_party/desktop_multi_window/lib/desktop_multi_window.dart @@ -0,0 +1,3 @@ +export 'src/window_controller.dart'; +export 'src/window_configuration.dart'; +export 'src/window_channel.dart'; diff --git a/third_party/desktop_multi_window/lib/src/window_channel.dart b/third_party/desktop_multi_window/lib/src/window_channel.dart new file mode 100644 index 0000000..b27117e --- /dev/null +++ b/third_party/desktop_multi_window/lib/src/window_channel.dart @@ -0,0 +1,219 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +typedef MethodCallHandler = Future Function(MethodCall call); + +/// Channel communication mode +enum ChannelMode { + /// Unidirectional mode: All engines can invoke this channel + /// Only one engine can register as handler + unidirectional('unidirectional'), + + /// Bidirectional mode: Only paired engines can invoke each other + /// Maximum of 2 engines can register, and only they can call each other + bidirectional('bidirectional'); + + final String value; + const ChannelMode(this.value); +} + +/// Exception thrown when a window channel operation fails. +class WindowChannelException implements Exception { + final String code; + final String message; + final dynamic details; + + WindowChannelException(this.code, this.message, [this.details]); + + @override + String toString() { + if (details != null) { + return 'WindowChannelException($code, $message, $details)'; + } + return 'WindowChannelException($code, $message)'; + } +} + +/// A method channel for cross-window communication. +/// +/// Supports two modes: +/// - [ChannelMode.unidirectional]: One engine registers as handler, all engines can invoke +/// - [ChannelMode.bidirectional]: Two engines form a pair and can only invoke each other +class WindowMethodChannel { + final String name; + final ChannelMode mode; + + const WindowMethodChannel( + this.name, { + this.mode = ChannelMode.bidirectional, + }); + + /// Invokes a method on the target engine that has registered this channel. + /// + /// For unidirectional channels: Invokes the single registered handler + /// For bidirectional channels: Invokes the peer engine in the pair + /// + /// Throws [WindowChannelException] if: + /// - The channel is not registered + /// - The target engine is not available + /// - For bidirectional: caller is not part of the pair + @optionalTypeArgs + Future invokeMethod(String method, [dynamic arguments]) async { + _initializeChannelManager(); + try { + return await _invokeMethodOnChannel(name, method, arguments); + } on PlatformException catch (e) { + throw WindowChannelException( + e.code, + e.message ?? 'Failed to invoke method on channel $name', + e.details, + ); + } + } + + /// Sets the method call handler for this channel. + /// + /// The communication mode is determined by the [mode] parameter passed to the constructor: + /// - [ChannelMode.unidirectional]: Only one engine can register, all can invoke + /// - [ChannelMode.bidirectional]: Up to 2 engines can register, only they can invoke each other + /// + /// Pass `null` as handler to remove the handler and unregister the channel. + /// + /// Throws [WindowChannelException] if: + /// - Registration fails (e.g., channel limit reached) + /// - Mode conflicts with existing registration + Future setMethodCallHandler( + Future Function(MethodCall call)? handler, + ) async { + _initializeChannelManager(); + + if (handler != null) { + // Update handler if already registered + if (_registeredHandlers.containsKey(name)) { + _registeredHandlers[name] = handler; + return; + } + + // Register new handler + try { + await _registerMethodHandler(name, mode); + _registeredHandlers[name] = handler; + } on PlatformException catch (e) { + throw WindowChannelException( + e.code, + e.message ?? 'Failed to register handler for channel $name', + e.details, + ); + } + } else { + // Remove handler + if (!_registeredHandlers.containsKey(name)) { + return; + } + + try { + await _unregisterMethodHandler(name); + _registeredHandlers.remove(name); + } on PlatformException catch (e) { + // Even if unregistration fails, remove the handler locally + _registeredHandlers.remove(name); + if (kDebugMode) { + print( + 'Warning: Failed to unregister handler for channel $name: ${e.message}'); + } + } + } + } +} + +final _registeredHandlers = {}; + +const _methodChannel = MethodChannel('mixin.one/desktop_multi_window/channels'); + +bool _initialized = false; + +void _initializeChannelManager() { + if (_initialized) { + return; + } + _initialized = true; + _methodChannel.setMethodCallHandler((call) async { + if (call.method == 'methodCall') { + final arguments = call.arguments as Map; + final channelName = arguments['channel'] as String; + final method = arguments['method'] as String; + final args = arguments['arguments']; + + final handler = _registeredHandlers[channelName]; + if (handler == null) { + throw WindowChannelException( + 'NO_HANDLER', + 'No method call handler registered for channel $channelName', + ); + } + + final methodCall = MethodCall(method, args); + return await handler.call(methodCall); + } else { + throw MissingPluginException('No handler for method ${call.method}'); + } + }); +} + +Future _registerMethodHandler(String name, ChannelMode mode) async { + try { + await _methodChannel.invokeMethod('registerMethodHandler', { + 'channel': name, + 'mode': mode.value, + }); + } on PlatformException catch (e) { + if (e.code == 'CHANNEL_LIMIT_REACHED') { + throw WindowChannelException( + e.code, + mode == ChannelMode.unidirectional + ? 'Cannot register channel "$name": already registered in unidirectional mode' + : 'Cannot register channel "$name": maximum of 2 engines allowed per channel', + e.details, + ); + } else if (e.code == 'CHANNEL_MODE_CONFLICT') { + throw WindowChannelException( + e.code, + 'Cannot register channel "$name": already registered in a different mode', + e.details, + ); + } + rethrow; + } +} + +Future _unregisterMethodHandler(String name) async { + await _methodChannel.invokeMethod('unregisterMethodHandler', { + 'channel': name, + }); +} + +Future _invokeMethodOnChannel( + String name, String method, dynamic arguments) async { + try { + return await _methodChannel.invokeMethod('invokeMethod', { + 'channel': name, + 'method': method, + 'arguments': arguments, + }); + } on PlatformException catch (e) { + if (e.code == 'CHANNEL_UNREGISTERED') { + throw WindowChannelException( + e.code, + 'Channel "$name" not accessible (may be unregistered, bidirectional pair, or permission denied)', + e.details, + ); + } else if (e.code == 'CHANNEL_NOT_FOUND') { + throw WindowChannelException( + e.code, + 'Channel "$name" not found in target engine', + e.details, + ); + } + rethrow; + } +} diff --git a/third_party/desktop_multi_window/lib/src/window_configuration.dart b/third_party/desktop_multi_window/lib/src/window_configuration.dart new file mode 100644 index 0000000..fae3ad9 --- /dev/null +++ b/third_party/desktop_multi_window/lib/src/window_configuration.dart @@ -0,0 +1,43 @@ +class WindowConfiguration { + const WindowConfiguration({ + required this.arguments, + this.hiddenAtLaunch = true, + }); + + /// The arguments passed to the new window. + final String arguments; + + final bool hiddenAtLaunch; + + factory WindowConfiguration.fromJson(Map json) { + return WindowConfiguration( + arguments: json['arguments'] as String? ?? '', + hiddenAtLaunch: json['hiddenAtLaunch'] as bool? ?? false, + ); + } + + Map toJson() { + return { + 'arguments': arguments, + 'hiddenAtLaunch': hiddenAtLaunch, + }; + } + + @override + String toString() { + return 'WindowConfiguration(arguments: $arguments, hiddenAtLaunch: $hiddenAtLaunch)'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is WindowConfiguration && + other.arguments == arguments && + other.hiddenAtLaunch == hiddenAtLaunch; + } + + @override + int get hashCode { + return arguments.hashCode ^ hiddenAtLaunch.hashCode; + } +} diff --git a/third_party/desktop_multi_window/lib/src/window_controller.dart b/third_party/desktop_multi_window/lib/src/window_controller.dart new file mode 100644 index 0000000..805fd60 --- /dev/null +++ b/third_party/desktop_multi_window/lib/src/window_controller.dart @@ -0,0 +1,158 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import 'window_channel.dart'; +import 'window_configuration.dart'; + +final _windowEvent = _windowEventAsStream(); + +/// A listenable that notifies when the windows list changes. +/// Listen to this to be notified when windows are created or destroyed. +Stream get onWindowsChanged => _windowEvent.map((call) { + if (call.method == 'onWindowsChanged') { + return call.method; + } + return null; + }).where((event) => event != null); + +/// The [WindowController] instance that is used to control this window. +class WindowController { + WindowController._(this.windowId, this.arguments) + : _windowChannel = WindowMethodChannel( + 'mixin.one/window_controller/$windowId', + mode: ChannelMode.unidirectional, + ); + + final String windowId; + final String arguments; + + final WindowMethodChannel _windowChannel; + + factory WindowController.fromWindowId(String id) => + WindowController._(id, ''); + + static Future create( + WindowConfiguration configuration) async { + final windowId = await _channel.invokeMethod( + 'createWindow', + configuration.toJson(), + ); + assert(windowId != null, 'windowId is null'); + assert(windowId!.isNotEmpty, 'windowId is empty'); + return WindowController._(windowId!, configuration.arguments); + } + + static Future fromCurrentEngine() async { + final definition = await _channel + .invokeMethod>('getWindowDefinition'); + if (definition == null) { + throw Exception('Failed to get window definition'); + } + final windowId = definition['windowId'] as String; + final windowArgument = definition['windowArgument'] as String; + return WindowController._(windowId, windowArgument); + } + + static Future> getAll() async { + final result = await _channel.invokeMethod>('getAllWindows'); + if (result == null) { + return []; + } + return result.cast>().map((e) { + final windowId = e['windowId'] as String; + final windowArgument = e['windowArgument'] as String; + return WindowController._(windowId, windowArgument); + }).toList(); + } + + Future _callWindowMethod(String method, + [Map? arguments]) { + assert(windowId.isNotEmpty, 'windowId is empty'); + assert(method.startsWith('window_'), 'method must start with "window_"'); + return _channel.invokeMethod( + method, + { + 'windowId': windowId, + ...?arguments, + }, + ); + } + + Future show() => _callWindowMethod('window_show', {}); + + Future hide() => _callWindowMethod('window_hide', {}); + + /// Close (destroy) this window. (macOS) + Future close() => _callWindowMethod('window_close', {}); + + /// Position/size this window in screen coordinates. (macOS) + Future setFrame(Rect frame) => _callWindowMethod('window_setFrame', { + 'x': frame.left, + 'y': frame.top, + 'width': frame.width, + 'height': frame.height, + }); + + /// Make this window a borderless surface filling an entire screen. When + /// [external] is true the first non-main screen (e.g. a beamer) is used, + /// otherwise the main screen. The window does not become key, so keyboard + /// focus stays with the window that had it. (macOS) + Future coverScreen({bool external = true}) => + _callWindowMethod('window_coverScreen', {'external': external}); + + @optionalTypeArgs + Future invokeMethod(String method, [dynamic arguments]) => + _windowChannel.invokeMethod(method, arguments); + + Future setWindowMethodHandler( + Future Function(MethodCall call)? handler) { + assert(() { + scheduleMicrotask(() async { + final c = await WindowController.fromCurrentEngine(); + if (c.windowId != windowId) { + throw FlutterError( + 'setWindowMethodHandler can only be called on the current window controller. ' + 'Current windowId: ${c.windowId}, this windowId: $windowId'); + } + }); + return true; + }()); + return _windowChannel.setMethodCallHandler(handler); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other.runtimeType != runtimeType) return false; + final WindowController otherController = other as WindowController; + return windowId == otherController.windowId && + arguments == otherController.arguments; + } + + @override + int get hashCode => windowId.hashCode ^ arguments.hashCode; + + @override + String toString() { + return 'WindowController(windowId: $windowId, arguments: $arguments)'; + } +} + +final _channel = MethodChannel('mixin.one/desktop_multi_window'); + +Stream _windowEventAsStream() { + late StreamController controller; + controller = StreamController.broadcast( + onListen: () { + _channel.setMethodCallHandler((call) async { + controller.add(call); + }); + }, + onCancel: () { + _channel.setMethodCallHandler(null); + }, + ); + return controller.stream; +} diff --git a/third_party/desktop_multi_window/linux/CMakeLists.txt b/third_party/desktop_multi_window/linux/CMakeLists.txt new file mode 100644 index 0000000..a97788a --- /dev/null +++ b/third_party/desktop_multi_window/linux/CMakeLists.txt @@ -0,0 +1,27 @@ +cmake_minimum_required(VERSION 3.10) +set(PROJECT_NAME "desktop_multi_window") +project(${PROJECT_NAME} LANGUAGES CXX) + +# This value is used when generating builds using this plugin, so it must +# not be changed +set(PLUGIN_NAME "desktop_multi_window_plugin") + +add_library(${PLUGIN_NAME} SHARED + "desktop_multi_window_plugin.cc" + "multi_window_manager.cc" + "flutter_window.cc" + "window_channel_plugin.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) + +# List of absolute paths to libraries that should be bundled with the plugin +set(desktop_multi_window_bundled_libraries + "" + PARENT_SCOPE +) diff --git a/third_party/desktop_multi_window/linux/desktop_multi_window_plugin.cc b/third_party/desktop_multi_window/linux/desktop_multi_window_plugin.cc new file mode 100644 index 0000000..3c74152 --- /dev/null +++ b/third_party/desktop_multi_window/linux/desktop_multi_window_plugin.cc @@ -0,0 +1,137 @@ +#include "include/desktop_multi_window/desktop_multi_window_plugin.h" + +#include +#include + +#include + +#include "desktop_multi_window_plugin_internal.h" +#include "flutter_window.h" +#include "multi_window_manager.h" +#include "window_channel_plugin.h" + +#define DESKTOP_MULTI_WINDOW_PLUGIN(obj) \ + (G_TYPE_CHECK_INSTANCE_CAST((obj), desktop_multi_window_plugin_get_type(), \ + DesktopMultiWindowPlugin)) + +struct _DesktopMultiWindowPlugin { + GObject parent_instance; + FlutterWindow* window; +}; + +G_DEFINE_TYPE(DesktopMultiWindowPlugin, + desktop_multi_window_plugin, + g_object_get_type()) + +// Called when a method call is received from Flutter. +static void desktop_multi_window_plugin_handle_method_call( + DesktopMultiWindowPlugin* self, + FlMethodCall* method_call) { + const gchar* method = fl_method_call_get_name(method_call); + + // Check if this is a window-specific method (starts with "window_") + if (g_str_has_prefix(method, "window_")) { + auto* args = fl_method_call_get_args(method_call); + auto window_id_value = fl_value_lookup_string(args, "windowId"); + if (window_id_value == nullptr) { + g_autoptr(FlMethodResponse) response = FL_METHOD_RESPONSE( + fl_method_error_response_new("-1", "windowId is required", nullptr)); + fl_method_call_respond(method_call, response, nullptr); + return; + } + + const gchar* window_id = fl_value_get_string(window_id_value); + auto window = MultiWindowManager::Instance()->GetWindow(window_id); + if (!window) { + g_autofree gchar* error_msg = + g_strdup_printf("failed to find target window: %s", window_id); + g_autoptr(FlMethodResponse) response = FL_METHOD_RESPONSE( + fl_method_error_response_new("-1", error_msg, nullptr)); + fl_method_call_respond(method_call, response, nullptr); + return; + } + + window->HandleWindowMethod(method, args, method_call); + return; // Window handles the response + } + + g_autoptr(FlMethodResponse) response = nullptr; + + if (strcmp(method, "createWindow") == 0) { + auto* args = fl_method_call_get_args(method_call); + auto window_id = MultiWindowManager::Instance()->Create(args); + response = FL_METHOD_RESPONSE( + fl_method_success_response_new(fl_value_new_string(window_id.c_str()))); + } else if (strcmp(method, "getWindowDefinition") == 0) { + auto window_id = self->window->GetWindowId(); + auto window_argument = self->window->GetWindowArgument(); + + g_autoptr(FlValue) definition = fl_value_new_map(); + fl_value_set_string_take(definition, "windowId", + fl_value_new_string(window_id.c_str())); + fl_value_set_string_take(definition, "windowArgument", + fl_value_new_string(window_argument.c_str())); + + response = FL_METHOD_RESPONSE(fl_method_success_response_new(definition)); + } else if (strcmp(method, "getAllWindows") == 0) { + auto windows = MultiWindowManager::Instance()->GetAllWindows(); + response = FL_METHOD_RESPONSE(fl_method_success_response_new(windows)); + } else { + response = FL_METHOD_RESPONSE(fl_method_not_implemented_response_new()); + } + + fl_method_call_respond(method_call, response, nullptr); +} + +static void desktop_multi_window_plugin_dispose(GObject* object) { + G_OBJECT_CLASS(desktop_multi_window_plugin_parent_class)->dispose(object); +} + +static void desktop_multi_window_plugin_class_init( + DesktopMultiWindowPluginClass +* klass) { + G_OBJECT_CLASS(klass)->dispose = desktop_multi_window_plugin_dispose; +} + +static void desktop_multi_window_plugin_init(DesktopMultiWindowPlugin* self) {} + +static void method_call_cb(FlMethodChannel* channel, + FlMethodCall* method_call, + gpointer user_data) { + DesktopMultiWindowPlugin* plugin = DESKTOP_MULTI_WINDOW_PLUGIN(user_data); + desktop_multi_window_plugin_handle_method_call(plugin, method_call); +} + +void desktop_multi_window_plugin_register_with_registrar_internal( + FlPluginRegistrar* registrar, + FlutterWindow* window) { + DesktopMultiWindowPlugin* plugin = DESKTOP_MULTI_WINDOW_PLUGIN( + g_object_new(desktop_multi_window_plugin_get_type(), nullptr)); + plugin->window = window; + + g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); + FlMethodChannel* channel = fl_method_channel_new( + fl_plugin_registrar_get_messenger(registrar), + "mixin.one/desktop_multi_window", FL_METHOD_CODEC(codec)); + fl_method_channel_set_method_call_handler( + channel, method_call_cb, g_object_ref(plugin), g_object_unref); + + // Set channel to window for event notifications + window->SetChannel(channel); + + // Register WindowChannel plugin for each engine + window_channel_plugin_register_with_registrar(registrar); + + g_object_unref(plugin); +} + +void desktop_multi_window_plugin_register_with_registrar( + FlPluginRegistrar* registrar) { + auto view = fl_plugin_registrar_get_view(registrar); + auto window = gtk_widget_get_toplevel(GTK_WIDGET(view)); + if (GTK_IS_WINDOW(window)) { + MultiWindowManager::Instance()->AttachMainWindow(window, registrar); + } else { // variant + g_critical("can not find GtkWindow instance for main window."); + } +} diff --git a/third_party/desktop_multi_window/linux/desktop_multi_window_plugin_internal.h b/third_party/desktop_multi_window/linux/desktop_multi_window_plugin_internal.h new file mode 100644 index 0000000..21242da --- /dev/null +++ b/third_party/desktop_multi_window/linux/desktop_multi_window_plugin_internal.h @@ -0,0 +1,12 @@ +#ifndef DESKTOP_MULTI_WINDOW_LINUX_DESKTOP_MULTI_WINDOW_PLUGIN_INTERNAL_H_ +#define DESKTOP_MULTI_WINDOW_LINUX_DESKTOP_MULTI_WINDOW_PLUGIN_INTERNAL_H_ + +#include "flutter_linux/flutter_linux.h" + +class FlutterWindow; + +void desktop_multi_window_plugin_register_with_registrar_internal( + FlPluginRegistrar* registrar, + FlutterWindow* window); + +#endif // DESKTOP_MULTI_WINDOW_LINUX_DESKTOP_MULTI_WINDOW_PLUGIN_INTERNAL_H_ diff --git a/third_party/desktop_multi_window/linux/flutter_window.cc b/third_party/desktop_multi_window/linux/flutter_window.cc new file mode 100755 index 0000000..8a7a209 --- /dev/null +++ b/third_party/desktop_multi_window/linux/flutter_window.cc @@ -0,0 +1,52 @@ +#include "flutter_window.h" + +#include + +FlutterWindow::FlutterWindow(const std::string& id, + const std::string& argument, + GtkWidget* window) + : id_(id), window_argument_(argument), window_(window) {} + +FlutterWindow::~FlutterWindow() = default; + +void FlutterWindow::SetChannel(FlMethodChannel* channel) { + channel_ = channel; +} + +void FlutterWindow::NotifyWindowEvent(const gchar* event, FlValue* data) { + if (channel_) { + fl_method_channel_invoke_method(channel_, event, data, nullptr, nullptr, nullptr); + } +} + +void FlutterWindow::Show() { + if (window_) { + gtk_widget_show(GTK_WIDGET(window_)); + } +} + +void FlutterWindow::Hide() { + if (window_) { + gtk_widget_hide(GTK_WIDGET(window_)); + } +} + +void FlutterWindow::HandleWindowMethod(const gchar* method, + FlValue* arguments, + FlMethodCall* method_call) { + g_autoptr(FlMethodResponse) response = nullptr; + + if (strcmp(method, "window_show") == 0) { + Show(); + response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr)); + } else if (strcmp(method, "window_hide") == 0) { + Hide(); + response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr)); + } else { + g_autofree gchar* error_msg = g_strdup_printf("unknown method: %s", method); + response = FL_METHOD_RESPONSE( + fl_method_error_response_new("-1", error_msg, nullptr)); + } + + fl_method_call_respond(method_call, response, nullptr); +} diff --git a/third_party/desktop_multi_window/linux/flutter_window.h b/third_party/desktop_multi_window/linux/flutter_window.h new file mode 100755 index 0000000..fcce292 --- /dev/null +++ b/third_party/desktop_multi_window/linux/flutter_window.h @@ -0,0 +1,44 @@ +#ifndef DESKTOP_MULTI_WINDOW_WINDOWS_FLUTTER_WINDOW_H_ +#define DESKTOP_MULTI_WINDOW_WINDOWS_FLUTTER_WINDOW_H_ + +#include +#include +#include +#include + +#include +#include + +class FlutterWindow { + public: + FlutterWindow(const std::string& id, + const std::string& argument, + GtkWidget* window); + ~FlutterWindow(); + + std::string GetWindowId() const { return id_; } + + std::string GetWindowArgument() const { return window_argument_; } + + GtkWindow* GetWindow() { return GTK_WINDOW(window_); } + + void SetChannel(FlMethodChannel* channel); + + void NotifyWindowEvent(const gchar* event, FlValue* data); + + void Show(); + + void Hide(); + + void HandleWindowMethod(const gchar* method, + FlValue* arguments, + FlMethodCall* method_call); + + private: + std::string id_; + std::string window_argument_; + GtkWidget* window_ = nullptr; + FlMethodChannel* channel_ = nullptr; +}; + +#endif // DESKTOP_MULTI_WINDOW_WINDOWS_FLUTTER_WINDOW_H_ diff --git a/third_party/desktop_multi_window/linux/include/desktop_multi_window/desktop_multi_window_plugin.h b/third_party/desktop_multi_window/linux/include/desktop_multi_window/desktop_multi_window_plugin.h new file mode 100644 index 0000000..dd5c136 --- /dev/null +++ b/third_party/desktop_multi_window/linux/include/desktop_multi_window/desktop_multi_window_plugin.h @@ -0,0 +1,32 @@ +#ifndef FLUTTER_PLUGIN_DESKTOP_MULTI_WINDOW_PLUGIN_H_ +#define FLUTTER_PLUGIN_DESKTOP_MULTI_WINDOW_PLUGIN_H_ + +#include + +G_BEGIN_DECLS + +#ifdef FLUTTER_PLUGIN_IMPL +#define FLUTTER_PLUGIN_EXPORT __attribute__((visibility("default"))) +#else +#define FLUTTER_PLUGIN_EXPORT +#endif + +typedef struct _DesktopMultiWindowPlugin DesktopMultiWindowPlugin; +typedef struct { + GObjectClass parent_class; +} DesktopMultiWindowPluginClass; + +FLUTTER_PLUGIN_EXPORT GType desktop_multi_window_plugin_get_type(); + +FLUTTER_PLUGIN_EXPORT void desktop_multi_window_plugin_register_with_registrar( + FlPluginRegistrar* registrar); + +typedef void (*WindowCreatedCallback)(FlPluginRegistry *registry); + +FLUTTER_PLUGIN_EXPORT void desktop_multi_window_plugin_set_window_created_callback( + WindowCreatedCallback callback); + + +G_END_DECLS + +#endif // FLUTTER_PLUGIN_DESKTOP_MULTI_WINDOW_PLUGIN_H_ diff --git a/third_party/desktop_multi_window/linux/multi_window_manager.cc b/third_party/desktop_multi_window/linux/multi_window_manager.cc new file mode 100755 index 0000000..f00c6dd --- /dev/null +++ b/third_party/desktop_multi_window/linux/multi_window_manager.cc @@ -0,0 +1,235 @@ +#include "multi_window_manager.h" + +#include +#include +#include + +#include "desktop_multi_window_plugin_internal.h" +#include "flutter_window.h" +#include "include/desktop_multi_window/desktop_multi_window_plugin.h" +#include "window_configuration.h" +#ifdef GDK_WINDOWING_X11 +#include +#endif + +namespace { + +std::string GenerateWindowId() { + std::random_device rd; + std::mt19937_64 gen(rd()); + std::uniform_int_distribution dis; + + uint64_t part1 = dis(gen); + uint64_t part2 = dis(gen); + + part1 = (part1 & 0xFFFFFFFFFFFF0FFFULL) | 0x0000000000004000ULL; + part2 = (part2 & 0x3FFFFFFFFFFFFFFFULL) | 0x8000000000000000ULL; + + char uuid_str[37]; + snprintf(uuid_str, sizeof(uuid_str), "%08x-%04x-%04x-%04x-%012llx", + static_cast(part1 >> 32), + static_cast(part1 >> 16), static_cast(part1), + static_cast(part2 >> 48), part2 & 0xFFFFFFFFFFFFULL); + + return std::string(uuid_str); +} + +WindowCreatedCallback _g_window_created_callback = nullptr; + +} // namespace + +// static +MultiWindowManager* MultiWindowManager::Instance() { + static auto manager = std::make_shared(); + return manager.get(); +} + +MultiWindowManager::MultiWindowManager() : windows_() {} + +MultiWindowManager::~MultiWindowManager() = default; + +std::string MultiWindowManager::Create(FlValue* args) { + WindowConfiguration config = WindowConfiguration::FromFlValue(args); + std::string window_id = GenerateWindowId(); + + // Create GTK window + GtkApplication* app = GTK_APPLICATION(g_application_get_default()); + GtkWindow* window = GTK_WINDOW(gtk_application_window_new(app)); + gtk_application_add_window(app, window); + + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, ""); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, ""); + } + + gtk_window_set_default_size(window, 1280, 720); + + gtk_window_set_title(window, ""); + if (config.hidden_at_launch) { + gtk_widget_realize(GTK_WIDGET(window)); + } else { + gtk_widget_show(GTK_WIDGET(window)); + } + + // Create FlutterWindow instance + auto w = std::make_unique(window_id, config.arguments, + GTK_WIDGET(window)); + windows_[window_id] = std::move(w); + + // Setup Flutter project + g_autoptr(FlDartProject) project = fl_dart_project_new(); + const char* entrypoint_args[] = {"multi_window", window_id.c_str(), + config.arguments.c_str(), nullptr}; + fl_dart_project_set_dart_entrypoint_arguments( + project, const_cast(entrypoint_args)); + + // Create Flutter view + auto fl_view = fl_view_new(project); + gtk_widget_show(GTK_WIDGET(fl_view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(fl_view)); + + // Issues from flutter/engine: https://github.com/flutter/engine/pull/40033 + // Prevent delete-event from flutter engine shell, which will quit the whole + // appplication when the window is closed. this can be done by + // [window_manager] plugin, but we need it here if user is not using that + // plugin. + guint handler_id = g_signal_handler_find(window, G_SIGNAL_MATCH_DATA, 0, 0, + NULL, NULL, fl_view); + if (handler_id > 0) { + g_signal_handler_disconnect(window, handler_id); + } + + // Call window created callback + if (_g_window_created_callback) { + _g_window_created_callback(FL_PLUGIN_REGISTRY(fl_view)); + } + + ObserveWindowClose(window_id, window); + // Register plugin + g_autoptr(FlPluginRegistrar) desktop_multi_window_registrar = + fl_plugin_registry_get_registrar_for_plugin(FL_PLUGIN_REGISTRY(fl_view), + "DesktopMultiWindowPlugin"); + + desktop_multi_window_plugin_register_with_registrar_internal( + desktop_multi_window_registrar, windows_[window_id].get()); + + gtk_widget_grab_focus(GTK_WIDGET(fl_view)); + + // Notify all windows about the change + NotifyWindowsChanged(); + + return window_id; +} + +void MultiWindowManager::AttachMainWindow(GtkWidget* window_widget, + FlPluginRegistrar* registrar) { + // check window widget is in windows_ + for (const auto& pair : windows_) { + if (pair.second->GetWindow() == GTK_WINDOW(window_widget)) { + return; + } + } + + const std::string main_window_id = GenerateWindowId(); + auto window = + std::make_unique(main_window_id, "", window_widget); + windows_[main_window_id] = std::move(window); + + ObserveWindowClose(main_window_id, GTK_WINDOW(window_widget)); + desktop_multi_window_plugin_register_with_registrar_internal( + registrar, windows_[main_window_id].get()); + + // Notify all windows about the change + NotifyWindowsChanged(); +} + +void MultiWindowManager::ObserveWindowClose(const std::string& window_id, + GtkWindow* window) { + g_signal_connect( + GTK_WIDGET(window), "destroy", + G_CALLBACK(+[](GtkWidget* widget, gpointer arg) { + auto* window_id_ptr = static_cast(arg); + + GtkWidget* child = gtk_bin_get_child(GTK_BIN(widget)); + if (child && FL_IS_VIEW(child)) { + gtk_container_remove(GTK_CONTAINER(widget), child); + } + + MultiWindowManager::Instance()->RemoveWindow(*window_id_ptr); + delete window_id_ptr; + }), + new std::string(window_id)); +} + +FlutterWindow* MultiWindowManager::GetWindow(const std::string& window_id) { + auto it = windows_.find(window_id); + if (it != windows_.end()) { + return it->second.get(); + } + return nullptr; +} + +FlValue* MultiWindowManager::GetAllWindows() { + g_autoptr(FlValue) windows = fl_value_new_list(); + for (const auto& pair : windows_) { + g_autoptr(FlValue) window_info = fl_value_new_map(); + fl_value_set_string_take( + window_info, "windowId", + fl_value_new_string(pair.second->GetWindowId().c_str())); + fl_value_set_string_take( + window_info, "windowArgument", + fl_value_new_string(pair.second->GetWindowArgument().c_str())); + fl_value_append_take(windows, fl_value_ref(window_info)); + } + return fl_value_ref(windows); +} + +std::vector MultiWindowManager::GetAllWindowIds() { + std::vector window_ids; + for (const auto& pair : windows_) { + window_ids.push_back(pair.first); + } + return window_ids; +} + +void MultiWindowManager::NotifyWindowsChanged() { + auto window_ids = GetAllWindowIds(); + + g_autoptr(FlValue) window_ids_list = fl_value_new_list(); + for (const auto& id : window_ids) { + fl_value_append_take(window_ids_list, fl_value_new_string(id.c_str())); + } + + g_autoptr(FlValue) data = fl_value_new_map(); + fl_value_set_string_take(data, "windowIds", fl_value_ref(window_ids_list)); + + for (const auto& pair : windows_) { + pair.second->NotifyWindowEvent("onWindowsChanged", data); + } +} + +void MultiWindowManager::RemoveWindow(const std::string& window_id) { + g_warning("RemoveWindow: %s", window_id.c_str()); + windows_.erase(window_id); + NotifyWindowsChanged(); +} + +void desktop_multi_window_plugin_set_window_created_callback( + WindowCreatedCallback callback) { + _g_window_created_callback = callback; +} diff --git a/third_party/desktop_multi_window/linux/multi_window_manager.h b/third_party/desktop_multi_window/linux/multi_window_manager.h new file mode 100755 index 0000000..c064948 --- /dev/null +++ b/third_party/desktop_multi_window/linux/multi_window_manager.h @@ -0,0 +1,47 @@ +#ifndef DESKTOP_MULTI_WINDOW_WINDOWS_MULTI_WINDOW_MANAGER_H_ +#define DESKTOP_MULTI_WINDOW_WINDOWS_MULTI_WINDOW_MANAGER_H_ + +#include +#include +#include +#include +#include + +#include +#include + +#include "flutter_window.h" + +class MultiWindowManager + : public std::enable_shared_from_this { + public: + static MultiWindowManager* Instance(); + + MultiWindowManager(); + + virtual ~MultiWindowManager(); + + std::string Create(FlValue* args); + + void AttachMainWindow(GtkWidget* main_flutter_window, + FlPluginRegistrar* registrar); + + FlutterWindow* GetWindow(const std::string& window_id); + + FlValue* GetAllWindows(); + + std::vector GetAllWindowIds(); + + void RemoveWindow(const std::string& window_id); + + private: + + void ObserveWindowClose(const std::string& window_id, + GtkWindow* window); + + void NotifyWindowsChanged(); + + std::map> windows_; +}; + +#endif // DESKTOP_MULTI_WINDOW_WINDOWS_MULTI_WINDOW_MANAGER_H_ diff --git a/third_party/desktop_multi_window/linux/window_channel_plugin.cc b/third_party/desktop_multi_window/linux/window_channel_plugin.cc new file mode 100644 index 0000000..bb91937 --- /dev/null +++ b/third_party/desktop_multi_window/linux/window_channel_plugin.cc @@ -0,0 +1,370 @@ +#include "window_channel_plugin.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +enum class ChannelMode { kUnidirectional, kBidirectional }; + +enum class RegistrationOutcome { + kAdded, + kAlreadyRegistered, + kLimitReached, + kModeConflict +}; + +struct _WindowChannelPlugin { + GObject parent_instance; + FlMethodChannel* channel; + std::vector* registered_channels; +}; + +G_DEFINE_TYPE(WindowChannelPlugin, window_channel_plugin, G_TYPE_OBJECT) + +class ChannelRegistry { + public: + static ChannelRegistry& GetInstance() { + static ChannelRegistry instance; + return instance; + } + + RegistrationOutcome Register(const std::string& channel, + WindowChannelPlugin* plugin, + ChannelMode mode) { + std::lock_guard lock(mutex_); + + if (mode == ChannelMode::kUnidirectional) { + return RegisterUnidirectional(channel, plugin); + } else { + return RegisterBidirectional(channel, plugin); + } + } + + private: + RegistrationOutcome RegisterUnidirectional(const std::string& channel, + WindowChannelPlugin* plugin) { + // Check if already used in bidirectional mode + if (bidirectional_channels_.find(channel) != + bidirectional_channels_.end()) { + return RegistrationOutcome::kModeConflict; + } + + auto it = unidirectional_channels_.find(channel); + if (it != unidirectional_channels_.end()) { + if (it->second == plugin) { + return RegistrationOutcome::kAlreadyRegistered; + } + // Already registered by another plugin + return RegistrationOutcome::kLimitReached; + } + + unidirectional_channels_[channel] = plugin; + return RegistrationOutcome::kAdded; + } + + RegistrationOutcome RegisterBidirectional(const std::string& channel, + WindowChannelPlugin* plugin) { + // Check if already used in unidirectional mode + if (unidirectional_channels_.find(channel) != + unidirectional_channels_.end()) { + return RegistrationOutcome::kModeConflict; + } + + auto& plugins = bidirectional_channels_[channel]; + + // Check if already registered + if (plugins.find(plugin) != plugins.end()) { + return RegistrationOutcome::kAlreadyRegistered; + } + + // Check limit + if (plugins.size() >= 2) { + return RegistrationOutcome::kLimitReached; + } + + plugins.insert(plugin); + return RegistrationOutcome::kAdded; + } + + public: + void Unregister(const std::string& channel, WindowChannelPlugin* plugin) { + std::lock_guard lock(mutex_); + + // Try unidirectional + auto uni_it = unidirectional_channels_.find(channel); + if (uni_it != unidirectional_channels_.end() && + uni_it->second == plugin) { + unidirectional_channels_.erase(uni_it); + return; + } + + // Try bidirectional + auto bi_it = bidirectional_channels_.find(channel); + if (bi_it != bidirectional_channels_.end()) { + bi_it->second.erase(plugin); + if (bi_it->second.empty()) { + bidirectional_channels_.erase(bi_it); + } + } + } + + WindowChannelPlugin* GetTarget(const std::string& channel, + WindowChannelPlugin* from) { + std::lock_guard lock(mutex_); + + // Check unidirectional - anyone can call + auto uni_it = unidirectional_channels_.find(channel); + if (uni_it != unidirectional_channels_.end()) { + return uni_it->second; + } + + // Check bidirectional - only peer can call + auto bi_it = bidirectional_channels_.find(channel); + if (bi_it != bidirectional_channels_.end()) { + const auto& plugins = bi_it->second; + + // Check if caller is in the pair + if (plugins.find(from) == plugins.end()) { + return nullptr; + } + + // Return the peer + for (auto* plugin : plugins) { + if (plugin != from) { + return plugin; + } + } + } + + return nullptr; + } + + bool HasRegistrations(const std::string& channel) { + std::lock_guard lock(mutex_); + + if (unidirectional_channels_.find(channel) != + unidirectional_channels_.end()) { + return true; + } + + auto it = bidirectional_channels_.find(channel); + return it != bidirectional_channels_.end() && !it->second.empty(); + } + + private: + ChannelRegistry() = default; + std::mutex mutex_; + std::map unidirectional_channels_; + std::map> + bidirectional_channels_; +}; + +static void window_channel_plugin_dispose(GObject* object) { + WindowChannelPlugin* self = (WindowChannelPlugin*)object; + + if (self->registered_channels) { + for (const auto& channel : *self->registered_channels) { + ChannelRegistry::GetInstance().Unregister(channel, self); + } + delete self->registered_channels; + self->registered_channels = nullptr; + } + + G_OBJECT_CLASS(window_channel_plugin_parent_class)->dispose(object); +} + +static void window_channel_plugin_class_init(WindowChannelPluginClass* klass) { + G_OBJECT_CLASS(klass)->dispose = window_channel_plugin_dispose; +} + +static void window_channel_plugin_init(WindowChannelPlugin* self) { + self->registered_channels = new std::vector(); +} + +void window_channel_plugin_invoke_method(WindowChannelPlugin* self, + const gchar* channel, + FlValue* arguments, + FlMethodCall* method_call) { + // Check if this plugin has registered this channel + auto it = std::find(self->registered_channels->begin(), + self->registered_channels->end(), std::string(channel)); + if (it == self->registered_channels->end()) { + g_autofree gchar* error_msg = + g_strdup_printf("channel %s not found in this engine", channel); + fl_method_call_respond_error(method_call, "CHANNEL_NOT_FOUND", error_msg, + nullptr, nullptr); + return; + } + + fl_method_channel_invoke_method(self->channel, "methodCall", arguments, + nullptr, + +[](GObject* source_object, GAsyncResult* res, + gpointer user_data) { + auto* call = (FlMethodCall*)user_data; + GError* error = nullptr; + auto* result = fl_method_channel_invoke_method_finish( + FL_METHOD_CHANNEL(source_object), res, + &error); + if (error != nullptr) { + fl_method_call_respond_error( + call, "INVOKE_ERROR", error->message, + nullptr, nullptr); + g_error_free(error); + } else { + fl_method_call_respond(call, result, + nullptr); + } + g_object_unref(call); + }, + g_object_ref(method_call)); +} + +static void handle_method_call(FlMethodChannel* channel, + FlMethodCall* method_call, + gpointer user_data) { + WindowChannelPlugin* self = (WindowChannelPlugin*)user_data; + + const gchar* method = fl_method_call_get_name(method_call); + FlValue* args = fl_method_call_get_args(method_call); + + if (strcmp(method, "registerMethodHandler") == 0) { + FlValue* channel_value = fl_value_lookup_string(args, "channel"); + if (channel_value == nullptr || + fl_value_get_type(channel_value) != FL_VALUE_TYPE_STRING) { + fl_method_call_respond_error(method_call, "INVALID_ARGUMENTS", + "channel is required", nullptr, nullptr); + return; + } + + const gchar* channel_name = fl_value_get_string(channel_value); + + // Get mode (default to bidirectional) + ChannelMode mode = ChannelMode::kBidirectional; + FlValue* mode_value = fl_value_lookup_string(args, "mode"); + if (mode_value != nullptr && + fl_value_get_type(mode_value) == FL_VALUE_TYPE_STRING) { + const gchar* mode_str = fl_value_get_string(mode_value); + if (strcmp(mode_str, "unidirectional") == 0) { + mode = ChannelMode::kUnidirectional; + } else if (strcmp(mode_str, "bidirectional") == 0) { + mode = ChannelMode::kBidirectional; + } else { + g_autofree gchar* error_msg = g_strdup_printf( + "invalid mode: %s, must be 'unidirectional' or 'bidirectional'", + mode_str); + fl_method_call_respond_error(method_call, "INVALID_MODE", error_msg, + nullptr, nullptr); + return; + } + } + + auto outcome = + ChannelRegistry::GetInstance().Register(channel_name, self, mode); + + switch (outcome) { + case RegistrationOutcome::kAdded: + self->registered_channels->push_back(channel_name); + fl_method_call_respond_success(method_call, nullptr, nullptr); + break; + case RegistrationOutcome::kAlreadyRegistered: + fl_method_call_respond_success(method_call, nullptr, nullptr); + break; + case RegistrationOutcome::kLimitReached: { + g_autofree gchar* error_msg; + if (mode == ChannelMode::kUnidirectional) { + error_msg = g_strdup_printf( + "channel %s already registered in unidirectional mode", + channel_name); + } else { + error_msg = g_strdup_printf( + "channel %s already has the maximum number of registrations (2)", + channel_name); + } + fl_method_call_respond_error(method_call, "CHANNEL_LIMIT_REACHED", + error_msg, nullptr, nullptr); + break; + } + case RegistrationOutcome::kModeConflict: { + g_autofree gchar* error_msg = g_strdup_printf( + "channel %s is already registered in a different mode", + channel_name); + fl_method_call_respond_error(method_call, "CHANNEL_MODE_CONFLICT", + error_msg, nullptr, nullptr); + break; + } + } + } else if (strcmp(method, "unregisterMethodHandler") == 0) { + FlValue* channel_value = fl_value_lookup_string(args, "channel"); + if (channel_value == nullptr || + fl_value_get_type(channel_value) != FL_VALUE_TYPE_STRING) { + fl_method_call_respond_error(method_call, "INVALID_ARGUMENTS", + "channel is required", nullptr, nullptr); + return; + } + + const gchar* channel_name = fl_value_get_string(channel_value); + ChannelRegistry::GetInstance().Unregister(channel_name, self); + + auto it = std::find(self->registered_channels->begin(), + self->registered_channels->end(), + std::string(channel_name)); + if (it != self->registered_channels->end()) { + self->registered_channels->erase(it); + } + + fl_method_call_respond_success(method_call, nullptr, nullptr); + } else if (strcmp(method, "invokeMethod") == 0) { + FlValue* channel_value = fl_value_lookup_string(args, "channel"); + if (channel_value == nullptr || + fl_value_get_type(channel_value) != FL_VALUE_TYPE_STRING) { + fl_method_call_respond_error(method_call, "INVALID_ARGUMENTS", + "channel is required", nullptr, nullptr); + return; + } + + const gchar* channel_name = fl_value_get_string(channel_value); + auto* target = ChannelRegistry::GetInstance().GetTarget(channel_name, self); + + if (target) { + window_channel_plugin_invoke_method(target, channel_name, args, + method_call); + } else { + g_autofree gchar* error_msg; + if (ChannelRegistry::GetInstance().HasRegistrations(channel_name)) { + error_msg = g_strdup_printf( + "channel %s not accessible from this engine (may be bidirectional " + "pair or not registered)", + channel_name); + } else { + error_msg = + g_strdup_printf("unknown registered channel %s", channel_name); + } + fl_method_call_respond_error(method_call, "CHANNEL_UNREGISTERED", + error_msg, nullptr, nullptr); + } + } else { + fl_method_call_respond_not_implemented(method_call, nullptr); + } +} + +void window_channel_plugin_register_with_registrar( + FlPluginRegistrar* registrar) { + WindowChannelPlugin* plugin = (WindowChannelPlugin*)g_object_new( + window_channel_plugin_get_type(), nullptr); + + g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); + plugin->channel = fl_method_channel_new( + fl_plugin_registrar_get_messenger(registrar), + "mixin.one/desktop_multi_window/channels", FL_METHOD_CODEC(codec)); + + fl_method_channel_set_method_call_handler(plugin->channel, handle_method_call, + plugin, g_object_unref); + + // Keep plugin alive - it will be cleaned up when the registrar is destroyed + g_object_ref(plugin); +} diff --git a/third_party/desktop_multi_window/linux/window_channel_plugin.h b/third_party/desktop_multi_window/linux/window_channel_plugin.h new file mode 100644 index 0000000..60254ce --- /dev/null +++ b/third_party/desktop_multi_window/linux/window_channel_plugin.h @@ -0,0 +1,18 @@ +#ifndef DESKTOP_MULTI_WINDOW_LINUX_WINDOW_CHANNEL_PLUGIN_H_ +#define DESKTOP_MULTI_WINDOW_LINUX_WINDOW_CHANNEL_PLUGIN_H_ + +#include + +G_BEGIN_DECLS + +G_DECLARE_FINAL_TYPE(WindowChannelPlugin, + window_channel_plugin, + WINDOW, + CHANNEL_PLUGIN, + GObject) + +void window_channel_plugin_register_with_registrar(FlPluginRegistrar* registrar); + +G_END_DECLS + +#endif // DESKTOP_MULTI_WINDOW_LINUX_WINDOW_CHANNEL_PLUGIN_H_ diff --git a/third_party/desktop_multi_window/linux/window_configuration.h b/third_party/desktop_multi_window/linux/window_configuration.h new file mode 100644 index 0000000..1ab3ff6 --- /dev/null +++ b/third_party/desktop_multi_window/linux/window_configuration.h @@ -0,0 +1,42 @@ +#pragma once + +#include +#include + +struct WindowConfiguration { + std::string arguments; + bool hidden_at_launch = false; + + static WindowConfiguration FromFlValue(FlValue* value) { + WindowConfiguration config; + + if (!value || fl_value_get_type(value) != FL_VALUE_TYPE_MAP) { + return config; + } + + FlValue* arguments_value = fl_value_lookup_string(value, "arguments"); + if (arguments_value && + fl_value_get_type(arguments_value) == FL_VALUE_TYPE_STRING) { + config.arguments = fl_value_get_string(arguments_value); + } + + FlValue* hidden_value = fl_value_lookup_string(value, "hiddenAtLaunch"); + if (hidden_value && fl_value_get_type(hidden_value) == FL_VALUE_TYPE_BOOL) { + config.hidden_at_launch = fl_value_get_bool(hidden_value); + } + + return config; + } + + FlValue* ToFlValue() const { + g_autoptr(FlValue) result = fl_value_new_map(); + + fl_value_set_string_take(result, "arguments", + fl_value_new_string(arguments.c_str())); + + fl_value_set_string_take(result, "hiddenAtLaunch", + fl_value_new_bool(hidden_at_launch)); + + return fl_value_ref(result); + } +}; \ No newline at end of file diff --git a/third_party/desktop_multi_window/macos/Classes/FlutterMultiWindowPlugin.swift b/third_party/desktop_multi_window/macos/Classes/FlutterMultiWindowPlugin.swift new file mode 100644 index 0000000..6236314 --- /dev/null +++ b/third_party/desktop_multi_window/macos/Classes/FlutterMultiWindowPlugin.swift @@ -0,0 +1,171 @@ +import Cocoa +import FlutterMacOS + +public class FlutterMultiWindowPlugin: NSObject, FlutterPlugin { + + private let windowId: WindowId + private let windowArgument: String + + + init(window: FlutterWindow) { + self.windowId = window.windowId + self.windowArgument = window.windowArgument + super.init() + } + + public static func register(with registrar: FlutterPluginRegistrar) { + guard let app = NSApplication.shared.delegate as? FlutterAppDelegate else { + debugPrint( + "failed to find flutter main window, application delegate is not FlutterAppDelegate" + ) + return + } + guard let window = app.mainFlutterWindow else { + debugPrint("failed to find flutter main window") + return + } + MultiWindowManager.shared.AttachWindow(window: window, registrar: registrar) + } + + public typealias OnWindowCreatedCallback = (FlutterViewController) -> Void + static var onWindowCreatedCallback: OnWindowCreatedCallback? + + public static func setOnWindowCreatedCallback(_ callback: @escaping OnWindowCreatedCallback) { + onWindowCreatedCallback = callback + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + let isWindowEvent = call.method.hasPrefix("window_") + if isWindowEvent { + let arguments = call.arguments as! [String: Any?] + let windowId = arguments["windowId"] as! WindowId + guard let window = MultiWindowManager.shared.windows[windowId] else { + result( + FlutterError( + code: "-1", message: "failed to find target window. \(windowId)", + details: nil)) + return + } + + window.handleWindowMethod(method: call.method, arguments: arguments, result: result) + return + } + + switch call.method { + case "createWindow": + let arguments = call.arguments as! [String: Any?] + let windowId = MultiWindowManager.shared.CreateWindow(arguments: arguments) + result(windowId) + case "getWindowDefinition": + let definition: [String: Any] = [ + "windowId": windowId, + "windowArgument": windowArgument, + ] + result(definition) + case "getAllWindows": + let windows = MultiWindowManager.shared.getAllWindows() + result(windows) + default: + result(FlutterMethodNotImplemented) + } + + } +} + +class MultiWindowManager: NSObject { + + static let shared: MultiWindowManager = MultiWindowManager() + + private override init() {} + + var windows: [WindowId: FlutterWindow] = [:] + + func AttachWindow(window: NSWindow, registrar: FlutterPluginRegistrar) { + // check window exists + for (_, flutterWindow) in windows { + if flutterWindow.window == window { + return + } + } + let windowId = WindowId.generate() + let flutterWindow = FlutterWindow(windowId: windowId, windowArgument: "", window: window) + windows[windowId] = flutterWindow + + let channel = registerMultiWindowChannel(window: flutterWindow, with: registrar) + flutterWindow.setChannel(channel) + + notifyWindowsChanged() + } + + func CreateWindow(arguments: [String: Any?]) -> WindowId { + let windowId = WindowId.generate() + + let config = WindowConfiguration.fromJson(arguments) + + let window = CustomWindow(configuration: config) + + let project = FlutterDartProject() + project.dartEntrypointArguments = ["multi_window", windowId, config.arguments] + let flutterViewController = FlutterViewController(project: project) + window.contentViewController = flutterViewController + window.setFrame(NSRect(x: 0, y: 0, width: 800, height: 600), display: true) + + window.orderFront(nil) + window.setIsVisible(!config.hiddenAtLaunch) + + FlutterMultiWindowPlugin.onWindowCreatedCallback?(flutterViewController) + + let registrar = flutterViewController.registrar(forPlugin: "DesktopMultiWindowPlugin") + + let flutterWindow = FlutterWindow( + windowId: windowId, windowArgument: config.arguments, window: window) + windows[windowId] = flutterWindow + + let channel = registerMultiWindowChannel(window: flutterWindow, with: registrar) + flutterWindow.setChannel(channel) + + notifyWindowsChanged() + + return windowId + } + + func removeWindow(windowId: WindowId) { + if windows.removeValue(forKey: windowId) != nil { + notifyWindowsChanged() + } + } + + func getAllWindowIds() -> [WindowId] { + return Array(windows.keys) + } + + func getAllWindows() -> [[String: String]] { + return windows.values.map { window in + [ + "windowId": window.windowId, + "windowArgument": window.windowArgument, + ] + } + } + + private func notifyWindowsChanged() { + for (_, window) in windows { + window.notifyWindowEvent("onWindowsChanged", data: [:]) + } + } + + // register multi window method channel for all engine. include main or created by this plugin + private func registerMultiWindowChannel( + window: FlutterWindow, with registrar: FlutterPluginRegistrar + ) -> FlutterMethodChannel { + let channel = FlutterMethodChannel( + name: "mixin.one/desktop_multi_window", binaryMessenger: registrar.messenger) + registrar.addMethodCallDelegate(FlutterMultiWindowPlugin(window: window), channel: channel) + + // register window method channel plugin + WindowChannel.register(with: registrar) + + return channel + } + +} diff --git a/third_party/desktop_multi_window/macos/Classes/FlutterWindow.swift b/third_party/desktop_multi_window/macos/Classes/FlutterWindow.swift new file mode 100644 index 0000000..e31e48c --- /dev/null +++ b/third_party/desktop_multi_window/macos/Classes/FlutterWindow.swift @@ -0,0 +1,147 @@ +import Cocoa +import FlutterMacOS +import Foundation + +typealias WindowId = String + +extension WindowId { + static func generate() -> WindowId { + return UUID().uuidString + } +} + +class CustomWindow: NSWindow { + + init(configuration: WindowConfiguration) { + super.init( + contentRect: NSRect(x: 10, y: 10, width: 800, height: 600), + styleMask: [.miniaturizable, .closable, .titled, .resizable], backing: .buffered, + defer: false) + + self.isReleasedWhenClosed = false + } + + deinit { + debugPrint("Child window deinit") + } + +} + +class FlutterWindow: NSObject { + let windowId: WindowId + let windowArgument: String + private(set) var window: NSWindow + private var channel: FlutterMethodChannel? + + private var willBecomeActiveObserver: NSObjectProtocol? + private var didResignActiveObserver: NSObjectProtocol? + private var closeObserver: NSObjectProtocol? + + init(windowId: WindowId, windowArgument: String, window: NSWindow) { + self.windowId = windowId + self.windowArgument = windowArgument + self.window = window + super.init() + + willBecomeActiveObserver = NotificationCenter.default.addObserver( + forName: NSApplication.willBecomeActiveNotification, + object: nil, + queue: .main + ) { [weak self] notification in + self?.didChangeOcclusionState(notification) + } + + didResignActiveObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didResignActiveNotification, + object: nil, + queue: .main + ) { [weak self] notification in + self?.didChangeOcclusionState(notification) + } + + closeObserver = NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, object: window, queue: .main + ) { [windowId] _ in + MultiWindowManager.shared.removeWindow(windowId: windowId) + } + } + + deinit { + if let willBecomeActiveObserver = willBecomeActiveObserver { + NotificationCenter.default.removeObserver(willBecomeActiveObserver) + } + if let didResignActiveObserver = didResignActiveObserver { + NotificationCenter.default.removeObserver(didResignActiveObserver) + } + if let closeObserver = closeObserver { + NotificationCenter.default.removeObserver(closeObserver) + } + } + + @objc func didChangeOcclusionState(_ notification: Notification) { + if let controller = window.contentViewController as? FlutterViewController { + controller.engine.handleDidChangeOcclusionState(notification) + } + } + + func setChannel(_ channel: FlutterMethodChannel) { + self.channel = channel + } + + func notifyWindowEvent(_ event: String, data: [String: Any]) { + if let channel = channel { + channel.invokeMethod(event, arguments: data) + } else { + debugPrint("Channel not set for window \(windowId), cannot notify event \(event)") + } + } + + func handleWindowMethod(method: String, arguments: Any?, result: @escaping FlutterResult) { + let args = arguments as? [String: Any?] + switch method { + case "window_show": + window.makeKeyAndOrderFront(nil) + window.setIsVisible(true) + NSApp.activate(ignoringOtherApps: true) + result(nil) + case "window_hide": + window.orderOut(nil) + result(nil) + case "window_close": + window.close() + result(nil) + case "window_setFrame": + if let x = args?["x"] as? Double, + let y = args?["y"] as? Double, + let w = args?["width"] as? Double, + let h = args?["height"] as? Double + { + window.setFrame(NSRect(x: x, y: y, width: w, height: h), display: true) + } + result(nil) + case "window_coverScreen": + // Make this window a borderless surface that fills an entire screen — + // used to show the audience slide fullscreen on the beamer while the + // main (presenter) window stays on the laptop. We deliberately do not + // make it the key window, so the keyboard stays with the presenter. + let external = (args?["external"] as? Bool) ?? true + let screens = NSScreen.screens + var target: NSScreen? = NSScreen.main + if external, let ext = screens.first(where: { $0 != NSScreen.main }) { + target = ext + } + if let screen = target ?? screens.first { + window.styleMask = [.borderless] + window.level = .normal + window.isOpaque = true + window.setFrame(screen.frame, display: true) + window.orderFrontRegardless() + window.setIsVisible(true) + } + result(nil) + default: + result(FlutterError(code: "-1", message: "unknown method \(method)", details: nil)) + } + } + +} diff --git a/third_party/desktop_multi_window/macos/Classes/WindowChannel.swift b/third_party/desktop_multi_window/macos/Classes/WindowChannel.swift new file mode 100644 index 0000000..a1ea438 --- /dev/null +++ b/third_party/desktop_multi_window/macos/Classes/WindowChannel.swift @@ -0,0 +1,281 @@ +// +// WindowChannel.swift +// desktop_multi_window +// +// Created by Bin Yang on 2022/1/28. +// + +import FlutterMacOS +import Foundation + +typealias ChannelId = String + +/// Channel communication mode +enum ChannelMode: String { + /// Unidirectional mode: All engines can invoke this channel + case unidirectional = "unidirectional" + /// Bidirectional mode: Only paired engines can invoke each other + case bidirectional = "bidirectional" +} + +private class ChannelRegistry { + static let shared = ChannelRegistry() + + private let lock = NSLock() + + // Unidirectional channels: channel -> single window + private var unidirectionalChannels = [String: WeakBox]() + + // Bidirectional channels: channel -> pair of windows + private var bidirectionalChannels = [String: NSHashTable]() + + enum RegistrationOutcome { + case added + case alreadyRegistered + case limitReached + case modeConflict + } + + private init() {} + + // Helper class to wrap weak reference + private class WeakBox { + weak var value: T? + init(_ value: T) { + self.value = value + } + } + + @discardableResult + func register(_ channel: String, window: WindowChannel, mode: ChannelMode) -> RegistrationOutcome { + lock.lock(); defer { lock.unlock() } + + switch mode { + case .unidirectional: + return registerUnidirectional(channel, window: window) + case .bidirectional: + return registerBidirectional(channel, window: window) + } + } + + private func registerUnidirectional(_ channel: String, window: WindowChannel) -> RegistrationOutcome { + // Check if channel is already used in bidirectional mode + if bidirectionalChannels[channel] != nil { + return .modeConflict + } + + if let existing = unidirectionalChannels[channel]?.value { + if existing === window { + return .alreadyRegistered + } + // Already registered by another window + return .limitReached + } + + unidirectionalChannels[channel] = WeakBox(window) + return .added + } + + private func registerBidirectional(_ channel: String, window: WindowChannel) -> RegistrationOutcome { + // Check if channel is already used in unidirectional mode + if unidirectionalChannels[channel] != nil { + return .modeConflict + } + + let table: NSHashTable + if let existing = bidirectionalChannels[channel] { + table = existing + } else { + table = NSHashTable.weakObjects() + bidirectionalChannels[channel] = table + } + + let activeWindows = table.allObjects.compactMap { $0 as? WindowChannel } + + if activeWindows.contains(where: { $0 === window }) { + return .alreadyRegistered + } + + if activeWindows.count >= 2 { + return .limitReached + } + + table.add(window) + return .added + } + + func unregister(_ channel: String, window: WindowChannel) { + lock.lock(); defer { lock.unlock() } + + // Try unidirectional + if let existing = unidirectionalChannels[channel]?.value, existing === window { + unidirectionalChannels.removeValue(forKey: channel) + return + } + + // Try bidirectional + if let table = bidirectionalChannels[channel] { + table.remove(window) + if table.allObjects.isEmpty { + bidirectionalChannels.removeValue(forKey: channel) + } + } + } + + func getTarget(for channel: String, from window: WindowChannel) -> WindowChannel? { + lock.lock(); defer { lock.unlock() } + + // Check unidirectional + if let target = unidirectionalChannels[channel]?.value { + // Anyone can call unidirectional channel + return target + } + + // Check bidirectional - only peer can call + if let table = bidirectionalChannels[channel] { + let candidates = table.allObjects.compactMap { $0 as? WindowChannel } + if candidates.isEmpty { + bidirectionalChannels.removeValue(forKey: channel) + return nil + } + + // Check if caller is in the pair + guard candidates.contains(where: { $0 === window }) else { + return nil + } + + // Return the peer + return candidates.first { $0 !== window } + } + + return nil + } + + func hasRegistrations(for channel: String) -> Bool { + lock.lock(); defer { lock.unlock() } + + if let box = unidirectionalChannels[channel], box.value != nil { + return true + } + + if let table = bidirectionalChannels[channel] { + let hasActive = !table.allObjects.isEmpty + if !hasActive { + bidirectionalChannels.removeValue(forKey: channel) + } + return hasActive + } + + return false + } +} + + +class WindowChannel: NSObject, FlutterPlugin { + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel( + name: "mixin.one/desktop_multi_window/channels", binaryMessenger: registrar.messenger) + let instance = WindowChannel(methodChannel: channel) + registrar.addMethodCallDelegate(instance, channel: channel) + } + + init(methodChannel: FlutterMethodChannel) { + self.methodChannel = methodChannel + super.init() + } + + private let methodChannel: FlutterMethodChannel + + private var methodChannels: [String] = [] + + func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "registerMethodHandler": + let arguments = call.arguments as! [String: Any?] + let channel = arguments["channel"] as! String + let modeString = arguments["mode"] as? String ?? "bidirectional" + + guard let mode = ChannelMode(rawValue: modeString) else { + result( + FlutterError( + code: "INVALID_MODE", + message: "invalid mode: \(modeString), must be 'unidirectional' or 'bidirectional'", + details: nil)) + return + } + + let outcome = ChannelRegistry.shared.register(channel, window: self, mode: mode) + switch outcome { + case .added: + methodChannels.append(channel) + result(nil) + case .alreadyRegistered: + result(nil) + case .limitReached: + let message = mode == .unidirectional + ? "channel \(channel) already registered in unidirectional mode" + : "channel \(channel) already has the maximum number of registrations (2)" + result( + FlutterError( + code: "CHANNEL_LIMIT_REACHED", + message: message, + details: nil)) + case .modeConflict: + result( + FlutterError( + code: "CHANNEL_MODE_CONFLICT", + message: "channel \(channel) is already registered in a different mode", + details: nil)) + } + case "unregisterMethodHandler": + let arguments = call.arguments as! [String: Any?] + let channel = arguments["channel"] as! String + + ChannelRegistry.shared.unregister(channel, window: self) + + if let index = methodChannels.firstIndex(of: channel) { + methodChannels.remove(at: index) + } + + result(nil) + case "invokeMethod": + let arguments = call.arguments as! [String: Any?] + let channel = arguments["channel"] as! String + + if let targetChannel = ChannelRegistry.shared.getTarget(for: channel, from: self) { + targetChannel.invokeMethod(channel: channel, arguments: call.arguments, result: result) + } else { + let message: String + if ChannelRegistry.shared.hasRegistrations(for: channel) { + message = "channel \(channel) not accessible from this engine (may be bidirectional pair or not registered)" + } else { + message = "unknown registered channel \(channel)" + } + result( + FlutterError( + code: "CHANNEL_UNREGISTERED", message: message, + details: nil)) + } + default: + result(FlutterMethodNotImplemented) + } + } + + func invokeMethod(channel: String, arguments: Any?, result: @escaping FlutterResult) { + // check channelIds contains channel + if !methodChannels.contains(channel) { + result( + FlutterError( + code: "CHANNEL_NOT_FOUND", message: "channel \(channel) not found in this engine", + details: nil)) + return + } + methodChannel.invokeMethod("methodCall", arguments: arguments, result: result) + } + + deinit { + for channel in methodChannels { + ChannelRegistry.shared.unregister(channel, window: self) + } + } +} diff --git a/third_party/desktop_multi_window/macos/Classes/WindowConfiguration.swift b/third_party/desktop_multi_window/macos/Classes/WindowConfiguration.swift new file mode 100644 index 0000000..6a013e8 --- /dev/null +++ b/third_party/desktop_multi_window/macos/Classes/WindowConfiguration.swift @@ -0,0 +1,51 @@ +import Foundation +import Cocoa + + +struct WindowConfiguration: Codable { + + let arguments: String + let hiddenAtLaunch: Bool + + enum CodingKeys: String, CodingKey { + case arguments + case hiddenAtLaunch + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + arguments = try container.decodeIfPresent(String.self, forKey: .arguments) ?? "" + hiddenAtLaunch = try container.decodeIfPresent(Bool.self, forKey: .hiddenAtLaunch) ?? false + } + + init(arguments: String, hiddenAtLaunch: Bool) { + self.arguments = arguments + self.hiddenAtLaunch = hiddenAtLaunch + } + + static let defaultConfiguration = WindowConfiguration( + arguments: "", + hiddenAtLaunch: false + ) + + static func fromJson(_ json: [String: Any?]) -> WindowConfiguration { + guard let jsonData = try? JSONSerialization.data(withJSONObject: json, options: []) else { + debugPrint("invalid json object: \(json)") + return defaultConfiguration + } + + do { + let decoder = JSONDecoder() + return try decoder.decode(WindowConfiguration.self, from: jsonData) + } catch { + debugPrint("Failed to parse window configuration: \(error)") + return defaultConfiguration + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(arguments, forKey: .arguments) + try container.encode(hiddenAtLaunch, forKey: .hiddenAtLaunch) + } +} diff --git a/third_party/desktop_multi_window/macos/desktop_multi_window.podspec b/third_party/desktop_multi_window/macos/desktop_multi_window.podspec new file mode 100644 index 0000000..51a2a48 --- /dev/null +++ b/third_party/desktop_multi_window/macos/desktop_multi_window.podspec @@ -0,0 +1,22 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint flutter_multi_window.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'desktop_multi_window' + s.version = '0.0.1' + s.summary = 'A new flutter plugin project.' + s.description = <<-DESC +A new flutter plugin project. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'FlutterMacOS' + + s.platform = :osx, '10.11' + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } + s.swift_version = '5.0' +end diff --git a/third_party/desktop_multi_window/pubspec.yaml b/third_party/desktop_multi_window/pubspec.yaml new file mode 100644 index 0000000..1b5bab6 --- /dev/null +++ b/third_party/desktop_multi_window/pubspec.yaml @@ -0,0 +1,28 @@ +name: desktop_multi_window +description: A flutter plugin that create and manager multi window in desktop. +version: 0.3.0 +homepage: https://github.com/MixinNetwork/flutter-plugins/tree/main/packages/desktop_multi_window + +environment: + sdk: ">=3.0.0 <4.0.0" + flutter: ">=3.0.0" + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + +# The following section is specific to Flutter. +flutter: + plugin: + platforms: + macos: + pluginClass: FlutterMultiWindowPlugin + windows: + pluginClass: DesktopMultiWindowPlugin + linux: + pluginClass: DesktopMultiWindowPlugin \ No newline at end of file diff --git a/third_party/desktop_multi_window/windows/CMakeLists.txt b/third_party/desktop_multi_window/windows/CMakeLists.txt new file mode 100644 index 0000000..3a1617d --- /dev/null +++ b/third_party/desktop_multi_window/windows/CMakeLists.txt @@ -0,0 +1,29 @@ +cmake_minimum_required(VERSION 3.14) +set(PROJECT_NAME "desktop_multi_window") +project(${PROJECT_NAME} LANGUAGES CXX) + +# This value is used when generating builds using this plugin, so it must +# not be changed +set(PLUGIN_NAME "desktop_multi_window_plugin") + +add_library(${PLUGIN_NAME} SHARED + "desktop_multi_window_plugin.cpp" + "multi_window_manager.cc" + "flutter_window.cc" + "window_channel_plugin.cc" + "win32_window.cpp" + ) +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 flutter_wrapper_app) +target_link_libraries(${PLUGIN_NAME} PRIVATE "dwmapi.lib") + +# List of absolute paths to libraries that should be bundled with the plugin +set(desktop_multi_window_bundled_libraries + "" + PARENT_SCOPE +) diff --git a/third_party/desktop_multi_window/windows/desktop_multi_window_plugin.cpp b/third_party/desktop_multi_window/windows/desktop_multi_window_plugin.cpp new file mode 100644 index 0000000..3f31c72 --- /dev/null +++ b/third_party/desktop_multi_window/windows/desktop_multi_window_plugin.cpp @@ -0,0 +1,118 @@ +#include "include/desktop_multi_window/desktop_multi_window_plugin.h" +#include "multi_window_plugin_internal.h" + +#include +#include +#include + +#include + +#include "flutter_window_wrapper.h" +#include "multi_window_manager.h" +#include "window_channel_plugin.h" + +namespace { + +class DesktopMultiWindowPlugin : public flutter::Plugin { + public: + DesktopMultiWindowPlugin(FlutterWindowWrapper* window, + flutter::PluginRegistrarWindows* registrar); + + ~DesktopMultiWindowPlugin() override; + + private: + void HandleMethodCall( + const flutter::MethodCall& method_call, + std::unique_ptr> result); + + FlutterWindowWrapper* window_; + flutter::PluginRegistrarWindows* registrar_; +}; + +DesktopMultiWindowPlugin::DesktopMultiWindowPlugin( + FlutterWindowWrapper* window, + flutter::PluginRegistrarWindows* registrar) + : window_(window), registrar_(registrar) { + auto channel = + std::make_shared>( + registrar->messenger(), "mixin.one/desktop_multi_window", + &flutter::StandardMethodCodec::GetInstance()); + channel->SetMethodCallHandler([this](const auto& call, auto result) { + HandleMethodCall(call, std::move(result)); + }); + + // Set channel to window for event notifications + window_->SetChannel(channel); + + // Register WindowChannel plugin for each engine + WindowChannelPluginRegisterWithRegistrar(registrar); +} + +DesktopMultiWindowPlugin::~DesktopMultiWindowPlugin() { + MultiWindowManager::Instance()->RemoveWindow(window_->GetWindowId()); +} + +void DesktopMultiWindowPlugin::HandleMethodCall( + const flutter::MethodCall& method_call, + std::unique_ptr> result) { + // Check if this is a window-specific method (starts with "window_") + const auto& method = method_call.method_name(); + if (method.rfind("window_", 0) == 0) { + auto* arguments = + std::get_if(method_call.arguments()); + auto window_id = std::get( + arguments->at(flutter::EncodableValue("windowId"))); + + auto window = MultiWindowManager::Instance()->GetWindow(window_id); + if (!window) { + result->Error("-1", "failed to find target window: " + window_id); + return; + } + + window->HandleWindowMethod(method, arguments, std::move(result)); + return; + } + + if (method == "createWindow") { + auto args = std::get_if(method_call.arguments()); + auto window_id = MultiWindowManager::Instance()->Create(args); + result->Success(flutter::EncodableValue(window_id)); + return; + } else if (method == "getWindowDefinition") { + flutter::EncodableMap definition; + definition[flutter::EncodableValue("windowId")] = + flutter::EncodableValue(window_->GetWindowId()); + definition[flutter::EncodableValue("windowArgument")] = + flutter::EncodableValue(window_->GetWindowArgument()); + result->Success(flutter::EncodableValue(definition)); + return; + } else if (method == "getAllWindows") { + auto windows = MultiWindowManager::Instance()->GetAllWindows(); + result->Success(flutter::EncodableValue(windows)); + return; + } + + result->NotImplemented(); +} + +} // namespace + +void DesktopMultiWindowPluginRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar) { + // Attach MainWindow + auto hwnd = FlutterDesktopViewGetHWND( + FlutterDesktopPluginRegistrarGetView(registrar)); + MultiWindowManager::Instance()->AttachFlutterMainWindow( + GetAncestor(hwnd, GA_ROOT), registrar); +} + +void InternalMultiWindowPluginRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar, + FlutterWindowWrapper* window) { + auto plugin_registrar = + flutter::PluginRegistrarManager::GetInstance() + ->GetRegistrar(registrar); + auto plugin = + std::make_unique(window, plugin_registrar); + plugin_registrar->AddPlugin(std::move(plugin)); +} diff --git a/third_party/desktop_multi_window/windows/flutter_window.cc b/third_party/desktop_multi_window/windows/flutter_window.cc new file mode 100644 index 0000000..b91a6e9 --- /dev/null +++ b/third_party/desktop_multi_window/windows/flutter_window.cc @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include "flutter_windows.h" + +#include "tchar.h" + +#include + +#include "multi_window_manager.h" +#include "multi_window_plugin_internal.h" + +FlutterWindow::FlutterWindow(const std::string& id, + const WindowConfiguration config) + : id_(id), window_argument_(config.arguments) {} + +bool FlutterWindow::OnCreate() { + // Called when the window is created + RECT frame = GetClientArea(); + + flutter::DartProject project(L"data"); + std::vector entrypoint_args = {"multi_window", id_, + window_argument_}; + project.set_dart_entrypoint_arguments(entrypoint_args); + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project); + + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + std::cerr << "Failed to setup FlutterViewController." << std::endl; + return false; + } + + auto view_handle = flutter_controller_->view()->GetNativeWindow(); + SetChildContent(view_handle); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + MultiWindowManager::Instance()->RemoveManagedFlutterWindowLater(id_); +} + +LRESULT FlutterWindow::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} + +FlutterWindow::~FlutterWindow() { + // Cleanup is handled by Win32Window::Destroy() +} diff --git a/third_party/desktop_multi_window/windows/flutter_window.h b/third_party/desktop_multi_window/windows/flutter_window.h new file mode 100644 index 0000000..417f5a6 --- /dev/null +++ b/third_party/desktop_multi_window/windows/flutter_window.h @@ -0,0 +1,44 @@ +#ifndef DESKTOP_MULTI_WINDOW_WINDOWS_FLUTTER_WINDOW_H_ +#define DESKTOP_MULTI_WINDOW_WINDOWS_FLUTTER_WINDOW_H_ + +#include + +#include + +#include +#include + +#include "win32_window.h" +#include "window_configuration.h" + +class FlutterWindow : public Win32Window { + public: + FlutterWindow(const std::string& id, const WindowConfiguration config); + ~FlutterWindow() override; + + std::string GetWindowId() const { return id_; } + + std::string GetWindowArgument() const { return window_argument_; } + + flutter::FlutterViewController* GetFlutterViewController() const { + return flutter_controller_.get(); + } + + protected: + // Win32Window overrides + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + std::string id_; + std::string window_argument_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // DESKTOP_MULTI_WINDOW_WINDOWS_FLUTTER_WINDOW_H_ diff --git a/third_party/desktop_multi_window/windows/flutter_window_wrapper.h b/third_party/desktop_multi_window/windows/flutter_window_wrapper.h new file mode 100644 index 0000000..a4281a9 --- /dev/null +++ b/third_party/desktop_multi_window/windows/flutter_window_wrapper.h @@ -0,0 +1,69 @@ +#ifndef DESKTOP_MULTI_WINDOW_WINDOWS_FLUTTER_WINDOW_WRAPPER_H_ +#define DESKTOP_MULTI_WINDOW_WINDOWS_FLUTTER_WINDOW_WRAPPER_H_ + +#include +#include +#include +#include +#include +#include + +class FlutterWindowWrapper { + public: + FlutterWindowWrapper(const std::string& window_id, + HWND hwnd, + const std::string& window_argument = "") + : window_id_(window_id), hwnd_(hwnd), window_argument_(window_argument) {} + + ~FlutterWindowWrapper() = default; + + std::string GetWindowId() const { return window_id_; } + + std::string GetWindowArgument() const { return window_argument_; } + + HWND GetWindowHandle() { return hwnd_; } + + void SetChannel( + std::shared_ptr> + channel) { + channel_ = channel; + } + + void NotifyWindowEvent(const std::string& event, + const flutter::EncodableMap& data) { + if (channel_) { + channel_->InvokeMethod(event, + std::make_unique(data)); + } + } + + void HandleWindowMethod( + const std::string& method, + const flutter::EncodableMap* arguments, + std::unique_ptr> result) { + if (method == "window_show") { + if (hwnd_) { + ::ShowWindow(hwnd_, SW_SHOW); + } + result->Success(); + } else if (method == "window_hide") { + if (hwnd_) { + ::ShowWindow(hwnd_, SW_HIDE); + } + result->Success(); + } else { + result->Error("-1", "unknown method: " + method); + } + } + + protected: + void SetWindowHandle(HWND hwnd) { hwnd_ = hwnd; } + + private: + std::string window_id_; + HWND hwnd_; + std::string window_argument_; + std::shared_ptr> channel_; +}; + +#endif // DESKTOP_MULTI_WINDOW_WINDOWS_FLUTTER_WINDOW_WRAPPER_H_ diff --git a/third_party/desktop_multi_window/windows/include/desktop_multi_window/desktop_multi_window_plugin.h b/third_party/desktop_multi_window/windows/include/desktop_multi_window/desktop_multi_window_plugin.h new file mode 100644 index 0000000..24a6e04 --- /dev/null +++ b/third_party/desktop_multi_window/windows/include/desktop_multi_window/desktop_multi_window_plugin.h @@ -0,0 +1,27 @@ +#ifndef FLUTTER_PLUGIN_DESKTOP_MULTI_WINDOW_PLUGIN_H_ +#define FLUTTER_PLUGIN_DESKTOP_MULTI_WINDOW_PLUGIN_H_ + +#include + +#ifdef FLUTTER_PLUGIN_IMPL +#define FLUTTER_PLUGIN_EXPORT __declspec(dllexport) +#else +#define FLUTTER_PLUGIN_EXPORT __declspec(dllimport) +#endif + +#if defined(__cplusplus) +extern "C" { +#endif + +FLUTTER_PLUGIN_EXPORT void DesktopMultiWindowPluginRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar); + +// flutter_view_controller: pointer to the flutter::FlutterViewController +typedef void (*WindowCreatedCallback)(void *flutter_view_controller); +FLUTTER_PLUGIN_EXPORT void DesktopMultiWindowSetWindowCreatedCallback(WindowCreatedCallback callback); + +#if defined(__cplusplus) +} // extern "C" +#endif + +#endif // FLUTTER_PLUGIN_DESKTOP_MULTI_WINDOW_PLUGIN_H_ diff --git a/third_party/desktop_multi_window/windows/multi_window_manager.cc b/third_party/desktop_multi_window/windows/multi_window_manager.cc new file mode 100644 index 0000000..e37d55a --- /dev/null +++ b/third_party/desktop_multi_window/windows/multi_window_manager.cc @@ -0,0 +1,190 @@ +#include "multi_window_manager.h" + +#include +#include +#include +#include +#include +#pragma comment(lib, "rpcrt4.lib") + +#include +#include "flutter_window.h" +#include "flutter_window_wrapper.h" +#include "include/desktop_multi_window/desktop_multi_window_plugin.h" +#include "multi_window_plugin_internal.h" +#include "win32_window.h" +#include "window_configuration.h" + +namespace { + +std::string GenerateWindowId() { + UUID uuid; + UuidCreate(&uuid); + + RPC_CSTR uuid_str = nullptr; + UuidToStringA(&uuid, &uuid_str); + + std::string result(reinterpret_cast(uuid_str)); + RpcStringFreeA(&uuid_str); + + return result; +} + +WindowCreatedCallback _g_window_created_callback = nullptr; + +} // namespace + +// static +MultiWindowManager* MultiWindowManager::Instance() { + static auto manager = std::make_shared(); + return manager.get(); +} + +MultiWindowManager::MultiWindowManager() : windows_() {} + +std::string MultiWindowManager::Create(const flutter::EncodableMap* args) { + std::string window_id = GenerateWindowId(); + WindowConfiguration config = WindowConfiguration::FromEncodableMap(args); + + auto flutter_window = std::make_unique(window_id, config); + + std::wstring title = L""; + Win32Window::Point origin(10, 10); + Win32Window::Size size(800, 600); + + if (!flutter_window->Create(title, origin, size)) { + std::cerr << "Failed to create window." << std::endl; + return ""; + } + + ::ShowWindow(flutter_window->GetHandle(), + config.hidden_at_launch ? SW_HIDE : SW_SHOW); + + auto wrapper = std::make_unique( + window_id, flutter_window->GetHandle(), config.arguments); + + windows_[window_id] = std::move(wrapper); + + if (_g_window_created_callback) { + _g_window_created_callback(flutter_window->GetFlutterViewController()); + } + auto registrar = flutter_window->GetFlutterViewController() + ->engine() + ->GetRegistrarForPlugin("DesktopMultiWindowPlugin"); + InternalMultiWindowPluginRegisterWithRegistrar(registrar, + windows_[window_id].get()); + + // keep flutter_window alive + managed_flutter_windows_[window_id] = std::move(flutter_window); + + // Notify all windows about the change + NotifyWindowsChanged(); + + CleanupRemovedWindows(); + + return window_id; +} + +void MultiWindowManager::AttachFlutterMainWindow( + HWND window_handle, + FlutterDesktopPluginRegistrarRef registrar) { + // check if window already exists + for (const auto& [id, window] : windows_) { + if (GetAncestor(window->GetWindowHandle(), GA_ROOT) == window_handle) { + return; + } + } + + const std::string window_id = GenerateWindowId(); + auto wrapper = + std::make_unique(window_id, window_handle); + + windows_[window_id] = std::move(wrapper); + + InternalMultiWindowPluginRegisterWithRegistrar(registrar, + windows_[window_id].get()); + + // Notify all windows about the change + NotifyWindowsChanged(); +} + +FlutterWindowWrapper* MultiWindowManager::GetWindow( + const std::string& window_id) { + auto it = windows_.find(window_id); + if (it != windows_.end()) { + return it->second.get(); + } + return nullptr; +} + +flutter::EncodableList MultiWindowManager::GetAllWindows() { + flutter::EncodableList windows; + for (const auto& [id, window] : windows_) { + flutter::EncodableMap window_info; + window_info[flutter::EncodableValue("windowId")] = + flutter::EncodableValue(window->GetWindowId()); + window_info[flutter::EncodableValue("windowArgument")] = + flutter::EncodableValue(window->GetWindowArgument()); + windows.push_back(flutter::EncodableValue(window_info)); + } + return windows; +} + +std::vector MultiWindowManager::GetAllWindowIds() { + std::vector window_ids; + for (const auto& [id, window] : windows_) { + window_ids.push_back(id); + } + return window_ids; +} + +void MultiWindowManager::RemoveWindow(const std::string& window_id) { + auto it = windows_.find(window_id); + if (it != windows_.end()) { + windows_.erase(it); + NotifyWindowsChanged(); + } + + // quit application if no windows left + if (windows_.empty()) { + PostQuitMessage(0); + } +} + +void MultiWindowManager::RemoveManagedFlutterWindowLater( + const std::string& window_id) { + pending_remove_ids_.push_back(window_id); +} + +// FIXME:maybe need a more robust way to cleanup removed windows +void MultiWindowManager::CleanupRemovedWindows() { + for (auto& id : pending_remove_ids_) { + auto it = managed_flutter_windows_.find(id); + if (it != managed_flutter_windows_.end()) { + std::cout << "Destroyed managed flutter window: " << id << std::endl; + managed_flutter_windows_.erase(it); + } + } + pending_remove_ids_.clear(); +} + +void MultiWindowManager::NotifyWindowsChanged() { + auto window_ids = GetAllWindowIds(); + flutter::EncodableList window_ids_list; + for (const auto& id : window_ids) { + window_ids_list.push_back(flutter::EncodableValue(id)); + } + + flutter::EncodableMap data; + data[flutter::EncodableValue("windowIds")] = + flutter::EncodableValue(window_ids_list); + + for (const auto& [id, window] : windows_) { + window->NotifyWindowEvent("onWindowsChanged", data); + } +} + +void DesktopMultiWindowSetWindowCreatedCallback( + WindowCreatedCallback callback) { + _g_window_created_callback = callback; +} \ No newline at end of file diff --git a/third_party/desktop_multi_window/windows/multi_window_manager.h b/third_party/desktop_multi_window/windows/multi_window_manager.h new file mode 100644 index 0000000..ac4b866 --- /dev/null +++ b/third_party/desktop_multi_window/windows/multi_window_manager.h @@ -0,0 +1,44 @@ +#ifndef DESKTOP_MULTI_WINDOW_WINDOWS_MULTI_WINDOW_MANAGER_H_ +#define DESKTOP_MULTI_WINDOW_WINDOWS_MULTI_WINDOW_MANAGER_H_ + +#include +#include +#include + +#include "flutter_plugin_registrar.h" +#include "flutter_window.h" +#include "flutter_window_wrapper.h" + +class MultiWindowManager { + public: + static MultiWindowManager* Instance(); + + MultiWindowManager(); + + std::string Create(const flutter::EncodableMap* args); + + void AttachFlutterMainWindow(HWND main_window_handle, + FlutterDesktopPluginRegistrarRef registrar); + + FlutterWindowWrapper* GetWindow(const std::string& window_id); + + void RemoveWindow(const std::string& window_id); + + void RemoveManagedFlutterWindowLater(const std::string& window_id); + + flutter::EncodableList GetAllWindows(); + + std::vector GetAllWindowIds(); + + private: + void NotifyWindowsChanged(); + + void CleanupRemovedWindows(); + + std::map> windows_; + std::map> + managed_flutter_windows_; + std::vector pending_remove_ids_; +}; + +#endif // DESKTOP_MULTI_WINDOW_WINDOWS_MULTI_WINDOW_MANAGER_H_ diff --git a/third_party/desktop_multi_window/windows/multi_window_plugin_internal.h b/third_party/desktop_multi_window/windows/multi_window_plugin_internal.h new file mode 100644 index 0000000..5721ffe --- /dev/null +++ b/third_party/desktop_multi_window/windows/multi_window_plugin_internal.h @@ -0,0 +1,12 @@ +#ifndef DESKTOP_MULTI_WINDOW_WINDOWS_MULTI_WINDOW_PLUGIN_INTERNAL_H_ +#define DESKTOP_MULTI_WINDOW_WINDOWS_MULTI_WINDOW_PLUGIN_INTERNAL_H_ + +#include "flutter_plugin_registrar.h" + +class FlutterWindowWrapper; + +void InternalMultiWindowPluginRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar, + FlutterWindowWrapper* window); + +#endif // DESKTOP_MULTI_WINDOW_WINDOWS_MULTI_WINDOW_PLUGIN_INTERNAL_H_ diff --git a/third_party/desktop_multi_window/windows/win32_window.cpp b/third_party/desktop_multi_window/windows/win32_window.cpp new file mode 100644 index 0000000..df4f92a --- /dev/null +++ b/third_party/desktop_multi_window/windows/win32_window.cpp @@ -0,0 +1,301 @@ +#include "win32_window.h" + +#include +#include + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: +/// https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = + L"FLUTTER_MULTI_WINDOW_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = + L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + + TCHAR exePath[MAX_PATH]; + GetModuleFileName(NULL, exePath, MAX_PATH); + HICON hIcon = ExtractIcon(GetModuleHandle(NULL), exePath, 0); + if (hIcon) { + window_class.hIcon = hIcon; + } else { + window_class.hIcon = LoadIcon(window_class.hInstance, IDI_APPLICATION); + } + + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + if (window_handle_) { + return false; + } + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = + RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, RRF_RT_REG_DWORD, nullptr, + &light_mode, &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/third_party/desktop_multi_window/windows/win32_window.h b/third_party/desktop_multi_window/windows/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/third_party/desktop_multi_window/windows/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/third_party/desktop_multi_window/windows/window_channel_plugin.cc b/third_party/desktop_multi_window/windows/window_channel_plugin.cc new file mode 100644 index 0000000..f5541da --- /dev/null +++ b/third_party/desktop_multi_window/windows/window_channel_plugin.cc @@ -0,0 +1,347 @@ +#include "window_channel_plugin.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +enum class ChannelMode { kUnidirectional, kBidirectional }; + +enum class RegistrationOutcome { + kAdded, + kAlreadyRegistered, + kLimitReached, + kModeConflict +}; + +class WindowChannelPlugin; + +class ChannelRegistry { + public: + static ChannelRegistry& GetInstance() { + static ChannelRegistry instance; + return instance; + } + + RegistrationOutcome Register(const std::string& channel, + WindowChannelPlugin* plugin, + ChannelMode mode) { + std::lock_guard lock(mutex_); + + if (mode == ChannelMode::kUnidirectional) { + return RegisterUnidirectional(channel, plugin); + } else { + return RegisterBidirectional(channel, plugin); + } + } + + private: + RegistrationOutcome RegisterUnidirectional(const std::string& channel, + WindowChannelPlugin* plugin) { + // Check if already used in bidirectional mode + if (bidirectional_channels_.find(channel) != + bidirectional_channels_.end()) { + return RegistrationOutcome::kModeConflict; + } + + auto it = unidirectional_channels_.find(channel); + if (it != unidirectional_channels_.end()) { + if (it->second == plugin) { + return RegistrationOutcome::kAlreadyRegistered; + } + // Already registered by another plugin + return RegistrationOutcome::kLimitReached; + } + + unidirectional_channels_[channel] = plugin; + return RegistrationOutcome::kAdded; + } + + RegistrationOutcome RegisterBidirectional(const std::string& channel, + WindowChannelPlugin* plugin) { + // Check if already used in unidirectional mode + if (unidirectional_channels_.find(channel) != + unidirectional_channels_.end()) { + return RegistrationOutcome::kModeConflict; + } + + auto& plugins = bidirectional_channels_[channel]; + + // Check if already registered + if (plugins.find(plugin) != plugins.end()) { + return RegistrationOutcome::kAlreadyRegistered; + } + + // Check limit + if (plugins.size() >= 2) { + return RegistrationOutcome::kLimitReached; + } + + plugins.insert(plugin); + return RegistrationOutcome::kAdded; + } + + public: + void Unregister(const std::string& channel, WindowChannelPlugin* plugin) { + std::lock_guard lock(mutex_); + + // Try unidirectional + auto uni_it = unidirectional_channels_.find(channel); + if (uni_it != unidirectional_channels_.end() && + uni_it->second == plugin) { + unidirectional_channels_.erase(uni_it); + return; + } + + // Try bidirectional + auto bi_it = bidirectional_channels_.find(channel); + if (bi_it != bidirectional_channels_.end()) { + bi_it->second.erase(plugin); + if (bi_it->second.empty()) { + bidirectional_channels_.erase(bi_it); + } + } + } + + WindowChannelPlugin* GetTarget(const std::string& channel, + WindowChannelPlugin* from) { + std::lock_guard lock(mutex_); + + // Check unidirectional - anyone can call + auto uni_it = unidirectional_channels_.find(channel); + if (uni_it != unidirectional_channels_.end()) { + return uni_it->second; + } + + // Check bidirectional - only peer can call + auto bi_it = bidirectional_channels_.find(channel); + if (bi_it != bidirectional_channels_.end()) { + const auto& plugins = bi_it->second; + + // Check if caller is in the pair + if (plugins.find(from) == plugins.end()) { + return nullptr; + } + + // Return the peer + for (auto* plugin : plugins) { + if (plugin != from) { + return plugin; + } + } + } + + return nullptr; + } + + bool HasRegistrations(const std::string& channel) { + std::lock_guard lock(mutex_); + + if (unidirectional_channels_.find(channel) != + unidirectional_channels_.end()) { + return true; + } + + auto it = bidirectional_channels_.find(channel); + return it != bidirectional_channels_.end() && !it->second.empty(); + } + + private: + ChannelRegistry() = default; + std::mutex mutex_; + std::map unidirectional_channels_; + std::map> + bidirectional_channels_; +}; + +class WindowChannelPlugin : public flutter::Plugin { + public: + WindowChannelPlugin(flutter::PluginRegistrarWindows* registrar) + : registrar_(registrar) { + channel_ = std::make_unique>( + registrar->messenger(), "mixin.one/desktop_multi_window/channels", + &flutter::StandardMethodCodec::GetInstance()); + + channel_->SetMethodCallHandler( + [this](const flutter::MethodCall<>& call, + std::unique_ptr> result) { + HandleMethodCall(call, std::move(result)); + }); + } + + ~WindowChannelPlugin() { + for (const auto& channel : registered_channels_) { + ChannelRegistry::GetInstance().Unregister(channel, this); + } + } + + void InvokeMethod(const std::string& channel, + const flutter::EncodableValue& arguments, + std::unique_ptr> result) { + // Check if this plugin has registered this channel + if (std::find(registered_channels_.begin(), registered_channels_.end(), + channel) == registered_channels_.end()) { + result->Error("CHANNEL_NOT_FOUND", + "channel " + channel + " not found in this engine"); + return; + } + + channel_->InvokeMethod("methodCall", std::make_unique(arguments), + std::move(result)); + } + + private: + void HandleMethodCall(const flutter::MethodCall<>& call, + std::unique_ptr> result) { + const auto& method = call.method_name(); + + if (method == "registerMethodHandler") { + auto* args = std::get_if(call.arguments()); + if (!args) { + result->Error("INVALID_ARGUMENTS", "arguments must be a map"); + return; + } + + auto channel_it = args->find(flutter::EncodableValue("channel")); + if (channel_it == args->end()) { + result->Error("INVALID_ARGUMENTS", "channel is required"); + return; + } + + auto* channel = std::get_if(&channel_it->second); + if (!channel) { + result->Error("INVALID_ARGUMENTS", "channel must be a string"); + return; + } + + // Get mode (default to bidirectional) + ChannelMode mode = ChannelMode::kBidirectional; + auto mode_it = args->find(flutter::EncodableValue("mode")); + if (mode_it != args->end()) { + auto* mode_str = std::get_if(&mode_it->second); + if (mode_str) { + if (*mode_str == "unidirectional") { + mode = ChannelMode::kUnidirectional; + } else if (*mode_str == "bidirectional") { + mode = ChannelMode::kBidirectional; + } else { + result->Error("INVALID_MODE", + "invalid mode: " + *mode_str + + ", must be 'unidirectional' or 'bidirectional'"); + return; + } + } + } + + auto outcome = ChannelRegistry::GetInstance().Register(*channel, this, mode); + switch (outcome) { + case RegistrationOutcome::kAdded: + registered_channels_.push_back(*channel); + result->Success(); + break; + case RegistrationOutcome::kAlreadyRegistered: + result->Success(); + break; + case RegistrationOutcome::kLimitReached: { + std::string message = mode == ChannelMode::kUnidirectional + ? "channel " + *channel + + " already registered in " + "unidirectional mode" + : "channel " + *channel + + " already has the maximum number of " + "registrations (2)"; + result->Error("CHANNEL_LIMIT_REACHED", message); + break; + } + case RegistrationOutcome::kModeConflict: + result->Error("CHANNEL_MODE_CONFLICT", + "channel " + *channel + + " is already registered in a different mode"); + break; + } + } else if (method == "unregisterMethodHandler") { + auto* args = std::get_if(call.arguments()); + if (!args) { + result->Error("INVALID_ARGUMENTS", "arguments must be a map"); + return; + } + + auto channel_it = args->find(flutter::EncodableValue("channel")); + if (channel_it == args->end()) { + result->Error("INVALID_ARGUMENTS", "channel is required"); + return; + } + + auto* channel = std::get_if(&channel_it->second); + if (!channel) { + result->Error("INVALID_ARGUMENTS", "channel must be a string"); + return; + } + + ChannelRegistry::GetInstance().Unregister(*channel, this); + + auto it = std::find(registered_channels_.begin(), + registered_channels_.end(), *channel); + if (it != registered_channels_.end()) { + registered_channels_.erase(it); + } + + result->Success(); + } else if (method == "invokeMethod") { + auto* args = std::get_if(call.arguments()); + if (!args) { + result->Error("INVALID_ARGUMENTS", "arguments must be a map"); + return; + } + + auto channel_it = args->find(flutter::EncodableValue("channel")); + if (channel_it == args->end()) { + result->Error("INVALID_ARGUMENTS", "channel is required"); + return; + } + + auto* channel = std::get_if(&channel_it->second); + if (!channel) { + result->Error("INVALID_ARGUMENTS", "channel must be a string"); + return; + } + + auto* target = ChannelRegistry::GetInstance().GetTarget(*channel, this); + if (target) { + target->InvokeMethod(*channel, *call.arguments(), std::move(result)); + } else { + std::string message; + if (ChannelRegistry::GetInstance().HasRegistrations(*channel)) { + message = "channel " + *channel + + " not accessible from this engine (may be bidirectional " + "pair or not registered)"; + } else { + message = "unknown registered channel " + *channel; + } + result->Error("CHANNEL_UNREGISTERED", message); + } + } else { + result->NotImplemented(); + } + } + + flutter::PluginRegistrarWindows* registrar_; + std::unique_ptr> channel_; + std::vector registered_channels_; +}; + +} // namespace + +void WindowChannelPluginRegisterWithRegistrar( + flutter::PluginRegistrarWindows* registrar) { + auto plugin = std::make_unique(registrar); + registrar->AddPlugin(std::move(plugin)); +} diff --git a/third_party/desktop_multi_window/windows/window_channel_plugin.h b/third_party/desktop_multi_window/windows/window_channel_plugin.h new file mode 100644 index 0000000..ce9b6f8 --- /dev/null +++ b/third_party/desktop_multi_window/windows/window_channel_plugin.h @@ -0,0 +1,17 @@ +#ifndef DESKTOP_MULTI_WINDOW_WINDOWS_WINDOW_CHANNEL_PLUGIN_H_ +#define DESKTOP_MULTI_WINDOW_WINDOWS_WINDOW_CHANNEL_PLUGIN_H_ + +#include +#include +#include + +#include +#include +#include +#include +#include + +void WindowChannelPluginRegisterWithRegistrar( + flutter::PluginRegistrarWindows* registrar); + +#endif // DESKTOP_MULTI_WINDOW_WINDOWS_WINDOW_CHANNEL_PLUGIN_H_ diff --git a/third_party/desktop_multi_window/windows/window_configuration.h b/third_party/desktop_multi_window/windows/window_configuration.h new file mode 100644 index 0000000..445465f --- /dev/null +++ b/third_party/desktop_multi_window/windows/window_configuration.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include +#include + +struct WindowConfiguration { + std::string arguments; + bool hidden_at_launch = false; + + static WindowConfiguration FromEncodableMap( + const flutter::EncodableMap* map) { + WindowConfiguration config; + + if (!map) return config; + + try { + auto it = map->find(flutter::EncodableValue("arguments")); + if (it != map->end()) { + config.arguments = std::get(it->second); + } + + it = map->find(flutter::EncodableValue("hiddenAtLaunch")); + if (it != map->end()) { + config.hidden_at_launch = std::get(it->second); + } + } catch (const std::exception& e) { + std::cerr << "Failed to parse WindowConfiguration: " << e.what() + << std::endl; + } + + return config; + } +}; \ No newline at end of file diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 789e6be..e9d804e 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,6 +7,7 @@ #include "generated_plugin_registrant.h" #include +#include #include #include #include @@ -15,6 +16,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { DesktopDropPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("DesktopDropPlugin")); + DesktopMultiWindowPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("DesktopMultiWindowPlugin")); PasteboardPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PasteboardPlugin")); ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 965883b..494a859 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST desktop_drop + desktop_multi_window pasteboard screen_retriever_windows url_launcher_windows