From 6efdad3812c1b4cc17665e85baf7f5a786a5658e Mon Sep 17 00:00:00 2001 From: Timothy Hatcher Date: Fri, 30 Aug 2024 17:09:16 -0700 Subject: [PATCH] Add support for icon_variants in Web Extensions. https://webkit.org/b/278818 rdar://problem/134885372 Reviewed by NOBODY (OOPS!). Add support for `icon_variants` manifest parsing under the `WK_WEB_EXTENSIONS_ICON_VARIANTS` flag, with optimizations to ensure efficient icon loading. This change introduces `icon_variants` manifest parsing, explicitly supporting different icon sets, such as dark mode icons. To achieve this efficiently, icons are now cached by size, reducing disk I/O by avoiding repeated loads when the browser frequently requests the same icon. The cache is automatically invalidated when device scales change, such as when connecting or disconnecting a display with a different scale factor. Only the necessary icons are loaded based on the specific scale factor of all screens, halving the image loads compared to previous behavior. This ensures that even as more extensions adopt dark mode icons, typical image loads remain at two images (light and dark). Proposal: https://github.com/w3c/webextensions/blob/main/proposals/dark_mode_extension_icons.md WECG issue: https://github.com/w3c/webextensions/issues/229 * Source/WTF/wtf/PlatformEnableCocoa.h: Added ENABLE_WK_WEB_EXTENSIONS_ICON_VARIANTS. * Source/WebCore/en.lproj/Localizable.strings: Updated. * Source/WebKit/Platform/cocoa/CocoaHelpers.h: * Source/WebKit/Platform/cocoa/CocoaHelpers.mm: (WebKit::availableScreenScales): Added. (WebKit::largestDisplayScale): Added. * Source/WebKit/Platform/spi/ios/UIKitSPI.h: * Source/WebKit/UIProcess/Extensions/Cocoa/WebExtensionActionCocoa.mm: (WebKit::WebExtensionAction::icon): * Source/WebKit/UIProcess/Extensions/Cocoa/WebExtensionCocoa.mm: (WebKit::WebExtension::icon): (WebKit::WebExtension::actionIcon): (WebKit::WebExtension::populateActionPropertiesIfNeeded): (WebKit::WebExtension::populateSidebarActionProperties): (WebKit::WebExtension::populateSidePanelProperties): (WebKit::WebExtension::imageForPath): (WebKit::WebExtension::bestSizeInIconsDictionary): Added. (WebKit::WebExtension::pathForBestImageInIconsDictionary): (WebKit::WebExtension::bestImageInIconsDictionary): (WebKit::WebExtension::bestImageForIconsDictionaryManifestKey): (WebKit::toColorSchemes): Added. (WebKit::WebExtension::iconsDictionaryForBestIconVariant): Added. (WebKit::WebExtension::bestImageForIconVariants): Added. (WebKit::WebExtension::bestImageForIconVariantsManifestKey): Added. * Source/WebKit/UIProcess/Extensions/Cocoa/WebExtensionMenuItemCocoa.mm: (WebKit::WebExtensionMenuItem::icon const): * Source/WebKit/UIProcess/Extensions/WebExtension.h: * Tools/TestWebKitAPI/Tests/WebKitCocoa/WKWebExtension.mm: (TestWebKitAPI::TEST(WKWebExtension, MultipleIconVariants)): Added. (TestWebKitAPI::TEST(WKWebExtension, SingleIconVariant)): Added. (TestWebKitAPI::TEST(WKWebExtension, AnySizeIconVariant)): Added. (TestWebKitAPI::TEST(WKWebExtension, NoIconVariants)): Added. (TestWebKitAPI::TEST(WKWebExtension, IconsAndIconVariantsSpecified)): Added. (TestWebKitAPI::TEST(WKWebExtension, ActionIconVariantsMultiple)): Added. (TestWebKitAPI::TEST(WKWebExtension, ActionIconSingleVariant)): Added. (TestWebKitAPI::TEST(WKWebExtension, ActionIconAnySizeVariant)): Added. (TestWebKitAPI::TEST(WKWebExtension, ActionNoIconVariants)): Added. (TestWebKitAPI::TEST(WKWebExtension, ActionIconsAndIconVariantsSpecified)): Added. * Tools/TestWebKitAPI/cocoa/WebExtensionUtilities.h: * Tools/TestWebKitAPI/cocoa/WebExtensionUtilities.mm: (TestWebKitAPI::Util::performWithAppearance): Added. (TestWebKitAPI::Util::pixelColor): Added. (TestWebKitAPI::Util::toSRGBColor): Added. (TestWebKitAPI::Util::compareColors): Added. --- Source/WTF/wtf/PlatformEnableCocoa.h | 9 +- Source/WebCore/en.lproj/Localizable.strings | 15 + Source/WebKit/Platform/cocoa/CocoaHelpers.h | 3 + Source/WebKit/Platform/cocoa/CocoaHelpers.mm | 38 ++ Source/WebKit/Platform/spi/ios/UIKitSPI.h | 10 + .../Cocoa/WebExtensionActionCocoa.mm | 9 +- .../Extensions/Cocoa/WebExtensionCocoa.mm | 415 ++++++++++++++---- .../Cocoa/WebExtensionMenuItemCocoa.mm | 8 +- .../UIProcess/Extensions/WebExtension.h | 28 +- .../xcschemes/TestWebKitAPI.xcscheme | 6 - .../Tests/WebKitCocoa/WKWebExtension.mm | 336 ++++++++++++++ .../WebKitCocoa/WKWebExtensionAPIAction.mm | 32 -- .../cocoa/WebExtensionUtilities.h | 18 + .../cocoa/WebExtensionUtilities.mm | 67 +++ 14 files changed, 862 insertions(+), 132 deletions(-) diff --git a/Source/WTF/wtf/PlatformEnableCocoa.h b/Source/WTF/wtf/PlatformEnableCocoa.h index 323a4babc2491..bc023f0405061 100644 --- a/Source/WTF/wtf/PlatformEnableCocoa.h +++ b/Source/WTF/wtf/PlatformEnableCocoa.h @@ -1010,10 +1010,17 @@ #define ENABLE_WIRELESS_PLAYBACK_TARGET_AVAILABILITY_API 1 #endif -#if !defined(ENABLE_WK_WEB_EXTENSIONS) && (PLATFORM(MAC) || PLATFORM(MACCATALYST) || PLATFORM(IOS) || PLATFORM(IOS_SIMULATOR) || PLATFORM(VISION)) +#if !defined(ENABLE_WK_WEB_EXTENSIONS) && (PLATFORM(MAC) || PLATFORM(MACCATALYST) || PLATFORM(IOS) || PLATFORM(VISION)) #define ENABLE_WK_WEB_EXTENSIONS 1 #endif +#if !defined(ENABLE_WK_WEB_EXTENSIONS_ICON_VARIANTS) \ + && ((PLATFORM(MAC) && __MAC_OS_X_VERSION_MAX_ALLOWED >= 150500) \ + || ((PLATFORM(IOS) || PLATFORM(MACCATALYST)) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 180500) \ + || (PLATFORM(VISION) && __VISION_OS_VERSION_MAX_ALLOWED >= 20500)) +#define ENABLE_WK_WEB_EXTENSIONS_ICON_VARIANTS ENABLE_WK_WEB_EXTENSIONS +#endif + #if !defined(ENABLE_WK_WEB_EXTENSIONS_SIDEBAR) #define ENABLE_WK_WEB_EXTENSIONS_SIDEBAR 0 && ENABLE_WK_WEB_EXTENSIONS #endif diff --git a/Source/WebCore/en.lproj/Localizable.strings b/Source/WebCore/en.lproj/Localizable.strings index 6a1bf0a5698e5..c5f49801e64ad 100644 --- a/Source/WebCore/en.lproj/Localizable.strings +++ b/Source/WebCore/en.lproj/Localizable.strings @@ -532,6 +532,15 @@ /* WKWebExtensionErrorInvalidManifestEntry description for externally_connectable */ "Empty or invalid `externally_connectable` manifest entry." = "Empty or invalid `externally_connectable` manifest entry."; +/* WKWebExtensionErrorInvalidManifestEntry description for icon_variants in action only */ +"Empty or invalid `icon_variants` for the `action` manifest entry." = "Empty or invalid `icon_variants` for the `action` manifest entry."; + +/* WKWebExtensionErrorInvalidManifestEntry description for icon_variants in browser_action or page_action */ +"Empty or invalid `icon_variants` for the `browser_action` or `page_action` manifest entry." = "Empty or invalid `icon_variants` for the `browser_action` or `page_action` manifest entry."; + +/* WKWebExtensionErrorInvalidManifestEntry description for icon_variants */ +"Empty or invalid `icon_variants` manifest entry." = "Empty or invalid `icon_variants` manifest entry."; + /* WKWebExtensionErrorInvalidManifestEntry description for invalid new tab entry */ "Empty or invalid `newtab` manifest entry." = "Empty or invalid `newtab` manifest entry."; @@ -616,6 +625,12 @@ /* WKWebExtensionErrorInvalidActionIcon description for failing to load images for browser_action or page_action */ "Failed to load images in `default_icon` for the `browser_action` or `page_action` manifest entry." = "Failed to load images in `default_icon` for the `browser_action` or `page_action` manifest entry."; +/* WKWebExtensionErrorInvalidActionIcon description for failing to load image variants for action */ +"Failed to load images in `icon_variants` for the `action` manifest entry." = "Failed to load images in `icon_variants` for the `action` manifest entry."; + +/* WKWebExtensionErrorInvalidIcon description for failing to load image variants */ +"Failed to load images in `icon_variants` manifest entry." = "Failed to load images in `icon_variants` manifest entry."; + /* WKWebExtensionErrorInvalidIcon description for failing to load images */ "Failed to load images in `icons` manifest entry." = "Failed to load images in `icons` manifest entry."; diff --git a/Source/WebKit/Platform/cocoa/CocoaHelpers.h b/Source/WebKit/Platform/cocoa/CocoaHelpers.h index 4d30f4fcb7abf..b267d4fe6f2f6 100644 --- a/Source/WebKit/Platform/cocoa/CocoaHelpers.h +++ b/Source/WebKit/Platform/cocoa/CocoaHelpers.h @@ -136,6 +136,9 @@ NSString *escapeCharactersInString(NSString *, NSString *charactersToEscape); void callAfterRandomDelay(Function&&); +NSSet *availableScreenScales(); +CGFloat largestDisplayScale(); + NSDate *toAPI(const WallTime&); WallTime toImpl(NSDate *); diff --git a/Source/WebKit/Platform/cocoa/CocoaHelpers.mm b/Source/WebKit/Platform/cocoa/CocoaHelpers.mm index 428b3930ea9c2..16390fad2e827 100644 --- a/Source/WebKit/Platform/cocoa/CocoaHelpers.mm +++ b/Source/WebKit/Platform/cocoa/CocoaHelpers.mm @@ -38,6 +38,10 @@ #import #import +#if PLATFORM(IOS_FAMILY) +#import +#endif + namespace WebKit { static NSString * const privacyPreservingDescriptionKey = @"privacyPreservingDescription"; @@ -440,6 +444,40 @@ void callAfterRandomDelay(Function&& completionHandler) dispatch_after(dispatch_time(DISPATCH_TIME_NOW, delay.nanosecondsAs()), dispatch_get_main_queue(), makeBlockPtr(WTFMove(completionHandler)).get()); } +NSSet *availableScreenScales() +{ + NSMutableSet *screenScales = [NSMutableSet set]; + +#if USE(APPKIT) + for (NSScreen *screen in NSScreen.screens) + [screenScales addObject:@(screen.backingScaleFactor)]; +#else + ALLOW_DEPRECATED_DECLARATIONS_BEGIN + for (UIScreen *screen in UIScreen.screens) + [screenScales addObject:@(screen.scale)]; + ALLOW_DEPRECATED_DECLARATIONS_END +#endif + + if (screenScales.count) + return [screenScales copy]; + + // Assume 1x if we got no results. This can happen on headless devices (bots). + return [NSSet setWithObject:@1]; +} + +CGFloat largestDisplayScale() +{ + auto largestDisplayScale = 1.0; + + for (NSNumber *scale in availableScreenScales()) { + auto doubleValue = scale.doubleValue; + if (doubleValue > largestDisplayScale) + largestDisplayScale = doubleValue; + } + + return largestDisplayScale; +} + NSDate *toAPI(const WallTime& time) { if (time.isNaN()) diff --git a/Source/WebKit/Platform/spi/ios/UIKitSPI.h b/Source/WebKit/Platform/spi/ios/UIKitSPI.h index f685418d76e7f..03bca88ccd8ef 100644 --- a/Source/WebKit/Platform/spi/ios/UIKitSPI.h +++ b/Source/WebKit/Platform/spi/ios/UIKitSPI.h @@ -45,6 +45,7 @@ #import #import #import +#import #import #import #import @@ -338,6 +339,10 @@ typedef enum { @property (nonatomic, setter=_setShowsFileSizePicker:) BOOL _showsFileSizePicker; @end +@interface UIImageAsset () ++ (instancetype)_dynamicAssetNamed:(NSString *)name generator:(UIImage *(^)(UIImageAsset *, UIImageConfiguration *, UIImage *))block; +@end + typedef struct CGSVGDocument *CGSVGDocumentRef; @interface UIImage () @@ -345,6 +350,7 @@ typedef struct CGSVGDocument *CGSVGDocumentRef; - (UIImage *)_flatImageWithColor:(UIColor *)color; + (UIImage *)_systemImageNamed:(NSString *)name; + (UIImage *)_imageWithCGSVGDocument:(CGSVGDocumentRef)cgSVGDocument; ++ (UIImage *)_imageWithCGSVGDocument:(CGSVGDocumentRef)cgSVGDocument scale:(CGFloat)scale orientation:(UIImageOrientation)orientation; @end @protocol UIKeyboardImplGeometryDelegate @@ -1111,6 +1117,10 @@ typedef NS_ENUM(NSUInteger, _UIScrollDeviceCategory) { + (UIColor *)insertionPointColor; @end +@interface UIImage () +- (UIImage *)_rasterizedImage; +@end + @interface UIView (IPI) - (UIScrollView *)_scroller; - (CGPoint)accessibilityConvertPointFromSceneReferenceCoordinates:(CGPoint)point; diff --git a/Source/WebKit/UIProcess/Extensions/Cocoa/WebExtensionActionCocoa.mm b/Source/WebKit/UIProcess/Extensions/Cocoa/WebExtensionActionCocoa.mm index 81b9a166d26db..c481e509f5f44 100644 --- a/Source/WebKit/UIProcess/Extensions/Cocoa/WebExtensionActionCocoa.mm +++ b/Source/WebKit/UIProcess/Extensions/Cocoa/WebExtensionActionCocoa.mm @@ -646,11 +646,12 @@ - (void)_otherPopoverWillShow:(NSNotification *)notification return nil; if (m_customIcons) { - NSError *error; - if (CocoaImage *result = extensionContext()->extension().bestImageInIconsDictionary(m_customIcons.get(), idealSize.width > idealSize.height ? idealSize.width : idealSize.height, &error)) - return result; + auto *result = extensionContext()->extension().bestImageInIconsDictionary(m_customIcons.get(), idealSize, [&](auto *error) { + extensionContext()->recordError(error); + }); - extensionContext()->recordErrorIfNeeded(error); + if (result) + return result; // If custom icons fail, fallback to the default icons. } diff --git a/Source/WebKit/UIProcess/Extensions/Cocoa/WebExtensionCocoa.mm b/Source/WebKit/UIProcess/Extensions/Cocoa/WebExtensionCocoa.mm index 8ffb0471be128..540fdef53c17e 100644 --- a/Source/WebKit/UIProcess/Extensions/Cocoa/WebExtensionCocoa.mm +++ b/Source/WebKit/UIProcess/Extensions/Cocoa/WebExtensionCocoa.mm @@ -78,6 +78,14 @@ static NSString * const iconsManifestKey = @"icons"; +#if ENABLE(WK_WEB_EXTENSIONS_ICON_VARIANTS) +static NSString * const iconVariantsManifestKey = @"icon_variants"; +static NSString * const colorSchemesManifestKey = @"color_schemes"; +static NSString * const lightManifestKey = @"light"; +static NSString * const darkManifestKey = @"dark"; +static NSString * const anyManifestKey = @"any"; +#endif + static NSString * const actionManifestKey = @"action"; static NSString * const browserActionManifestKey = @"browser_action"; static NSString * const pageActionManifestKey = @"page_action"; @@ -700,6 +708,14 @@ static WKWebExtensionError toAPI(WebExtension::Error error) break; case Error::InvalidActionIcon: +#if ENABLE(WK_WEB_EXTENSIONS_ICON_VARIANTS) + if (m_actionDictionary.get()[iconVariantsManifestKey]) { + if (supportsManifestVersion(3)) + localizedDescription = WEB_UI_STRING("Empty or invalid `icon_variants` for the `action` manifest entry.", "WKWebExtensionErrorInvalidManifestEntry description for icon_variants in action only"); + else + localizedDescription = WEB_UI_STRING("Empty or invalid `icon_variants` for the `browser_action` or `page_action` manifest entry.", "WKWebExtensionErrorInvalidManifestEntry description for icon_variants in browser_action or page_action"); + } else +#endif if (supportsManifestVersion(3)) localizedDescription = WEB_UI_STRING("Empty or invalid `default_icon` for the `action` manifest entry.", "WKWebExtensionErrorInvalidManifestEntry description for default_icon in action only"); else @@ -740,7 +756,12 @@ static WKWebExtensionError toAPI(WebExtension::Error error) break; case Error::InvalidIcon: - localizedDescription = WEB_UI_STRING("Missing or empty `icons` manifest entry.", "WKWebExtensionErrorInvalidManifestEntry description for icons"); +#if ENABLE(WK_WEB_EXTENSIONS_ICON_VARIANTS) + if ([manifest() objectForKey:iconVariantsManifestKey]) + localizedDescription = WEB_UI_STRING("Empty or invalid `icon_variants` manifest entry.", "WKWebExtensionErrorInvalidManifestEntry description for icon_variants"); + else +#endif + localizedDescription = WEB_UI_STRING("Missing or empty `icons` manifest entry.", "WKWebExtensionErrorInvalidManifestEntry description for icons"); break; case Error::InvalidName: @@ -1011,8 +1032,15 @@ static WKWebExtensionError toAPI(WebExtension::Error error) if (!manifestParsedSuccessfully()) return nil; +#if ENABLE(WK_WEB_EXTENSIONS_ICON_VARIANTS) + if (m_manifest.get()[iconVariantsManifestKey]) { + NSString *localizedErrorDescription = WEB_UI_STRING("Failed to load images in `icon_variants` manifest entry.", "WKWebExtensionErrorInvalidIcon description for failing to load image variants"); + return bestImageForIconVariantsManifestKey(m_manifest.get(), iconVariantsManifestKey, size, m_iconsCache, Error::InvalidIcon, localizedErrorDescription); + } +#endif + NSString *localizedErrorDescription = WEB_UI_STRING("Failed to load images in `icons` manifest entry.", "WKWebExtensionErrorInvalidIcon description for failing to load images"); - return bestImageForIconsDictionaryManifestKey(m_manifest.get(), iconsManifestKey, size, m_icon, Error::InvalidIcon, localizedErrorDescription); + return bestImageForIconsDictionaryManifestKey(m_manifest.get(), iconsManifestKey, size, m_iconsCache, Error::InvalidIcon, localizedErrorDescription); } CocoaImage *WebExtension::actionIcon(CGSize size) @@ -1022,13 +1050,25 @@ static WKWebExtensionError toAPI(WebExtension::Error error) populateActionPropertiesIfNeeded(); + if (m_defaultActionIcon) + return m_defaultActionIcon.get(); + +#if ENABLE(WK_WEB_EXTENSIONS_ICON_VARIANTS) + if (m_actionDictionary.get()[iconVariantsManifestKey]) { + NSString *localizedErrorDescription = WEB_UI_STRING("Failed to load images in `icon_variants` for the `action` manifest entry.", "WKWebExtensionErrorInvalidActionIcon description for failing to load image variants for action"); + if (auto *result = bestImageForIconVariantsManifestKey(m_actionDictionary.get(), iconVariantsManifestKey, size, m_actionIconsCache, Error::InvalidActionIcon, localizedErrorDescription)) + return result; + return icon(size); + } +#endif + NSString *localizedErrorDescription; if (supportsManifestVersion(3)) localizedErrorDescription = WEB_UI_STRING("Failed to load images in `default_icon` for the `action` manifest entry.", "WKWebExtensionErrorInvalidActionIcon description for failing to load images for action only"); else localizedErrorDescription = WEB_UI_STRING("Failed to load images in `default_icon` for the `browser_action` or `page_action` manifest entry.", "WKWebExtensionErrorInvalidActionIcon description for failing to load images for browser_action or page_action"); - if (auto *result = bestImageForIconsDictionaryManifestKey(m_actionDictionary.get(), defaultIconManifestKey, size, m_actionIcon, Error::InvalidActionIcon, localizedErrorDescription)) + if (auto *result = bestImageForIconsDictionaryManifestKey(m_actionDictionary.get(), defaultIconManifestKey, size, m_actionIconsCache, Error::InvalidActionIcon, localizedErrorDescription)) return result; return icon(size); } @@ -1086,12 +1126,11 @@ static WKWebExtensionError toAPI(WebExtension::Error error) return; // Look for the "default_icon" as a string, which is useful for SVG icons. Only supported by Firefox currently. - NSString *defaultIconPath = objectForKey(m_actionDictionary, defaultIconManifestKey); - if (defaultIconPath.length) { + if (auto *defaultIconPath = objectForKey(m_actionDictionary, defaultIconManifestKey)) { NSError *resourceError; - m_actionIcon = imageForPath(defaultIconPath, &resourceError); + m_defaultActionIcon = imageForPath(defaultIconPath, &resourceError); - if (!m_actionIcon) { + if (!m_defaultActionIcon) { recordError(resourceError); NSString *localizedErrorDescription; @@ -1153,28 +1192,30 @@ static WKWebExtensionError toAPI(WebExtension::Error error) void WebExtension::populateSidebarActionProperties(RetainPtr sidebarActionDictionary) { // FIXME: implement sidebar icon parsing - m_sidebarIcon = nil; + m_sidebarIconsCache = nil; m_sidebarTitle = objectForKey(sidebarActionDictionary, sidebarActionTitleManifestKey); m_sidebarDocumentPath = objectForKey(sidebarActionDictionary, sidebarActionPathManifestKey); } void WebExtension::populateSidePanelProperties(RetainPtr sidePanelDictionary) { - // Since sidePanel cannot set a default title or icon from the manifest, setting these nil here is intentional - m_sidebarIcon = nil; + // Since sidePanel cannot set a default title or icon from the manifest, setting these nil here is intentional. + m_sidebarIconsCache = nil; m_sidebarTitle = nil; m_sidebarDocumentPath = objectForKey(sidePanelDictionary, sidePanelPathManifestKey); } #endif -CocoaImage *WebExtension::imageForPath(NSString *imagePath, NSError **outError) +CocoaImage *WebExtension::imageForPath(NSString *imagePath, NSError **outError, CGSize sizeForResizing) { ASSERT(imagePath); - NSData *imageData = resourceDataForPath(imagePath, outError, CacheResult::Yes); + NSData *imageData = resourceDataForPath(imagePath, outError); if (!imageData) return nil; + CocoaImage *result; + #if !USE(NSIMAGE_FOR_SVG_SUPPORT) UTType *imageType = resourceTypeForPath(imagePath); if ([imageType.identifier isEqualToString:UTTypeSVG.identifier]) { @@ -1186,59 +1227,75 @@ static WKWebExtensionError toAPI(WebExtension::Error error) if (!imageRep) return nil; - NSImage *result = [[NSImage alloc] init]; + result = [[NSImage alloc] init]; [result addRepresentation:imageRep]; result.size = imageRep.size; - - return result; #else CGSVGDocumentRef document = CGSVGDocumentCreateFromData(bridge_cast(imageData), nullptr); if (!document) return nil; - UIImage *result = [UIImage _imageWithCGSVGDocument:document]; + // Since we need to rasterize, scale the image for the densest display, so it will have enough pixels to be sharp. + result = [UIImage _imageWithCGSVGDocument:document scale:largestDisplayScale() orientation:UIImageOrientationUp]; CGSVGDocumentRelease(document); - - return result; #endif // not USE(APPKIT) } #endif // !USE(NSIMAGE_FOR_SVG_SUPPORT) - return [[CocoaImage alloc] initWithData:imageData]; + if (!result) + result = [[CocoaImage alloc] initWithData:imageData]; + +#if USE(APPKIT) + if (!CGSizeEqualToSize(sizeForResizing, CGSizeZero)) { + // Proportionally scale the size. + auto originalSize = result.size; + auto aspectWidth = sizeForResizing.width / originalSize.width; + auto aspectHeight = sizeForResizing.height / originalSize.height; + auto aspectRatio = std::min(aspectWidth, aspectHeight); + + result.size = CGSizeMake(originalSize.width * aspectRatio, originalSize.height * aspectRatio); + } + + return result; +#else + // Rasterization is needed because UIImageAsset will not register the image unless it is a CGImage. + // If the image is already a CGImage bitmap, this operation is a no-op. + result = result._rasterizedImage; + + if (!CGSizeEqualToSize(sizeForResizing, CGSizeZero)) + result = [result imageByPreparingThumbnailOfSize:sizeForResizing]; + + return result; +#endif // not USE(APPKIT) } -NSString *WebExtension::pathForBestImageInIconsDictionary(NSDictionary *iconsDictionary, size_t idealPixelSize) +size_t WebExtension::bestSizeInIconsDictionary(NSDictionary *iconsDictionary, size_t idealPixelSize) { if (!iconsDictionary.count) - return nil; + return 0; + +#if ENABLE(WK_WEB_EXTENSIONS_ICON_VARIANTS) + // Check if the "any" size exists (typically a vector image), and prefer it. + if (iconsDictionary[anyManifestKey]) { + // Return max to ensure it takes precedence over all other sizes. + return std::numeric_limits::max(); + } +#endif // Check if the ideal size exists, if so return it. NSString *idealSizeString = @(idealPixelSize).stringValue; - if (NSString *resultPath = iconsDictionary[idealSizeString]) - return resultPath; - - // Check if the ideal retina size exists, if so return it. This will cause downsampling to the ideal size, - // but it is an even multiplier so it is better than a fractional scaling. - idealSizeString = @(idealPixelSize * 2).stringValue; - if (NSString *resultPath = iconsDictionary[idealSizeString]) - return resultPath; - -#if PLATFORM(IOS) || PLATFORM(VISION) - // Check if the ideal 3x retina size exists. This will usually be called on 2x iOS devices when a 3x image might exist. - // Since the ideal size is likly already 2x, multiply by 1.5x to get the 3x pixel size. - idealSizeString = @(idealPixelSize * 1.5).stringValue; - if (NSString *resultPath = iconsDictionary[idealSizeString]) - return resultPath; -#endif + if (iconsDictionary[idealSizeString]) + return idealPixelSize; - // Since the ideal size does not exist, sort the keys and return the next largest size. This could cause - // fractional scaling when the final image is displayed. Better than nothing. + // Sort the remaining keys and find the next largest size. NSArray *sizeKeys = filterObjects(iconsDictionary.allKeys, ^bool(id, id value) { - return [value isKindOfClass:NSString.class]; + // Filter the values to only include numeric strings representing sizes. This will exclude non-numeric string + // values such as "any", "color_schemes", and any other strings that cannot be converted to a positive integer. + return dynamic_objc_cast(value).integerValue > 0; }); if (!sizeKeys.count) - return nil; + return 0; NSSortDescriptor *sortDescriptor = [[NSSortDescriptor alloc] initWithKey:@"self" ascending:YES selector:@selector(localizedStandardCompare:)]; NSArray *sortedKeys = [sizeKeys sortedArrayUsingDescriptors:@[ sortDescriptor ]]; @@ -1250,62 +1307,116 @@ static WKWebExtensionError toAPI(WebExtension::Error error) break; } - ASSERT(bestSize); + return bestSize; +} + +NSString *WebExtension::pathForBestImageInIconsDictionary(NSDictionary *iconsDictionary, size_t idealPixelSize) +{ + size_t bestSize = bestSizeInIconsDictionary(iconsDictionary, idealPixelSize); + if (!bestSize) + return nil; + +#if ENABLE(WK_WEB_EXTENSIONS_ICON_VARIANTS) + if (bestSize == std::numeric_limits::max()) + return iconsDictionary[anyManifestKey]; +#endif return iconsDictionary[@(bestSize).stringValue]; } -CocoaImage *WebExtension::bestImageInIconsDictionary(NSDictionary *iconsDictionary, size_t idealPointSize, NSError **outError) +CocoaImage *WebExtension::bestImageInIconsDictionary(NSDictionary *iconsDictionary, CGSize idealSize, const Function& reportError) { if (!iconsDictionary.count) return nil; - auto standardPixelSize = idealPointSize; -#if PLATFORM(IOS) || PLATFORM(VISION) - standardPixelSize *= UIScreen.mainScreen.scale; + auto idealPointSize = idealSize.width > idealSize.height ? idealSize.width : idealSize.height; + auto *screenScales = availableScreenScales(); + auto *uniquePaths = [NSMutableSet set]; +#if PLATFORM(IOS_FAMILY) + auto *scalePaths = [NSMutableDictionary dictionary]; #endif - auto *standardIconPath = pathForBestImageInIconsDictionary(iconsDictionary, standardPixelSize); - if (!standardIconPath.length) - return nil; + for (NSNumber *scale in screenScales) { + auto pixelSize = idealPointSize * scale.doubleValue; + auto *iconPath = pathForBestImageInIconsDictionary(iconsDictionary, pixelSize); + if (!iconPath) + continue; - auto *resultImage = imageForPath(standardIconPath, outError); - if (!resultImage) - return nil; + [uniquePaths addObject:iconPath]; -#if PLATFORM(MAC) - static Class svgImageRep = NSClassFromString(@"_NSSVGImageRep"); - RELEASE_ASSERT(svgImageRep); +#if PLATFORM(IOS_FAMILY) + scalePaths[scale] = iconPath; +#endif + } - BOOL isVectorImage = [resultImage.representations indexOfObjectPassingTest:^BOOL(NSImageRep *imageRep, NSUInteger, BOOL *) { - return [imageRep isKindOfClass:svgImageRep]; - }] != NSNotFound; + if (!uniquePaths.count) + return nil; - if (isVectorImage) - return resultImage; +#if USE(APPKIT) + // Return a combined image so the system can select the most appropriate representation based on the current screen scale. + NSImage *resultImage; - auto retinaPixelSize = standardPixelSize * 2; - auto *retinaIconPath = pathForBestImageInIconsDictionary(iconsDictionary, retinaPixelSize); - if (retinaIconPath.length && ![retinaIconPath isEqualToString:standardIconPath]) { - if (auto *retinaImage = imageForPath(retinaIconPath, nullptr)) - [resultImage addRepresentations:retinaImage.representations]; + for (NSString *iconPath in uniquePaths) { + NSError *resourceError; + if (auto *image = imageForPath(iconPath, &resourceError, idealSize)) { + if (!resultImage) + resultImage = image; + else + [resultImage addRepresentations:image.representations]; + } else if (reportError && resourceError) + reportError(resourceError); } -#endif // PLATFORM(MAC) return resultImage; +#else + if (uniquePaths.count == 1) { + [scalePaths removeAllObjects]; + + // Add a single value back that has 0 for the scale, which is the + // unspecified (universal) trait value for display scale. + scalePaths[@0] = uniquePaths.anyObject; + } + + auto *images = mapObjects(scalePaths, ^id(NSNumber *scale, NSString *path) { + NSError *resourceError; + if (auto *image = imageForPath(path, &resourceError, idealSize)) + return image; + + if (reportError && resourceError) + reportError(resourceError); + + return nil; + }); + + // Make a dynamic image asset that returns an image based on the trait collection. + auto *imageAsset = [UIImageAsset _dynamicAssetNamed:NSUUID.UUID.UUIDString generator:^(UIImageAsset *, UIImageConfiguration *configuration, UIImage *) { + return images[@(configuration.traitCollection.displayScale)] ?: images[@0]; + }]; + + // The returned image retains its link to the image asset and adapts to trait changes, + // automatically displaying the correct variant based on the current traits. + return [imageAsset imageWithTraitCollection:UITraitCollection.currentTraitCollection]; +#endif // not USE(APPKIT) } -CocoaImage *WebExtension::bestImageForIconsDictionaryManifestKey(NSDictionary *dictionary, NSString *manifestKey, CGSize idealSize, RetainPtr& cacheLocation, Error error, NSString *customLocalizedDescription) +CocoaImage *WebExtension::bestImageForIconsDictionaryManifestKey(NSDictionary *dictionary, NSString *manifestKey, CGSize idealSize, RetainPtr& cacheLocation, Error error, NSString *customLocalizedDescription) { - if (cacheLocation) - return cacheLocation.get(); + // Clear the cache if the display scales change (connecting display, etc.) + auto *currentScales = availableScreenScales(); + auto *cachedScales = objectForKey(cacheLocation, @"scales"); + if (!cacheLocation || ![currentScales isEqualToSet:cachedScales]) + cacheLocation = [NSMutableDictionary dictionaryWithObject:currentScales forKey:@"scales"]; + + auto *cacheKey = @(idealSize); + if (id cachedResult = cacheLocation.get()[cacheKey]) + return dynamic_objc_cast(cachedResult); - CGFloat idealPointSize = idealSize.width > idealSize.height ? idealSize.width : idealSize.height; - NSDictionary *iconDictionary = objectForKey(dictionary, manifestKey); + auto *iconDictionary = objectForKey(dictionary, manifestKey); + auto *result = bestImageInIconsDictionary(iconDictionary, idealSize, [&](auto *error) { + recordError(error); + }); - NSError *resourceError; - auto *result = bestImageInIconsDictionary(iconDictionary, idealPointSize, &resourceError); - recordErrorIfNeeded(resourceError); + cacheLocation.get()[cacheKey] = result ?: NSNull.null; if (!result) { if (iconDictionary.count) { @@ -1319,12 +1430,162 @@ static WKWebExtensionError toAPI(WebExtension::Error error) return nil; } - // Cache the icon if there is only one size, usually this is a vector icon or a large bitmap. - if (iconDictionary.count == 1) - cacheLocation = result; + return result; +} + +#if ENABLE(WK_WEB_EXTENSIONS_ICON_VARIANTS) +static OptionSet toColorSchemes(id value) +{ + using ColorScheme = WebExtension::ColorScheme; + + if (!value) { + // A nil value counts as all color schemes. + return { ColorScheme::Light, ColorScheme::Dark }; + } + + OptionSet result; + + auto *array = dynamic_objc_cast(value); + if ([array containsObject:lightManifestKey]) + result.add(ColorScheme::Light); + + if ([array containsObject:darkManifestKey]) + result.add(ColorScheme::Dark); + + return result; +} + +NSDictionary *WebExtension::iconsDictionaryForBestIconVariant(NSArray *variants, size_t idealPixelSize, ColorScheme idealColorScheme) +{ + if (!variants.count) + return nil; + + if (variants.count == 1) + return variants.firstObject; + + NSDictionary *bestVariant; + NSDictionary *fallbackVariant; + bool foundIdealFallbackVariant = false; + + size_t bestSize = 0; + size_t fallbackSize = 0; + + // Pick the first variant matching color scheme and/or size. + for (NSDictionary *variant in variants) { + auto colorSchemes = toColorSchemes(variant[colorSchemesManifestKey]); + auto currentBestSize = bestSizeInIconsDictionary(variant, idealPixelSize); + + if (colorSchemes.contains(idealColorScheme)) { + if (currentBestSize >= idealPixelSize) { + // Found the best variant, return it. + return variant; + } + + if (currentBestSize > bestSize) { + // Found a larger ideal variant. + bestSize = currentBestSize; + bestVariant = variant; + } + } else if (!foundIdealFallbackVariant && currentBestSize >= idealPixelSize) { + // Found an ideal fallback variant, based only on size. + fallbackSize = currentBestSize; + fallbackVariant = variant; + foundIdealFallbackVariant = true; + } else if (!foundIdealFallbackVariant && currentBestSize > fallbackSize) { + // Found a smaller fallback variant. + fallbackSize = currentBestSize; + fallbackVariant = variant; + } + } + + return bestVariant ?: fallbackVariant; +} + +CocoaImage *WebExtension::bestImageForIconVariants(NSArray *variants, CGSize idealSize, const Function& reportError) +{ + auto idealPointSize = idealSize.width > idealSize.height ? idealSize.width : idealSize.height; + auto *lightIconsDictionary = iconsDictionaryForBestIconVariant(variants, idealPointSize, ColorScheme::Light); + auto *darkIconsDictionary = iconsDictionaryForBestIconVariant(variants, idealPointSize, ColorScheme::Dark); + + // If the light and dark icons dictionaries are the same, or if either is nil, return the available image directly. + if (!lightIconsDictionary || !darkIconsDictionary || [lightIconsDictionary isEqualToDictionary:darkIconsDictionary]) + return bestImageInIconsDictionary(lightIconsDictionary ?: darkIconsDictionary, idealSize, reportError); + + auto *lightImage = bestImageInIconsDictionary(lightIconsDictionary, idealSize, reportError); + auto *darkImage = bestImageInIconsDictionary(darkIconsDictionary, idealSize, reportError); + + // If either the light or dark icon is nil, return the available image directly. + if (!lightImage || !darkImage) + return lightImage ?: darkImage; + +#if USE(APPKIT) + // The images need to be the same size to draw correctly in the block. + auto imageSize = lightImage.size.width >= darkImage.size.width ? lightImage.size : darkImage.size; + lightImage.size = imageSize; + darkImage.size = imageSize; + + // Make a dynamic image that draws the light or dark image based on the current appearance. + return [NSImage imageWithSize:imageSize flipped:NO drawingHandler:^BOOL(NSRect rect) { + static auto *darkAppearanceNames = @[ + NSAppearanceNameDarkAqua, + NSAppearanceNameVibrantDark, + NSAppearanceNameAccessibilityHighContrastDarkAqua, + NSAppearanceNameAccessibilityHighContrastVibrantDark, + ]; + + if ([NSAppearance.currentDrawingAppearance bestMatchFromAppearancesWithNames:darkAppearanceNames]) + [darkImage drawInRect:rect]; + else + [lightImage drawInRect:rect]; + + return YES; + }]; +#else + // Make a dynamic image asset that returns the light or dark image based on the trait collection. + auto *imageAsset = [UIImageAsset _dynamicAssetNamed:NSUUID.UUID.UUIDString generator:^(UIImageAsset *, UIImageConfiguration *configuration, UIImage *) { + return configuration.traitCollection.userInterfaceStyle == UIUserInterfaceStyleDark ? darkImage : lightImage; + }]; + + // The returned image retains its link to the image asset and adapts to trait changes, + // automatically displaying the correct variant based on the current traits. + return [imageAsset imageWithTraitCollection:UITraitCollection.currentTraitCollection]; +#endif // not USE(APPKIT) +} + +CocoaImage *WebExtension::bestImageForIconVariantsManifestKey(NSDictionary *dictionary, NSString *manifestKey, CGSize idealSize, RetainPtr& cacheLocation, Error error, NSString *customLocalizedDescription) +{ + // Clear the cache if the display scales change (connecting display, etc.) + auto *currentScales = availableScreenScales(); + auto *cachedScales = objectForKey(cacheLocation, @"scales"); + if (!cacheLocation || ![currentScales isEqualToSet:cachedScales]) + cacheLocation = [NSMutableDictionary dictionaryWithObject:currentScales forKey:@"scales"]; + + auto *cacheKey = @(idealSize); + if (id cachedResult = cacheLocation.get()[cacheKey]) + return dynamic_objc_cast(cachedResult); + + auto *variants = objectForKey(dictionary, manifestKey, false, NSDictionary.class); + auto *result = bestImageForIconVariants(variants, idealSize, [&](auto *error) { + recordError(error); + }); + + cacheLocation.get()[cacheKey] = result ?: NSNull.null; + + if (!result) { + if (variants.count) { + // Record an error if the array had values, meaning the likely failure is the images were missing on disk or bad format. + recordError(createError(error, customLocalizedDescription)); + } else if ((variants && !variants.count) || dictionary[manifestKey]) { + // Record an error if the key had an array that was empty, or the key had a value of the wrong type. + recordError(createError(error)); + } + + return nil; + } return result; } +#endif // ENABLE(WK_WEB_EXTENSIONS_ICON_VARIANTS) bool WebExtension::hasBackgroundContent() { diff --git a/Source/WebKit/UIProcess/Extensions/Cocoa/WebExtensionMenuItemCocoa.mm b/Source/WebKit/UIProcess/Extensions/Cocoa/WebExtensionMenuItemCocoa.mm index a47afc81565dc..2cd1aad33a191 100644 --- a/Source/WebKit/UIProcess/Extensions/Cocoa/WebExtensionMenuItemCocoa.mm +++ b/Source/WebKit/UIProcess/Extensions/Cocoa/WebExtensionMenuItemCocoa.mm @@ -331,11 +331,9 @@ - (IBAction)_performAction:(id)sender { ASSERT(extensionContext()); - NSError *error; - auto *result = extensionContext()->extension().bestImageInIconsDictionary(m_icons.get(), idealSize.width > idealSize.height ? idealSize.width : idealSize.height, &error); - extensionContext()->recordErrorIfNeeded(error); - - return result; + return extensionContext()->extension().bestImageInIconsDictionary(m_icons.get(), idealSize, [&](auto *error) { + extensionContext()->recordError(error); + }); } } // namespace WebKit diff --git a/Source/WebKit/UIProcess/Extensions/WebExtension.h b/Source/WebKit/UIProcess/Extensions/WebExtension.h index 418cd0277a67e..1461779f4f1ad 100644 --- a/Source/WebKit/UIProcess/Extensions/WebExtension.h +++ b/Source/WebKit/UIProcess/Extensions/WebExtension.h @@ -119,6 +119,11 @@ class WebExtension : public API::ObjectImpl, pu ServiceWorker, }; + enum class ColorScheme : uint8_t { + Light = 1 << 0, + Dark = 1 << 1 + }; + using PermissionsSet = HashSet; using MatchPatternSet = HashSet>; @@ -240,11 +245,19 @@ class WebExtension : public API::ObjectImpl, pu NSString *sidebarTitle(); #endif - CocoaImage *imageForPath(NSString *, NSError **); + CocoaImage *imageForPath(NSString *, NSError **, CGSize sizeForResizing = CGSizeZero); + size_t bestSizeInIconsDictionary(NSDictionary *, size_t idealPixelSize); NSString *pathForBestImageInIconsDictionary(NSDictionary *, size_t idealPixelSize); - CocoaImage *bestImageInIconsDictionary(NSDictionary *, size_t idealPointSize, NSError **); - CocoaImage *bestImageForIconsDictionaryManifestKey(NSDictionary *, NSString *manifestKey, CGSize idealSize, RetainPtr& cacheLocation, Error, NSString *customLocalizedDescription); + + CocoaImage *bestImageInIconsDictionary(NSDictionary *, CGSize idealSize, const Function&); + CocoaImage *bestImageForIconsDictionaryManifestKey(NSDictionary *, NSString *manifestKey, CGSize idealSize, RetainPtr& cacheLocation, Error, NSString *customLocalizedDescription); + +#if ENABLE(WK_WEB_EXTENSIONS_ICON_VARIANTS) + NSDictionary *iconsDictionaryForBestIconVariant(NSArray *, size_t idealPixelSize, ColorScheme); + CocoaImage *bestImageForIconVariants(NSArray *, CGSize idealSize, const Function&); + CocoaImage *bestImageForIconVariantsManifestKey(NSDictionary *, NSString *manifestKey, CGSize idealSize, RetainPtr& cacheLocation, Error, NSString *customLocalizedDescription); +#endif bool hasBackgroundContent(); bool backgroundContentIsPersistent(); @@ -358,18 +371,19 @@ class WebExtension : public API::ObjectImpl, pu RetainPtr m_displayDescription; RetainPtr m_version; - RetainPtr m_icon; + RetainPtr m_iconsCache; RetainPtr m_actionDictionary; - RetainPtr m_actionIcon; + RetainPtr m_actionIconsCache; + RetainPtr m_defaultActionIcon; RetainPtr m_displayActionLabel; RetainPtr m_actionPopupPath; #if ENABLE(WK_WEB_EXTENSIONS_SIDEBAR) - RetainPtr m_sidebarIcon; + RetainPtr m_sidebarIconsCache; RetainPtr m_sidebarDocumentPath; RetainPtr m_sidebarTitle; -#endif // ENABLE(WK_WEB_EXTENSIONS_SIDEBAR) +#endif RetainPtr m_contentSecurityPolicy; diff --git a/Tools/TestWebKitAPI/TestWebKitAPI.xcodeproj/xcshareddata/xcschemes/TestWebKitAPI.xcscheme b/Tools/TestWebKitAPI/TestWebKitAPI.xcodeproj/xcshareddata/xcschemes/TestWebKitAPI.xcscheme index b9419725b7251..b19232e3d3509 100644 --- a/Tools/TestWebKitAPI/TestWebKitAPI.xcodeproj/xcshareddata/xcschemes/TestWebKitAPI.xcscheme +++ b/Tools/TestWebKitAPI/TestWebKitAPI.xcodeproj/xcshareddata/xcschemes/TestWebKitAPI.xcscheme @@ -64,12 +64,6 @@ ReferencedContainer = "container:TestWebKitAPI.xcodeproj"> - - - - "; + + auto *resources = @{ + @"icon-any.svg": iconAny, + }; + + auto testExtension = [[WKWebExtension alloc] _initWithManifestDictionary:testManifestDictionary resources:resources]; + EXPECT_NS_EQUAL(testExtension.errors, @[ ]); + + auto *icon = [testExtension iconForSize:CGSizeMake(64, 64)]; + EXPECT_NOT_NULL(icon); + EXPECT_TRUE(CGSizeEqualToSize(icon.size, CGSizeMake(64, 64))); +} + +TEST(WKWebExtension, NoIconVariants) +{ + auto *testManifestDictionary = @{ + @"manifest_version": @3, + + @"name": @"Test No Variants", + @"version": @"1.0", + @"description": @"Test with no icon variants" + }; + + auto testExtension = [[WKWebExtension alloc] _initWithManifestDictionary:testManifestDictionary resources:@{ }]; + EXPECT_NS_EQUAL(testExtension.errors, @[ ]); + + auto *icon = [testExtension iconForSize:CGSizeMake(32, 32)]; + EXPECT_NULL(icon); +} + +TEST(WKWebExtension, IconsAndIconVariantsSpecified) +{ + auto *testManifestDictionary = @{ + @"manifest_version": @3, + + @"name": @"Test Icons and Variants", + @"version": @"1.0", + @"description": @"Test with both icons and icon variants specified", + + @"icons": @{ + @"32": @"icon-legacy.png" + }, + + @"icon_variants": @[ + @{ @"32": @"icon-variant.png" } + ] + }; + + auto *iconLegacy = Util::makePNGData(CGSizeMake(32, 32), @selector(blackColor)); + auto *iconVariant = Util::makePNGData(CGSizeMake(32, 32), @selector(whiteColor)); + + auto *resources = @{ + @"icon-legacy.png": iconLegacy, + @"icon-variant.png": iconVariant, + }; + + auto testExtension = [[WKWebExtension alloc] _initWithManifestDictionary:testManifestDictionary resources:resources]; + EXPECT_NS_EQUAL(testExtension.errors, @[ ]); + + auto *icon = [testExtension iconForSize:CGSizeMake(32, 32)]; + EXPECT_NOT_NULL(icon); + EXPECT_TRUE(CGSizeEqualToSize(icon.size, CGSizeMake(32, 32))); + EXPECT_TRUE(Util::compareColors(Util::pixelColor(icon), [CocoaColor whiteColor])); +} + +TEST(WKWebExtension, ActionIconVariantsMultiple) +{ + auto *testManifestDictionary = @{ + @"manifest_version": @3, + + @"name": @"Test Action Multiple Variants", + @"version": @"1.0", + @"description": @"Test action with multiple icon variants", + + @"action": @{ + @"icon_variants": @[ + @{ @"32": @"action-dark-32.png", @"64": @"action-dark-64.png", @"color_schemes": @[ @"dark" ] }, + @{ @"32": @"action-light-32.png", @"64": @"action-light-64.png", @"color_schemes": @[ @"light" ] } + ] + } + }; + + auto *dark32Icon = Util::makePNGData(CGSizeMake(32, 32), @selector(whiteColor)); + auto *dark64Icon = Util::makePNGData(CGSizeMake(64, 64), @selector(whiteColor)); + auto *light32Icon = Util::makePNGData(CGSizeMake(32, 32), @selector(blackColor)); + auto *light64Icon = Util::makePNGData(CGSizeMake(64, 64), @selector(blackColor)); + + auto *resources = @{ + @"action-dark-32.png": dark32Icon, + @"action-dark-64.png": dark64Icon, + @"action-light-32.png": light32Icon, + @"action-light-64.png": light64Icon, + }; + + auto testExtension = [[WKWebExtension alloc] _initWithManifestDictionary:testManifestDictionary resources:resources]; + EXPECT_NS_EQUAL(testExtension.errors, @[ ]); + + Util::performWithAppearance(Util::Appearance::Dark, ^{ + auto *iconDark32 = [testExtension actionIconForSize:CGSizeMake(32, 32)]; + EXPECT_NOT_NULL(iconDark32); + EXPECT_TRUE(CGSizeEqualToSize(iconDark32.size, CGSizeMake(32, 32))); + EXPECT_TRUE(Util::compareColors(Util::pixelColor(iconDark32), [CocoaColor whiteColor])); + + auto *iconDark64 = [testExtension actionIconForSize:CGSizeMake(64, 64)]; + EXPECT_NOT_NULL(iconDark64); + EXPECT_TRUE(CGSizeEqualToSize(iconDark64.size, CGSizeMake(64, 64))); + EXPECT_TRUE(Util::compareColors(Util::pixelColor(iconDark64), [CocoaColor whiteColor])); + }); + + Util::performWithAppearance(Util::Appearance::Light, ^{ + auto *iconLight32 = [testExtension actionIconForSize:CGSizeMake(32, 32)]; + EXPECT_NOT_NULL(iconLight32); + EXPECT_TRUE(CGSizeEqualToSize(iconLight32.size, CGSizeMake(32, 32))); + EXPECT_TRUE(Util::compareColors(Util::pixelColor(iconLight32), [CocoaColor blackColor])); + + auto *iconLight64 = [testExtension actionIconForSize:CGSizeMake(64, 64)]; + EXPECT_NOT_NULL(iconLight64); + EXPECT_TRUE(CGSizeEqualToSize(iconLight64.size, CGSizeMake(64, 64))); + EXPECT_TRUE(Util::compareColors(Util::pixelColor(iconLight64), [CocoaColor blackColor])); + }); +} + +TEST(WKWebExtension, ActionIconSingleVariant) +{ + auto *testManifestDictionary = @{ + @"manifest_version": @3, + + @"name": @"Test Action Single Variant", + @"version": @"1.0", + @"description": @"Test action with a single icon variant", + + @"action": @{ + @"icon_variants": @[ + @{ @"32": @"action-icon-32.png" } + ] + } + }; + + auto *icon32 = Util::makePNGData(CGSizeMake(32, 32), @selector(whiteColor)); + + auto *resources = @{ + @"action-icon-32.png": icon32, + }; + + auto testExtension = [[WKWebExtension alloc] _initWithManifestDictionary:testManifestDictionary resources:resources]; + EXPECT_NS_EQUAL(testExtension.errors, @[ ]); + + auto *icon = [testExtension actionIconForSize:CGSizeMake(32, 32)]; + EXPECT_NOT_NULL(icon); + EXPECT_TRUE(CGSizeEqualToSize(icon.size, CGSizeMake(32, 32))); + EXPECT_TRUE(Util::compareColors(Util::pixelColor(icon), [CocoaColor whiteColor])); +} + +TEST(WKWebExtension, ActionIconAnySizeVariant) +{ + auto *testManifestDictionary = @{ + @"manifest_version": @3, + + @"name": @"Test Action Any Size", + @"version": @"1.0", + @"description": @"Test action with any size icon", + + @"action": @{ + @"icon_variants": @[ + @{ @"any": @"action-icon-any.svg" } + ] + } + }; + + auto *iconAny = @""; + + auto *resources = @{ + @"action-icon-any.svg": iconAny, + }; + + auto testExtension = [[WKWebExtension alloc] _initWithManifestDictionary:testManifestDictionary resources:resources]; + EXPECT_NS_EQUAL(testExtension.errors, @[ ]); + + auto *icon = [testExtension actionIconForSize:CGSizeMake(64, 64)]; + EXPECT_NOT_NULL(icon); + EXPECT_TRUE(CGSizeEqualToSize(icon.size, CGSizeMake(64, 64))); +} + +TEST(WKWebExtension, ActionNoIconVariants) +{ + auto *testManifestDictionary = @{ + @"manifest_version": @3, + + @"name": @"Test Action No Variants", + @"version": @"1.0", + @"description": @"Test action with no icon variants" + }; + + auto testExtension = [[WKWebExtension alloc] _initWithManifestDictionary:testManifestDictionary resources:@{ }]; + EXPECT_NS_EQUAL(testExtension.errors, @[ ]); + + auto *icon = [testExtension actionIconForSize:CGSizeMake(32, 32)]; + EXPECT_NULL(icon); +} + +TEST(WKWebExtension, ActionIconsAndIconVariantsSpecified) +{ + auto *testManifestDictionary = @{ + @"manifest_version": @3, + + @"name": @"Test Action Icons and Variants", + @"version": @"1.0", + @"description": @"Test action with both icons and icon variants specified", + + @"action": @{ + @"default_icon": @{ + @"32": @"action-icon-legacy.png" + }, + @"icon_variants": @[ + @{ @"32": @"action-icon-variant.png" } + ] + } + }; + + auto *iconLegacy = Util::makePNGData(CGSizeMake(32, 32), @selector(blackColor)); + auto *iconVariant = Util::makePNGData(CGSizeMake(32, 32), @selector(whiteColor)); + + auto *resources = @{ + @"action-icon-legacy.png": iconLegacy, + @"action-icon-variant.png": iconVariant, + }; + + auto testExtension = [[WKWebExtension alloc] _initWithManifestDictionary:testManifestDictionary resources:resources]; + EXPECT_NS_EQUAL(testExtension.errors, @[ ]); + + auto *icon = [testExtension actionIconForSize:CGSizeMake(32, 32)]; + EXPECT_NOT_NULL(icon); + EXPECT_TRUE(CGSizeEqualToSize(icon.size, CGSizeMake(32, 32))); + EXPECT_TRUE(Util::compareColors(Util::pixelColor(icon), [CocoaColor whiteColor])); +} +#endif // ENABLE(WK_WEB_EXTENSIONS_ICON_VARIANTS) + TEST(WKWebExtension, ActionParsing) { NSDictionary *testManifestDictionary = @{ @"manifest_version": @2, @"name": @"Test", @"description": @"Test", @"version": @"1.0" }; diff --git a/Tools/TestWebKitAPI/Tests/WebKitCocoa/WKWebExtensionAPIAction.mm b/Tools/TestWebKitAPI/Tests/WebKitCocoa/WKWebExtensionAPIAction.mm index 9960cb1388a5b..25556b8c23d30 100644 --- a/Tools/TestWebKitAPI/Tests/WebKitCocoa/WKWebExtensionAPIAction.mm +++ b/Tools/TestWebKitAPI/Tests/WebKitCocoa/WKWebExtensionAPIAction.mm @@ -158,11 +158,7 @@ auto *smallIcon = [action iconForSize:CGSizeMake(16, 16)]; EXPECT_NOT_NULL(smallIcon); -#if USE(APPKIT) EXPECT_TRUE(CGSizeEqualToSize(smallIcon.size, CGSizeMake(16, 16))); -#else - EXPECT_TRUE(CGSizeEqualToSize(smallIcon.size, CGSizeMake(32, 32))); -#endif auto *largeIcon = [action iconForSize:CGSizeMake(32, 32)]; EXPECT_NOT_NULL(largeIcon); @@ -680,18 +676,10 @@ auto *icon128 = [action iconForSize:CGSizeMake(128, 128)]; EXPECT_NOT_NULL(icon48); -#if USE(APPKIT) EXPECT_TRUE(CGSizeEqualToSize(icon48.size, CGSizeMake(48, 48))); -#else - EXPECT_TRUE(CGSizeEqualToSize(icon48.size, CGSizeMake(128, 128))); -#endif EXPECT_NOT_NULL(icon96); -#if USE(APPKIT) EXPECT_TRUE(CGSizeEqualToSize(icon96.size, CGSizeMake(96, 96))); -#else - EXPECT_TRUE(CGSizeEqualToSize(icon96.size, CGSizeMake(128, 128))); -#endif EXPECT_NOT_NULL(icon128); EXPECT_TRUE(CGSizeEqualToSize(icon128.size, CGSizeMake(128, 128))); @@ -755,18 +743,10 @@ auto *icon128 = [action iconForSize:CGSizeMake(128, 128)]; EXPECT_NOT_NULL(icon48); -#if USE(APPKIT) EXPECT_TRUE(CGSizeEqualToSize(icon48.size, CGSizeMake(48, 48))); -#else - EXPECT_TRUE(CGSizeEqualToSize(icon48.size, CGSizeMake(128, 128))); -#endif EXPECT_NOT_NULL(icon96); -#if USE(APPKIT) EXPECT_TRUE(CGSizeEqualToSize(icon96.size, CGSizeMake(96, 96))); -#else - EXPECT_TRUE(CGSizeEqualToSize(icon96.size, CGSizeMake(128, 128))); -#endif EXPECT_NOT_NULL(icon128); EXPECT_TRUE(CGSizeEqualToSize(icon128.size, CGSizeMake(128, 128))); @@ -844,18 +824,10 @@ auto *icon128 = [action iconForSize:CGSizeMake(128, 128)]; EXPECT_NOT_NULL(icon48); -#if USE(APPKIT) EXPECT_TRUE(CGSizeEqualToSize(icon48.size, CGSizeMake(48, 48))); -#else - EXPECT_TRUE(CGSizeEqualToSize(icon48.size, CGSizeMake(128, 128))); -#endif EXPECT_NOT_NULL(icon96); -#if USE(APPKIT) EXPECT_TRUE(CGSizeEqualToSize(icon96.size, CGSizeMake(96, 96))); -#else - EXPECT_TRUE(CGSizeEqualToSize(icon96.size, CGSizeMake(128, 128))); -#endif EXPECT_NOT_NULL(icon128); EXPECT_TRUE(CGSizeEqualToSize(icon128.size, CGSizeMake(128, 128))); @@ -942,11 +914,7 @@ manager.get().internalDelegate.presentPopupForAction = ^(WKWebExtensionAction *action) { auto *icon48 = [action iconForSize:CGSizeMake(48, 48)]; EXPECT_NOT_NULL(icon48); -#if USE(APPKIT) EXPECT_TRUE(CGSizeEqualToSize(icon48.size, CGSizeMake(48, 48))); -#else - EXPECT_TRUE(CGSizeEqualToSize(icon48.size, CGSizeMake(96, 96))); -#endif auto *icon96 = [action iconForSize:CGSizeMake(96, 96)]; EXPECT_NOT_NULL(icon96); diff --git a/Tools/TestWebKitAPI/cocoa/WebExtensionUtilities.h b/Tools/TestWebKitAPI/cocoa/WebExtensionUtilities.h index 9e5fe2f5d91fb..84256e04801bf 100644 --- a/Tools/TestWebKitAPI/cocoa/WebExtensionUtilities.h +++ b/Tools/TestWebKitAPI/cocoa/WebExtensionUtilities.h @@ -32,6 +32,16 @@ #include "Utilities.h" #include "WTFTestUtilities.h" +#if USE(APPKIT) +OBJC_CLASS NSImage; +using CocoaImage = NSImage; +using CocoaColor = NSColor; +#else +OBJC_CLASS UIImage; +using CocoaImage = UIImage; +using CocoaColor = UIColor; +#endif + #ifdef __OBJC__ @class TestWebExtensionTab; @@ -147,6 +157,14 @@ NSData *makePNGData(CGSize, SEL colorSelector); void runScriptWithUserGesture(const String&, WKWebView *); +enum class Appearance { Light, Dark }; + +void performWithAppearance(Appearance, void (^block)(void)); + +CocoaColor *pixelColor(CocoaImage *, CGPoint = CGPointZero); +CocoaColor *toSRGBColor(CocoaColor *); +bool compareColors(CocoaColor *, CocoaColor *); + #endif RetainPtr loadAndRunExtension(WKWebExtension *, WKWebExtensionControllerConfiguration * = nil); diff --git a/Tools/TestWebKitAPI/cocoa/WebExtensionUtilities.mm b/Tools/TestWebKitAPI/cocoa/WebExtensionUtilities.mm index c6f44a90d46cf..7df0104a7387c 100644 --- a/Tools/TestWebKitAPI/cocoa/WebExtensionUtilities.mm +++ b/Tools/TestWebKitAPI/cocoa/WebExtensionUtilities.mm @@ -929,6 +929,73 @@ void runScriptWithUserGesture(const String& script, WKWebView *webView) TestWebKitAPI::Util::run(&callbackComplete); } +void performWithAppearance(Appearance appearance, void (^block)(void)) +{ +#if USE(APPKIT) + auto *appearanceName = appearance == Appearance::Dark ? NSAppearanceNameDarkAqua : NSAppearanceNameAqua; + [[NSAppearance appearanceNamed:appearanceName] performAsCurrentDrawingAppearance:block]; +#else + auto *traitCollection = appearance == Appearance::Dark ? [UITraitCollection traitCollectionWithUserInterfaceStyle:UIUserInterfaceStyleDark] + : [UITraitCollection traitCollectionWithUserInterfaceStyle:UIUserInterfaceStyleLight]; + [traitCollection performAsCurrentTraitCollection:block]; +#endif +} + +CocoaColor *pixelColor(CocoaImage *image, CGPoint point) +{ +#if USE(APPKIT) + auto imageRef = [image CGImageForProposedRect:nullptr context:nil hints:nil]; + auto *bitmap = [[NSBitmapImageRep alloc] initWithCGImage:imageRef]; + auto *color = [bitmap colorAtX:point.x y:point.y]; + return color; +#else + UIGraphicsBeginImageContext(image.size); + + [image drawAtPoint:CGPointZero]; + + CGContextRef context = UIGraphicsGetCurrentContext(); + unsigned char *data = (unsigned char *)CGBitmapContextGetData(context); + if (!data) + return nil; + + unsigned offset = ((image.size.width * point.y) + point.x) * 4; + UIColor *color = [UIColor colorWithRed:data[offset] / 255.0 green:data[offset + 1] / 255.0 blue:data[offset + 2] / 255.0 alpha:data[offset + 3] / 255.0]; + + UIGraphicsEndImageContext(); + + return color; +#endif +} + +CocoaColor *toSRGBColor(CocoaColor *color) +{ +#if USE(APPKIT) + return [color colorUsingColorSpace:NSColorSpace.sRGBColorSpace]; +#else + return color; +#endif +} + +bool compareColors(CocoaColor *color1, CocoaColor *color2) +{ + if (color1 == color2 || [color1 isEqual:color2]) + return true; + + if (!color1 || !color2) + return false; + + color1 = toSRGBColor(color1); + color2 = toSRGBColor(color2); + + CGFloat red1, green1, blue1, alpha1; + [color1 getRed:&red1 green:&green1 blue:&blue1 alpha:&alpha1]; + + CGFloat red2, green2, blue2, alpha2; + [color2 getRed:&red2 green:&green2 blue:&blue2 alpha:&alpha2]; + + return fabs(red1 - red2) < 0.01 && fabs(green1 - green2) < 0.01 && fabs(blue1 - blue2) < 0.01 && fabs(alpha1 - alpha2) < 0.01; +} + } // namespace Util } // namespace TestWebKitAPI