blob: 0d8913e2b0751ba43df8470a9b4d103b45c5974d [file] [log] [blame]
// Copyright (c) 2012 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "ui/shell_dialogs/select_file_dialog_mac.h"
#include <CoreServices/CoreServices.h>
#include <stddef.h>
#include <vector>
#include "base/bind.h"
#include "base/files/file_util.h"
#include "base/i18n/case_conversion.h"
#include "base/logging.h"
#include "base/mac/foundation_util.h"
#include "base/mac/scoped_cftyperef.h"
#include "base/strings/sys_string_conversions.h"
#include "base/strings/utf_string_conversions.h"
#include "base/threading/thread_restrictions.h"
#import "ui/base/cocoa/controls/textfield_utils.h"
#include "ui/base/cocoa/remote_views_window.h"
#include "ui/base/l10n/l10n_util_mac.h"
#include "ui/shell_dialogs/select_file_policy.h"
#include "ui/strings/grit/ui_strings.h"
namespace {
const int kFileTypePopupTag = 1234;
CFStringRef CreateUTIFromExtension(const base::FilePath::StringType& ext) {
base::ScopedCFTypeRef<CFStringRef> ext_cf(base::SysUTF8ToCFStringRef(ext));
return UTTypeCreatePreferredIdentifierForTag(
kUTTagClassFilenameExtension, ext_cf.get(), NULL);
}
NSString* GetDescriptionFromExtension(const base::FilePath::StringType& ext) {
base::ScopedCFTypeRef<CFStringRef> uti(CreateUTIFromExtension(ext));
base::ScopedCFTypeRef<CFStringRef> description(
UTTypeCopyDescription(uti.get()));
if (description && CFStringGetLength(description))
return [[base::mac::CFToNSCast(description.get()) retain] autorelease];
// 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)").
base::string16 ext_name = base::UTF8ToUTF16(ext);
return l10n_util::GetNSStringF(IDS_APP_SAVEAS_EXTENSION_FORMAT,
base::i18n::ToUpper(ext_name), ext_name);
}
base::scoped_nsobject<NSView> CreateAccessoryView() {
static constexpr CGFloat kControlPadding = 2;
base::scoped_nsobject<NSView> view(
[[NSView alloc] initWithFrame:NSMakeRect(0, 0, 350, 60)]);
// Create the label and center it vertically.
NSTextField* label = [TextFieldUtils
labelWithString:l10n_util::GetNSString(
IDS_SAVE_PAGE_FILE_FORMAT_PROMPT_MAC)];
[label sizeToFit];
NSRect label_frame = [label frame];
label_frame.origin =
NSMakePoint(kControlPadding, NSMidY([view frame]) - NSMidY(label_frame));
[label setFrame:label_frame];
[view addSubview:label];
// Create the pop-up button, positioning it to the right of the label.
// Its X position needs to be slightly below the label's, so that the text
// baselines are aligned.
base::scoped_nsobject<NSPopUpButton> pop_up_button([[NSPopUpButton alloc]
initWithFrame:NSMakeRect(NSWidth(label_frame) + kControlPadding,
NSMinY(label_frame) - 5, 230, 25)
pullsDown:NO]);
[pop_up_button setTag:kFileTypePopupTag];
[view addSubview:pop_up_button.get()];
// Resize the containing view to fit the controls.
CGFloat total_width = NSMaxX([pop_up_button frame]);
NSRect view_frame = [view frame];
view_frame.size.width = total_width + kControlPadding;
[view setFrame:view_frame];
return view;
}
} // 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* dialog_;
// An array whose each item corresponds to an array of different extensions in
// an extension group.
base::scoped_nsobject<NSArray> fileTypeLists_;
}
- (id)initWithDialog:(NSSavePanel*)dialog fileTypeLists:(NSArray*)fileTypeLists;
- (void)popupAction:(id)sender;
@end
namespace ui {
using Type = SelectFileDialog::Type;
using FileTypeInfo = SelectFileDialog::FileTypeInfo;
SavePanelBridge::SavePanelBridge(PanelEndedCallback callback)
: callback_(std::move(callback)), weak_factory_(this) {}
SavePanelBridge::~SavePanelBridge() {
// If we never executed our callback, then the panel never closed. Cancel it
// now.
if (callback_)
[panel_ cancel:panel_];
// Balance the setDelegate called during Initialize.
[panel_ setDelegate:nil];
}
SelectFileDialogImpl::SelectFileDialogImpl(
Listener* listener,
std::unique_ptr<ui::SelectFilePolicy> policy)
: SelectFileDialog(listener, std::move(policy)), weak_factory_(this) {}
bool SelectFileDialogImpl::IsRunning(gfx::NativeWindow parent_window) const {
for (const auto& dialog_data : dialog_data_list_) {
if (dialog_data.parent_window == parent_window)
return true;
}
return false;
}
void SelectFileDialogImpl::ListenerDestroyed() {
listener_ = NULL;
}
void SelectFileDialogImpl::FileWasSelected(
DialogData* dialog_data,
bool is_multi,
bool was_cancelled,
const std::vector<base::FilePath>& files,
int index) {
auto it = std::find_if(
dialog_data_list_.begin(), dialog_data_list_.end(),
[dialog_data](const DialogData& d) { return &d == dialog_data; });
DCHECK(it != dialog_data_list_.end());
void* params = dialog_data->params;
dialog_data_list_.erase(it);
if (!listener_)
return;
if (was_cancelled || files.empty()) {
listener_->FileSelectionCanceled(params);
} else {
if (is_multi) {
listener_->MultiFilesSelected(files, params);
} else {
listener_->FileSelected(files[0], index, params);
}
}
}
void SelectFileDialogImpl::SelectFileImpl(
Type type,
const base::string16& title,
const base::FilePath& default_path,
const FileTypeInfo* file_types,
int file_type_index,
const base::FilePath::StringType& default_extension,
gfx::NativeWindow owning_native_window,
void* params) {
DCHECK(type == SELECT_FOLDER || type == SELECT_UPLOAD_FOLDER ||
type == SELECT_EXISTING_FOLDER || type == SELECT_OPEN_FILE ||
type == SELECT_OPEN_MULTI_FILE || type == SELECT_SAVEAS_FILE);
NSWindow* owning_window = owning_native_window.GetNativeNSWindow();
// TODO(https://crbug.com/913303): The select file dialog's interface to
// Cocoa should be wrapped in a mojo interface in order to allow instantiating
// across processes. As a temporary solution, raise the remote windows'
// transparent in-process window to the front.
if (ui::IsWindowUsingRemoteViews(owning_window)) {
[owning_window makeKeyAndOrderFront:nil];
[owning_window setLevel:NSModalPanelWindowLevel];
}
hasMultipleFileTypeChoices_ =
file_types ? file_types->extensions.size() > 1 : true;
// Add a new DialogData to the list.
dialog_data_list_.emplace_back(owning_window, params);
DialogData& dialog_data = dialog_data_list_.back();
// Create a NSSavePanel for it. Note that it is safe to pass |dialog_data| by
// a pointer because it will only be removed from the list when the callback
// is made or after the callback has been cancelled by |weak_factory_|.
dialog_data.save_panel_bridge =
std::make_unique<SavePanelBridge>(base::BindOnce(
&SelectFileDialogImpl::FileWasSelected, weak_factory_.GetWeakPtr(),
&dialog_data, type == SELECT_OPEN_MULTI_FILE));
dialog_data.save_panel_bridge->Initialize(type, owning_window, title,
default_path, file_types,
file_type_index, default_extension);
}
void SavePanelBridge::Initialize(
Type type,
NSWindow* owning_window,
const base::string16& title,
const base::FilePath& default_path,
const FileTypeInfo* file_types,
int file_type_index,
const base::FilePath::StringType& default_extension) {
type_ = type;
// Note: we need to retain the dialog as |owning_window| can be null.
// (See http://crbug.com/29213 .)
if (type_ == SelectFileDialog::SELECT_SAVEAS_FILE)
panel_.reset([[NSSavePanel savePanel] retain]);
else
panel_.reset([[NSOpenPanel openPanel] retain]);
NSSavePanel* dialog = panel_.get();
if (!title.empty())
[dialog setMessage: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::ThreadRestrictions::ScopedAllowIO allow_io;
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());
}
}
if (type_ != SelectFileDialog::SELECT_FOLDER &&
type_ != SelectFileDialog::SELECT_UPLOAD_FOLDER &&
type_ != SelectFileDialog::SELECT_EXISTING_FOLDER) {
if (file_types) {
SetAccessoryView(file_types, file_type_index, default_extension);
} else {
// If no type_ info is specified, anything goes.
[dialog setAllowsOtherFileTypes:YES];
}
}
if (type_ == SelectFileDialog::SELECT_SAVEAS_FILE) {
// When file extensions are hidden and removing the extension from
// the default filename gives one which still has an extension
// that OS X 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()) {
[dialog setExtensionHidden:NO];
} else {
[dialog setExtensionHidden:YES];
[dialog setCanSelectHiddenExtension:YES];
}
} else {
NSOpenPanel* open_dialog = base::mac::ObjCCastStrict<NSOpenPanel>(dialog);
if (type_ == SelectFileDialog::SELECT_OPEN_MULTI_FILE)
[open_dialog setAllowsMultipleSelection:YES];
else
[open_dialog setAllowsMultipleSelection:NO];
if (type_ == SelectFileDialog::SELECT_FOLDER ||
type_ == SelectFileDialog::SELECT_UPLOAD_FOLDER ||
type_ == SelectFileDialog::SELECT_EXISTING_FOLDER) {
[open_dialog setCanChooseFiles:NO];
[open_dialog setCanChooseDirectories:YES];
if (type_ == SelectFileDialog::SELECT_FOLDER)
[open_dialog setCanCreateDirectories:YES];
else
[open_dialog setCanCreateDirectories:NO];
NSString* prompt =
(type_ == SelectFileDialog::SELECT_UPLOAD_FOLDER)
? l10n_util::GetNSString(IDS_SELECT_UPLOAD_FOLDER_BUTTON_TITLE)
: l10n_util::GetNSString(IDS_SELECT_FOLDER_BUTTON_TITLE);
[open_dialog setPrompt:prompt];
} else {
[open_dialog setCanChooseFiles:YES];
[open_dialog setCanChooseDirectories:NO];
}
delegate_.reset([[SelectFileDialogDelegate alloc] init]);
[open_dialog setDelegate:delegate_.get()];
}
if (default_dir)
[dialog setDirectoryURL:[NSURL fileURLWithPath:default_dir]];
if (default_filename)
[dialog setNameFieldStringValue:default_filename];
// Ensure that |callback| (rather than |this|) be retained by the block.
auto callback = base::BindRepeating(&SavePanelBridge::OnPanelEnded,
weak_factory_.GetWeakPtr());
[dialog beginSheetModalForWindow:owning_window
completionHandler:^(NSInteger result) {
callback.Run(result != NSFileHandlingPanelOKButton);
}];
}
SelectFileDialogImpl::DialogData::DialogData(gfx::NativeWindow parent_window_,
void* params_)
: parent_window(parent_window_), params(params_) {}
SelectFileDialogImpl::DialogData::~DialogData() {}
SelectFileDialogImpl::~SelectFileDialogImpl() {
// Clear |weak_factory_| beforehand, to ensure that no callbacks will be made
// when we cancel the NSSavePanels.
weak_factory_.InvalidateWeakPtrs();
// Walk through the open dialogs and issue the cancel callbacks that would
// have been made.
for (const auto& dialog_data : dialog_data_list_) {
if (listener_)
listener_->FileSelectionCanceled(dialog_data.params);
}
// Cancel the NSSavePanels be destroying their bridges.
dialog_data_list_.clear();
}
void SavePanelBridge::SetAccessoryView(
const FileTypeInfo* file_types,
int file_type_index,
const base::FilePath::StringType& default_extension) {
DCHECK(file_types);
base::scoped_nsobject<NSView> accessory_view = CreateAccessoryView();
NSSavePanel* dialog = panel_.get();
[dialog setAccessoryView:accessory_view.get()];
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* file_type_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];
// Populate file_type_lists.
// Set to store different extensions in the current extension group.
NSMutableSet* file_type_set = [NSMutableSet set];
for (const base::FilePath::StringType& ext : ext_list) {
if (ext == default_extension)
default_extension_index = i;
// Crash reports suggest that CreateUTIFromExtension may return nil. Hence
// we nil check before adding to |file_type_set|. See crbug.com/630101 and
// rdar://27490414.
base::ScopedCFTypeRef<CFStringRef> uti(CreateUTIFromExtension(ext));
if (uti)
[file_type_set addObject:base::mac::CFToNSCast(uti.get())];
// Always allow the extension itself, in case the UTI doesn't map
// back to the original extension correctly. This occurs with dynamic
// UTIs on 10.7 and 10.8.
// See http://crbug.com/148840, http://openradar.me/12316273
base::ScopedCFTypeRef<CFStringRef> ext_cf(
base::SysUTF8ToCFStringRef(ext));
[file_type_set addObject:base::mac::CFToNSCast(ext_cf.get())];
}
[file_type_lists addObject:[file_type_set allObjects]];
}
if (file_types->include_all_files || file_types->extensions.empty()) {
[popup addItemWithTitle:l10n_util::GetNSString(IDS_APP_SAVEAS_ALL_FILES)];
[dialog setAllowsOtherFileTypes:YES];
}
extension_dropdown_handler_.reset([[ExtensionDropdownHandler alloc]
initWithDialog:dialog
fileTypeLists:file_type_lists]);
// This establishes a weak reference to handler. Hence we persist it as part
// of dialog_data_list_.
[popup setTarget:extension_dropdown_handler_];
[popup setAction:@selector(popupAction:)];
// 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];
[extension_dropdown_handler_ popupAction:popup];
} else if (!default_extension.empty() && default_extension_index != -1) {
[popup selectItemAtIndex:default_extension_index];
[dialog
setAllowedFileTypes:@[ base::SysUTF8ToNSString(default_extension) ]];
} else {
// Select the first item.
[popup selectItemAtIndex:0];
[extension_dropdown_handler_ popupAction:popup];
}
}
bool SelectFileDialogImpl::HasMultipleFileTypeChoicesImpl() {
return hasMultipleFileTypeChoices_;
}
SelectFileDialog* CreateSelectFileDialog(
SelectFileDialog::Listener* listener,
std::unique_ptr<SelectFilePolicy> policy) {
return new SelectFileDialogImpl(listener, std::move(policy));
}
void SavePanelBridge::OnPanelEnded(bool did_cancel) {
if (!callback_)
return;
int index = 0;
std::vector<base::FilePath> paths;
if (!did_cancel) {
if (type_ == SelectFileDialog::SELECT_SAVEAS_FILE) {
if ([[panel_ URL] isFileURL]) {
paths.push_back(base::mac::NSStringToFilePath([[panel_ URL] path]));
}
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;
}
} else {
CHECK([panel_ isKindOfClass:[NSOpenPanel class]]);
NSArray* urls = [static_cast<NSOpenPanel*>(panel_) URLs];
for (NSURL* url in urls)
if ([url isFileURL])
paths.push_back(base::FilePath(base::SysNSStringToUTF8([url path])));
}
}
std::move(callback_).Run(did_cancel, paths, index);
}
} // namespace ui
@implementation SelectFileDialogDelegate
- (BOOL)panel:(id)sender shouldEnableURL:(NSURL *)url {
return [url isFileURL];
}
- (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/637098.
if ([[NSApp currentEvent] type] == NSKeyDown &&
[[NSApp currentEvent] isARepeat]) {
return NO;
}
return YES;
}
@end
@implementation ExtensionDropdownHandler
- (id)initWithDialog:(NSSavePanel*)dialog
fileTypeLists:(NSArray*)fileTypeLists {
if ((self = [super init])) {
dialog_ = dialog;
fileTypeLists_.reset([fileTypeLists retain]);
}
return self;
}
- (void)popupAction:(id)sender {
NSUInteger index = [sender indexOfSelectedItem];
if (index < [fileTypeLists_ count]) {
// For save dialogs, this causes the first item in the allowedFileTypes
// array to be used as the extension for the save panel.
[dialog_ setAllowedFileTypes:[fileTypeLists_ objectAtIndex:index]];
} else {
// The user selected "All files" option.
[dialog_ setAllowedFileTypes:nil];
}
}
@end