select_file_dialog_bridge.mm 19 KB

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