blob: 4851a771f931bcb4189d2e78bc0c46831ad33b7c [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/files/file_util.h"
#include "base/i18n/case_conversion.h"
#include "base/logging.h"
#include "base/mac/bundle_locations.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/nib_loading.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);
}
} // namespace
// A bridge class to act as the modal delegate to the save/open sheet and send
// the results to the C++ class.
@interface SelectFileDialogBridge : NSObject<NSOpenSavePanelDelegate> {
@private
ui::SelectFileDialogImpl* selectFileDialogImpl_; // WEAK; owns us
}
- (id)initWithSelectFileDialogImpl:(ui::SelectFileDialogImpl*)s;
- (void)selectFileDialogImplWillBeDestroyed;
- (void)endedPanel:(NSSavePanel*)panel
didCancel:(bool)did_cancel
type:(ui::SelectFileDialog::Type)type
parentWindow:(NSWindow*)parentWindow;
// NSSavePanel delegate method
- (BOOL)panel:(id)sender shouldEnableURL:(NSURL *)url;
@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 {
SelectFileDialogImpl::SelectFileDialogImpl(
Listener* listener,
std::unique_ptr<ui::SelectFilePolicy> policy)
: SelectFileDialog(listener, std::move(policy)),
bridge_(
[[SelectFileDialogBridge alloc] initWithSelectFileDialogImpl:this]) {}
bool SelectFileDialogImpl::IsRunning(gfx::NativeWindow parent_window) const {
return parents_.find(parent_window.GetNativeNSWindow()) != parents_.end();
}
void SelectFileDialogImpl::ListenerDestroyed() {
listener_ = NULL;
}
void SelectFileDialogImpl::FileWasSelected(
NSSavePanel* dialog,
NSWindow* parent_window,
bool was_cancelled,
bool is_multi,
const std::vector<base::FilePath>& files,
int index) {
parents_.erase(parent_window);
auto it = dialog_data_map_.find(dialog);
DCHECK(it != dialog_data_map_.end());
void* params = it->second.params;
dialog_data_map_.erase(it);
[dialog setDelegate:nil];
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];
}
parents_.insert(owning_window);
// Note: we need to retain the dialog as owning_window can be null.
// (See http://crbug.com/29213 .)
NSSavePanel* dialog;
if (type == SELECT_SAVEAS_FILE)
dialog = [[NSSavePanel savePanel] retain];
else
dialog = [[NSOpenPanel openPanel] retain];
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());
}
}
base::scoped_nsobject<ExtensionDropdownHandler> handler;
if (type != SELECT_FOLDER && type != SELECT_UPLOAD_FOLDER &&
type != SELECT_EXISTING_FOLDER) {
if (file_types) {
handler = SelectFileDialogImpl::SetAccessoryView(
dialog, file_types, file_type_index, default_extension);
} else {
// If no type info is specified, anything goes.
[dialog setAllowsOtherFileTypes:YES];
}
}
auto inserted = dialog_data_map_.insert(
std::make_pair(dialog, DialogData(params, handler)));
DCHECK(inserted.second); // Dialog should never exist already.
hasMultipleFileTypeChoices_ =
file_types ? file_types->extensions.size() > 1 : true;
if (type == 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() &&
penultimate_extension.length() <= 5U) {
[dialog setExtensionHidden:NO];
} else {
[dialog setCanSelectHiddenExtension:YES];
}
} else {
NSOpenPanel* open_dialog = base::mac::ObjCCastStrict<NSOpenPanel>(dialog);
if (type == SELECT_OPEN_MULTI_FILE)
[open_dialog setAllowsMultipleSelection:YES];
else
[open_dialog setAllowsMultipleSelection:NO];
if (type == SELECT_FOLDER || type == SELECT_UPLOAD_FOLDER ||
type == SELECT_EXISTING_FOLDER) {
[open_dialog setCanChooseFiles:NO];
[open_dialog setCanChooseDirectories:YES];
if (type == SELECT_FOLDER)
[open_dialog setCanCreateDirectories:YES];
else
[open_dialog setCanCreateDirectories:NO];
NSString *prompt = (type == 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];
}
[open_dialog setDelegate:bridge_.get()];
}
if (default_dir)
[dialog setDirectoryURL:[NSURL fileURLWithPath:default_dir]];
if (default_filename)
[dialog setNameFieldStringValue:default_filename];
// Ensure the bridge (rather than |this|) is retained by the block.
SelectFileDialogBridge* bridge = bridge_.get();
[dialog beginSheetModalForWindow:owning_window
completionHandler:^(NSInteger result) {
[bridge endedPanel:dialog
didCancel:result != NSFileHandlingPanelOKButton
type:type
parentWindow:owning_window];
// Balance the setDelegate above. Note this should usually
// have been done already in FileWasSelected().
[dialog setDelegate:nil];
// Balance the retain at the start of SelectFileImpl().
[dialog release];
}];
}
SelectFileDialogImpl::DialogData::DialogData(
void* params_,
base::scoped_nsobject<ExtensionDropdownHandler> handler)
: params(params_), extension_dropdown_handler(handler) {}
SelectFileDialogImpl::DialogData::DialogData(const DialogData& other) = default;
SelectFileDialogImpl::DialogData::~DialogData() {}
SelectFileDialogImpl::~SelectFileDialogImpl() {
// Walk through the open dialogs and close them all. Use a temporary vector
// to hold the pointers, since we can't delete from the map as we're iterating
// through it.
std::vector<NSSavePanel*> panels;
for (const auto& value : dialog_data_map_)
panels.push_back(value.first);
for (const auto& panel : panels)
[panel cancel:panel];
// Running |cancel| on all the panels should have run all the completion
// handlers, but retaining references to C++ objects inside an NSObject can
// result in subtle problems. Ensure the reference to |this| is cleared.
DCHECK(dialog_data_map_.empty());
[bridge_ selectFileDialogImplWillBeDestroyed];
}
// static
base::scoped_nsobject<ExtensionDropdownHandler>
SelectFileDialogImpl::SetAccessoryView(
NSSavePanel* dialog,
const FileTypeInfo* file_types,
int file_type_index,
const base::FilePath::StringType& default_extension) {
DCHECK(file_types);
NSView* accessory_view = ui::GetViewFromNib(@"SaveAccessoryView");
if (!accessory_view)
return base::scoped_nsobject<ExtensionDropdownHandler>();
[dialog setAccessoryView:accessory_view];
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];
}
base::scoped_nsobject<ExtensionDropdownHandler> handler(
[[ExtensionDropdownHandler alloc] initWithDialog:dialog
fileTypeLists:file_type_lists]);
// This establishes a weak reference to handler. Hence we persist it as part
// of dialog_data_map_.
[popup setTarget: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];
[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];
[handler popupAction:popup];
}
return handler;
}
bool SelectFileDialogImpl::HasMultipleFileTypeChoicesImpl() {
return hasMultipleFileTypeChoices_;
}
SelectFileDialog* CreateSelectFileDialog(
SelectFileDialog::Listener* listener,
std::unique_ptr<SelectFilePolicy> policy) {
return new SelectFileDialogImpl(listener, std::move(policy));
}
} // namespace ui
@implementation SelectFileDialogBridge
- (id)initWithSelectFileDialogImpl:(ui::SelectFileDialogImpl*)s {
if ((self = [super init])) {
selectFileDialogImpl_ = s;
}
return self;
}
- (void)selectFileDialogImplWillBeDestroyed {
selectFileDialogImpl_ = nullptr;
}
- (void)endedPanel:(NSSavePanel*)panel
didCancel:(bool)did_cancel
type:(ui::SelectFileDialog::Type)type
parentWindow:(NSWindow*)parentWindow {
if (!selectFileDialogImpl_)
return;
int index = 0;
std::vector<base::FilePath> paths;
if (!did_cancel) {
if (type == ui::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])));
}
}
bool isMulti = type == ui::SelectFileDialog::SELECT_OPEN_MULTI_FILE;
selectFileDialogImpl_->FileWasSelected(panel,
parentWindow,
did_cancel,
isMulti,
paths,
index);
}
- (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