1// Copyright 2019 The Chromium Authors. All rights reserved. 2// Use of this source code is governed by a BSD-style license that can be 3// found in the LICENSE file. 4 5#include "components/remote_cocoa/app_shim/select_file_dialog_bridge.h" 6 7#include <CoreServices/CoreServices.h> 8#include <stddef.h> 9 10#include "base/files/file_util.h" 11#include "base/i18n/case_conversion.h" 12#include "base/mac/foundation_util.h" 13#include "base/mac/scoped_cftyperef.h" 14#include "base/strings/sys_string_conversions.h" 15#include "base/strings/utf_string_conversions.h" 16#import "ui/base/cocoa/controls/textfield_utils.h" 17#include "ui/base/l10n/l10n_util_mac.h" 18#include "ui/strings/grit/ui_strings.h" 19 20namespace { 21 22const int kFileTypePopupTag = 1234; 23 24CFStringRef CreateUTIFromExtension(const base::FilePath::StringType& ext) { 25 base::ScopedCFTypeRef<CFStringRef> ext_cf(base::SysUTF8ToCFStringRef(ext)); 26 return UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, 27 ext_cf.get(), NULL); 28} 29 30NSString* GetDescriptionFromExtension(const base::FilePath::StringType& ext) { 31 base::ScopedCFTypeRef<CFStringRef> uti(CreateUTIFromExtension(ext)); 32 base::ScopedCFTypeRef<CFStringRef> description( 33 UTTypeCopyDescription(uti.get())); 34 35 if (description && CFStringGetLength(description)) 36 return [[base::mac::CFToNSCast(description.get()) retain] autorelease]; 37 38 // In case no description is found, create a description based on the 39 // unknown extension type (i.e. if the extension is .qqq, the we create 40 // a description "QQQ File (.qqq)"). 41 base::string16 ext_name = base::UTF8ToUTF16(ext); 42 return l10n_util::GetNSStringF(IDS_APP_SAVEAS_EXTENSION_FORMAT, 43 base::i18n::ToUpper(ext_name), ext_name); 44} 45 46base::scoped_nsobject<NSView> CreateAccessoryView() { 47 static constexpr CGFloat kControlPadding = 2; 48 49 base::scoped_nsobject<NSView> view( 50 [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 350, 60)]); 51 52 // Create the label and center it vertically. 53 NSTextField* label = [TextFieldUtils 54 labelWithString:l10n_util::GetNSString( 55 IDS_SAVE_PAGE_FILE_FORMAT_PROMPT_MAC)]; 56 [label sizeToFit]; 57 NSRect label_frame = [label frame]; 58 label_frame.origin = 59 NSMakePoint(kControlPadding, NSMidY([view frame]) - NSMidY(label_frame)); 60 [label setFrame:label_frame]; 61 [view addSubview:label]; 62 63 // Create the pop-up button, positioning it to the right of the label. 64 // Its X position needs to be slightly below the label's, so that the text 65 // baselines are aligned. 66 base::scoped_nsobject<NSPopUpButton> pop_up_button([[NSPopUpButton alloc] 67 initWithFrame:NSMakeRect(NSWidth(label_frame) + kControlPadding, 68 NSMinY(label_frame) - 5, 230, 25) 69 pullsDown:NO]); 70 [pop_up_button setTag:kFileTypePopupTag]; 71 [view addSubview:pop_up_button.get()]; 72 73 // Resize the containing view to fit the controls. 74 CGFloat total_width = NSMaxX([pop_up_button frame]); 75 NSRect view_frame = [view frame]; 76 view_frame.size.width = total_width + kControlPadding; 77 [view setFrame:view_frame]; 78 79 return view; 80} 81 82NSSavePanel* g_last_created_panel_for_testing = nil; 83 84} // namespace 85 86// A bridge class to act as the modal delegate to the save/open sheet and send 87// the results to the C++ class. 88@interface SelectFileDialogDelegate : NSObject <NSOpenSavePanelDelegate> 89@end 90 91// Target for NSPopupButton control in file dialog's accessory view. 92@interface ExtensionDropdownHandler : NSObject { 93 @private 94 // The file dialog to which this target object corresponds. Weak reference 95 // since the dialog_ will stay alive longer than this object. 96 NSSavePanel* _dialog; 97 98 // An array whose each item corresponds to an array of different extensions in 99 // an extension group. 100 base::scoped_nsobject<NSArray> _fileTypeLists; 101} 102 103- (id)initWithDialog:(NSSavePanel*)dialog fileTypeLists:(NSArray*)fileTypeLists; 104 105- (void)popupAction:(id)sender; 106@end 107 108@implementation SelectFileDialogDelegate 109 110- (BOOL)panel:(id)sender shouldEnableURL:(NSURL*)url { 111 return [url isFileURL]; 112} 113 114- (BOOL)panel:(id)sender validateURL:(NSURL*)url error:(NSError**)outError { 115 // Refuse to accept users closing the dialog with a key repeat, since the key 116 // may have been first pressed while the user was looking at insecure content. 117 // See https://crbug.com/637098. 118 if ([[NSApp currentEvent] type] == NSKeyDown && 119 [[NSApp currentEvent] isARepeat]) { 120 return NO; 121 } 122 123 return YES; 124} 125 126@end 127 128@implementation ExtensionDropdownHandler 129 130- (id)initWithDialog:(NSSavePanel*)dialog 131 fileTypeLists:(NSArray*)fileTypeLists { 132 if ((self = [super init])) { 133 _dialog = dialog; 134 _fileTypeLists.reset([fileTypeLists retain]); 135 } 136 return self; 137} 138 139- (void)popupAction:(id)sender { 140 NSUInteger index = [sender indexOfSelectedItem]; 141 if (index < [_fileTypeLists count]) { 142 // For save dialogs, this causes the first item in the allowedFileTypes 143 // array to be used as the extension for the save panel. 144 [_dialog setAllowedFileTypes:[_fileTypeLists objectAtIndex:index]]; 145 } else { 146 // The user selected "All files" option. 147 [_dialog setAllowedFileTypes:nil]; 148 } 149} 150 151@end 152 153namespace remote_cocoa { 154 155using mojom::SelectFileDialogType; 156using mojom::SelectFileTypeInfoPtr; 157 158SelectFileDialogBridge::SelectFileDialogBridge(NSWindow* owning_window) 159 : owning_window_(owning_window, base::scoped_policy::RETAIN), 160 weak_factory_(this) {} 161 162SelectFileDialogBridge::~SelectFileDialogBridge() { 163 // If we never executed our callback, then the panel never closed. Cancel it 164 // now. 165 if (show_callback_) 166 [panel_ cancel:panel_]; 167 168 // Balance the setDelegate called during Show. 169 [panel_ setDelegate:nil]; 170} 171 172void SelectFileDialogBridge::Show( 173 SelectFileDialogType type, 174 const base::string16& title, 175 const base::FilePath& default_path, 176 SelectFileTypeInfoPtr file_types, 177 int file_type_index, 178 const base::FilePath::StringType& default_extension, 179 PanelEndedCallback initialize_callback) { 180 show_callback_ = std::move(initialize_callback); 181 type_ = type; 182 // Note: we need to retain the dialog as |owning_window_| can be null. 183 // (See http://crbug.com/29213 .) 184 if (type_ == SelectFileDialogType::kSaveAsFile) 185 panel_.reset([[NSSavePanel savePanel] retain]); 186 else 187 panel_.reset([[NSOpenPanel openPanel] retain]); 188 NSSavePanel* dialog = panel_.get(); 189 g_last_created_panel_for_testing = dialog; 190 191 if (!title.empty()) 192 [dialog setMessage:base::SysUTF16ToNSString(title)]; 193 194 NSString* default_dir = nil; 195 NSString* default_filename = nil; 196 if (!default_path.empty()) { 197 // The file dialog is going to do a ton of stats anyway. Not much 198 // point in eliminating this one. 199 base::ThreadRestrictions::ScopedAllowIO allow_io; 200 if (base::DirectoryExists(default_path)) { 201 default_dir = base::SysUTF8ToNSString(default_path.value()); 202 } else { 203 default_dir = base::SysUTF8ToNSString(default_path.DirName().value()); 204 default_filename = 205 base::SysUTF8ToNSString(default_path.BaseName().value()); 206 } 207 } 208 209 if (type_ != SelectFileDialogType::kFolder && 210 type_ != SelectFileDialogType::kUploadFolder && 211 type_ != SelectFileDialogType::kExistingFolder) { 212 if (file_types) { 213 SetAccessoryView(std::move(file_types), file_type_index, 214 default_extension); 215 } else { 216 // If no type_ info is specified, anything goes. 217 [dialog setAllowsOtherFileTypes:YES]; 218 } 219 } 220 221 if (type_ == SelectFileDialogType::kSaveAsFile) { 222 // When file extensions are hidden and removing the extension from 223 // the default filename gives one which still has an extension 224 // that OS X recognizes, it will get confused and think the user 225 // is trying to override the default extension. This happens with 226 // filenames like "foo.tar.gz" or "ball.of.tar.png". Work around 227 // this by never hiding extensions in that case. 228 base::FilePath::StringType penultimate_extension = 229 default_path.RemoveFinalExtension().FinalExtension(); 230 if (!penultimate_extension.empty()) { 231 [dialog setExtensionHidden:NO]; 232 } else { 233 [dialog setExtensionHidden:YES]; 234 [dialog setCanSelectHiddenExtension:YES]; 235 } 236 } else { 237 // This does not use ObjCCast because the underlying object could be a 238 // non-exported AppKit type (https://crbug.com/995476). 239 NSOpenPanel* open_dialog = static_cast<NSOpenPanel*>(dialog); 240 241 if (type_ == SelectFileDialogType::kOpenMultiFile) 242 [open_dialog setAllowsMultipleSelection:YES]; 243 else 244 [open_dialog setAllowsMultipleSelection:NO]; 245 246 if (type_ == SelectFileDialogType::kFolder || 247 type_ == SelectFileDialogType::kUploadFolder || 248 type_ == SelectFileDialogType::kExistingFolder) { 249 [open_dialog setCanChooseFiles:NO]; 250 [open_dialog setCanChooseDirectories:YES]; 251 252 if (type_ == SelectFileDialogType::kFolder) 253 [open_dialog setCanCreateDirectories:YES]; 254 else 255 [open_dialog setCanCreateDirectories:NO]; 256 257 NSString* prompt = 258 (type_ == SelectFileDialogType::kUploadFolder) 259 ? l10n_util::GetNSString(IDS_SELECT_UPLOAD_FOLDER_BUTTON_TITLE) 260 : l10n_util::GetNSString(IDS_SELECT_FOLDER_BUTTON_TITLE); 261 [open_dialog setPrompt:prompt]; 262 } else { 263 [open_dialog setCanChooseFiles:YES]; 264 [open_dialog setCanChooseDirectories:NO]; 265 } 266 267 delegate_.reset([[SelectFileDialogDelegate alloc] init]); 268 [open_dialog setDelegate:delegate_.get()]; 269 } 270 if (default_dir) 271 [dialog setDirectoryURL:[NSURL fileURLWithPath:default_dir]]; 272 if (default_filename) 273 [dialog setNameFieldStringValue:default_filename]; 274 275 // Ensure that |callback| (rather than |this|) be retained by the block. 276 auto callback = base::BindRepeating(&SelectFileDialogBridge::OnPanelEnded, 277 weak_factory_.GetWeakPtr()); 278 [dialog beginSheetModalForWindow:owning_window_ 279 completionHandler:^(NSInteger result) { 280 callback.Run(result != NSFileHandlingPanelOKButton); 281 }]; 282} 283 284void SelectFileDialogBridge::SetAccessoryView( 285 SelectFileTypeInfoPtr file_types, 286 int file_type_index, 287 const base::FilePath::StringType& default_extension) { 288 DCHECK(file_types); 289 base::scoped_nsobject<NSView> accessory_view = CreateAccessoryView(); 290 NSSavePanel* dialog = panel_.get(); 291 [dialog setAccessoryView:accessory_view.get()]; 292 293 NSPopUpButton* popup = [accessory_view viewWithTag:kFileTypePopupTag]; 294 DCHECK(popup); 295 296 // Create an array with each item corresponding to an array of different 297 // extensions in an extension group. 298 NSMutableArray* file_type_lists = [NSMutableArray array]; 299 int default_extension_index = -1; 300 for (size_t i = 0; i < file_types->extensions.size(); ++i) { 301 const std::vector<base::FilePath::StringType>& ext_list = 302 file_types->extensions[i]; 303 304 // Generate type description for the extension group. 305 NSString* type_description = nil; 306 if (i < file_types->extension_description_overrides.size() && 307 !file_types->extension_description_overrides[i].empty()) { 308 type_description = base::SysUTF16ToNSString( 309 file_types->extension_description_overrides[i]); 310 } else { 311 // No description given for a list of extensions; pick the first one 312 // from the list (arbitrarily) and use its description. 313 DCHECK(!ext_list.empty()); 314 type_description = GetDescriptionFromExtension(ext_list[0]); 315 } 316 DCHECK_NE(0u, [type_description length]); 317 [popup addItemWithTitle:type_description]; 318 319 // Populate file_type_lists. 320 // Set to store different extensions in the current extension group. 321 NSMutableSet* file_type_set = [NSMutableSet set]; 322 for (const base::FilePath::StringType& ext : ext_list) { 323 if (ext == default_extension) 324 default_extension_index = i; 325 326 // Crash reports suggest that CreateUTIFromExtension may return nil. Hence 327 // we nil check before adding to |file_type_set|. See crbug.com/630101 and 328 // rdar://27490414. 329 base::ScopedCFTypeRef<CFStringRef> uti(CreateUTIFromExtension(ext)); 330 if (uti) 331 [file_type_set addObject:base::mac::CFToNSCast(uti.get())]; 332 333 // Always allow the extension itself, in case the UTI doesn't map 334 // back to the original extension correctly. This occurs with dynamic 335 // UTIs on 10.7 and 10.8. 336 // See http://crbug.com/148840, http://openradar.me/12316273 337 base::ScopedCFTypeRef<CFStringRef> ext_cf( 338 base::SysUTF8ToCFStringRef(ext)); 339 [file_type_set addObject:base::mac::CFToNSCast(ext_cf.get())]; 340 } 341 [file_type_lists addObject:[file_type_set allObjects]]; 342 } 343 344 if (file_types->include_all_files || file_types->extensions.empty()) { 345 [popup addItemWithTitle:l10n_util::GetNSString(IDS_APP_SAVEAS_ALL_FILES)]; 346 [dialog setAllowsOtherFileTypes:YES]; 347 } 348 349 extension_dropdown_handler_.reset([[ExtensionDropdownHandler alloc] 350 initWithDialog:dialog 351 fileTypeLists:file_type_lists]); 352 353 // This establishes a weak reference to handler. Hence we persist it as part 354 // of dialog_data_list_. 355 [popup setTarget:extension_dropdown_handler_]; 356 [popup setAction:@selector(popupAction:)]; 357 358 // file_type_index uses 1 based indexing. 359 if (file_type_index) { 360 DCHECK_LE(static_cast<size_t>(file_type_index), 361 file_types->extensions.size()); 362 DCHECK_GE(file_type_index, 1); 363 [popup selectItemAtIndex:file_type_index - 1]; 364 [extension_dropdown_handler_ popupAction:popup]; 365 } else if (!default_extension.empty() && default_extension_index != -1) { 366 [popup selectItemAtIndex:default_extension_index]; 367 [dialog 368 setAllowedFileTypes:@[ base::SysUTF8ToNSString(default_extension) ]]; 369 } else { 370 // Select the first item. 371 [popup selectItemAtIndex:0]; 372 [extension_dropdown_handler_ popupAction:popup]; 373 } 374} 375 376void SelectFileDialogBridge::OnPanelEnded(bool did_cancel) { 377 if (!show_callback_) 378 return; 379 380 int index = 0; 381 std::vector<base::FilePath> paths; 382 if (!did_cancel) { 383 if (type_ == SelectFileDialogType::kSaveAsFile) { 384 if ([[panel_ URL] isFileURL]) { 385 paths.push_back(base::mac::NSStringToFilePath([[panel_ URL] path])); 386 } 387 388 NSView* accessoryView = [panel_ accessoryView]; 389 if (accessoryView) { 390 NSPopUpButton* popup = [accessoryView viewWithTag:kFileTypePopupTag]; 391 if (popup) { 392 // File type indexes are 1-based. 393 index = [popup indexOfSelectedItem] + 1; 394 } 395 } else { 396 index = 1; 397 } 398 } else { 399 NSArray* urls = [static_cast<NSOpenPanel*>(panel_) URLs]; 400 for (NSURL* url in urls) 401 if ([url isFileURL]) 402 paths.push_back(base::FilePath(base::SysNSStringToUTF8([url path]))); 403 } 404 } 405 406 std::move(show_callback_).Run(did_cancel, paths, index); 407} 408 409// static 410NSSavePanel* SelectFileDialogBridge::GetLastCreatedNativePanelForTesting() { 411 return g_last_created_panel_for_testing; 412} 413 414} // namespace remote_cocoa 415