chromium/components/remote_cocoa/app_shim/alert.mm

// Copyright 2019 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#include "components/remote_cocoa/app_shim/alert.h"

#import "base/apple/foundation_util.h"
#include "base/functional/bind.h"
#include "base/i18n/rtl.h"
#include "base/memory/raw_ptr_exclusion.h"
#include "base/strings/sys_string_conversions.h"
#include "ui/accelerated_widget_mac/window_resize_helper_mac.h"
#include "ui/base/l10n/l10n_util_mac.h"

using remote_cocoa::mojom::AlertBridgeInitParams;
using remote_cocoa::mojom::AlertDisposition;

namespace {

const int kSlotsPerLine = 50;
const int kMessageTextMaxSlots = 2000;

}  // namespace

////////////////////////////////////////////////////////////////////////////////
// AlertBridgeHelper:

// Helper object that receives the notification that the dialog/sheet is
// going away. Is responsible for cleaning itself up.
@interface AlertBridgeHelper : NSObject <NSAlertDelegate> {
 @private
  NSAlert* __strong _alert;
  // This field is not a raw_ptr<> because it requires @property rewrite.
  RAW_PTR_EXCLUSION remote_cocoa::AlertBridge* _alertBridge;  // Weak.
  NSTextField* __strong _textField;
}
@property(assign, nonatomic) remote_cocoa::AlertBridge* alertBridge;

// Returns the underlying alert.
- (NSAlert*)alert;

// Set a blank icon for dialogs with text provided by the page.
- (void)setBlankIcon;

// Add a text field to the alert.
- (void)addTextFieldWithPrompt:(NSString*)prompt;

// Presents an AppKit blocking dialog.
- (void)showAlert;
@end

@implementation AlertBridgeHelper
@synthesize alertBridge = _alertBridge;

- (void)initAlert:(AlertBridgeInitParams*)params {
  _alert = [[NSAlert alloc] init];
  _alert.delegate = self;

  if (params->hide_application_icon)
    [self setBlankIcon];
  if (params->text_field_text) {
    [self addTextFieldWithPrompt:base::SysUTF16ToNSString(
                                     *params->text_field_text)];
  }
  NSString* informative_text = base::SysUTF16ToNSString(params->message_text);

  // Truncate long JS alerts - crbug.com/331219
  NSCharacterSet* newline_char_set = [NSCharacterSet newlineCharacterSet];
  for (size_t index = 0, slots_count = 0; index < informative_text.length;
       ++index) {
    unichar current_char = [informative_text characterAtIndex:index];
    if ([newline_char_set characterIsMember:current_char])
      slots_count += kSlotsPerLine;
    else
      slots_count++;
    if (slots_count > kMessageTextMaxSlots) {
      std::u16string info_text = base::SysNSStringToUTF16(informative_text);
      informative_text = base::SysUTF16ToNSString(
          gfx::TruncateString(info_text, index, gfx::WORD_BREAK));
      break;
    }
  }

  _alert.informativeText = informative_text;
  NSString* message_text = l10n_util::FixUpWindowsStyleLabel(params->title);
  _alert.messageText = message_text;
  [_alert addButtonWithTitle:l10n_util::FixUpWindowsStyleLabel(
                                 params->primary_button_text)];

  if (params->secondary_button_text) {
    NSButton* other =
        [_alert addButtonWithTitle:l10n_util::FixUpWindowsStyleLabel(
                                       *params->secondary_button_text)];
    other.keyEquivalent = @"\e";
  }
  if (params->check_box_text) {
    _alert.showsSuppressionButton = YES;
    NSString* suppression_title =
        l10n_util::FixUpWindowsStyleLabel(*params->check_box_text);
    [_alert.suppressionButton setTitle:suppression_title];
  }

  // Fix RTL dialogs.
  //
  // macOS will always display NSAlert strings as LTR. A workaround is to
  // manually set the text as attributed strings in the implementing
  // NSTextFields. This is a basic correctness issue.
  //
  // In addition, for readability, the overall alignment is set based on the
  // directionality of the first strongly-directional character.
  //
  // If the dialog fields are selectable then they will scramble when clicked.
  // Therefore, selectability is disabled.
  //
  // See http://crbug.com/70806 for more details.

  bool message_has_rtl =
      base::i18n::StringContainsStrongRTLChars(params->title);
  bool informative_has_rtl =
      base::i18n::StringContainsStrongRTLChars(params->message_text);

  NSTextField* message_text_field = nil;
  NSTextField* informative_text_field = nil;
  if (message_has_rtl || informative_has_rtl) {
    // Force layout of the dialog. NSAlert leaves its dialog alone once laid
    // out; if this is not done then all the modifications that are to come will
    // be un-done when the dialog is finally displayed.
    [_alert layout];

    // Locate the NSTextFields that implement the text display. These are
    // actually available as the ivars |_messageField| and |_informationField|
    // of the NSAlert, but it is safer (and more forward-compatible) to search
    // for them in the subviews.
    for (NSView* view in _alert.window.contentView.subviews) {
      NSTextField* text_field = base::apple::ObjCCast<NSTextField>(view);
      if ([text_field.stringValue isEqualTo:message_text]) {
        message_text_field = text_field;
      } else if ([text_field.stringValue isEqualTo:informative_text]) {
        informative_text_field = text_field;
      }
    }

    // This may fail in future OS releases, but it will still work for shipped
    // versions of Chromium.
    DCHECK(message_text_field);
    DCHECK(informative_text_field);
  }

  if (message_has_rtl && message_text_field) {
    NSMutableParagraphStyle* alignment =
        [NSParagraphStyle.defaultParagraphStyle mutableCopy];
    alignment.alignment = NSTextAlignmentRight;

    NSDictionary* alignment_attributes =
        @{NSParagraphStyleAttributeName : alignment};
    NSAttributedString* attr_string =
        [[NSAttributedString alloc] initWithString:message_text
                                        attributes:alignment_attributes];

    message_text_field.attributedStringValue = attr_string;
    message_text_field.selectable = NO;
  }

  if (informative_has_rtl && informative_text_field) {
    base::i18n::TextDirection direction =
        base::i18n::GetFirstStrongCharacterDirection(params->message_text);
    NSMutableParagraphStyle* alignment =
        [NSParagraphStyle.defaultParagraphStyle mutableCopy];
    alignment.alignment = direction == base::i18n::RIGHT_TO_LEFT
                              ? NSTextAlignmentRight
                              : NSTextAlignmentLeft;

    NSDictionary* alignment_attributes =
        @{NSParagraphStyleAttributeName : alignment};
    NSAttributedString* attr_string =
        [[NSAttributedString alloc] initWithString:informative_text
                                        attributes:alignment_attributes];

    informative_text_field.attributedStringValue = attr_string;
    informative_text_field.selectable = NO;
  }
}

- (void)setBlankIcon {
  NSImage* image = [[NSImage alloc] initWithSize:NSMakeSize(1, 1)];
  _alert.icon = image;
}

- (NSAlert*)alert {
  return _alert;
}

- (void)addTextFieldWithPrompt:(NSString*)prompt {
  DCHECK(!_textField);
  _textField = [[NSTextField alloc] initWithFrame:NSMakeRect(0, 0, 300, 22)];
  _textField.cell.lineBreakMode = NSLineBreakByTruncatingTail;
  self.alert.accessoryView = _textField;
  _alert.window.initialFirstResponder = _textField;

  [_textField setStringValue:prompt];
}

// |contextInfo| is the JavaScriptAppModalDialogCocoa that owns us.
- (void)alertDidEnd:(NSAlert*)alert
         returnCode:(int)returnCode
        contextInfo:(void*)contextInfo {
  switch (returnCode) {
    case NSAlertFirstButtonReturn:  // OK
      _alertBridge->SendResultAndDestroy(AlertDisposition::PRIMARY_BUTTON);
      break;
    case NSAlertSecondButtonReturn:  // Cancel
      _alertBridge->SendResultAndDestroy(AlertDisposition::SECONDARY_BUTTON);
      break;
    case NSModalResponseStop:  // Window was closed underneath us
      _alertBridge->SendResultAndDestroy(AlertDisposition::CLOSE);
      break;
    default:
      NOTREACHED_IN_MIGRATION();
  }
}

- (void)showAlert {
  DCHECK(_alertBridge);
  _alertBridge->SetAlertHasShown();
  NSAlert* alert = [self alert];
  [alert layout];
  [alert.window recalculateKeyViewLoop];
  // TODO(crbug.com/40575730): Migrate to `[NSWindow
  // beginSheetModalForWindow:completionHandler:]` instead.
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
  [alert beginSheetModalForWindow:nil  // nil here makes it app-modal
                    modalDelegate:self
                   didEndSelector:@selector(alertDidEnd:returnCode:contextInfo:)
                      contextInfo:nullptr];
#pragma clang diagnostic pop
}

- (void)closeWindow {
  DCHECK(_alertBridge);
  [NSApp endSheet:self.alert.window];
}

- (std::u16string)input {
  if (_textField)
    return base::SysNSStringToUTF16(_textField.stringValue);
  return std::u16string();
}

- (bool)shouldSuppress {
  if ([[self alert] showsSuppressionButton])
    return [[[self alert] suppressionButton] state] == NSControlStateValueOn;
  return false;
}

@end

namespace remote_cocoa {

////////////////////////////////////////////////////////////////////////////////
// AlertBridge:

AlertBridge::AlertBridge(
    mojo::PendingReceiver<mojom::AlertBridge> bridge_receiver)
    : weak_factory_(this) {
  if (bridge_receiver.is_valid()) {
    mojo_receiver_.Bind(std::move(bridge_receiver),
                        ui::WindowResizeHelperMac::Get()->task_runner());
    mojo_receiver_.set_disconnect_handler(base::BindOnce(
        &AlertBridge::OnMojoDisconnect, weak_factory_.GetWeakPtr()));
  }
}

AlertBridge::~AlertBridge() {
  helper_.alertBridge = nil;
  [NSObject cancelPreviousPerformRequestsWithTarget:helper_];
}

void AlertBridge::OnMojoDisconnect() {
  // If the alert has been shown, then close the window, and |this| will delete
  // itself after the window is closed. Otherwise, just delete |this|
  // immediately.
  if (alert_shown_)
    [helper_ closeWindow];
  else
    delete this;
}

void AlertBridge::SendResultAndDestroy(AlertDisposition disposition) {
  if (!alert_dismissed_) {
    DCHECK(callback_);
    std::move(callback_).Run(disposition, [helper_ input],
                             [helper_ shouldSuppress]);
  }
  delete this;
}

void AlertBridge::SetAlertHasShown() {
  DCHECK(!alert_shown_);
  alert_shown_ = true;
}

////////////////////////////////////////////////////////////////////////////////
// AlertBridge, mojo::AlertBridge:

void AlertBridge::Show(mojom::AlertBridgeInitParamsPtr params,
                       ShowCallback callback) {
  callback_ = std::move(callback);

  // Create a helper which will receive the sheet ended selector.
  helper_ = [[AlertBridgeHelper alloc] init];
  helper_.alertBridge = this;
  [helper_ initAlert:params.get()];

  // Dispatch the method to show the alert back to the top of the CFRunLoop.
  // This fixes an interaction bug with NSSavePanel. http://crbug.com/375785
  // When this object is destroyed, outstanding performSelector: requests
  // should be cancelled.
  [helper_ performSelector:@selector(showAlert) withObject:nil afterDelay:0];
}

void AlertBridge::Dismiss() {
  alert_dismissed_ = true;
  OnMojoDisconnect();
}

}  // namespace remote_cocoa