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 <noreply@anthropic.com>
This commit is contained in:
Brenno de Winter 2026-06-06 21:25:34 +02:00
parent b7db54e033
commit 2aca44365a
54 changed files with 4466 additions and 15 deletions

View file

@ -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',

View file

@ -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<String> 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<String, dynamic>.from(parsed as Map);
runApp(AudienceWindowApp(args: map));
return;
}
if (!kIsWeb && (Platform.isMacOS || Platform.isWindows || Platform.isLinux)) {
await windowManager.ensureInitialized();
const options = WindowOptions(

View file

@ -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

View file

@ -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,

View file

@ -83,10 +83,7 @@ class _CodeEditorState extends State<CodeEditor> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
EditorField(
label: 'Titel (optioneel)',
controller: _title,
),
EditorField(label: 'Titel (optioneel)', controller: _title),
const SizedBox(height: 16),
Row(
children: [

View file

@ -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<String, dynamic> args;
const AudienceWindowApp({super.key, required this.args});
@override
State<AudienceWindowApp> createState() => _AudienceWindowAppState();
}
class _AudienceWindowAppState extends State<AudienceWindowApp> {
List<Slide> _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<dynamic> _onPresenterCall(MethodCall call) async {
switch (call.method) {
case 'update':
final m = Map<String, dynamic>.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'),
),
),
);
},
);
}
}

View file

@ -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<void> present(
BuildContext context, {
required List<Slide> 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<void> show(
BuildContext context, {
required List<Slide> 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<void> showDualScreen(
BuildContext context, {
required List<Slide> 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<FullscreenPresenter> createState() => _FullscreenPresenterState();
}
@ -150,12 +284,38 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
List<Display> _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<FullscreenPresenter> {
_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<FullscreenPresenter> {
Future<void> _exit() async {
_advanceTimer?.cancel();
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<FullscreenPresenter> {
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<FullscreenPresenter> {
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,
),
),

View file

@ -7,6 +7,7 @@
#include "generated_plugin_registrant.h"
#include <desktop_drop/desktop_drop_plugin.h>
#include <desktop_multi_window/desktop_multi_window_plugin.h>
#include <pasteboard/pasteboard_plugin.h>
#include <screen_retriever_linux/screen_retriever_linux_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
@ -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);

View file

@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
desktop_drop
desktop_multi_window
pasteboard
screen_retriever_linux
url_launcher_linux

View file

@ -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"))

View file

@ -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

View file

@ -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()
}
}

View file

@ -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:

View file

@ -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:

View file

@ -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.

201
third_party/desktop_multi_window/LICENSE vendored Normal file
View file

@ -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.

View file

@ -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<void> main(List<String> 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<void> 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<void> center() {
return invokeMethod('window_center');
}
Future<void> 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 <optional>
#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<flutter::FlutterViewController *>(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 <flutter_linux/flutter_linux.h>
#ifdef GDK_WINDOWING_X11
#include <gdk/gdkx.h>
#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

View file

@ -0,0 +1,3 @@
export 'src/window_controller.dart';
export 'src/window_configuration.dart';
export 'src/window_channel.dart';

View file

@ -0,0 +1,219 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
typedef MethodCallHandler = Future<dynamic> 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<T?> invokeMethod<T>(String method, [dynamic arguments]) async {
_initializeChannelManager();
try {
return await _invokeMethodOnChannel<T>(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<void> setMethodCallHandler(
Future<dynamic> 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 = <String, MethodCallHandler>{};
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<void> _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<void> _unregisterMethodHandler(String name) async {
await _methodChannel.invokeMethod('unregisterMethodHandler', {
'channel': name,
});
}
Future<T?> _invokeMethodOnChannel<T>(
String name, String method, dynamic arguments) async {
try {
return await _methodChannel.invokeMethod<T>('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;
}
}

View file

@ -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<String, dynamic> json) {
return WindowConfiguration(
arguments: json['arguments'] as String? ?? '',
hiddenAtLaunch: json['hiddenAtLaunch'] as bool? ?? false,
);
}
Map<String, dynamic> 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;
}
}

View file

@ -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<void> 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<WindowController> create(
WindowConfiguration configuration) async {
final windowId = await _channel.invokeMethod<String>(
'createWindow',
configuration.toJson(),
);
assert(windowId != null, 'windowId is null');
assert(windowId!.isNotEmpty, 'windowId is empty');
return WindowController._(windowId!, configuration.arguments);
}
static Future<WindowController> fromCurrentEngine() async {
final definition = await _channel
.invokeMethod<Map<dynamic, dynamic>>('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<List<WindowController>> getAll() async {
final result = await _channel.invokeMethod<List<dynamic>>('getAllWindows');
if (result == null) {
return [];
}
return result.cast<Map<dynamic, dynamic>>().map((e) {
final windowId = e['windowId'] as String;
final windowArgument = e['windowArgument'] as String;
return WindowController._(windowId, windowArgument);
}).toList();
}
Future<void> _callWindowMethod(String method,
[Map<String, dynamic>? arguments]) {
assert(windowId.isNotEmpty, 'windowId is empty');
assert(method.startsWith('window_'), 'method must start with "window_"');
return _channel.invokeMethod(
method,
{
'windowId': windowId,
...?arguments,
},
);
}
Future<void> show() => _callWindowMethod('window_show', {});
Future<void> hide() => _callWindowMethod('window_hide', {});
/// Close (destroy) this window. (macOS)
Future<void> close() => _callWindowMethod('window_close', {});
/// Position/size this window in screen coordinates. (macOS)
Future<void> 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<void> coverScreen({bool external = true}) =>
_callWindowMethod('window_coverScreen', {'external': external});
@optionalTypeArgs
Future<T?> invokeMethod<T>(String method, [dynamic arguments]) =>
_windowChannel.invokeMethod<T>(method, arguments);
Future<void> setWindowMethodHandler(
Future<dynamic> 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<MethodCall> _windowEventAsStream() {
late StreamController<MethodCall> controller;
controller = StreamController<MethodCall>.broadcast(
onListen: () {
_channel.setMethodCallHandler((call) async {
controller.add(call);
});
},
onCancel: () {
_channel.setMethodCallHandler(null);
},
);
return controller.stream;
}

View file

@ -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
)

View file

@ -0,0 +1,137 @@
#include "include/desktop_multi_window/desktop_multi_window_plugin.h"
#include <flutter_linux/flutter_linux.h>
#include <gtk/gtk.h>
#include <cstring>
#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.");
}
}

View file

@ -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_

View file

@ -0,0 +1,52 @@
#include "flutter_window.h"
#include <iostream>
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);
}

View file

@ -0,0 +1,44 @@
#ifndef DESKTOP_MULTI_WINDOW_WINDOWS_FLUTTER_WINDOW_H_
#define DESKTOP_MULTI_WINDOW_WINDOWS_FLUTTER_WINDOW_H_
#include <cmath>
#include <cstdint>
#include <memory>
#include <string>
#include <flutter_linux/flutter_linux.h>
#include <gtk/gtk.h>
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_

View file

@ -0,0 +1,32 @@
#ifndef FLUTTER_PLUGIN_DESKTOP_MULTI_WINDOW_PLUGIN_H_
#define FLUTTER_PLUGIN_DESKTOP_MULTI_WINDOW_PLUGIN_H_
#include <flutter_linux/flutter_linux.h>
G_BEGIN_DECLS
#ifdef FLUTTER_PLUGIN_IMPL
#define FLUTTER_PLUGIN_EXPORT __attribute__((visibility("default")))
#else
#define FLUTTER_PLUGIN_EXPORT
#endif
typedef struct _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_

View file

@ -0,0 +1,235 @@
#include "multi_window_manager.h"
#include <iomanip>
#include <random>
#include <sstream>
#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 <gdk/gdkx.h>
#endif
namespace {
std::string GenerateWindowId() {
std::random_device rd;
std::mt19937_64 gen(rd());
std::uniform_int_distribution<uint64_t> 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<uint32_t>(part1 >> 32),
static_cast<uint16_t>(part1 >> 16), static_cast<uint16_t>(part1),
static_cast<uint16_t>(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<MultiWindowManager>();
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<FlutterWindow>(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<char**>(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<FlutterWindow>(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<std::string*>(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<std::string> MultiWindowManager::GetAllWindowIds() {
std::vector<std::string> 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;
}

View file

@ -0,0 +1,47 @@
#ifndef DESKTOP_MULTI_WINDOW_WINDOWS_MULTI_WINDOW_MANAGER_H_
#define DESKTOP_MULTI_WINDOW_WINDOWS_MULTI_WINDOW_MANAGER_H_
#include <cmath>
#include <cstdint>
#include <map>
#include <string>
#include <vector>
#include <flutter_linux/flutter_linux.h>
#include <gtk/gtk.h>
#include "flutter_window.h"
class MultiWindowManager
: public std::enable_shared_from_this<MultiWindowManager> {
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<std::string> GetAllWindowIds();
void RemoveWindow(const std::string& window_id);
private:
void ObserveWindowClose(const std::string& window_id,
GtkWindow* window);
void NotifyWindowsChanged();
std::map<std::string, std::unique_ptr<FlutterWindow>> windows_;
};
#endif // DESKTOP_MULTI_WINDOW_WINDOWS_MULTI_WINDOW_MANAGER_H_

View file

@ -0,0 +1,370 @@
#include "window_channel_plugin.h"
#include <algorithm>
#include <cstring>
#include <map>
#include <memory>
#include <mutex>
#include <set>
#include <string>
#include <vector>
enum class ChannelMode { kUnidirectional, kBidirectional };
enum class RegistrationOutcome {
kAdded,
kAlreadyRegistered,
kLimitReached,
kModeConflict
};
struct _WindowChannelPlugin {
GObject parent_instance;
FlMethodChannel* channel;
std::vector<std::string>* 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::string, WindowChannelPlugin*> unidirectional_channels_;
std::map<std::string, std::set<WindowChannelPlugin*>>
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<std::string>();
}
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);
}

View file

@ -0,0 +1,18 @@
#ifndef DESKTOP_MULTI_WINDOW_LINUX_WINDOW_CHANNEL_PLUGIN_H_
#define DESKTOP_MULTI_WINDOW_LINUX_WINDOW_CHANNEL_PLUGIN_H_
#include <flutter_linux/flutter_linux.h>
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_

View file

@ -0,0 +1,42 @@
#pragma once
#include <flutter_linux/flutter_linux.h>
#include <string>
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);
}
};

View file

@ -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
}
}

View file

@ -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))
}
}
}

View file

@ -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<WindowChannel>]()
// Bidirectional channels: channel -> pair of windows
private var bidirectionalChannels = [String: NSHashTable<AnyObject>]()
enum RegistrationOutcome {
case added
case alreadyRegistered
case limitReached
case modeConflict
}
private init() {}
// Helper class to wrap weak reference
private class WeakBox<T: AnyObject> {
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<AnyObject>
if let existing = bidirectionalChannels[channel] {
table = existing
} else {
table = NSHashTable<AnyObject>.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)
}
}
}

View file

@ -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)
}
}

View file

@ -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

View file

@ -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

View file

@ -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
)

View file

@ -0,0 +1,118 @@
#include "include/desktop_multi_window/desktop_multi_window_plugin.h"
#include "multi_window_plugin_internal.h"
#include <flutter/method_channel.h>
#include <flutter/plugin_registrar_windows.h>
#include <flutter/standard_method_codec.h>
#include <memory>
#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<flutter::EncodableValue>& method_call,
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result);
FlutterWindowWrapper* window_;
flutter::PluginRegistrarWindows* registrar_;
};
DesktopMultiWindowPlugin::DesktopMultiWindowPlugin(
FlutterWindowWrapper* window,
flutter::PluginRegistrarWindows* registrar)
: window_(window), registrar_(registrar) {
auto channel =
std::make_shared<flutter::MethodChannel<flutter::EncodableValue>>(
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<flutter::EncodableValue>& method_call,
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> 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<flutter::EncodableMap>(method_call.arguments());
auto window_id = std::get<std::string>(
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<flutter::EncodableMap>(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<flutter::PluginRegistrarWindows>(registrar);
auto plugin =
std::make_unique<DesktopMultiWindowPlugin>(window, plugin_registrar);
plugin_registrar->AddPlugin(std::move(plugin));
}

View file

@ -0,0 +1,71 @@
#include "flutter_window.h"
#include "flutter_windows.h"
#include "tchar.h"
#include <iostream>
#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<std::string> entrypoint_args = {"multi_window", id_,
window_argument_};
project.set_dart_entrypoint_arguments(entrypoint_args);
flutter_controller_ = std::make_unique<flutter::FlutterViewController>(
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<LRESULT> 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()
}

View file

@ -0,0 +1,44 @@
#ifndef DESKTOP_MULTI_WINDOW_WINDOWS_FLUTTER_WINDOW_H_
#define DESKTOP_MULTI_WINDOW_WINDOWS_FLUTTER_WINDOW_H_
#include <Windows.h>
#include <flutter/flutter_view_controller.h>
#include <memory>
#include <string>
#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::FlutterViewController> flutter_controller_;
};
#endif // DESKTOP_MULTI_WINDOW_WINDOWS_FLUTTER_WINDOW_H_

View file

@ -0,0 +1,69 @@
#ifndef DESKTOP_MULTI_WINDOW_WINDOWS_FLUTTER_WINDOW_WRAPPER_H_
#define DESKTOP_MULTI_WINDOW_WINDOWS_FLUTTER_WINDOW_WRAPPER_H_
#include <Windows.h>
#include <flutter/encodable_value.h>
#include <flutter/method_channel.h>
#include <flutter/method_result.h>
#include <memory>
#include <string>
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<flutter::MethodChannel<flutter::EncodableValue>>
channel) {
channel_ = channel;
}
void NotifyWindowEvent(const std::string& event,
const flutter::EncodableMap& data) {
if (channel_) {
channel_->InvokeMethod(event,
std::make_unique<flutter::EncodableValue>(data));
}
}
void HandleWindowMethod(
const std::string& method,
const flutter::EncodableMap* arguments,
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> 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<flutter::MethodChannel<flutter::EncodableValue>> channel_;
};
#endif // DESKTOP_MULTI_WINDOW_WINDOWS_FLUTTER_WINDOW_WRAPPER_H_

View file

@ -0,0 +1,27 @@
#ifndef FLUTTER_PLUGIN_DESKTOP_MULTI_WINDOW_PLUGIN_H_
#define FLUTTER_PLUGIN_DESKTOP_MULTI_WINDOW_PLUGIN_H_
#include <flutter_plugin_registrar.h>
#ifdef FLUTTER_PLUGIN_IMPL
#define FLUTTER_PLUGIN_EXPORT __declspec(dllexport)
#else
#define FLUTTER_PLUGIN_EXPORT __declspec(dllimport)
#endif
#if defined(__cplusplus)
extern "C" {
#endif
FLUTTER_PLUGIN_EXPORT void 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_

View file

@ -0,0 +1,190 @@
#include "multi_window_manager.h"
#include <rpc.h>
#include <iomanip>
#include <memory>
#include <random>
#include <sstream>
#pragma comment(lib, "rpcrt4.lib")
#include <iostream>
#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<char*>(uuid_str));
RpcStringFreeA(&uuid_str);
return result;
}
WindowCreatedCallback _g_window_created_callback = nullptr;
} // namespace
// static
MultiWindowManager* MultiWindowManager::Instance() {
static auto manager = std::make_shared<MultiWindowManager>();
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<FlutterWindow>(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<FlutterWindowWrapper>(
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<FlutterWindowWrapper>(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<std::string> MultiWindowManager::GetAllWindowIds() {
std::vector<std::string> 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;
}

View file

@ -0,0 +1,44 @@
#ifndef DESKTOP_MULTI_WINDOW_WINDOWS_MULTI_WINDOW_MANAGER_H_
#define DESKTOP_MULTI_WINDOW_WINDOWS_MULTI_WINDOW_MANAGER_H_
#include <cstdint>
#include <map>
#include <string>
#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<std::string> GetAllWindowIds();
private:
void NotifyWindowsChanged();
void CleanupRemovedWindows();
std::map<std::string, std::unique_ptr<FlutterWindowWrapper>> windows_;
std::map<std::string, std::unique_ptr<FlutterWindow>>
managed_flutter_windows_;
std::vector<std::string> pending_remove_ids_;
};
#endif // DESKTOP_MULTI_WINDOW_WINDOWS_MULTI_WINDOW_MANAGER_H_

View file

@ -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_

View file

@ -0,0 +1,301 @@
#include "win32_window.h"
#include <dwmapi.h>
#include <flutter_windows.h>
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<int>(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<EnableNonClientDpiScaling*>(
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<LONG>(origin.x),
static_cast<LONG>(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<CREATESTRUCT*>(lparam);
SetWindowLongPtr(window, GWLP_USERDATA,
reinterpret_cast<LONG_PTR>(window_struct->lpCreateParams));
auto that = static_cast<Win32Window*>(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<RECT*>(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<Win32Window*>(
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));
}
}

View file

@ -0,0 +1,102 @@
#ifndef RUNNER_WIN32_WINDOW_H_
#define RUNNER_WIN32_WINDOW_H_
#include <windows.h>
#include <functional>
#include <memory>
#include <string>
// 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_

View file

@ -0,0 +1,347 @@
#include "window_channel_plugin.h"
#include <flutter/encodable_value.h>
#include <flutter/method_result_functions.h>
#include <iostream>
#include <map>
#include <memory>
#include <mutex>
#include <set>
#include <string>
#include <vector>
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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::string, WindowChannelPlugin*> unidirectional_channels_;
std::map<std::string, std::set<WindowChannelPlugin*>>
bidirectional_channels_;
};
class WindowChannelPlugin : public flutter::Plugin {
public:
WindowChannelPlugin(flutter::PluginRegistrarWindows* registrar)
: registrar_(registrar) {
channel_ = std::make_unique<flutter::MethodChannel<>>(
registrar->messenger(), "mixin.one/desktop_multi_window/channels",
&flutter::StandardMethodCodec::GetInstance());
channel_->SetMethodCallHandler(
[this](const flutter::MethodCall<>& call,
std::unique_ptr<flutter::MethodResult<>> 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<flutter::MethodResult<>> 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<flutter::EncodableValue>(arguments),
std::move(result));
}
private:
void HandleMethodCall(const flutter::MethodCall<>& call,
std::unique_ptr<flutter::MethodResult<>> result) {
const auto& method = call.method_name();
if (method == "registerMethodHandler") {
auto* args = std::get_if<flutter::EncodableMap>(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<std::string>(&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<std::string>(&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<flutter::EncodableMap>(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<std::string>(&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<flutter::EncodableMap>(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<std::string>(&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<flutter::MethodChannel<>> channel_;
std::vector<std::string> registered_channels_;
};
} // namespace
void WindowChannelPluginRegisterWithRegistrar(
flutter::PluginRegistrarWindows* registrar) {
auto plugin = std::make_unique<WindowChannelPlugin>(registrar);
registrar->AddPlugin(std::move(plugin));
}

View file

@ -0,0 +1,17 @@
#ifndef DESKTOP_MULTI_WINDOW_WINDOWS_WINDOW_CHANNEL_PLUGIN_H_
#define DESKTOP_MULTI_WINDOW_WINDOWS_WINDOW_CHANNEL_PLUGIN_H_
#include <flutter/method_channel.h>
#include <flutter/plugin_registrar_windows.h>
#include <flutter/standard_method_codec.h>
#include <map>
#include <memory>
#include <mutex>
#include <string>
#include <vector>
void WindowChannelPluginRegisterWithRegistrar(
flutter::PluginRegistrarWindows* registrar);
#endif // DESKTOP_MULTI_WINDOW_WINDOWS_WINDOW_CHANNEL_PLUGIN_H_

View file

@ -0,0 +1,34 @@
#pragma once
#include <string>
#include <flutter/encodable_value.h>
#include <iostream>
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<std::string>(it->second);
}
it = map->find(flutter::EncodableValue("hiddenAtLaunch"));
if (it != map->end()) {
config.hidden_at_launch = std::get<bool>(it->second);
}
} catch (const std::exception& e) {
std::cerr << "Failed to parse WindowConfiguration: " << e.what()
<< std::endl;
}
return config;
}
};

View file

@ -7,6 +7,7 @@
#include "generated_plugin_registrant.h"
#include <desktop_drop/desktop_drop_plugin.h>
#include <desktop_multi_window/desktop_multi_window_plugin.h>
#include <pasteboard/pasteboard_plugin.h>
#include <screen_retriever_windows/screen_retriever_windows_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h>
@ -15,6 +16,8 @@
void RegisterPlugins(flutter::PluginRegistry* registry) {
DesktopDropPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("DesktopDropPlugin"));
DesktopMultiWindowPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("DesktopMultiWindowPlugin"));
PasteboardPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PasteboardPlugin"));
ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar(

View file

@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
desktop_drop
desktop_multi_window
pasteboard
screen_retriever_windows
url_launcher_windows