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