chromium/components/remote_cocoa/app_shim/select_file_dialog_bridge.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/select_file_dialog_bridge.h"

#import <AppKit/AppKit.h>
#import <Foundation/Foundation.h>
#import <UniformTypeIdentifiers/UniformTypeIdentifiers.h>
#include <stddef.h>

#include "base/apple/bridging.h"
#import "base/apple/foundation_util.h"
#include "base/apple/scoped_cftyperef.h"
#include "base/files/file_util.h"
#include "base/i18n/case_conversion.h"
#import "base/mac/mac_util.h"
#include "base/strings/sys_string_conversions.h"
#include "base/strings/utf_string_conversions.h"
#include "base/threading/hang_watcher.h"
#include "base/threading/thread_restrictions.h"
#import "components/remote_cocoa/app_shim/native_widget_mac_nswindow.h"
#import "ui/base/l10n/l10n_util_mac.h"
#include "ui/strings/grit/ui_strings.h"

namespace {

const int kFileTypePopupTag = 1234;

// Returns whether the Uniform Type system considers `ext` to be a valid file
// extension.
bool IsValidExtension(const base::FilePath::StringType& ext) {
  UTType* type =
      [UTType typeWithFilenameExtension:base::SysUTF8ToNSString(ext)];
  return !!type;
}

NSString* GetDescriptionFromExtension(const base::FilePath::StringType& ext) {
  UTType* type =
      [UTType typeWithFilenameExtension:base::SysUTF8ToNSString(ext)];
  NSString* description = type.localizedDescription;

  if (description.length) {
    return description;
  }

  // In case no description is found, create a description based on the
  // unknown extension type (i.e. if the extension is .qqq, the we create
  // a description "QQQ File (.qqq)").
  std::u16string ext_name = base::UTF8ToUTF16(ext);
  return l10n_util::GetNSStringF(IDS_APP_SAVEAS_EXTENSION_FORMAT,
                                 base::i18n::ToUpper(ext_name), ext_name);
}

NSView* CreateAccessoryView() {
  // The label.
  NSTextField* label =
      [NSTextField labelWithString:l10n_util::GetNSString(
                                       IDS_SAVE_PAGE_FILE_FORMAT_PROMPT_MAC)];
  label.translatesAutoresizingMaskIntoConstraints = NO;
  label.textColor = NSColor.secondaryLabelColor;
  label.font = [NSFont systemFontOfSize:NSFont.smallSystemFontSize];

  // The popup.
  NSPopUpButton* popup = [[NSPopUpButton alloc] initWithFrame:NSZeroRect
                                                    pullsDown:NO];
  popup.translatesAutoresizingMaskIntoConstraints = NO;
  popup.tag = kFileTypePopupTag;

  // A view to group the label and popup together. The top-level view used as
  // the accessory view will be stretched horizontally to match the width of
  // the dialog, and the label and popup need to be grouped together as one
  // view to do centering within it, so use a view to group the label and
  // popup.
  NSView* group = [[NSView alloc] initWithFrame:NSZeroRect];
  group.translatesAutoresizingMaskIntoConstraints = NO;
  [group addSubview:label];
  [group addSubview:popup];

  // This top-level view will be forced by the system to have the width of the
  // save dialog.
  NSView* view = [[NSView alloc] initWithFrame:NSZeroRect];
  view.translatesAutoresizingMaskIntoConstraints = NO;
  [view addSubview:group];

  NSMutableArray* constraints = [NSMutableArray array];

  // The required constraints for the group, instantiated top-to-bottom:
  // ┌───────────────────┐
  // │             ↕︎     │
  // │ ↔︎ label ↔︎ popup ↔︎ │
  // │             ↕︎     │
  // └───────────────────┘

  // Top.
  [constraints
      addObject:[popup.topAnchor constraintEqualToAnchor:group.topAnchor
                                                constant:10]];

  // Leading.
  [constraints
      addObject:[label.leadingAnchor constraintEqualToAnchor:group.leadingAnchor
                                                    constant:10]];

  // Horizontal and vertical baseline between the label and popup.
  [constraints addObject:[popup.leadingAnchor
                             constraintEqualToAnchor:label.trailingAnchor
                                            constant:8]];
  [constraints
      addObject:[popup.firstBaselineAnchor
                    constraintEqualToAnchor:label.firstBaselineAnchor]];

  // Trailing.
  [constraints addObject:[group.trailingAnchor
                             constraintEqualToAnchor:popup.trailingAnchor
                                            constant:10]];

  // Bottom.
  [constraints
      addObject:[group.bottomAnchor constraintEqualToAnchor:popup.bottomAnchor
                                                   constant:10]];

  // Then the constraints centering the group in the accessory view. Vertical
  // spacing is fully specified, but as the horizontal size of the accessory
  // view will be forced to conform to the save dialog, only specify horizontal
  // centering.
  // ┌──────────────┐
  // │      ↕︎       │
  // │   ↔group↔︎    │
  // │      ↕︎       │
  // └──────────────┘

  // Top.
  [constraints
      addObject:[group.topAnchor constraintEqualToAnchor:view.topAnchor]];

  // Centering.
  [constraints addObject:[group.centerXAnchor
                             constraintEqualToAnchor:view.centerXAnchor]];

  // Bottom.
  [constraints
      addObject:[view.bottomAnchor constraintEqualToAnchor:group.bottomAnchor]];

  [NSLayoutConstraint activateConstraints:constraints];

  return view;
}

NSSavePanel* __weak g_last_created_panel_for_testing = nil;

}  // namespace

// A bridge class to act as the modal delegate to the save/open sheet and send
// the results to the C++ class.
@interface SelectFileDialogDelegate : NSObject <NSOpenSavePanelDelegate>
@end

// Target for NSPopupButton control in file dialog's accessory view.
@interface ExtensionDropdownHandler : NSObject {
 @private
  // The file dialog to which this target object corresponds. Weak reference
  // since the _dialog will stay alive longer than this object.
  NSSavePanel* __weak _dialog;

  // An array where each item is an array of different extensions in an
  // extension group.
  NSArray<NSArray<NSString*>*>* __strong _fileExtensionLists;
}

- (instancetype)initWithDialog:(NSSavePanel*)dialog
            fileExtensionLists:
                (NSArray<NSArray<NSString*>*>*)fileExtensionLists;

- (void)popupAction:(id)sender;
@end

@implementation SelectFileDialogDelegate

- (BOOL)panel:(id)sender validateURL:(NSURL*)url error:(NSError**)outError {
  // Refuse to accept users closing the dialog with a key repeat, since the key
  // may have been first pressed while the user was looking at insecure content.
  // See https://crbug.com/40085079.
  if (NSApp.currentEvent.type == NSEventTypeKeyDown &&
      NSApp.currentEvent.ARepeat) {
    return NO;
  }

  return YES;
}

@end

@implementation ExtensionDropdownHandler

- (instancetype)initWithDialog:(NSSavePanel*)dialog
            fileExtensionLists:
                (NSArray<NSArray<NSString*>*>*)fileExtensionLists {
  if ((self = [super init])) {
    _dialog = dialog;
    _fileExtensionLists = fileExtensionLists;
  }
  return self;
}

- (void)popupAction:(id)sender {
  NSUInteger index = [sender indexOfSelectedItem];

  // When provided UTTypes, NSOpenPanel determines whether files are selectable
  // by conformance, not by strict type matching. For example, with
  // public.plain-text, not only .txt files will be selectable, but .js, .m3u,
  // and .csv files will be as well. With public.zip-archive, not only .zip
  // files will be selectable, but also .jar files and .xlsb files.
  //
  // While this can be great for normal viewing/editing apps, this is not
  // desirable for Chromium, where the web platform requires strict type
  // matching on the provided extensions, and files that have a conforming file
  // type by accident of their implementation shouldn't qualify for selection.
  //
  // Unfortunately, there's no great way to do strict type matching with
  // NSOpenPanel. Setting explicit extensions via -allowedFileTypes is
  // deprecated, and there's no way to specify that strict type equality should
  // be used for -allowedContentTypes (FB13721802).
  //
  // -[NSOpenSavePanelDelegate panel:shouldEnableURL:] could be used to enforce
  // strict type matching, however its presence on the delegate means that all
  // files in the file list start off being displayed as disabled, and slowly
  // become enabled if they qualify. This is non-performant and quite a poor
  // user experience.
  //
  // Therefore, use the deprecated API, because it's the only way to remain
  // performant while achieving strict type matching.

#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
  if (index < _fileExtensionLists.count && _fileExtensionLists[index].count) {
    // For save dialogs, this causes the first item in the allowedFileTypes
    // array to be used as the extension for the save panel.
    _dialog.allowedFileTypes = _fileExtensionLists[index];
  } else {
    // The user selected "All files" option (or this is the error case where the
    // page specified literally no valid extensions).
    //
    // (API note: nil is "all types" and an empty array is an error, unlike with
    // -allowedContentTypes, where an empty array is "all types" and nil is an
    // error.)
    _dialog.allowedFileTypes = nil;
  }
#pragma clang diagnostic pop
}

@end

namespace remote_cocoa {

using mojom::SelectFileDialogType;
using mojom::SelectFileTypeInfoPtr;

SelectFileDialogBridge::SelectFileDialogBridge(NSWindow* owning_window)
    : owning_window_(owning_window), weak_factory_(this) {}

SelectFileDialogBridge::~SelectFileDialogBridge() {
  // If we never executed our callback, then the panel never closed. Cancel it
  // now.
  if (show_callback_) {
    [panel_ cancel:panel_];
  }

  // Balance the setDelegate called during Show.
  panel_.delegate = nil;
}

void SelectFileDialogBridge::Show(
    SelectFileDialogType type,
    const std::u16string& title,
    const base::FilePath& default_path,
    SelectFileTypeInfoPtr file_types,
    int file_type_index,
    const base::FilePath::StringType& default_extension,
    ShowCallback callback) {
  // Never consider the current WatchHangsInScope as hung. There was most likely
  // one created in ThreadControllerWithMessagePumpImpl::DoWork(). The current
  // hang watching deadline is not valid since the user can take unbounded time
  // to select a file. HangWatching will resume when the next task
  // or event is pumped in MessagePumpCFRunLoop so there is no need to
  // reactivate it. You can see the function comments for more details.
  base::HangWatcher::InvalidateActiveExpectations();

  show_callback_ = std::move(callback);
  type_ = type;
  // Note: we need to retain the dialog as |owning_window_| can be null.
  // (See https://crbug.com/41052845.)
  if (type_ == SelectFileDialogType::kSaveAsFile) {
    panel_ = [NSSavePanel savePanel];
  } else {
    panel_ = [NSOpenPanel openPanel];
  }
  g_last_created_panel_for_testing = panel_;

  if (!title.empty()) {
    panel_.message = base::SysUTF16ToNSString(title);
  }

  NSString* default_dir = nil;
  NSString* default_filename = nil;
  if (!default_path.empty()) {
    // The file dialog is going to do a ton of stats anyway. Not much
    // point in eliminating this one.
    base::ScopedAllowBlocking allow_blocking;
    if (base::DirectoryExists(default_path)) {
      default_dir = base::SysUTF8ToNSString(default_path.value());
    } else {
      default_dir = base::SysUTF8ToNSString(default_path.DirName().value());
      default_filename =
          base::SysUTF8ToNSString(default_path.BaseName().value());
    }
  }

  const bool keep_extension_visible =
      file_types ? file_types->keep_extension_visible : false;
  if (type_ != SelectFileDialogType::kFolder &&
      type_ != SelectFileDialogType::kUploadFolder &&
      type_ != SelectFileDialogType::kExistingFolder) {
    if (file_types) {
      SetAccessoryView(
          std::move(file_types), file_type_index, default_extension,
          /*is_save_panel=*/type_ == SelectFileDialogType::kSaveAsFile);
    } else {
      // If no type_ info is specified, anything goes.
      panel_.allowsOtherFileTypes = YES;
    }
  }

  if (type_ == SelectFileDialogType::kSaveAsFile) {
    // When file extensions are hidden and removing the extension from
    // the default filename gives one which still has an extension
    // that macOS recognizes, it will get confused and think the user
    // is trying to override the default extension. This happens with
    // filenames like "foo.tar.gz" or "ball.of.tar.png". Work around
    // this by never hiding extensions in that case.
    base::FilePath::StringType penultimate_extension =
        default_path.RemoveFinalExtension().FinalExtension();
    if (!penultimate_extension.empty() || keep_extension_visible) {
      panel_.extensionHidden = NO;
    } else {
      panel_.extensionHidden = YES;
      panel_.canSelectHiddenExtension = YES;
    }

    // The tag autosetter in macOS is not reliable (see
    // https://crbug.com/41482996). Explicitly set the `showsTagField` property
    // as a signal to macOS that we will handle all the file tagging; a
    // side-effect of setting the property to any value is that it turns off
    // the tag autosetter.
    panel_.showsTagField = YES;
  } else {
    // This does not use ObjCCast because the underlying object could be a
    // non-exported AppKit type (https://crbug.com/41477018).
    NSOpenPanel* open_dialog = static_cast<NSOpenPanel*>(panel_);

    if (type_ == SelectFileDialogType::kOpenMultiFile) {
      open_dialog.allowsMultipleSelection = YES;
    } else {
      open_dialog.allowsMultipleSelection = NO;
    }

    if (type_ == SelectFileDialogType::kFolder ||
        type_ == SelectFileDialogType::kUploadFolder ||
        type_ == SelectFileDialogType::kExistingFolder) {
      open_dialog.canChooseFiles = NO;
      open_dialog.canChooseDirectories = YES;

      if (type_ == SelectFileDialogType::kFolder) {
        open_dialog.canCreateDirectories = YES;
      } else {
        open_dialog.canCreateDirectories = NO;
      }

      NSString* prompt =
          (type_ == SelectFileDialogType::kUploadFolder)
              ? l10n_util::GetNSString(IDS_SELECT_UPLOAD_FOLDER_BUTTON_TITLE)
              : l10n_util::GetNSString(IDS_SELECT_FOLDER_BUTTON_TITLE);
      open_dialog.prompt = prompt;
    } else {
      open_dialog.canChooseFiles = YES;
      open_dialog.canChooseDirectories = NO;
    }

    delegate_ = [[SelectFileDialogDelegate alloc] init];
    open_dialog.delegate = delegate_;
  }
  if (default_dir) {
    panel_.directoryURL = [NSURL fileURLWithPath:default_dir];
  }
  if (default_filename) {
    panel_.nameFieldStringValue = default_filename;
  }

  // Ensure that |callback| (rather than |this|) be retained by the block.
  auto ended_callback = base::BindRepeating(
      &SelectFileDialogBridge::OnPanelEnded, weak_factory_.GetWeakPtr());

  NSWindow* sheet_parent = owning_window_;
  if (NativeWidgetMacNSWindow* sheet_parent_widget_window =
          base::apple::ObjCCast<NativeWidgetMacNSWindow>(sheet_parent)) {
    sheet_parent = [sheet_parent_widget_window preferredSheetParent];
  }
  [panel_ beginSheetModalForWindow:sheet_parent
                 completionHandler:^(NSInteger result) {
                   ended_callback.Run(result != NSModalResponseOK);
                 }];
}

void SelectFileDialogBridge::SetAccessoryView(
    SelectFileTypeInfoPtr file_types,
    int file_type_index,
    const base::FilePath::StringType& default_extension,
    bool is_save_panel) {
  DCHECK(file_types);
  NSView* accessory_view = CreateAccessoryView();

  NSPopUpButton* popup = [accessory_view viewWithTag:kFileTypePopupTag];
  DCHECK(popup);

  // Create an array with each item corresponding to an array of different
  // extensions in an extension group.
  NSMutableArray<NSArray<NSString*>*>* file_extension_lists =
      [NSMutableArray array];
  int default_extension_index = -1;
  for (size_t i = 0; i < file_types->extensions.size(); ++i) {
    const std::vector<base::FilePath::StringType>& ext_list =
        file_types->extensions[i];

    // Generate type description for the extension group.
    NSString* type_description = nil;
    if (i < file_types->extension_description_overrides.size() &&
        !file_types->extension_description_overrides[i].empty()) {
      type_description = base::SysUTF16ToNSString(
          file_types->extension_description_overrides[i]);
    } else {
      // No description given for a list of extensions; pick the first one
      // from the list (arbitrarily) and use its description.
      DCHECK(!ext_list.empty());
      type_description = GetDescriptionFromExtension(ext_list[0]);
    }
    DCHECK_NE(0u, [type_description length]);
    [popup addItemWithTitle:type_description];

    // Store different extensions in the current extension group.
    NSMutableArray<NSString*>* file_extensions_array = [NSMutableArray array];
    for (const base::FilePath::StringType& ext : ext_list) {
      // If an extension can't be mapped to a UTType (not even a dynamic one)
      // then attempting to use it with a save panel will cause the save panel
      // service to fail (see https://crbug.com/40900143).
      if (!IsValidExtension(ext)) {
        continue;
      }

      if (ext == default_extension) {
        default_extension_index = i;
      }

      // See -[ExtensionDropdownHandler popupAction:] as to why file extensions
      // are collected here rather than being converted to UTTypes.
      // TODO(FB13721802): Use UTTypes when strict type matching can be
      // specified.
      NSString* ext_ns = base::SysUTF8ToNSString(ext);
      if (![file_extensions_array containsObject:ext_ns]) {
        [file_extensions_array addObject:ext_ns];
      }
    }

    [file_extension_lists addObject:file_extensions_array];
  }

  if (file_types->include_all_files || file_types->extensions.empty()) {
    panel_.allowsOtherFileTypes = YES;
    // If "all files" is specified for a save panel, allow the user to add an
    // alternate non-suggested extension, but don't add it to the popup. It
    // makes no sense to save as an "all files" file type.
    if (!is_save_panel) {
      [popup addItemWithTitle:l10n_util::GetNSString(IDS_APP_SAVEAS_ALL_FILES)];
    }
  }

  extension_dropdown_handler_ =
      [[ExtensionDropdownHandler alloc] initWithDialog:panel_
                                    fileExtensionLists:file_extension_lists];

  // This establishes a weak reference to handler. Hence we persist it as part
  // of `dialog_data_list_`.
  popup.target = extension_dropdown_handler_;
  popup.action = @selector(popupAction:);

  // Note that `file_type_index` uses 1-based indexing.
  if (file_type_index) {
    DCHECK_LE(static_cast<size_t>(file_type_index),
              file_types->extensions.size());
    DCHECK_GE(file_type_index, 1);
    [popup selectItemAtIndex:file_type_index - 1];
  } else if (!default_extension.empty() && default_extension_index != -1) {
    [popup selectItemAtIndex:default_extension_index];
  } else {
    // Select the first item.
    [popup selectItemAtIndex:0];
  }
  [extension_dropdown_handler_ popupAction:popup];

  // There's no need for a popup unless there are at least two choices.
  if (popup.numberOfItems >= 2) {
    panel_.accessoryView = accessory_view;
  }
}

void SelectFileDialogBridge::OnPanelEnded(bool did_cancel) {
  if (!show_callback_) {
    return;
  }

  int index = 0;
  std::vector<base::FilePath> paths;
  std::vector<std::string> file_tags;
  if (!did_cancel) {
    if (type_ == SelectFileDialogType::kSaveAsFile) {
      NSURL* url = panel_.URL;
      if (url.isFileURL) {
        paths.push_back(base::apple::NSURLToFilePath(url));
      }

      NSView* accessoryView = panel_.accessoryView;
      if (accessoryView) {
        NSPopUpButton* popup = [accessoryView viewWithTag:kFileTypePopupTag];
        if (popup) {
          // File type indexes are 1-based.
          index = popup.indexOfSelectedItem + 1;
        }
      } else {
        index = 1;
      }

      // The tag autosetter was turned off when `showsTagField` was set above.
      // Retrieve the tags for assignment later.
      for (NSString* tag in panel_.tagNames) {
        file_tags.push_back(base::SysNSStringToUTF8(tag));
      }
    } else {
      // This does not use ObjCCast because the underlying object could be a
      // non-exported AppKit type (https://crbug.com/41477018).
      NSOpenPanel* open_panel = static_cast<NSOpenPanel*>(panel_);

      for (NSURL* url in open_panel.URLs) {
        if (!url.isFileURL) {
          continue;
        }
        NSString* path = url.path;

        // There is a bug in macOS where, despite a request to disallow file
        // selection, files/packages are able to be selected. If indeed file
        // selection was disallowed, drop any files selected.
        // https://crbug.com/40861123, FB11405008
        if (!open_panel.canChooseFiles) {
          BOOL is_directory;
          BOOL exists =
              [NSFileManager.defaultManager fileExistsAtPath:path
                                                 isDirectory:&is_directory];
          BOOL is_package =
              [NSWorkspace.sharedWorkspace isFilePackageAtPath:path];
          if (!exists || !is_directory || is_package) {
            continue;
          }
        }

        paths.push_back(base::apple::NSStringToFilePath(path));
      }
    }
  }

  std::move(show_callback_).Run(did_cancel, paths, index, file_tags);
}

// static
NSSavePanel* SelectFileDialogBridge::GetLastCreatedNativePanelForTesting() {
  return g_last_created_panel_for_testing;
}

}  // namespace remote_cocoa