| // Copyright 2019 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #include "components/remote_cocoa/app_shim/select_file_dialog_bridge.h" |
| |
| #include <AppKit/AppKit.h> |
| #include <CoreServices/CoreServices.h> // pre-macOS 11 |
| #include <Foundation/Foundation.h> |
| #import <UniformTypeIdentifiers/UniformTypeIdentifiers.h> // macOS 11 |
| #include <stddef.h> |
| |
| #include "base/apple/bridging.h" |
| #include "base/apple/foundation_util.h" |
| #include "base/apple/scoped_cftyperef.h" |
| #include "base/files/file_util.h" |
| #include "base/i18n/case_conversion.h" |
| #import "base/mac/mac_util.h" |
| #include "base/strings/sys_string_conversions.h" |
| #include "base/strings/utf_string_conversions.h" |
| #include "base/threading/hang_watcher.h" |
| #include "base/threading/thread_restrictions.h" |
| #include "ui/base/l10n/l10n_util_mac.h" |
| #include "ui/strings/grit/ui_strings.h" |
| |
| namespace { |
| |
| const int kFileTypePopupTag = 1234; |
| |
| // TODO(macOS 11): Remove this. |
| CFStringRef CreateUTIFromExtension(const base::FilePath::StringType& ext) { |
| base::ScopedCFTypeRef<CFStringRef> ext_cf(base::SysUTF8ToCFStringRef(ext)); |
| return UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, |
| ext_cf.get(), nullptr); |
| } |
| |
| NSString* GetDescriptionFromExtension(const base::FilePath::StringType& ext) { |
| if (@available(macOS 11, *)) { |
| UTType* type = |
| [UTType typeWithFilenameExtension:base::SysUTF8ToNSString(ext)]; |
| NSString* description = type.localizedDescription; |
| |
| if (description.length) { |
| return description; |
| } |
| } else { |
| base::ScopedCFTypeRef<CFStringRef> uti(CreateUTIFromExtension(ext)); |
| NSString* description = |
| base::apple::CFToNSOwnershipCast(UTTypeCopyDescription(uti.get())); |
| |
| if (description && description.length) { |
| return description; |
| } |
| } |
| |
| // 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)"). |
| std::u16string ext_name = base::UTF8ToUTF16(ext); |
| return l10n_util::GetNSStringF(IDS_APP_SAVEAS_EXTENSION_FORMAT, |
| base::i18n::ToUpper(ext_name), ext_name); |
| } |
| |
| NSView* CreateAccessoryView() { |
| // The label. Add attributes per-OS to match the labels that macOS uses. |
| NSTextField* label = |
| [NSTextField labelWithString:l10n_util::GetNSString( |
| IDS_SAVE_PAGE_FILE_FORMAT_PROMPT_MAC)]; |
| label.translatesAutoresizingMaskIntoConstraints = NO; |
| label.textColor = NSColor.secondaryLabelColor; |
| if (base::mac::IsAtLeastOS11()) |
| label.font = [NSFont systemFontOfSize:[NSFont smallSystemFontSize]]; |
| |
| // The popup. |
| NSPopUpButton* popup = [[NSPopUpButton alloc] initWithFrame:NSZeroRect |
| pullsDown:NO]; |
| popup.translatesAutoresizingMaskIntoConstraints = NO; |
| popup.tag = kFileTypePopupTag; |
| |
| // A view to group the label and popup together. The top-level view used as |
| // the accessory view will be stretched horizontally to match the width of |
| // the dialog, and the label and popup need to be grouped together as one |
| // view to do centering within it, so use a view to group the label and |
| // popup. |
| NSView* group = [[NSView alloc] initWithFrame:NSZeroRect]; |
| group.translatesAutoresizingMaskIntoConstraints = NO; |
| [group addSubview:label]; |
| [group addSubview:popup]; |
| |
| // This top-level view will be forced by the system to have the width of the |
| // save dialog. |
| NSView* view = [[NSView alloc] initWithFrame:NSZeroRect]; |
| view.translatesAutoresizingMaskIntoConstraints = NO; |
| [view addSubview:group]; |
| |
| NSMutableArray* constraints = [NSMutableArray array]; |
| |
| // The required constraints for the group, instantiated top-to-bottom: |
| // ┌───────────────────┐ |
| // │ ↕︎ │ |
| // │ ↔︎ label ↔︎ popup ↔︎ │ |
| // │ ↕︎ │ |
| // └───────────────────┘ |
| |
| // Top. |
| [constraints |
| addObject:[popup.topAnchor constraintEqualToAnchor:group.topAnchor |
| constant:10]]; |
| |
| // Leading. |
| [constraints |
| addObject:[label.leadingAnchor constraintEqualToAnchor:group.leadingAnchor |
| constant:10]]; |
| |
| // Horizontal and vertical baseline between the label and popup. |
| CGFloat labelPopupPadding; |
| if (base::mac::IsAtLeastOS11()) |
| labelPopupPadding = 8; |
| else |
| labelPopupPadding = 5; |
| [constraints addObject:[popup.leadingAnchor |
| constraintEqualToAnchor:label.trailingAnchor |
| constant:labelPopupPadding]]; |
| [constraints |
| addObject:[popup.firstBaselineAnchor |
| constraintEqualToAnchor:label.firstBaselineAnchor]]; |
| |
| // Trailing. |
| [constraints addObject:[group.trailingAnchor |
| constraintEqualToAnchor:popup.trailingAnchor |
| constant:10]]; |
| |
| // Bottom. |
| [constraints |
| addObject:[group.bottomAnchor constraintEqualToAnchor:popup.bottomAnchor |
| constant:10]]; |
| |
| // Then the constraints centering the group in the accessory view. Vertical |
| // spacing is fully specified, but as the horizontal size of the accessory |
| // view will be forced to conform to the save dialog, only specify horizontal |
| // centering. |
| // ┌──────────────┐ |
| // │ ↕︎ │ |
| // │ ↔group↔︎ │ |
| // │ ↕︎ │ |
| // └──────────────┘ |
| |
| // Top. |
| [constraints |
| addObject:[group.topAnchor constraintEqualToAnchor:view.topAnchor]]; |
| |
| // Centering. |
| [constraints addObject:[group.centerXAnchor |
| constraintEqualToAnchor:view.centerXAnchor]]; |
| |
| // Bottom. |
| [constraints |
| addObject:[view.bottomAnchor constraintEqualToAnchor:group.bottomAnchor]]; |
| |
| [NSLayoutConstraint activateConstraints:constraints]; |
| |
| return view; |
| } |
| |
| NSSavePanel* __weak g_last_created_panel_for_testing = nil; |
| |
| } // 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; |
| |
| // Two ivars serving the same purpose. While `_fileTypeLists` is for pre-macOS |
| // 11, and contains NSStrings with UTType identifiers, `_fileUTTypeLists` is |
| // for macOS 11 and later, and contains UTTypes. TODO(macOS 11): Clean this |
| // up. |
| |
| // An array where each item is an array of different extensions in an |
| // extension group. |
| NSArray<NSArray<NSString*>*>* __strong _fileTypeLists; |
| NSArray<NSArray<UTType*>*>* __strong _fileUTTypeLists |
| API_AVAILABLE(macos(11.0)); |
| } |
| |
| // TODO(macOS 11): Remove this. |
| - (instancetype)initWithDialog:(NSSavePanel*)dialog |
| fileTypeLists:(NSArray<NSArray<NSString*>*>*)fileTypeLists; |
| |
| - (instancetype)initWithDialog:(NSSavePanel*)dialog |
| fileUTTypeLists:(NSArray<NSArray<UTType*>*>*)fileUTTypeLists |
| API_AVAILABLE(macos(11.0)); |
| |
| - (void)popupAction:(id)sender; |
| @end |
| |
| @implementation SelectFileDialogDelegate |
| |
| - (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 == NSEventTypeKeyDown && |
| NSApp.currentEvent.ARepeat) { |
| return NO; |
| } |
| |
| return YES; |
| } |
| |
| @end |
| |
| @implementation ExtensionDropdownHandler |
| |
| // TODO(macOS 11): Remove this. |
| - (instancetype)initWithDialog:(NSSavePanel*)dialog |
| fileTypeLists:(NSArray<NSArray<NSString*>*>*)fileTypeLists { |
| if ((self = [super init])) { |
| _dialog = dialog; |
| _fileTypeLists = fileTypeLists; |
| } |
| return self; |
| } |
| |
| - (instancetype)initWithDialog:(NSSavePanel*)dialog |
| fileUTTypeLists:(NSArray<NSArray<UTType*>*>*)fileUTTypeLists |
| API_AVAILABLE(macos(11.0)) { |
| if ((self = [super init])) { |
| _dialog = dialog; |
| _fileUTTypeLists = fileUTTypeLists; |
| } |
| return self; |
| } |
| |
| - (void)popupAction:(id)sender { |
| NSUInteger index = [sender indexOfSelectedItem]; |
| if (@available(macOS 11, *)) { |
| if (index < [_fileUTTypeLists count]) { |
| // For save dialogs, this causes the first item in the allowedContentTypes |
| // array to be used as the extension for the save panel. |
| _dialog.allowedContentTypes = [_fileUTTypeLists objectAtIndex:index]; |
| } else { |
| // The user selected "All files" option. (Note that an empty array is "all |
| // types" and nil is an error.) |
| _dialog.allowedContentTypes = @[]; |
| } |
| } else { |
| 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.allowedFileTypes = [_fileTypeLists objectAtIndex:index]; |
| } else { |
| // The user selected "All files" option. (Note that nil is "all types" and |
| // an empty array is an error.) |
| _dialog.allowedFileTypes = nil; |
| } |
| } |
| } |
| |
| @end |
| |
| namespace remote_cocoa { |
| |
| using mojom::SelectFileDialogType; |
| using mojom::SelectFileTypeInfoPtr; |
| |
| SelectFileDialogBridge::SelectFileDialogBridge(NSWindow* owning_window) |
| : owning_window_(owning_window), weak_factory_(this) {} |
| |
| SelectFileDialogBridge::~SelectFileDialogBridge() { |
| // If we never executed our callback, then the panel never closed. Cancel it |
| // now. |
| if (show_callback_) |
| [panel_ cancel:panel_]; |
| |
| // Balance the setDelegate called during Show. |
| [panel_ setDelegate:nil]; |
| } |
| |
| void SelectFileDialogBridge::Show( |
| SelectFileDialogType type, |
| const std::u16string& title, |
| const base::FilePath& default_path, |
| SelectFileTypeInfoPtr file_types, |
| int file_type_index, |
| const base::FilePath::StringType& default_extension, |
| PanelEndedCallback initialize_callback) { |
| // Never consider the current WatchHangsInScope as hung. There was most likely |
| // one created in ThreadControllerWithMessagePumpImpl::DoWork(). The current |
| // hang watching deadline is not valid since the user can take unbounded time |
| // to select a file. HangWatching will resume when the next task |
| // or event is pumped in MessagePumpCFRunLoop so there is no need to |
| // reactivate it. You can see the function comments for more details. |
| base::HangWatcher::InvalidateActiveExpectations(); |
| |
| show_callback_ = std::move(initialize_callback); |
| type_ = type; |
| // Note: we need to retain the dialog as |owning_window_| can be null. |
| // (See https://crbug.com/29213 .) |
| if (type_ == SelectFileDialogType::kSaveAsFile) |
| panel_ = [NSSavePanel savePanel]; |
| else |
| panel_ = [NSOpenPanel openPanel]; |
| g_last_created_panel_for_testing = panel_; |
| |
| if (!title.empty()) |
| panel_.message = 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::ScopedAllowBlocking allow_blocking; |
| 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()); |
| } |
| } |
| |
| const bool keep_extension_visible = |
| file_types ? file_types->keep_extension_visible : false; |
| if (type_ != SelectFileDialogType::kFolder && |
| type_ != SelectFileDialogType::kUploadFolder && |
| type_ != SelectFileDialogType::kExistingFolder) { |
| if (file_types) { |
| SetAccessoryView( |
| std::move(file_types), file_type_index, default_extension, |
| /*is_save_panel=*/type_ == SelectFileDialogType::kSaveAsFile); |
| } else { |
| // If no type_ info is specified, anything goes. |
| panel_.allowsOtherFileTypes = YES; |
| } |
| } |
| |
| if (type_ == SelectFileDialogType::kSaveAsFile) { |
| // When file extensions are hidden and removing the extension from |
| // the default filename gives one which still has an extension |
| // that macOS 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() || keep_extension_visible) { |
| panel_.extensionHidden = NO; |
| } else { |
| panel_.extensionHidden = YES; |
| panel_.canSelectHiddenExtension = YES; |
| } |
| } else { |
| // This does not use ObjCCast because the underlying object could be a |
| // non-exported AppKit type (https://crbug.com/995476). |
| NSOpenPanel* open_dialog = static_cast<NSOpenPanel*>(panel_); |
| |
| if (type_ == SelectFileDialogType::kOpenMultiFile) |
| open_dialog.allowsMultipleSelection = YES; |
| else |
| open_dialog.allowsMultipleSelection = NO; |
| |
| if (type_ == SelectFileDialogType::kFolder || |
| type_ == SelectFileDialogType::kUploadFolder || |
| type_ == SelectFileDialogType::kExistingFolder) { |
| open_dialog.canChooseFiles = NO; |
| open_dialog.canChooseDirectories = YES; |
| |
| if (type_ == SelectFileDialogType::kFolder) |
| open_dialog.canCreateDirectories = YES; |
| else |
| open_dialog.canCreateDirectories = NO; |
| |
| NSString* prompt = |
| (type_ == SelectFileDialogType::kUploadFolder) |
| ? l10n_util::GetNSString(IDS_SELECT_UPLOAD_FOLDER_BUTTON_TITLE) |
| : l10n_util::GetNSString(IDS_SELECT_FOLDER_BUTTON_TITLE); |
| open_dialog.prompt = prompt; |
| } else { |
| open_dialog.canChooseFiles = YES; |
| open_dialog.canChooseDirectories = NO; |
| } |
| |
| delegate_ = [[SelectFileDialogDelegate alloc] init]; |
| open_dialog.delegate = delegate_; |
| } |
| if (default_dir) |
| panel_.directoryURL = [NSURL fileURLWithPath:default_dir]; |
| if (default_filename) |
| panel_.nameFieldStringValue = default_filename; |
| |
| // Ensure that |callback| (rather than |this|) be retained by the block. |
| auto callback = base::BindRepeating(&SelectFileDialogBridge::OnPanelEnded, |
| weak_factory_.GetWeakPtr()); |
| [panel_ beginSheetModalForWindow:owning_window_ |
| completionHandler:^(NSInteger result) { |
| callback.Run(result != NSModalResponseOK); |
| }]; |
| } |
| |
| void SelectFileDialogBridge::SetAccessoryView( |
| SelectFileTypeInfoPtr file_types, |
| int file_type_index, |
| const base::FilePath::StringType& default_extension, |
| bool is_save_panel) { |
| DCHECK(file_types); |
| NSView* accessory_view = CreateAccessoryView(); |
| |
| 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. TODO(macOS 11): Remove the first, |
| // uncomment the second. |
| NSMutableArray<NSArray<NSString*>*>* file_type_lists = [NSMutableArray array]; |
| NSMutableArray /*<NSArray<UTType*>*>*/* file_uttype_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]; |
| |
| // Store different extensions in the current extension group. TODO(macOS |
| // 11): Remove the first, uncomment the second. |
| NSMutableArray<NSString*>* file_type_array = [NSMutableArray array]; |
| NSMutableArray /*<UTType*>*/* file_uttype_array = [NSMutableArray array]; |
| for (const base::FilePath::StringType& ext : ext_list) { |
| if (ext == default_extension) { |
| default_extension_index = i; |
| } |
| |
| if (@available(macOS 11, *)) { |
| UTType* type = |
| [UTType typeWithFilenameExtension:base::SysUTF8ToNSString(ext)]; |
| // If the extension string is invalid (e.g. contains dots), it's not a |
| // valid extension and `type` will be nil. In that case, invent a type |
| // that doesn't match any real files. When passed to the file picker, no |
| // files will be allowed to be selected. This matches the pre-UTTypes |
| // behavior in which an invalid specified type did not allow any files |
| // to be selected, and this matches Firefox and Safari behavior (see |
| // https://crbug.com/1423362#c17 for a test case). |
| if (!type) { |
| NSString* identifier = |
| [NSString stringWithFormat:@"org.chromium.not-a-real-type.%@", |
| [NSUUID UUID].UUIDString]; |
| type = [UTType importedTypeWithIdentifier:identifier]; |
| } |
| |
| if (![file_uttype_array containsObject:type]) { |
| [file_uttype_array addObject:type]; |
| } |
| } else { |
| // 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. |
| NSString* uti = |
| base::apple::CFToNSOwnershipCast(CreateUTIFromExtension(ext)); |
| if (uti) { |
| if (![file_type_array containsObject:uti]) { |
| [file_type_array addObject:uti]; |
| } |
| } |
| |
| // 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 https://crbug.com/148840, https://openradar.appspot.com/12316273 |
| NSString* ext_ns = base::SysUTF8ToNSString(ext); |
| if (![file_type_array containsObject:ext_ns]) { |
| [file_type_array addObject:ext_ns]; |
| } |
| } |
| } |
| |
| if (@available(macOS 11, *)) { |
| [file_uttype_lists addObject:file_uttype_array]; |
| } else { |
| [file_type_lists addObject:file_type_array]; |
| } |
| } |
| |
| if (file_types->include_all_files || file_types->extensions.empty()) { |
| panel_.allowsOtherFileTypes = YES; |
| // If "all files" is specified for a save panel, allow the user to add an |
| // alternate non-suggested extension, but don't add it to the popup. It |
| // makes no sense to save as an "all files" file type. |
| if (!is_save_panel) { |
| [popup addItemWithTitle:l10n_util::GetNSString(IDS_APP_SAVEAS_ALL_FILES)]; |
| } |
| } |
| |
| if (@available(macOS 11, *)) { |
| extension_dropdown_handler_ = |
| [[ExtensionDropdownHandler alloc] initWithDialog:panel_ |
| fileUTTypeLists:file_uttype_lists]; |
| } else { |
| extension_dropdown_handler_ = |
| [[ExtensionDropdownHandler alloc] initWithDialog:panel_ |
| fileTypeLists:file_type_lists]; |
| } |
| |
| // This establishes a weak reference to handler. Hence we persist it as part |
| // of `dialog_data_list_`. |
| popup.target = extension_dropdown_handler_; |
| popup.action = @selector(popupAction:); |
| |
| // Note that `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]; |
| } else if (!default_extension.empty() && default_extension_index != -1) { |
| [popup selectItemAtIndex:default_extension_index]; |
| } else { |
| // Select the first item. |
| [popup selectItemAtIndex:0]; |
| } |
| [extension_dropdown_handler_ popupAction:popup]; |
| |
| // There's no need for a popup unless there are at least two choices. |
| if (popup.numberOfItems >= 2) { |
| panel_.accessoryView = accessory_view; |
| } |
| } |
| |
| void SelectFileDialogBridge::OnPanelEnded(bool did_cancel) { |
| if (!show_callback_) |
| return; |
| |
| int index = 0; |
| std::vector<base::FilePath> paths; |
| if (!did_cancel) { |
| if (type_ == SelectFileDialogType::kSaveAsFile) { |
| NSURL* url = [panel_ URL]; |
| if ([url isFileURL]) { |
| paths.push_back(base::apple::NSStringToFilePath([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 { |
| // This does not use ObjCCast because the underlying object could be a |
| // non-exported AppKit type (https://crbug.com/995476). |
| NSOpenPanel* open_panel = static_cast<NSOpenPanel*>(panel_); |
| |
| for (NSURL* url in open_panel.URLs) { |
| if (!url.isFileURL) |
| continue; |
| NSString* path = url.path; |
| |
| // There is a bug in macOS where, despite a request to disallow file |
| // selection, files/packages are able to be selected. If indeed file |
| // selection was disallowed, drop any files selected. |
| // https://crbug.com/1357523, FB11405008 |
| if (!open_panel.canChooseFiles) { |
| BOOL is_directory; |
| BOOL exists = |
| [[NSFileManager defaultManager] fileExistsAtPath:path |
| isDirectory:&is_directory]; |
| BOOL is_package = |
| [[NSWorkspace sharedWorkspace] isFilePackageAtPath:path]; |
| if (!exists || !is_directory || is_package) |
| continue; |
| } |
| |
| paths.push_back(base::apple::NSStringToFilePath(path)); |
| } |
| } |
| } |
| |
| std::move(show_callback_).Run(did_cancel, paths, index); |
| } |
| |
| // static |
| NSSavePanel* SelectFileDialogBridge::GetLastCreatedNativePanelForTesting() { |
| return g_last_created_panel_for_testing; |
| } |
| |
| } // namespace remote_cocoa |