// Copyright 2013 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. #import "flutter/shell/platform/darwin/macos/framework/Headers/FlutterViewController.h" #import "flutter/shell/platform/darwin/macos/framework/Source/FlutterViewController_Internal.h" #import "flutter/shell/platform/darwin/common/framework/Headers/FlutterChannels.h" #import "flutter/shell/platform/darwin/common/framework/Headers/FlutterCodecs.h" #import "flutter/shell/platform/darwin/macos/framework/Headers/FlutterEngine.h" #import "flutter/shell/platform/darwin/macos/framework/Source/FlutterEngine_Internal.h" #import "flutter/shell/platform/darwin/macos/framework/Source/FlutterTextInputPlugin.h" #import "flutter/shell/platform/darwin/macos/framework/Source/FlutterView.h" #import "flutter/shell/platform/embedder/embedder.h" namespace { /// Clipboard plain text format. constexpr char kTextPlainFormat[] = "text/plain"; /** * State tracking for mouse events, to adapt between the events coming from the system and the * events that the embedding API expects. */ struct MouseState { /** * Whether or not a kAdd event has been sent (or sent again since the last kRemove if tracking is * enabled). Used to determine whether to send a kAdd event before sending an incoming mouse * event, since Flutter expects pointers to be added before events are sent for them. */ bool flutter_state_is_added = false; /** * Whether or not a kDown has been sent since the last kAdd/kUp. */ bool flutter_state_is_down = false; /** * Whether or not mouseExited: was received while a button was down. Cocoa's behavior when * dragging out of a tracked area is to send an exit, then keep sending drag events until the last * button is released. If it was released inside the view, mouseEntered: is sent the next time the * mouse moves. Flutter doesn't expect to receive events after a kRemove, so the kRemove for the * exit needs to be delayed until after the last mouse button is released. */ bool has_pending_exit = false; /** * The currently pressed buttons, as represented in FlutterPointerEvent. */ int64_t buttons = 0; /** * Resets all state to default values. */ void Reset() { flutter_state_is_added = false; flutter_state_is_down = false; has_pending_exit = false; buttons = 0; } }; /** * State tracking for keyboard events, to adapt between the events coming from the system and the * events that the embedding API expects. */ struct KeyboardState { /** * The last known pressed modifier flag keys. */ uint64_t previously_pressed_flags = 0; }; } // namespace #pragma mark - Private interface declaration. /** * Private interface declaration for FlutterViewController. */ @interface FlutterViewController () /** * A list of additional responders to keyboard events. Keybord events are forwarded to all of them. */ @property(nonatomic) NSMutableOrderedSet* additionalKeyResponders; /** * The tracking area used to generate hover events, if enabled. */ @property(nonatomic) NSTrackingArea* trackingArea; /** * The current state of the mouse and the sent mouse events. */ @property(nonatomic) MouseState mouseState; /** * The current state of the keyboard and pressed keys. */ @property(nonatomic) KeyboardState keyboardState; /** * Event monitor for keyUp events. */ @property(nonatomic) id keyUpMonitor; /** * Starts running |engine|, including any initial setup. */ - (BOOL)launchEngine; /** * Updates |trackingArea| for the current tracking settings, creating it with * the correct mode if tracking is enabled, or removing it if not. */ - (void)configureTrackingArea; /** * Creates and registers plugins used by this view controller. */ - (void)addInternalPlugins; /** * Calls dispatchMouseEvent:phase: with a phase determined by self.mouseState. * * mouseState.buttons should be updated before calling this method. */ - (void)dispatchMouseEvent:(nonnull NSEvent*)event; /** * Converts |event| to a FlutterPointerEvent with the given phase, and sends it to the engine. */ - (void)dispatchMouseEvent:(nonnull NSEvent*)event phase:(FlutterPointerPhase)phase; /** * Converts |event| to a key event channel message, and sends it to the engine. */ - (void)dispatchKeyEvent:(NSEvent*)event ofType:(NSString*)type; /** * Initializes the KVO for user settings and passes the initial user settings to the engine. */ - (void)sendInitialSettings; /** * Responsds to updates in the user settings and passes this data to the engine. */ - (void)onSettingsChanged:(NSNotification*)notification; /** * Handles messages received from the Flutter engine on the _*Channel channels. */ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result; /** * Reads the data from the clipboard. |format| specifies the media type of the * data to obtain. */ - (NSDictionary*)getClipboardData:(NSString*)format; /** * Clears contents and writes new data into clipboard. |data| is a dictionary where * the keys are the type of data, and tervalue the data to be stored. */ - (void)setClipboardData:(NSDictionary*)data; @end #pragma mark - FlutterViewController implementation. @implementation FlutterViewController { // The project to run in this controller's engine. FlutterDartProject* _project; // The plugin used to handle text input. This is not an FlutterPlugin, so must be owned // separately. FlutterTextInputPlugin* _textInputPlugin; // A message channel for passing key events to the Flutter engine. This should be replaced with // an embedding API; see Issue #47. FlutterBasicMessageChannel* _keyEventChannel; // A message channel for sending user settings to the flutter engine. FlutterBasicMessageChannel* _settingsChannel; // A method channel for miscellaneous platform functionality. FlutterMethodChannel* _platformChannel; } @dynamic view; /** * Performs initialization that's common between the different init paths. */ static void CommonInit(FlutterViewController* controller) { controller->_engine = [[FlutterEngine alloc] initWithName:@"io.flutter" project:controller->_project allowHeadlessExecution:NO]; controller->_additionalKeyResponders = [[NSMutableOrderedSet alloc] init]; controller->_mouseTrackingMode = FlutterMouseTrackingModeInKeyWindow; } - (instancetype)initWithCoder:(NSCoder*)coder { self = [super initWithCoder:coder]; NSAssert(self, @"Super init cannot be nil"); CommonInit(self); return self; } - (instancetype)initWithNibName:(NSString*)nibNameOrNil bundle:(NSBundle*)nibBundleOrNil { self = [super initWithNibName:nibNameOrNil bundle:nibBundleOrNil]; NSAssert(self, @"Super init cannot be nil"); CommonInit(self); return self; } - (instancetype)initWithProject:(nullable FlutterDartProject*)project { self = [super initWithNibName:nil bundle:nil]; NSAssert(self, @"Super init cannot be nil"); _project = project; CommonInit(self); return self; } - (void)loadView { NSOpenGLContext* resourceContext = _engine.resourceContext; if (!resourceContext) { NSLog(@"Unable to create FlutterView; no resource context available."); return; } FlutterView* flutterView = [[FlutterView alloc] initWithShareContext:resourceContext reshapeListener:self]; self.view = flutterView; } - (void)viewDidLoad { [self configureTrackingArea]; } - (void)viewWillAppear { [super viewWillAppear]; if (!_engine.running) { [self launchEngine]; } [self listenForMetaModifiedKeyUpEvents]; } - (void)viewWillDisappear { // Per Apple's documentation, it is discouraged to call removeMonitor: in dealloc, and it's // recommended to be called earlier in the lifecycle. [NSEvent removeMonitor:_keyUpMonitor]; _keyUpMonitor = nil; } - (void)dealloc { _engine.viewController = nil; } #pragma mark - Public methods - (void)setMouseTrackingMode:(FlutterMouseTrackingMode)mode { if (_mouseTrackingMode == mode) { return; } _mouseTrackingMode = mode; [self configureTrackingArea]; } #pragma mark - Framework-internal methods - (FlutterView*)flutterView { return static_cast(self.view); } - (void)addKeyResponder:(NSResponder*)responder { [self.additionalKeyResponders addObject:responder]; } - (void)removeKeyResponder:(NSResponder*)responder { } #pragma mark - Private methods - (BOOL)launchEngine { // Register internal plugins before starting the engine. [self addInternalPlugins]; _engine.viewController = self; if (![_engine runWithEntrypoint:nil]) { return NO; } // Send the initial user settings such as brightness and text scale factor // to the engine. [self sendInitialSettings]; return YES; } // macOS does not call keyUp: on a key while the command key is pressed. This results in a loss // of a key event once the modified key is released. This method registers the // ViewController as a listener for a keyUp event before it's handled by NSApplication, and should // NOT modify the event to avoid any unexpected behavior. - (void)listenForMetaModifiedKeyUpEvents { NSAssert(_keyUpMonitor == nil, @"_keyUpMonitor was already created"); FlutterViewController* __weak weakSelf = self; _keyUpMonitor = [NSEvent addLocalMonitorForEventsMatchingMask:NSEventMaskKeyUp handler:^NSEvent*(NSEvent* event) { // Intercept keyUp only for events triggered on the current // view. if (weakSelf.view && ([[event window] firstResponder] == weakSelf.view) && ([event modifierFlags] & NSEventModifierFlagCommand) && ([event type] == NSEventTypeKeyUp)) [weakSelf keyUp:event]; return event; }]; } - (void)configureTrackingArea { if (_mouseTrackingMode != FlutterMouseTrackingModeNone && self.view) { NSTrackingAreaOptions options = NSTrackingMouseEnteredAndExited | NSTrackingMouseMoved | NSTrackingInVisibleRect; switch (_mouseTrackingMode) { case FlutterMouseTrackingModeInKeyWindow: options |= NSTrackingActiveInKeyWindow; break; case FlutterMouseTrackingModeInActiveApp: options |= NSTrackingActiveInActiveApp; break; case FlutterMouseTrackingModeAlways: options |= NSTrackingActiveAlways; break; default: NSLog(@"Error: Unrecognized mouse tracking mode: %ld", _mouseTrackingMode); return; } _trackingArea = [[NSTrackingArea alloc] initWithRect:NSZeroRect options:options owner:self userInfo:nil]; [self.view addTrackingArea:_trackingArea]; } else if (_trackingArea) { [self.view removeTrackingArea:_trackingArea]; _trackingArea = nil; } } - (void)addInternalPlugins { _textInputPlugin = [[FlutterTextInputPlugin alloc] initWithViewController:self]; _keyEventChannel = [FlutterBasicMessageChannel messageChannelWithName:@"flutter/keyevent" binaryMessenger:_engine.binaryMessenger codec:[FlutterJSONMessageCodec sharedInstance]]; _settingsChannel = [FlutterBasicMessageChannel messageChannelWithName:@"flutter/settings" binaryMessenger:_engine.binaryMessenger codec:[FlutterJSONMessageCodec sharedInstance]]; _platformChannel = [FlutterMethodChannel methodChannelWithName:@"flutter/platform" binaryMessenger:_engine.binaryMessenger codec:[FlutterJSONMethodCodec sharedInstance]]; __weak FlutterViewController* weakSelf = self; [_platformChannel setMethodCallHandler:^(FlutterMethodCall* call, FlutterResult result) { [weakSelf handleMethodCall:call result:result]; }]; } - (void)dispatchMouseEvent:(nonnull NSEvent*)event { FlutterPointerPhase phase = _mouseState.buttons == 0 ? (_mouseState.flutter_state_is_down ? kUp : kHover) : (_mouseState.flutter_state_is_down ? kMove : kDown); [self dispatchMouseEvent:event phase:phase]; } - (void)dispatchMouseEvent:(NSEvent*)event phase:(FlutterPointerPhase)phase { // There are edge cases where the system will deliver enter out of order relative to other // events (e.g., drag out and back in, release, then click; mouseDown: will be called before // mouseEntered:). Discard those events, since the add will already have been synthesized. if (_mouseState.flutter_state_is_added && phase == kAdd) { return; } // If a pointer added event hasn't been sent, synthesize one using this event for the basic // information. if (!_mouseState.flutter_state_is_added && phase != kAdd) { // Only the values extracted for use in flutterEvent below matter, the rest are dummy values. NSEvent* addEvent = [NSEvent enterExitEventWithType:NSEventTypeMouseEntered location:event.locationInWindow modifierFlags:0 timestamp:event.timestamp windowNumber:event.windowNumber context:nil eventNumber:0 trackingNumber:0 userData:NULL]; [self dispatchMouseEvent:addEvent phase:kAdd]; } NSPoint locationInView = [self.view convertPoint:event.locationInWindow fromView:nil]; NSPoint locationInBackingCoordinates = [self.view convertPointToBacking:locationInView]; FlutterPointerEvent flutterEvent = { .struct_size = sizeof(flutterEvent), .device_kind = kFlutterPointerDeviceKindMouse, .phase = phase, .x = locationInBackingCoordinates.x, .y = -locationInBackingCoordinates.y, // convertPointToBacking makes this negative. .timestamp = static_cast(event.timestamp * NSEC_PER_MSEC), // If a click triggered a synthesized kAdd, don't pass the buttons in that event. .buttons = phase == kAdd ? 0 : _mouseState.buttons, }; if (event.type == NSEventTypeScrollWheel) { flutterEvent.signal_kind = kFlutterPointerSignalKindScroll; double pixelsPerLine = 1.0; if (!event.hasPreciseScrollingDeltas) { CGEventSourceRef source = CGEventCreateSourceFromEvent(event.CGEvent); pixelsPerLine = CGEventSourceGetPixelsPerLine(source); if (source) { CFRelease(source); } } double scaleFactor = self.view.layer.contentsScale; flutterEvent.scroll_delta_x = -event.scrollingDeltaX * pixelsPerLine * scaleFactor; flutterEvent.scroll_delta_y = -event.scrollingDeltaY * pixelsPerLine * scaleFactor; } [_engine sendPointerEvent:flutterEvent]; // Update tracking of state as reported to Flutter. if (phase == kDown) { _mouseState.flutter_state_is_down = true; } else if (phase == kUp) { _mouseState.flutter_state_is_down = false; if (_mouseState.has_pending_exit) { [self dispatchMouseEvent:event phase:kRemove]; _mouseState.has_pending_exit = false; } } else if (phase == kAdd) { _mouseState.flutter_state_is_added = true; } else if (phase == kRemove) { _mouseState.Reset(); } } - (void)dispatchKeyEvent:(NSEvent*)event ofType:(NSString*)type { NSMutableDictionary* keyMessage = [@{ @"keymap" : @"macos", @"type" : type, @"keyCode" : @(event.keyCode), @"modifiers" : @(event.modifierFlags), } mutableCopy]; // Calling these methods on any other type of event will raise an exception. if (event.type == NSEventTypeKeyDown || event.type == NSEventTypeKeyUp) { keyMessage[@"characters"] = event.characters; keyMessage[@"charactersIgnoringModifiers"] = event.charactersIgnoringModifiers; } [_keyEventChannel sendMessage:keyMessage]; } - (void)onSettingsChanged:(NSNotification*)notification { // TODO(jonahwilliams): https://github.com/flutter/flutter/issues/32015. NSString* brightness = [[NSUserDefaults standardUserDefaults] stringForKey:@"AppleInterfaceStyle"]; [_settingsChannel sendMessage:@{ @"platformBrightness" : [brightness isEqualToString:@"Dark"] ? @"dark" : @"light", // TODO(jonahwilliams): https://github.com/flutter/flutter/issues/32006. @"textScaleFactor" : @1.0, @"alwaysUse24HourFormat" : @false }]; } - (void)sendInitialSettings { // TODO(jonahwilliams): https://github.com/flutter/flutter/issues/32015. [[NSDistributedNotificationCenter defaultCenter] addObserver:self selector:@selector(onSettingsChanged:) name:@"AppleInterfaceThemeChangedNotification" object:nil]; [self onSettingsChanged:nil]; } - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { if ([call.method isEqualToString:@"SystemNavigator.pop"]) { [NSApp terminate:self]; result(nil); } else if ([call.method isEqualToString:@"Clipboard.getData"]) { result([self getClipboardData:call.arguments]); } else if ([call.method isEqualToString:@"Clipboard.setData"]) { [self setClipboardData:call.arguments]; result(nil); } else { result(FlutterMethodNotImplemented); } } - (NSDictionary*)getClipboardData:(NSString*)format { NSPasteboard* pasteboard = [NSPasteboard generalPasteboard]; if ([format isEqualToString:@(kTextPlainFormat)]) { NSString* stringInPasteboard = [pasteboard stringForType:NSPasteboardTypeString]; return stringInPasteboard == nil ? nil : @{@"text" : stringInPasteboard}; } return nil; } - (void)setClipboardData:(NSDictionary*)data { NSPasteboard* pasteboard = [NSPasteboard generalPasteboard]; NSString* text = data[@"text"]; if (text && ![text isEqual:[NSNull null]]) { [pasteboard clearContents]; [pasteboard setString:text forType:NSPasteboardTypeString]; } } #pragma mark - FlutterViewReshapeListener /** * Responds to view reshape by notifying the engine of the change in dimensions. */ - (void)viewDidReshape:(NSView*)view { [_engine updateWindowMetrics]; } #pragma mark - FlutterPluginRegistry - (id)registrarForPlugin:(NSString*)pluginName { return [_engine registrarForPlugin:pluginName]; } #pragma mark - NSResponder - (BOOL)acceptsFirstResponder { return YES; } - (void)keyDown:(NSEvent*)event { [self dispatchKeyEvent:event ofType:@"keydown"]; for (NSResponder* responder in self.additionalKeyResponders) { if ([responder respondsToSelector:@selector(keyDown:)]) { [responder keyDown:event]; } } } - (void)keyUp:(NSEvent*)event { [self dispatchKeyEvent:event ofType:@"keyup"]; for (NSResponder* responder in self.additionalKeyResponders) { if ([responder respondsToSelector:@selector(keyUp:)]) { [responder keyUp:event]; } } } - (void)flagsChanged:(NSEvent*)event { if (event.modifierFlags < _keyboardState.previously_pressed_flags) { [self keyUp:event]; } else { [self keyDown:event]; } _keyboardState.previously_pressed_flags = event.modifierFlags; } - (void)mouseEntered:(NSEvent*)event { [self dispatchMouseEvent:event phase:kAdd]; } - (void)mouseExited:(NSEvent*)event { if (_mouseState.buttons != 0) { _mouseState.has_pending_exit = true; return; } [self dispatchMouseEvent:event phase:kRemove]; } - (void)mouseDown:(NSEvent*)event { _mouseState.buttons |= kFlutterPointerButtonMousePrimary; [self dispatchMouseEvent:event]; } - (void)mouseUp:(NSEvent*)event { _mouseState.buttons &= ~static_cast(kFlutterPointerButtonMousePrimary); [self dispatchMouseEvent:event]; } - (void)mouseDragged:(NSEvent*)event { [self dispatchMouseEvent:event]; } - (void)rightMouseDown:(NSEvent*)event { _mouseState.buttons |= kFlutterPointerButtonMouseSecondary; [self dispatchMouseEvent:event]; } - (void)rightMouseUp:(NSEvent*)event { _mouseState.buttons &= ~static_cast(kFlutterPointerButtonMouseSecondary); [self dispatchMouseEvent:event]; } - (void)rightMouseDragged:(NSEvent*)event { [self dispatchMouseEvent:event]; } - (void)otherMouseDown:(NSEvent*)event { _mouseState.buttons |= (1 << event.buttonNumber); [self dispatchMouseEvent:event]; } - (void)otherMouseUp:(NSEvent*)event { _mouseState.buttons &= ~static_cast(1 << event.buttonNumber); [self dispatchMouseEvent:event]; } - (void)otherMouseDragged:(NSEvent*)event { [self dispatchMouseEvent:event]; } - (void)mouseMoved:(NSEvent*)event { [self dispatchMouseEvent:event]; } - (void)scrollWheel:(NSEvent*)event { // TODO: Add gesture-based (trackpad) scroll support once it's supported by the engine rather // than always using kHover. [self dispatchMouseEvent:event phase:kHover]; } @end