diff options
Diffstat (limited to 'src/plugins/platforms/cocoa/qcocoafiledialoghelper.mm')
-rw-r--r-- | src/plugins/platforms/cocoa/qcocoafiledialoghelper.mm | 360 |
1 files changed, 236 insertions, 124 deletions
diff --git a/src/plugins/platforms/cocoa/qcocoafiledialoghelper.mm b/src/plugins/platforms/cocoa/qcocoafiledialoghelper.mm index 91d76fa254..41170b74ea 100644 --- a/src/plugins/platforms/cocoa/qcocoafiledialoghelper.mm +++ b/src/plugins/platforms/cocoa/qcocoafiledialoghelper.mm @@ -14,8 +14,6 @@ #include <QtCore/qstringlist.h> #include <QtCore/qvarlengtharray.h> #include <QtCore/qabstracteventdispatcher.h> -#include <QtCore/qsysinfo.h> -#include <QtCore/qoperatingsystemversion.h> #include <QtCore/qdir.h> #include <QtCore/qregularexpression.h> #include <QtCore/qpointer.h> @@ -27,6 +25,8 @@ #include <qpa/qplatformtheme.h> #include <qpa/qplatformnativeinterface.h> +#include <UniformTypeIdentifiers/UniformTypeIdentifiers.h> + QT_USE_NAMESPACE using namespace Qt::StringLiterals; @@ -55,12 +55,11 @@ typedef QSharedPointer<QFileDialogOptions> SharedPointerFileDialogOptions; NSPopUpButton *m_popupButton; NSTextField *m_textField; QPointer<QCocoaFileDialogHelper> m_helper; - NSString *m_currentDirectory; SharedPointerFileDialogOptions m_options; - QString *m_currentSelection; - QStringList *m_nameFilterDropDownList; - QStringList *m_selectedNameFilter; + QString m_currentSelection; + QStringList m_nameFilterDropDownList; + QStringList m_selectedNameFilter; } - (instancetype)initWithAcceptMode:(const QString &)selectFile @@ -80,26 +79,56 @@ typedef QSharedPointer<QFileDialogOptions> SharedPointerFileDialogOptions; m_helper = helper; - m_nameFilterDropDownList = new QStringList(m_options->nameFilters()); + m_nameFilterDropDownList = m_options->nameFilters(); QString selectedVisualNameFilter = m_options->initiallySelectedNameFilter(); - m_selectedNameFilter = new QStringList([self findStrippedFilterWithVisualFilterName:selectedVisualNameFilter]); - - QFileInfo sel(selectFile); + m_selectedNameFilter = [self findStrippedFilterWithVisualFilterName:selectedVisualNameFilter]; + + m_panel.extensionHidden = [&]{ + for (const auto &nameFilter : m_nameFilterDropDownList) { + const auto extensions = QPlatformFileDialogHelper::cleanFilterList(nameFilter); + for (const auto &extension : extensions) { + // Explicitly show extensions if we detect a filter + // of "all files", as clicking a single file with + // extensions hidden will then populate the name + // field with only the file name, without any + // extension. + if (extension == "*"_L1 || extension == "*.*"_L1) + return false; + + // Explicitly show extensions if we detect a filter + // that has a multi-part extension. This prevents + // confusing situations where the user clicks e.g. + // 'foo.tar.gz' and 'foo.tar' is populated in the + // file name box, but when then clicking save macOS + // will warn that the file needs to end in .gz, + // due to thinking the user tried to save the file + // as a 'tar' file instead. Unfortunately this + // property can only be set before the panel is + // shown, so we can't toggle it on and off based + // on the active filter. + if (extension.count('.') > 1) + return false; + } + } + return true; + }(); + + const QFileInfo sel(selectFile); if (sel.isDir() && !sel.isBundle()){ - m_currentDirectory = [sel.absoluteFilePath().toNSString() retain]; - m_currentSelection = new QString; + m_panel.directoryURL = [NSURL fileURLWithPath:sel.absoluteFilePath().toNSString()]; + m_currentSelection.clear(); } else { - m_currentDirectory = [sel.absolutePath().toNSString() retain]; - m_currentSelection = new QString(sel.absoluteFilePath()); + m_panel.directoryURL = [NSURL fileURLWithPath:sel.absolutePath().toNSString()]; + m_currentSelection = sel.absoluteFilePath(); } [self createPopUpButton:selectedVisualNameFilter hideDetails:options->testOption(QFileDialogOptions::HideNameFilterDetails)]; [self createTextField]; [self createAccessory]; - m_panel.accessoryView = m_nameFilterDropDownList->size() > 1 ? m_accessoryView : nil; + m_panel.accessoryView = m_nameFilterDropDownList.size() > 1 ? m_accessoryView : nil; // -setAccessoryView: can result in -panel:directoryDidChange: - // resetting our m_currentDirectory, set the delegate + // resetting our current directory. Set the delegate // here to make sure it gets the correct value. m_panel.delegate = self; @@ -113,10 +142,6 @@ typedef QSharedPointer<QFileDialogOptions> SharedPointerFileDialogOptions; - (void)dealloc { - delete m_nameFilterDropDownList; - delete m_selectedNameFilter; - delete m_currentSelection; - [m_panel orderOut:m_panel]; m_panel.accessoryView = nil; [m_popupButton release]; @@ -124,19 +149,17 @@ typedef QSharedPointer<QFileDialogOptions> SharedPointerFileDialogOptions; [m_accessoryView release]; m_panel.delegate = nil; [m_panel release]; - [m_currentDirectory release]; [super dealloc]; } - (bool)showPanel:(Qt::WindowModality) windowModality withParent:(QWindow *)parent { - QFileInfo info(*m_currentSelection); + const QFileInfo info(m_currentSelection); NSString *filepath = info.filePath().toNSString(); NSURL *url = [NSURL fileURLWithPath:filepath isDirectory:info.isDir()]; bool selectable = (m_options->acceptMode() == QFileDialogOptions::AcceptSave) || [self panel:m_panel shouldEnableURL:url]; - m_panel.directoryURL = [NSURL fileURLWithPath:m_currentDirectory]; m_panel.nameFieldStringValue = selectable ? info.fileName().toNSString() : @""; [self updateProperties]; @@ -184,7 +207,7 @@ typedef QSharedPointer<QFileDialogOptions> SharedPointerFileDialogOptions; - (void)closePanel { - *m_currentSelection = QString::fromNSString(m_panel.URL.path).normalized(QString::NormalizationForm_C); + m_currentSelection = QString::fromNSString(m_panel.URL.path).normalized(QString::NormalizationForm_C); if (m_panel.sheet) [NSApp endSheet:m_panel]; @@ -194,19 +217,6 @@ typedef QSharedPointer<QFileDialogOptions> SharedPointerFileDialogOptions; [m_panel close]; } -- (BOOL)isHiddenFileAtURL:(NSURL *)url -{ - BOOL hidden = NO; - if (url) { - CFBooleanRef isHiddenProperty; - if (CFURLCopyResourcePropertyForKey((__bridge CFURLRef)url, kCFURLIsHiddenKey, &isHiddenProperty, nullptr)) { - hidden = CFBooleanGetValue(isHiddenProperty); - CFRelease(isHiddenProperty); - } - } - return hidden; -} - - (BOOL)panel:(id)sender shouldEnableURL:(NSURL *)url { Q_UNUSED(sender); @@ -215,64 +225,140 @@ typedef QSharedPointer<QFileDialogOptions> SharedPointerFileDialogOptions; if (!filename.length) return NO; - // Always accept directories regardless of their names (unless it is a bundle): - NSFileManager *fm = NSFileManager.defaultManager; - NSDictionary *fileAttrs = [fm attributesOfItemAtPath:filename error:nil]; - if (!fileAttrs) - return NO; // Error accessing the file means 'no'. - NSString *fileType = fileAttrs.fileType; - bool isDir = [fileType isEqualToString:NSFileTypeDirectory]; - if (isDir) { - if (!m_panel.treatsFilePackagesAsDirectories) { - if ([NSWorkspace.sharedWorkspace isFilePackageAtPath:filename] == NO) - return YES; - } + const QFileInfo fileInfo(QString::fromNSString(filename)); + + // Always accept directories regardless of their names. + // This also includes symlinks and aliases to directories. + if (fileInfo.isDir()) { + // Unless it's a bundle, and we should treat bundles as files. + // FIXME: We'd like to use QFileInfo::isBundle() here, but the + // detection in QFileInfo goes deeper than NSWorkspace does + // (likely a bug), and as a result causes TCC permission + // dialogs to pop up when used. + bool treatBundlesAsFiles = !m_panel.treatsFilePackagesAsDirectories; + if (!(treatBundlesAsFiles && [NSWorkspace.sharedWorkspace isFilePackageAtPath:filename])) + return YES; } - // Treat symbolic links and aliases to directories like directories - QFileInfo fileInfo(QString::fromNSString(filename)); - if (fileInfo.isSymLink() && QFileInfo(fileInfo.symLinkTarget()).isDir()) - return YES; - - QString qtFileName = fileInfo.fileName(); - // No filter means accept everything - bool nameMatches = m_selectedNameFilter->isEmpty(); - // Check if the current file name filter accepts the file: - for (int i = 0; !nameMatches && i < m_selectedNameFilter->size(); ++i) { - if (QDir::match(m_selectedNameFilter->at(i), qtFileName)) - nameMatches = true; - } - if (!nameMatches) + if (![self fileInfoMatchesCurrentNameFilter:fileInfo]) return NO; QDir::Filters filter = m_options->filter(); - if ((!(filter & (QDir::Dirs | QDir::AllDirs)) && isDir) - || (!(filter & QDir::Files) && [fileType isEqualToString:NSFileTypeRegular]) - || ((filter & QDir::NoSymLinks) && [fileType isEqualToString:NSFileTypeSymbolicLink])) + if ((!(filter & (QDir::Dirs | QDir::AllDirs)) && fileInfo.isDir()) + || (!(filter & QDir::Files) && (fileInfo.isFile() && !fileInfo.isSymLink())) + || ((filter & QDir::NoSymLinks) && fileInfo.isSymLink())) return NO; bool filterPermissions = ((filter & QDir::PermissionMask) && (filter & QDir::PermissionMask) != QDir::PermissionMask); if (filterPermissions) { - if ((!(filter & QDir::Readable) && [fm isReadableFileAtPath:filename]) - || (!(filter & QDir::Writable) && [fm isWritableFileAtPath:filename]) - || (!(filter & QDir::Executable) && [fm isExecutableFileAtPath:filename])) + if ((!(filter & QDir::Readable) && fileInfo.isReadable()) + || (!(filter & QDir::Writable) && fileInfo.isWritable()) + || (!(filter & QDir::Executable) && fileInfo.isExecutable())) return NO; } - if (!(filter & QDir::Hidden) - && (qtFileName.startsWith(u'.') || [self isHiddenFileAtURL:url])) + + // We control the visibility of hidden files via the showsHiddenFiles + // property on the panel, based on QDir::Hidden being set. But the user + // can also toggle this via the Command+Shift+. keyboard shortcut, + // in which case they have explicitly requested to show hidden files, + // and we should enable them even if QDir::Hidden was not set. In + // effect, we don't need to filter on QDir::Hidden here. + + return YES; +} + +- (BOOL)panel:(id)sender validateURL:(NSURL *)url error:(NSError * _Nullable *)outError +{ + Q_ASSERT(sender == m_panel); + + if (!m_panel.allowedFileTypes && !m_selectedNameFilter.isEmpty()) { + // The save panel hasn't done filtering on our behalf, + // either because we couldn't represent the filter via + // allowedFileTypes, or we opted out due to a multi part + // extension, so do the filtering/validation ourselves. + QFileInfo fileInfo(QString::fromNSString(url.path).normalized(QString::NormalizationForm_C)); + + if ([self fileInfoMatchesCurrentNameFilter:fileInfo]) + return YES; + + if (fileInfo.suffix().isEmpty()) { + // The filter requires a file name with an extension. + // We're going to add a default file name in selectedFiles, + // to match the native behavior. Check now that we can + // overwrite the file, if is already exists. + fileInfo = [self applyDefaultSuffixFromCurrentNameFilter:fileInfo]; + + if (!fileInfo.exists() || m_options->testOption(QFileDialogOptions::DontConfirmOverwrite)) + return YES; + + QMacAutoReleasePool pool; + auto *alert = [[NSAlert new] autorelease]; + alert.alertStyle = NSAlertStyleCritical; + + alert.messageText = [NSString stringWithFormat:qt_mac_AppKitString(@"SavePanel", + @"\\U201c%@\\U201d already exists. Do you want to replace it?"), + fileInfo.fileName().toNSString()]; + alert.informativeText = [NSString stringWithFormat:qt_mac_AppKitString(@"SavePanel", + @"A file or folder with the same name already exists in the folder %@. " + "Replacing it will overwrite its current contents."), + fileInfo.absoluteDir().dirName().toNSString()]; + + auto *replaceButton = [alert addButtonWithTitle:qt_mac_AppKitString(@"SavePanel", @"Replace")]; + replaceButton.hasDestructiveAction = YES; + replaceButton.tag = 1337; + [alert addButtonWithTitle:qt_mac_AppKitString(@"Common", @"Cancel")]; + + [alert beginSheetModalForWindow:m_panel + completionHandler:^(NSModalResponse returnCode) { + [NSApp stopModalWithCode:returnCode]; + }]; + return [NSApp runModalForWindow:alert.window] == replaceButton.tag; + } else { + QFileInfo firstFilter(m_selectedNameFilter.first()); + auto *domain = qGuiApp->organizationDomain().toNSString(); + *outError = [NSError errorWithDomain:domain code:0 userInfo:@{ + NSLocalizedDescriptionKey:[NSString stringWithFormat:qt_mac_AppKitString(@"SavePanel", + @"You cannot save this document with extension \\U201c.%1$@\\U201d at the end " + "of the name. The required extension is \\U201c.%2$@\\U201d."), + fileInfo.completeSuffix().toNSString(), firstFilter.completeSuffix().toNSString()] + }]; return NO; + } + } return YES; } +- (QFileInfo)applyDefaultSuffixFromCurrentNameFilter:(const QFileInfo &)fileInfo +{ + QFileInfo filterInfo(m_selectedNameFilter.first()); + return QFileInfo(fileInfo.absolutePath(), + fileInfo.baseName() + '.' + filterInfo.completeSuffix()); +} + +- (bool)fileInfoMatchesCurrentNameFilter:(const QFileInfo &)fileInfo +{ + // No filter means accept everything + if (m_selectedNameFilter.isEmpty()) + return true; + + // Check if the current file name filter accepts the file + for (const auto &filter : m_selectedNameFilter) { + if (QDir::match(filter, fileInfo.fileName())) + return true; + } + + return false; +} + - (void)setNameFilters:(const QStringList &)filters hideDetails:(BOOL)hideDetails { [m_popupButton removeAllItems]; - *m_nameFilterDropDownList = filters; + m_nameFilterDropDownList = filters; if (filters.size() > 0){ for (int i = 0; i < filters.size(); ++i) { - QString filter = hideDetails ? [self removeExtensions:filters.at(i)] : filters.at(i); + const QString filter = hideDetails ? [self removeExtensions:filters.at(i)] : filters.at(i); [m_popupButton.menu addItemWithTitle:filter.toNSString() action:nil keyEquivalent:@""]; } [m_popupButton selectItemAtIndex:0]; @@ -290,8 +376,8 @@ typedef QSharedPointer<QFileDialogOptions> SharedPointerFileDialogOptions; Q_UNUSED(sender); if (!m_helper) return; - QString selection = m_nameFilterDropDownList->value([m_popupButton indexOfSelectedItem]); - *m_selectedNameFilter = [self findStrippedFilterWithVisualFilterName:selection]; + const QString selection = m_nameFilterDropDownList.value([m_popupButton indexOfSelectedItem]); + m_selectedNameFilter = [self findStrippedFilterWithVisualFilterName:selection]; [m_panel validateVisibleColumns]; [self updateProperties]; @@ -310,18 +396,25 @@ typedef QSharedPointer<QFileDialogOptions> SharedPointerFileDialogOptions; } return result; } else { - QList<QUrl> result; QString filename = QString::fromNSString(m_panel.URL.path).normalized(QString::NormalizationForm_C); - const QString defaultSuffix = m_options->defaultSuffix(); - const QFileInfo fileInfo(filename); + QFileInfo fileInfo(filename); + + if (fileInfo.suffix().isEmpty() && ![self fileInfoMatchesCurrentNameFilter:fileInfo]) { + // We end up in this situation if we accept a file name without extension + // in panel:validateURL:error. If so, we match the behavior of the native + // save dialog and add the first of the accepted extension from the filter. + fileInfo = [self applyDefaultSuffixFromCurrentNameFilter:fileInfo]; + } // If neither the user or the NSSavePanel have provided a suffix, use // the default suffix (if it exists). - if (fileInfo.suffix().isEmpty() && !defaultSuffix.isEmpty()) - filename.append('.').append(defaultSuffix); + const QString defaultSuffix = m_options->defaultSuffix(); + if (fileInfo.suffix().isEmpty() && !defaultSuffix.isEmpty()) { + fileInfo.setFile(fileInfo.absolutePath(), + fileInfo.baseName() + '.' + defaultSuffix); + } - result << QUrl::fromLocalFile(filename); - return result; + return { QUrl::fromLocalFile(fileInfo.filePath()) }; } } @@ -353,19 +446,25 @@ typedef QSharedPointer<QFileDialogOptions> SharedPointerFileDialogOptions; m_panel.allowedFileTypes = [self computeAllowedFileTypes]; - // Explicitly show extensions if we detect a filter - // that has a multi-part extension. This prevents - // confusing situations where the user clicks e.g. - // 'foo.tar.gz' and 'foo.tar' is populated in the - // file name box, but when then clicking save macOS - // will warn that the file needs to end in .gz, - // due to thinking the user tried to save the file - // as a 'tar' file instead. Unfortunately this - // property can only be set before the panel is - // shown, so it will not have any effect when - // switching filters in an already opened dialog. - if (m_panel.allowedFileTypes.count > 2) - m_panel.extensionHidden = NO; + // Setting allowedFileTypes to nil is not enough to reset any + // automatically added extension based on a previous filter. + // This is problematic because extensions can in some cases + // be hidden from the user, resulting in confusion when the + // resulting file name doesn't match the current empty filter. + // We work around this by temporarily resetting the allowed + // content type to one without an extension, which forces + // the save panel to update and remove the extension. + const bool nameFieldHasExtension = m_panel.nameFieldStringValue.pathExtension.length > 0; + if (!m_panel.allowedFileTypes && !nameFieldHasExtension && !openpanel_cast(m_panel)) { + if (!UTTypeDirectory.preferredFilenameExtension) { + m_panel.allowedContentTypes = @[ UTTypeDirectory ]; + m_panel.allowedFileTypes = nil; + } else { + qWarning() << "UTTypeDirectory unexpectedly reported an extension"; + } + } + + m_panel.showsHiddenFiles = m_options->filter().testFlag(QDir::Hidden); if (m_panel.visible) [m_panel validateVisibleColumns]; @@ -378,10 +477,18 @@ typedef QSharedPointer<QFileDialogOptions> SharedPointerFileDialogOptions; if (!m_helper) return; + // Save panels only allow you to select directories, which + // means currentChanged will only be emitted when selecting + // a directory, and if so, with the latest chosen file name, + // which is confusing and inconsistent. We choose to bail + // out entirely for save panels, to give consistent behavior. + if (!openpanel_cast(m_panel)) + return; + if (m_panel.visible) { - QString selection = QString::fromNSString(m_panel.URL.path); - if (selection != *m_currentSelection) { - *m_currentSelection = selection; + const QString selection = QString::fromNSString(m_panel.URL.path); + if (selection != m_currentSelection) { + m_currentSelection = selection; emit m_helper->currentChanged(QUrl::fromLocalFile(selection)); } } @@ -394,14 +501,7 @@ typedef QSharedPointer<QFileDialogOptions> SharedPointerFileDialogOptions; if (!m_helper) return; - if (!(path && path.length) || [path isEqualToString:m_currentDirectory]) - return; - - [m_currentDirectory release]; - m_currentDirectory = [path retain]; - - // ### fixme: priv->setLastVisitedDirectory(newDir); - emit m_helper->directoryEntered(QUrl::fromLocalFile(QString::fromNSString(m_currentDirectory))); + m_helper->panelDirectoryDidChange(path); } /* @@ -409,11 +509,9 @@ typedef QSharedPointer<QFileDialogOptions> SharedPointerFileDialogOptions; for the current name filter, and updates the save panel. If a filter do not conform to the format *.xyz or * or *.*, - all files types are allowed. - - Extensions with more than one part (e.g. "tar.gz") are - reduced to their final part, as NSSavePanel does not deal - well with multi-part extensions. + or contains an extensions with more than one part (e.g. "tar.gz") + we treat that as allowing all file types, and do our own + validation in panel:validateURL:error. */ - (NSArray<NSString*>*)computeAllowedFileTypes { @@ -421,7 +519,7 @@ typedef QSharedPointer<QFileDialogOptions> SharedPointerFileDialogOptions; return nil; // panel:shouldEnableURL: does the file filtering for NSOpenPanel QStringList fileTypes; - for (const QString &filter : *m_selectedNameFilter) { + for (const QString &filter : std::as_const(m_selectedNameFilter)) { if (!filter.startsWith("*."_L1)) continue; @@ -432,6 +530,9 @@ typedef QSharedPointer<QFileDialogOptions> SharedPointerFileDialogOptions; continue; auto extensions = filter.split('.', Qt::SkipEmptyParts); + if (extensions.count() > 2) + return nil; + fileTypes += extensions.last(); } @@ -468,10 +569,10 @@ typedef QSharedPointer<QFileDialogOptions> SharedPointerFileDialogOptions; m_popupButton.target = self; m_popupButton.action = @selector(filterChanged:); - if (m_nameFilterDropDownList->size() > 0) { + if (!m_nameFilterDropDownList.isEmpty()) { int filterToUse = -1; - for (int i = 0; i < m_nameFilterDropDownList->size(); ++i) { - QString currentFilter = m_nameFilterDropDownList->at(i); + for (int i = 0; i < m_nameFilterDropDownList.size(); ++i) { + const QString currentFilter = m_nameFilterDropDownList.at(i); if (selectedFilter == currentFilter || (filterToUse == -1 && currentFilter.startsWith(selectedFilter))) filterToUse = i; @@ -485,9 +586,9 @@ typedef QSharedPointer<QFileDialogOptions> SharedPointerFileDialogOptions; - (QStringList) findStrippedFilterWithVisualFilterName:(QString)name { - for (int i = 0; i < m_nameFilterDropDownList->size(); ++i) { - if (m_nameFilterDropDownList->at(i).startsWith(name)) - return QPlatformFileDialogHelper::cleanFilterList(m_nameFilterDropDownList->at(i)); + for (const QString ¤tFilter : std::as_const(m_nameFilterDropDownList)) { + if (currentFilter.startsWith(name)) + return QPlatformFileDialogHelper::cleanFilterList(currentFilter); } return QStringList(); } @@ -528,21 +629,32 @@ void QCocoaFileDialogHelper::panelClosed(NSInteger result) void QCocoaFileDialogHelper::setDirectory(const QUrl &directory) { + m_directory = directory; + if (m_delegate) m_delegate->m_panel.directoryURL = [NSURL fileURLWithPath:directory.toLocalFile().toNSString()]; - else - m_directory = directory; } QUrl QCocoaFileDialogHelper::directory() const { - if (m_delegate) { - QString path = QString::fromNSString(m_delegate->m_panel.directoryURL.path).normalized(QString::NormalizationForm_C); - return QUrl::fromLocalFile(path); - } return m_directory; } +void QCocoaFileDialogHelper::panelDirectoryDidChange(NSString *path) +{ + if (!path || [path isEqual:NSNull.null] || !path.length) + return; + + const auto oldDirectory = m_directory; + m_directory = QUrl::fromLocalFile( + QString::fromNSString(path).normalized(QString::NormalizationForm_C)); + + if (m_directory != oldDirectory) { + // FIXME: Plumb old directory back to QFileDialog's lastVisitedDir? + emit directoryEntered(m_directory); + } +} + void QCocoaFileDialogHelper::selectFile(const QUrl &filename) { QString filePath = filename.toLocalFile(); |