// Copyright 2016 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "ui/base/clipboard/clipboard_util_mac.h"
#include <AppKit/AppKit.h>
#include <UniformTypeIdentifiers/UniformTypeIdentifiers.h>
#include <string>
#include "base/apple/bridging.h"
#include "base/apple/foundation_util.h"
#include "base/files/file_path.h"
#include "base/notreached.h"
#include "base/strings/sys_string_conversions.h"
#include "ui/base/clipboard/clipboard_constants.h"
#include "ui/base/clipboard/file_info.h"
#include "ui/base/clipboard/url_file_parser.h"
#include "url/gurl.h"
@interface URLAndTitle ()
@property(copy) NSString* URL;
@property(copy) NSString* title;
+ (instancetype)URLAndTitleWithURL:(NSString*)url title:(NSString*)title;
@end
@implementation URLAndTitle
@synthesize URL = _url;
@synthesize title = _title;
+ (instancetype)URLAndTitleWithURL:(NSString*)url title:(NSString*)title {
URLAndTitle* result = [[URLAndTitle alloc] init];
result.URL = url;
result.title = title;
return result;
}
@end
namespace ui {
namespace {
// Reads the "WebKitWebURLsWithTitles" type put onto the pasteboard by Safari
// and returns the URLs/titles found within.
NSArray<URLAndTitle*>* ReadWebURLsWithTitlesPboardType(NSPasteboard* pboard) {
NSArray* bookmark_pairs = base::apple::ObjCCast<NSArray>(
[pboard propertyListForType:kUTTypeWebKitWebURLsWithTitles]);
if (!bookmark_pairs) {
return [NSArray array];
}
if (bookmark_pairs.count != 2) {
return [NSArray array];
}
NSArray<NSString*>* urls_array =
base::apple::ObjCCast<NSArray>(bookmark_pairs[0]);
NSArray<NSString*>* titles_array =
base::apple::ObjCCast<NSArray>(bookmark_pairs[1]);
if (!urls_array || !titles_array) {
return [NSArray array];
}
if (urls_array.count < 1) {
return [NSArray array];
}
if (urls_array.count != titles_array.count) {
return [NSArray array];
}
for (id obj in urls_array) {
if (![obj isKindOfClass:[NSString class]]) {
return [NSArray array];
}
}
for (id obj in titles_array) {
if (![obj isKindOfClass:[NSString class]]) {
return [NSArray array];
}
}
NSMutableArray<URLAndTitle*>* result = [NSMutableArray array];
for (NSUInteger i = 0; i < urls_array.count; ++i) {
[result addObject:[URLAndTitle URLAndTitleWithURL:urls_array[i]
title:titles_array[i]]];
}
return result;
}
// Returns the user-visible name of the file, optionally without any extension.
// If given a non-empty `file_url`, will always return a title.
NSString* DeriveTitleFromFilename(NSURL* file_url, bool strip_extension) {
NSString* localized_name = nil;
BOOL success = [file_url getResourceValue:&localized_name
forKey:NSURLLocalizedNameKey
error:nil];
if (!success || !localized_name) {
// For the case where the actual display name of a file cannot be obtained,
// derive a quick-and-dirty version by swapping in "/" for ":", as that's
// the most common difference between the last path component of a file and
// how that file is presented to the user. See -[NSFileManager
// displayNameAtPath:] for an example of macOS doing this. Also, given that
// this is a failure case, don't bother trying to figure out the extension
// situation.
NSString* last_path_component = file_url.lastPathComponent;
return [last_path_component stringByReplacingOccurrencesOfString:@":"
withString:@"/"];
}
if (!strip_extension) {
return localized_name;
}
NSNumber* has_hidden_extension = nil;
success = [file_url getResourceValue:&has_hidden_extension
forKey:NSURLHasHiddenExtensionKey
error:nil];
if (!success || !has_hidden_extension || has_hidden_extension.boolValue) {
// If it's unknown if the extension is hidden, or if the extension is
// already hidden, return the filename unaltered.
return localized_name;
}
return [localized_name stringByDeletingPathExtension];
}
// Returns a URL and title if standard URL and URL title types are present on
// the pasteboard item. Because the Finder and/or the core macOS drag code
// automatically turn .webloc file drags into standard URL types, .webloc file
// drags are also handled by this function.
URLAndTitle* ExtractStandardURLAndTitle(NSPasteboardItem* item) {
NSString* url = [item stringForType:NSPasteboardTypeURL];
if (!url) {
return nil;
}
NSString* title = [item stringForType:kUTTypeURLName];
if (!title) {
// If there is no title on the drag, check to see if it's a URL drag
// reconstituted from a Finder .webloc. If so, use the name of the file as
// the title.
NSString* file = [item stringForType:NSPasteboardTypeFileURL];
if (file) {
NSURL* file_url = [NSURL URLWithString:file].filePathURL;
// The UTType for .webloc files is com.apple.web-internet-location, but
// there is no official constant for that. However, that type does conform
// to the generic "internet location" type (aka .inetloc), so check for
// that.
UTType* type;
if (![file_url getResourceValue:&type
forKey:NSURLContentTypeKey
error:nil]) {
return nil;
}
if (![type conformsToType:UTTypeInternetLocation]) {
return nil;
}
title = DeriveTitleFromFilename(file_url, /*strip_extension=*/true);
}
}
if (!title) {
// If still no title, use the hostname as the last resort.
title = [NSURL URLWithString:url].host;
}
return [URLAndTitle URLAndTitleWithURL:url title:title];
}
// Returns a URL and title if the pasteboard item is of a standard Microsoft
// Windows IShellLink-style .url file.
URLAndTitle* ExtractURLFromURLFile(NSPasteboardItem* item) {
NSString* file = [item stringForType:NSPasteboardTypeFileURL];
if (!file) {
return nil;
}
NSURL* file_url = [NSURL URLWithString:file].filePathURL;
NSDictionary* resource_values;
resource_values =
[file_url resourceValuesForKeys:@[ NSURLFileSizeKey, NSURLContentTypeKey ]
error:nil];
if (!resource_values) {
return nil;
}
NSNumber* file_size = resource_values[NSURLFileSizeKey];
if (file_size.unsignedLongValue >
clipboard_util::internal::kMaximumParsableFileSize) {
return nil;
}
UTType* type = resource_values[NSURLContentTypeKey];
if (![type conformsToType:UTTypeInternetShortcut]) {
return nil;
}
// Windows codepage 1252 (aka WinLatin1) is the best guess.
NSString* contents =
[NSString stringWithContentsOfURL:file_url
encoding:NSWindowsCP1252StringEncoding
error:nil];
if (!contents) {
return nil;
}
std::string found_url =
clipboard_util::internal::ExtractURLFromURLFileContents(
base::SysNSStringToUTF8(contents));
if (found_url.empty()) {
return nil;
}
NSString* title = DeriveTitleFromFilename(file_url, /*strip_extension=*/true);
return [URLAndTitle URLAndTitleWithURL:base::SysUTF8ToNSString(found_url)
title:title];
}
// Returns a URL and title if a string on the pasteboard item is formatted as a
// URL but doesn't actually have the URL type.
URLAndTitle* ExtractURLFromStringValue(NSPasteboardItem* item) {
NSString* string = [item stringForType:NSPasteboardTypeString];
if (!string) {
return nil;
}
string = [string
stringByTrimmingCharactersInSet:NSCharacterSet
.whitespaceAndNewlineCharacterSet];
// Check to see if this string is a valid URL; use GURL to do so. NSURL was
// found in 2010 to not be strict enough; see https://crbug.com/43100. It's
// unknown if things have changed since then, but there's no reason to revert.
// FYI earlier code also allowed all "javascript:" and "data:" URLs as
// "loosely validated". TODO(avi): If that "loosely validated" escape hatch
// needed? If significant time goes by and no one complains, remove this TODO
// and don't put that back in.
GURL url(base::SysNSStringToUTF8(string));
if (!url.is_valid()) {
return nil;
}
// The hostname is the best that can be done for the title.
return [URLAndTitle URLAndTitleWithURL:string
title:base::SysUTF8ToNSString(url.host())];
}
// If there is a file URL on the pasteboard, returns that file as the URL. For
// compatibility with other platforms, return no title.
URLAndTitle* ExtractFileURL(NSPasteboardItem* item) {
NSString* file = [item stringForType:NSPasteboardTypeFileURL];
if (!file) {
return nil;
}
NSURL* file_url = [NSURL URLWithString:file].filePathURL;
return [URLAndTitle URLAndTitleWithURL:file_url.absoluteString title:@""];
}
// Reads the given pasteboard, and returns URLs/titles found on it. If
// `include_files` is set, then any file references on the pasteboard will be
// returned as file URLs. Returns true if at least one URL was found on the
// pasteboard, and false if none were.
NSArray<URLAndTitle*>* ReadURLItemsWithTitles(NSPasteboard* pboard,
bool include_files) {
NSMutableArray<URLAndTitle*>* result = [NSMutableArray array];
for (NSPasteboardItem* item in pboard.pasteboardItems) {
// Try each of several ways of getting URLs from the pasteboard item and
// stop with the first one that works.
URLAndTitle* url_and_title = ExtractStandardURLAndTitle(item);
if (!url_and_title) {
url_and_title = ExtractURLFromURLFile(item);
}
if (!url_and_title) {
url_and_title = ExtractURLFromStringValue(item);
}
if (!url_and_title && include_files) {
url_and_title = ExtractFileURL(item);
}
if (url_and_title) {
[result addObject:url_and_title];
}
}
return result;
}
} // namespace
UniquePasteboard::UniquePasteboard()
: pasteboard_([NSPasteboard pasteboardWithUniqueName]) {}
UniquePasteboard::~UniquePasteboard() {
[pasteboard_ releaseGlobally];
}
namespace clipboard_util {
NSArray<NSPasteboardItem*>* PasteboardItemsFromUrls(
NSArray<NSString*>* urls,
NSArray<NSString*>* titles) {
DCHECK_EQ(urls.count, titles.count);
NSMutableArray<NSPasteboardItem*>* items = [NSMutableArray array];
for (NSUInteger i = 0; i < urls.count; ++i) {
NSPasteboardItem* item = [[NSPasteboardItem alloc] init];
NSString* url_string = urls[i];
NSString* title = titles[i];
NSURL* url = [NSURL URLWithString:url_string];
if (url.isFileURL && [url checkResourceIsReachableAndReturnError:nil]) {
[item setString:url_string forType:NSPasteboardTypeFileURL];
}
[item setString:url_string forType:NSPasteboardTypeString];
[item setString:url_string forType:NSPasteboardTypeURL];
if (title.length) {
[item setString:title forType:kUTTypeURLName];
}
// Safari puts the "Web URLs and Titles" pasteboard type onto the first
// pasteboard item.
if (i == 0) {
[item setPropertyList:@[ urls, titles ]
forType:kUTTypeWebKitWebURLsWithTitles];
}
[items addObject:item];
}
return items;
}
void AddDataToPasteboard(NSPasteboard* pboard, NSPasteboardItem* item) {
NSSet* old_types = [NSSet setWithArray:[pboard types]];
NSMutableSet* new_types = [NSMutableSet setWithArray:[item types]];
[new_types minusSet:old_types];
[pboard addTypes:[new_types allObjects] owner:nil];
for (NSString* type in new_types) {
// Technically, the object associated with |type| might be an NSString or a
// property list. It doesn't matter though, since the type gets pulled from
// and shoved into an NSDictionary.
[pboard setData:[item dataForType:type] forType:type];
}
}
NSArray<URLAndTitle*>* URLsAndTitlesFromPasteboard(NSPasteboard* pboard,
bool include_files) {
NSArray<URLAndTitle*>* result = ReadWebURLsWithTitlesPboardType(pboard);
if (result.count) {
return result;
}
return ReadURLItemsWithTitles(pboard, include_files);
}
std::vector<FileInfo> FilesFromPasteboard(NSPasteboard* pboard) {
std::vector<FileInfo> results;
for (NSPasteboardItem* item in pboard.pasteboardItems) {
NSString* file_url_string = [item stringForType:NSPasteboardTypeFileURL];
if (!file_url_string) {
continue;
}
NSURL* file_url = [NSURL URLWithString:file_url_string].filePathURL;
// Despite the second value being the "display name", it must be the full
// filename because deep in Blink it's used to determine the file's type.
// See https://crbug.com/1412205.
results.emplace_back(
base::apple::NSURLToFilePath(file_url),
base::apple::NSStringToFilePath(file_url.lastPathComponent));
}
return results;
}
void WriteFilesToPasteboard(NSPasteboard* pboard,
const std::vector<FileInfo>& files) {
if (files.empty()) {
return;
}
NSMutableArray<NSPasteboardItem*>* items =
[NSMutableArray arrayWithCapacity:files.size()];
for (const auto& file : files) {
NSURL* url = base::apple::FilePathToNSURL(file.path);
NSPasteboardItem* item = [[NSPasteboardItem alloc] init];
[item setString:url.absoluteString forType:NSPasteboardTypeFileURL];
[items addObject:item];
}
[pboard writeObjects:items];
}
NSPasteboard* PasteboardFromBuffer(ClipboardBuffer buffer) {
NSString* buffer_type = nil;
switch (buffer) {
case ClipboardBuffer::kCopyPaste:
buffer_type = NSPasteboardNameGeneral;
break;
case ClipboardBuffer::kDrag:
buffer_type = NSPasteboardNameDrag;
break;
case ClipboardBuffer::kSelection:
NOTREACHED();
}
return [NSPasteboard pasteboardWithName:buffer_type];
}
NSString* GetHTMLFromRTFOnPasteboard(NSPasteboard* pboard) {
NSData* rtf_data = [pboard dataForType:NSPasteboardTypeRTF];
if (!rtf_data)
return nil;
NSAttributedString* attributed =
[[NSAttributedString alloc] initWithRTF:rtf_data documentAttributes:nil];
NSData* html_data =
[attributed dataFromRange:NSMakeRange(0, attributed.length)
documentAttributes:@{
NSDocumentTypeDocumentAttribute : NSHTMLTextDocumentType
}
error:nil];
// According to the docs, NSHTMLTextDocumentType is UTF-8.
return [[NSString alloc] initWithData:html_data
encoding:NSUTF8StringEncoding];
}
} // namespace clipboard_util
} // namespace ui