diff --git a/Source/WebKit/Shared/Extensions/WebExtensionMenuItem.serialization.in b/Source/WebKit/Shared/Extensions/WebExtensionMenuItem.serialization.in index e653540e3b17c..f26aa96f19b14 100644 --- a/Source/WebKit/Shared/Extensions/WebExtensionMenuItem.serialization.in +++ b/Source/WebKit/Shared/Extensions/WebExtensionMenuItem.serialization.in @@ -31,7 +31,7 @@ struct WebKit::WebExtensionMenuItemParameters { String title; String command; - String iconDictionaryJSON; + String iconsJSON; std::optional checked; std::optional enabled; diff --git a/Source/WebKit/Shared/Extensions/WebExtensionMenuItemParameters.h b/Source/WebKit/Shared/Extensions/WebExtensionMenuItemParameters.h index ef3f0470a5fca..1001ced0732c5 100644 --- a/Source/WebKit/Shared/Extensions/WebExtensionMenuItemParameters.h +++ b/Source/WebKit/Shared/Extensions/WebExtensionMenuItemParameters.h @@ -44,7 +44,7 @@ struct WebExtensionMenuItemParameters { String title; String command; - String iconDictionaryJSON; + String iconsJSON; std::optional checked; std::optional enabled; diff --git a/Source/WebKit/UIProcess/Extensions/Cocoa/API/WebExtensionContextAPIActionCocoa.mm b/Source/WebKit/UIProcess/Extensions/Cocoa/API/WebExtensionContextAPIActionCocoa.mm index 17345284abc98..a53afdecb7c08 100644 --- a/Source/WebKit/UIProcess/Extensions/Cocoa/API/WebExtensionContextAPIActionCocoa.mm +++ b/Source/WebKit/UIProcess/Extensions/Cocoa/API/WebExtensionContextAPIActionCocoa.mm @@ -114,7 +114,7 @@ completionHandler({ }); } -void WebExtensionContext::actionSetIcon(std::optional windowIdentifier, std::optional tabIdentifier, const String& iconDictionaryJSON, CompletionHandler&&)>&& completionHandler) +void WebExtensionContext::actionSetIcon(std::optional windowIdentifier, std::optional tabIdentifier, const String& iconsJSON, CompletionHandler&&)>&& completionHandler) { static NSString * const apiName = @"action.setIcon()"; @@ -124,7 +124,19 @@ return; } - action.value()->setIconsDictionary(parseJSON(iconDictionaryJSON)); + id parsedIcons = parseJSON(iconsJSON, JSONOptions::FragmentsAllowed); + if (auto *dictionary = dynamic_objc_cast(parsedIcons)) + action.value()->setIcons(dictionary); +#if ENABLE(WK_WEB_EXTENSIONS_ICON_VARIANTS) + else if (auto *array = dynamic_objc_cast(parsedIcons)) + action.value()->setIconVariants(array); +#endif + else { + action.value()->setIcons(nil); +#if ENABLE(WK_WEB_EXTENSIONS_ICON_VARIANTS) + action.value()->setIconVariants(nil); +#endif + } completionHandler({ }); } diff --git a/Source/WebKit/UIProcess/Extensions/Cocoa/WebExtensionActionCocoa.mm b/Source/WebKit/UIProcess/Extensions/Cocoa/WebExtensionActionCocoa.mm index c481e509f5f44..e3e7431997a9b 100644 --- a/Source/WebKit/UIProcess/Extensions/Cocoa/WebExtensionActionCocoa.mm +++ b/Source/WebKit/UIProcess/Extensions/Cocoa/WebExtensionActionCocoa.mm @@ -591,10 +591,17 @@ - (void)_otherPopoverWillShow:(NSNotification *)notification void WebExtensionAction::clearCustomizations() { +#if ENABLE(WK_WEB_EXTENSIONS_ICON_VARIANTS) + if (!m_customIcons && !m_customIconVariants && m_customPopupPath.isNull() && m_customLabel.isNull() && m_customBadgeText.isNull() && !m_customEnabled && !m_blockedResourceCount) +#else if (!m_customIcons && m_customPopupPath.isNull() && m_customLabel.isNull() && m_customBadgeText.isNull() && !m_customEnabled && !m_blockedResourceCount) +#endif return; m_customIcons = nil; +#if ENABLE(WK_WEB_EXTENSIONS_ICON_VARIANTS) + m_customIconVariants = nil; +#endif m_customPopupPath = nullString(); m_customLabel = nullString(); m_customBadgeText = nullString(); @@ -606,6 +613,7 @@ - (void)_otherPopoverWillShow:(NSNotification *)notification m_popoverAppearance = Appearance::Default; #endif + clearIconCache(); propertiesDidChange(); } @@ -645,15 +653,46 @@ - (void)_otherPopoverWillShow:(NSNotification *)notification if (!extensionContext()) return nil; - if (m_customIcons) { - auto *result = extensionContext()->extension().bestImageInIconsDictionary(m_customIcons.get(), idealSize, [&](auto *error) { - extensionContext()->recordError(error); - }); +#if ENABLE(WK_WEB_EXTENSIONS_ICON_VARIANTS) + if (m_customIconVariants || m_customIcons) +#else + if (m_customIcons) +#endif + { + // Clear the cache if the display scales change (connecting display, etc.) + auto *currentScales = availableScreenScales(); + if (![currentScales isEqualToSet:m_cachedIconScales.get()]) + clearIconCache(); + + if (m_cachedIcon && CGSizeEqualToSize(idealSize, m_cachedIconIdealSize)) + return m_cachedIcon.get(); + + CocoaImage *result; + +#if ENABLE(WK_WEB_EXTENSIONS_ICON_VARIANTS) + if (m_customIconVariants) { + result = extensionContext()->extension().bestImageForIconVariants(m_customIconVariants.get(), idealSize, [&](auto *error) { + extensionContext()->recordError(error); + }); + } else +#endif // ENABLE(WK_WEB_EXTENSIONS_ICON_VARIANTS) + if (m_customIcons) { + result = extensionContext()->extension().bestImageInIconsDictionary(m_customIcons.get(), idealSize, [&](auto *error) { + extensionContext()->recordError(error); + }); + } + + if (result) { + m_cachedIcon = result; + m_cachedIconScales = currentScales; + m_cachedIconIdealSize = idealSize; - if (result) return result; + } + + clearIconCache(); - // If custom icons fail, fallback to the default icons. + // If custom icons fail, fallback. } if (RefPtr fallback = fallbackAction()) @@ -663,16 +702,41 @@ - (void)_otherPopoverWillShow:(NSNotification *)notification return extensionContext()->extension().actionIcon(idealSize); } -void WebExtensionAction::setIconsDictionary(NSDictionary *icons) +void WebExtensionAction::setIcons(NSDictionary *icons) { if ([(m_customIcons ?: @{ }) isEqualToDictionary:(icons ?: @{ })]) return; m_customIcons = icons.count ? icons : nil; +#if ENABLE(WK_WEB_EXTENSIONS_ICON_VARIANTS) + m_customIconVariants = nil; +#endif + clearIconCache(); propertiesDidChange(); } +#if ENABLE(WK_WEB_EXTENSIONS_ICON_VARIANTS) +void WebExtensionAction::setIconVariants(NSArray *iconVariants) +{ + if ([(m_customIconVariants ?: @[ ]) isEqualToArray:(iconVariants ?: @[ ])]) + return; + + m_customIconVariants = iconVariants.count ? iconVariants : nil; + m_customIcons = nil; + + clearIconCache(); + propertiesDidChange(); +} +#endif // ENABLE(WK_WEB_EXTENSIONS_ICON_VARIANTS) + +void WebExtensionAction::clearIconCache() +{ + m_cachedIcon = nil; + m_cachedIconScales = nil; + m_cachedIconIdealSize = CGSizeZero; +} + String WebExtensionAction::popupPath() const { if (!extensionContext()) diff --git a/Source/WebKit/UIProcess/Extensions/Cocoa/WebExtensionMenuItemCocoa.mm b/Source/WebKit/UIProcess/Extensions/Cocoa/WebExtensionMenuItemCocoa.mm index 2cd1aad33a191..8eaf876921911 100644 --- a/Source/WebKit/UIProcess/Extensions/Cocoa/WebExtensionMenuItemCocoa.mm +++ b/Source/WebKit/UIProcess/Extensions/Cocoa/WebExtensionMenuItemCocoa.mm @@ -107,8 +107,14 @@ - (IBAction)_performAction:(id)sender else m_contexts = WebExtensionMenuItemContextType::Page; - if (!parameters.iconDictionaryJSON.isEmpty()) - m_icons = parseJSON(parameters.iconDictionaryJSON); + if (!parameters.iconsJSON.isEmpty()) { + id parsedIcons = parseJSON(parameters.iconsJSON, JSONOptions::FragmentsAllowed); + m_icons = dynamic_objc_cast(parsedIcons); +#if ENABLE(WK_WEB_EXTENSIONS_ICON_VARIANTS) + m_iconVariants = dynamic_objc_cast(parsedIcons); +#endif + clearIconCache(); + } if (parameters.documentURLPatterns) { for (auto& patternString : parameters.documentURLPatterns.value()) { @@ -135,7 +141,7 @@ - (IBAction)_performAction:(id)sender nullString(), // title nullString(), // command - nullString(), // iconDictionaryJSON + nullString(), // iconsJSON isChecked(), isEnabled(), @@ -175,8 +181,14 @@ - (IBAction)_performAction:(id)sender if (!parameters.command.isNull()) m_command = extensionContext()->command(parameters.command); - if (!parameters.iconDictionaryJSON.isNull()) - m_icons = parseJSON(parameters.iconDictionaryJSON); + if (!parameters.iconsJSON.isNull()) { + id parsedIcons = parseJSON(parameters.iconsJSON, JSONOptions::FragmentsAllowed); + m_icons = dynamic_objc_cast(parsedIcons); +#if ENABLE(WK_WEB_EXTENSIONS_ICON_VARIANTS) + m_iconVariants = dynamic_objc_cast(parsedIcons); +#endif + clearIconCache(); + } if (parameters.checked) m_checked = parameters.checked.value(); @@ -331,9 +343,54 @@ - (IBAction)_performAction:(id)sender { ASSERT(extensionContext()); - return extensionContext()->extension().bestImageInIconsDictionary(m_icons.get(), idealSize, [&](auto *error) { - extensionContext()->recordError(error); - }); +#if ENABLE(WK_WEB_EXTENSIONS_ICON_VARIANTS) + if (!m_iconVariants && !m_icons) +#else + if (!m_icons) +#endif + return nil; + + // Clear the cache if the display scales change (connecting display, etc.) + auto *currentScales = availableScreenScales(); + if (![currentScales isEqualToSet:m_cachedIconScales.get()]) + clearIconCache(); + + if (m_cachedIcon && CGSizeEqualToSize(idealSize, m_cachedIconIdealSize)) + return m_cachedIcon.get(); + + CocoaImage *result; + +#if ENABLE(WK_WEB_EXTENSIONS_ICON_VARIANTS) + if (m_iconVariants) { + result = extensionContext()->extension().bestImageForIconVariants(m_iconVariants.get(), idealSize, [&](auto *error) { + extensionContext()->recordError(error); + }); + } else +#endif // ENABLE(WK_WEB_EXTENSIONS_ICON_VARIANTS) + if (m_icons) { + result = extensionContext()->extension().bestImageInIconsDictionary(m_icons.get(), idealSize, [&](auto *error) { + extensionContext()->recordError(error); + }); + } + + if (result) { + m_cachedIcon = result; + m_cachedIconIdealSize = idealSize; + m_cachedIconScales = currentScales; + + return result; + } + + clearIconCache(); + + return nil; +} + +void WebExtensionMenuItem::clearIconCache() const +{ + m_cachedIcon = nil; + m_cachedIconScales = nil; + m_cachedIconIdealSize = CGSizeZero; } } // namespace WebKit diff --git a/Source/WebKit/UIProcess/Extensions/WebExtensionAction.h b/Source/WebKit/UIProcess/Extensions/WebExtensionAction.h index ed89c8fafe212..e9a76eea108c7 100644 --- a/Source/WebKit/UIProcess/Extensions/WebExtensionAction.h +++ b/Source/WebKit/UIProcess/Extensions/WebExtensionAction.h @@ -87,7 +87,10 @@ class WebExtensionAction : public API::ObjectImpl m_cachedIcon; + RetainPtr m_cachedIconScales; + CGSize m_cachedIconIdealSize { CGSizeZero }; + RetainPtr m_customIcons; +#if ENABLE(WK_WEB_EXTENSIONS_ICON_VARIANTS) + RetainPtr m_customIconVariants; +#endif String m_customLabel; String m_customBadgeText; ssize_t m_blockedResourceCount { 0 }; diff --git a/Source/WebKit/UIProcess/Extensions/WebExtensionContext.h b/Source/WebKit/UIProcess/Extensions/WebExtensionContext.h index adae89e850a06..abda56eff5fc6 100644 --- a/Source/WebKit/UIProcess/Extensions/WebExtensionContext.h +++ b/Source/WebKit/UIProcess/Extensions/WebExtensionContext.h @@ -702,7 +702,7 @@ class WebExtensionContext : public API::ObjectImpl, std::optional, CompletionHandler&&)>&&); void actionSetTitle(std::optional, std::optional, const String& title, CompletionHandler&&)>&&); - void actionSetIcon(std::optional, std::optional, const String& iconDictionaryJSON, CompletionHandler&&)>&&); + void actionSetIcon(std::optional, std::optional, const String& iconsJSON, CompletionHandler&&)>&&); void actionGetPopup(std::optional, std::optional, CompletionHandler&&)>&&); void actionSetPopup(std::optional, std::optional, const String& popupPath, CompletionHandler&&)>&&); void actionOpenPopup(WebPageProxyIdentifier, std::optional, std::optional, CompletionHandler&&)>&&); diff --git a/Source/WebKit/UIProcess/Extensions/WebExtensionContext.messages.in b/Source/WebKit/UIProcess/Extensions/WebExtensionContext.messages.in index 88501072a0df5..f23d440278c67 100644 --- a/Source/WebKit/UIProcess/Extensions/WebExtensionContext.messages.in +++ b/Source/WebKit/UIProcess/Extensions/WebExtensionContext.messages.in @@ -29,7 +29,7 @@ messages -> WebExtensionContext { // Action APIs [EnabledIf='isActionMessageAllowed()'] ActionGetTitle(std::optional windowIdentifier, std::optional tabIdentifier) -> (Expected result) [EnabledIf='isActionMessageAllowed()'] ActionSetTitle(std::optional windowIdentifier, std::optional tabIdentifier, String title) -> (Expected result) - [EnabledIf='isActionMessageAllowed()'] ActionSetIcon(std::optional windowIdentifier, std::optional tabIdentifier, String iconDictionaryJSON) -> (Expected result) + [EnabledIf='isActionMessageAllowed()'] ActionSetIcon(std::optional windowIdentifier, std::optional tabIdentifier, String iconsJSON) -> (Expected result) [EnabledIf='isActionMessageAllowed()'] ActionGetPopup(std::optional windowIdentifier, std::optional tabIdentifier) -> (Expected result) [EnabledIf='isActionMessageAllowed()'] ActionSetPopup(std::optional windowIdentifier, std::optional tabIdentifier, String popupPath) -> (Expected result) [EnabledIf='isActionMessageAllowed()'] ActionOpenPopup(WebKit::WebPageProxyIdentifier identifier, std::optional windowIdentifier, std::optional tabIdentifier) -> (Expected result) @@ -126,7 +126,7 @@ messages -> WebExtensionContext { [EnabledIf='isSidebarMessageAllowed()'] SidebarSetOptions(std::optional windowIdentifier, std::optional tabIdentifier, std::optional panelSourcePath, std::optional enabled) -> (Expected result) [EnabledIf='isSidebarMessageAllowed()'] SidebarGetTitle(std::optional windowIdentifier, std::optional tabIdentifier) -> (Expected result) [EnabledIf='isSidebarMessageAllowed()'] SidebarSetTitle(std::optional windowIdentifier, std::optional tabIdentifier, std::optional title) -> (Expected result) - [EnabledIf='isSidebarMessageAllowed()'] SidebarSetIcon(std::optional windowIdentifier, std::optional tabIdentifier, String iconDictionaryJSON) -> (Expected result) + [EnabledIf='isSidebarMessageAllowed()'] SidebarSetIcon(std::optional windowIdentifier, std::optional tabIdentifier, String iconsJSON) -> (Expected result) #endif // ENABLE(WK_WEB_EXTENSIONS_SIDEBAR) // Storage APIs diff --git a/Source/WebKit/UIProcess/Extensions/WebExtensionMenuItem.h b/Source/WebKit/UIProcess/Extensions/WebExtensionMenuItem.h index edcdab8930fb6..29c49ed46e02f 100644 --- a/Source/WebKit/UIProcess/Extensions/WebExtensionMenuItem.h +++ b/Source/WebKit/UIProcess/Extensions/WebExtensionMenuItem.h @@ -122,6 +122,8 @@ class WebExtensionMenuItem : public RefCounted, public Can static String removeAmpersands(const String&); + void clearIconCache() const; + enum class ForceUnchecked : bool { No, Yes }; CocoaMenuItem *platformMenuItem(const WebExtensionMenuItemContextParameters&, ForceUnchecked = ForceUnchecked::No) const; @@ -132,7 +134,15 @@ class WebExtensionMenuItem : public RefCounted, public Can String m_title; RefPtr m_command; + + mutable RetainPtr m_cachedIcon; + mutable RetainPtr m_cachedIconScales; + mutable CGSize m_cachedIconIdealSize { CGSizeZero }; + RetainPtr m_icons; +#if ENABLE(WK_WEB_EXTENSIONS_ICON_VARIANTS) + RetainPtr m_iconVariants; +#endif bool m_checked : 1 { false }; bool m_enabled : 1 { true }; diff --git a/Source/WebKit/WebProcess/Extensions/API/Cocoa/WebExtensionAPIActionCocoa.mm b/Source/WebKit/WebProcess/Extensions/API/Cocoa/WebExtensionAPIActionCocoa.mm index e68aad220b031..44d9e0956326e 100644 --- a/Source/WebKit/WebProcess/Extensions/API/Cocoa/WebExtensionAPIActionCocoa.mm +++ b/Source/WebKit/WebProcess/Extensions/API/Cocoa/WebExtensionAPIActionCocoa.mm @@ -56,6 +56,14 @@ static NSString * const textKey = @"text"; static NSString * const titleKey = @"title"; +#if ENABLE(WK_WEB_EXTENSIONS_ICON_VARIANTS) +static NSString * const variantsKey = @"variants"; +static NSString * const colorSchemesKey = @"color_schemes"; +static NSString * const lightKey = @"light"; +static NSString * const darkKey = @"dark"; +static NSString * const anyKey = @"any"; +#endif + namespace WebKit { bool WebExtensionAPIAction::parseActionDetails(NSDictionary *details, std::optional& windowIdentifier, std::optional& tabIdentifier, NSString **outExceptionString) @@ -363,6 +371,11 @@ bool WebExtensionAPIAction::isValidDimensionKey(NSString *dimension) { +#if ENABLE(WK_WEB_EXTENSIONS_ICON_VARIANTS) + if ([dimension isEqualToString:anyKey]) + return true; +#endif + double value = dimension.doubleValue; if (!value) return false; @@ -379,9 +392,129 @@ return true; } +NSString *WebExtensionAPIAction::parseIconPath(NSString *path, const URL& baseURL) +{ + // Resolve paths as relative against the base URL, unless it is a data URL. + if (![path hasPrefix:@"data:"]) + path = URL { baseURL, path }.path().toString(); + return path; +} + +NSMutableDictionary *WebExtensionAPIAction::parseIconPathsDictionary(NSDictionary *input, const URL& baseURL, bool forVariants, NSString *inputKey, NSString **outExceptionString) +{ + auto *result = [NSMutableDictionary dictionaryWithCapacity:input.count]; + + for (NSString *key in input) { +#if ENABLE(WK_WEB_EXTENSIONS_ICON_VARIANTS) + if (forVariants && [key isEqualToString:colorSchemesKey]) + continue; +#endif + + if (!isValidDimensionKey(key)) { + if (outExceptionString) + *outExceptionString = toErrorString(nullptr, inputKey, @"'%@' is not a valid dimension", key); + return nil; + } + + NSString *path = input[key]; + if (!validateObject(path, [NSString stringWithFormat:@"%@[%@]", inputKey, key], NSString.class, outExceptionString)) + return nil; + + result[key] = parseIconPath(path, baseURL); + } + + return result; +} + +NSMutableDictionary *WebExtensionAPIAction::parseIconImageDataDictionary(NSDictionary *input, bool forVariants, NSString *inputKey, NSString **outExceptionString) +{ + auto *result = [NSMutableDictionary dictionaryWithCapacity:input.count]; + + for (NSString *key in input) { +#if ENABLE(WK_WEB_EXTENSIONS_ICON_VARIANTS) + if (forVariants && [key isEqualToString:colorSchemesKey]) + continue; +#endif + + if (!isValidDimensionKey(key)) { + if (outExceptionString) + *outExceptionString = toErrorString(nullptr, inputKey, @"'%@' is not a valid dimension", key); + return nil; + } + + id value = input[key]; + if (!validateObject(value, [NSString stringWithFormat:@"%@[%@]", inputKey, key], JSValue.class, outExceptionString)) + return nil; + + auto *dataURLString = dataURLFromImageData(value, nullptr, key, outExceptionString); + if (!dataURLString) + return nil; + + result[key] = dataURLString; + } + + return result; +} + +#if ENABLE(WK_WEB_EXTENSIONS_ICON_VARIANTS) +NSArray *WebExtensionAPIAction::parseIconVariants(NSArray *input, const URL& baseURL, NSString *inputKey, NSString **outExceptionString) +{ + auto *result = [NSMutableArray arrayWithCapacity:input.count]; + + NSString *firstExceptionString; + for (NSUInteger index = 0; index < input.count; ++index) { + NSDictionary *dictionary = input[index]; + auto *compositeKey = [NSString stringWithFormat:@"%@[%lu]", inputKey, index]; + + // Try parsing the variant as image data first. + auto *parsedDictionary = parseIconImageDataDictionary(dictionary, true, compositeKey, !firstExceptionString ? &firstExceptionString : nullptr); + + // If image data failed, try parsing as paths. + if (!parsedDictionary) + parsedDictionary = parseIconPathsDictionary(dictionary, baseURL, true, compositeKey, !firstExceptionString ? &firstExceptionString : nullptr); + + // If all types failed, continue. + if (!parsedDictionary) { + ASSERT(firstExceptionString); + continue; + } + + if (NSArray *colorSchemes = dictionary[colorSchemesKey]) { + auto *colorSchemesCompositeKey = [NSString stringWithFormat:@"%@['%@']", compositeKey, colorSchemesKey]; + if (!validateObject(colorSchemes, colorSchemesCompositeKey, @[ NSString.class ], !firstExceptionString ? &firstExceptionString : nullptr)) + continue; + + if (![colorSchemes containsObject:lightKey] && ![colorSchemes containsObject:darkKey]) { + if (!firstExceptionString) + firstExceptionString = toErrorString(nil, colorSchemesCompositeKey, @"it must specify either 'light' or 'dark'"); + continue; + } + + parsedDictionary[colorSchemesKey] = colorSchemes; + } + + ASSERT(parsedDictionary); + [result addObject:parsedDictionary]; + } + + if (input.count && !result.count) { + // An exception is only set if no valid icon variants were found, + // maintaining flexibility for future support of different inputs. + if (!firstExceptionString) + firstExceptionString = toErrorString(nil, inputKey, @"it didn't contain any valid icon variants"); + if (outExceptionString) + *outExceptionString = firstExceptionString; + return nil; + } + + return [result copy]; +} +#endif // ENABLE(WK_WEB_EXTENSIONS_ICON_VARIANTS) + void WebExtensionAPIAction::setIcon(WebFrame& frame, NSDictionary *details, Ref&& callback, NSString **outExceptionString) { // Documentation: https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/action/setIcon + // Icon Variants: https://github.com/w3c/webextensions/blob/main/proposals/dark_mode_extension_icons.md std::optional windowIdentifier; std::optional tabIdentifier; @@ -391,6 +524,9 @@ static NSDictionary *types = @{ pathKey: [NSOrderedSet orderedSetWithObjects:NSString.class, NSDictionary.class, NSNull.class, nil], imageDataKey: [NSOrderedSet orderedSetWithObjects:JSValue.class, NSDictionary.class, NSNull.class, nil], +#if ENABLE(WK_WEB_EXTENSIONS_ICON_VARIANTS) + variantsKey: [NSOrderedSet orderedSetWithObjects:@[ NSDictionary.class ], NSNull.class, nil], +#endif }; if (!validateDictionary(details, @"details", nil, types, outExceptionString)) @@ -413,59 +549,37 @@ } if (auto *images = objectForKey(details, imageDataKey)) { - auto *mutableIconDictionary = [NSMutableDictionary dictionaryWithCapacity:images.count]; - - for (NSString *key in images) { - if (!isValidDimensionKey(key)) { - *outExceptionString = toErrorString(nil, imageDataKey, @"'%@' in not a valid dimension", key); - return; - } - - if (!validateObject(images[key], [NSString stringWithFormat:@"%@[%@]", imageDataKey, key], JSValue.class, outExceptionString)) - return; - - JSValue *imageData = images[key]; - auto *dataURLString = dataURLFromImageData(imageData, nullptr, key, outExceptionString); - if (!dataURLString) - return; - - mutableIconDictionary[key] = dataURLString; - } - - iconDictionary = [mutableIconDictionary copy]; + iconDictionary = parseIconImageDataDictionary(images, false, imageDataKey, outExceptionString); + if (!iconDictionary) + return; } if (auto *path = objectForKey(details, pathKey)) { // Chrome documentation states that 'details.path = foo' is equivalent to 'details.path = { '16': foo }'. // Documentation: https://developer.chrome.com/docs/extensions/reference/action/#method-setIcon - iconDictionary = @{ @"16": path }; + iconDictionary = @{ @"16": parseIconPath(path, frame.url()) }; } if (auto *paths = objectForKey(details, pathKey)) { - for (NSString *key in paths) { - if (!isValidDimensionKey(key)) { - *outExceptionString = toErrorString(nil, pathKey, @"'%@' in not a valid dimension", key); - return; - } - - if (!validateObject(paths[key], [NSString stringWithFormat:@"%@[%@]", pathKey, key], NSString.class, outExceptionString)) - return; - } - - iconDictionary = paths; + iconDictionary = parseIconPathsDictionary(paths, frame.url(), false, pathKey, outExceptionString); + if (!iconDictionary) + return; } - // Resolve paths as relative against the frame's URL, unless it is a data URL. - // Documentation: https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/action/setIcon#path - iconDictionary = mapObjects(iconDictionary, ^(id key, NSString *path) { - if (![path hasPrefix:@"data:"]) - path = URL { frame.url(), path }.path().toString(); - return path; - }); +#if ENABLE(WK_WEB_EXTENSIONS_ICON_VARIANTS) + NSArray *iconVariants; + if (auto *variants = objectForKey(details, variantsKey)) { + iconVariants = parseIconVariants(variants, frame.url(), variantsKey, outExceptionString); + if (!iconVariants) + return; + } - auto *iconDictionaryJSON = encodeJSONString(iconDictionary); + auto *iconsJSON = encodeJSONString(iconVariants ?: iconDictionary, JSONOptions::FragmentsAllowed); +#else + auto *iconsJSON = encodeJSONString(iconDictionary); +#endif - WebProcess::singleton().sendWithAsyncReply(Messages::WebExtensionContext::ActionSetIcon(windowIdentifier, tabIdentifier, iconDictionaryJSON), [protectedThis = Ref { *this }, callback = WTFMove(callback)](Expected&& result) { + WebProcess::singleton().sendWithAsyncReply(Messages::WebExtensionContext::ActionSetIcon(windowIdentifier, tabIdentifier, iconsJSON), [protectedThis = Ref { *this }, callback = WTFMove(callback)](Expected&& result) { if (!result) { callback->reportError(result.error()); return; diff --git a/Source/WebKit/WebProcess/Extensions/API/Cocoa/WebExtensionAPIMenusCocoa.mm b/Source/WebKit/WebProcess/Extensions/API/Cocoa/WebExtensionAPIMenusCocoa.mm index 78d05771f513b..b14565e47683f 100644 --- a/Source/WebKit/WebProcess/Extensions/API/Cocoa/WebExtensionAPIMenusCocoa.mm +++ b/Source/WebKit/WebProcess/Extensions/API/Cocoa/WebExtensionAPIMenusCocoa.mm @@ -61,6 +61,10 @@ static NSString * const typeKey = @"type"; static NSString * const visibleKey = @"visible"; +#if ENABLE(WK_WEB_EXTENSIONS_ICON_VARIANTS) +static NSString * const iconVariantsKey = @"icon_variants"; +#endif + static NSString * const normalKey = @"normal"; static NSString * const checkboxKey = @"checkbox"; static NSString * const radioKey = @"radio"; @@ -96,7 +100,7 @@ static id toMenuIdentifierWebAPI(const String& identifier) return identifier; } -bool WebExtensionAPIMenus::parseCreateAndUpdateProperties(ForUpdate forUpdate, NSDictionary *properties, std::optional& outParameters, RefPtr& outClickCallback, NSString **outExceptionString) +bool WebExtensionAPIMenus::parseCreateAndUpdateProperties(ForUpdate forUpdate, NSDictionary *properties, const URL& baseURL, std::optional& outParameters, RefPtr& outClickCallback, NSString **outExceptionString) { static NSArray *requiredKeys = @[ titleKey, @@ -108,7 +112,10 @@ static id toMenuIdentifierWebAPI(const String& identifier) contextsKey: @[ NSString.class ], documentURLPatternsKey: @[ NSString.class ], enabledKey: @YES.class, - iconsKey: [NSOrderedSet orderedSetWithObjects:NSString.class, NSDictionary.class, nil], + iconsKey: [NSOrderedSet orderedSetWithObjects:NSString.class, NSDictionary.class, NSNull.class, nil], +#if ENABLE(WK_WEB_EXTENSIONS_ICON_VARIANTS) + iconVariantsKey: [NSOrderedSet orderedSetWithObjects:@[ NSDictionary.class ], NSNull.class, nil], +#endif idKey: [NSOrderedSet orderedSetWithObjects:NSString.class, NSNumber.class, nil], onclickKey: JSValue.class, parentIdKey: [NSOrderedSet orderedSetWithObjects:NSString.class, NSNumber.class, nil], @@ -248,25 +255,39 @@ static id toMenuIdentifierWebAPI(const String& identifier) NSDictionary *iconDictionary; - if (NSString *iconPath = objectForKey(properties, iconsKey)) - iconDictionary = @{ @"16": iconPath }; + if (auto *iconPath = objectForKey(properties, iconsKey)) + iconDictionary = @{ @"16": WebExtensionAPIAction::parseIconPath(iconPath, baseURL) }; - if (NSDictionary *iconPaths = objectForKey(properties, iconsKey)) { - for (NSString *key in iconPaths) { - if (!WebExtensionAPIAction::isValidDimensionKey(key)) { - *outExceptionString = toErrorString(nil, iconsKey, @"'%@' in not a valid dimension", key); - return false; - } - - if (!validateObject(iconPaths[key], [NSString stringWithFormat:@"%@[%@]", iconsKey, key], NSString.class, outExceptionString)) - return false; - } + if (auto *iconPaths = objectForKey(properties, iconsKey)) { + iconDictionary = WebExtensionAPIAction::parseIconPathsDictionary(iconPaths, baseURL, false, iconsKey, outExceptionString); + if (!iconDictionary) + return false; + } - iconDictionary = iconPaths; +#if ENABLE(WK_WEB_EXTENSIONS_ICON_VARIANTS) + NSArray *iconVariants; + if (auto *variants = objectForKey(properties, iconVariantsKey, false)) { + iconVariants = WebExtensionAPIAction::parseIconVariants(variants, baseURL, iconVariantsKey, outExceptionString); + if (!iconVariants) + return false; } + // Icon variants takes precedence over the old icons key, even if empty. + if (iconVariants || iconDictionary.count) + parameters.iconsJSON = encodeJSONString(iconVariants ?: iconDictionary, JSONOptions::FragmentsAllowed); +#else if (iconDictionary.count) - parameters.iconDictionaryJSON = encodeJSONString(iconDictionary); + parameters.iconsJSON = encodeJSONString(iconDictionary); +#endif + + // An explicit null icon variants or icons will clear the current icon. +#if ENABLE(WK_WEB_EXTENSIONS_ICON_VARIANTS) + if (properties[iconVariantsKey] && objectForKey(properties, iconVariantsKey)) + parameters.iconsJSON = emptyString(); + else +#endif + if (properties[iconsKey] && objectForKey(properties, iconsKey)) + parameters.iconsJSON = emptyString(); if (NSString *command = properties[commandKey]) { if (!command.length) { @@ -291,7 +312,7 @@ static id toMenuIdentifierWebAPI(const String& identifier) return true; } -id WebExtensionAPIMenus::createMenu(WebPage& page, NSDictionary *properties, Ref&& callback, NSString **outExceptionString) +id WebExtensionAPIMenus::createMenu(WebPage& page, WebFrame& frame, NSDictionary *properties, Ref&& callback, NSString **outExceptionString) { // Documentation: https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/menus/create @@ -299,7 +320,7 @@ static id toMenuIdentifierWebAPI(const String& identifier) std::optional parameters; RefPtr clickCallback; - if (!parseCreateAndUpdateProperties(ForUpdate::No, properties, parameters, clickCallback, outExceptionString)) + if (!parseCreateAndUpdateProperties(ForUpdate::No, properties, frame.url(), parameters, clickCallback, outExceptionString)) return nil; if (parameters.value().identifier.isEmpty()) @@ -324,7 +345,7 @@ static id toMenuIdentifierWebAPI(const String& identifier) return toMenuIdentifierWebAPI(parameters.value().identifier); } -void WebExtensionAPIMenus::update(WebPage& page, id identifier, NSDictionary *properties, Ref&& callback, NSString **outExceptionString) +void WebExtensionAPIMenus::update(WebPage& page, WebFrame& frame, id identifier, NSDictionary *properties, Ref&& callback, NSString **outExceptionString) { // Documentation: https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/menus/update @@ -335,7 +356,7 @@ static id toMenuIdentifierWebAPI(const String& identifier) std::optional parameters; RefPtr clickCallback; - if (!parseCreateAndUpdateProperties(ForUpdate::Yes, properties, parameters, clickCallback, outExceptionString)) + if (!parseCreateAndUpdateProperties(ForUpdate::Yes, properties, frame.url(), parameters, clickCallback, outExceptionString)) return; if (NSNumber *identifierNumber = dynamic_objc_cast(identifier)) diff --git a/Source/WebKit/WebProcess/Extensions/API/WebExtensionAPIAction.h b/Source/WebKit/WebProcess/Extensions/API/WebExtensionAPIAction.h index 0abacc3191711..bb218c16d4269 100644 --- a/Source/WebKit/WebProcess/Extensions/API/WebExtensionAPIAction.h +++ b/Source/WebKit/WebProcess/Extensions/API/WebExtensionAPIAction.h @@ -66,6 +66,14 @@ class WebExtensionAPIAction : public WebExtensionAPIObject, public JSWebExtensio friend class WebExtensionAPIMenus; static bool isValidDimensionKey(NSString *); + static NSString *parseIconPath(NSString *path, const URL& baseURL); + static NSMutableDictionary *parseIconPathsDictionary(NSDictionary *, const URL& baseURL, bool forVariants, NSString *inputKey, NSString **outExceptionString); + static NSMutableDictionary *parseIconImageDataDictionary(NSDictionary *, bool forVariants, NSString *inputKey, NSString **outExceptionString); + +#if ENABLE(WK_WEB_EXTENSIONS_ICON_VARIANTS) + static NSArray *parseIconVariants(NSArray *, const URL& baseURL, NSString *inputKey, NSString **outExceptionString); +#endif + static bool parseActionDetails(NSDictionary *, std::optional&, std::optional&, NSString **outExceptionString); RefPtr m_onClicked; diff --git a/Source/WebKit/WebProcess/Extensions/API/WebExtensionAPIMenus.h b/Source/WebKit/WebProcess/Extensions/API/WebExtensionAPIMenus.h index ec8def393ab09..ab7da1b710c1c 100644 --- a/Source/WebKit/WebProcess/Extensions/API/WebExtensionAPIMenus.h +++ b/Source/WebKit/WebProcess/Extensions/API/WebExtensionAPIMenus.h @@ -44,8 +44,8 @@ class WebExtensionAPIMenus : public WebExtensionAPIObject, public JSWebExtension #if PLATFORM(COCOA) using ClickHandlerMap = HashMap>; - id createMenu(WebPage&, NSDictionary *properties, Ref&&, NSString **outExceptionString); - void update(WebPage&, id identifier, NSDictionary *properties, Ref&&, NSString **outExceptionString); + id createMenu(WebPage&, WebFrame&, NSDictionary *properties, Ref&&, NSString **outExceptionString); + void update(WebPage&, WebFrame&, id identifier, NSDictionary *properties, Ref&&, NSString **outExceptionString); void remove(id identifier, Ref&&, NSString **outExceptionString); void removeAll(Ref&&); @@ -58,7 +58,7 @@ class WebExtensionAPIMenus : public WebExtensionAPIObject, public JSWebExtension private: enum class ForUpdate : bool { No, Yes }; - bool parseCreateAndUpdateProperties(ForUpdate, NSDictionary *, std::optional&, RefPtr&, NSString **outExceptionString); + bool parseCreateAndUpdateProperties(ForUpdate, NSDictionary *, const URL& baseURL, std::optional&, RefPtr&, NSString **outExceptionString); WebPageProxyIdentifier m_pageProxyIdentifier; RefPtr m_onClicked; diff --git a/Source/WebKit/WebProcess/Extensions/Bindings/Scripts/CodeGeneratorExtensions.pm b/Source/WebKit/WebProcess/Extensions/Bindings/Scripts/CodeGeneratorExtensions.pm index c7def0f091e80..70108d63334e5 100644 --- a/Source/WebKit/WebProcess/Extensions/Bindings/Scripts/CodeGeneratorExtensions.pm +++ b/Source/WebKit/WebProcess/Extensions/Bindings/Scripts/CodeGeneratorExtensions.pm @@ -678,12 +678,12 @@ EOF unshift(@methodSignatureNames, "context") if $needsScriptContext; unshift(@parameters, "context") if $needsScriptContext; - unshift(@methodSignatureNames, "page") if $needsPage; - unshift(@parameters, "*page") if $needsPage; - unshift(@methodSignatureNames, "frame") if $needsFrame; unshift(@parameters, "*frame") if $needsFrame; + unshift(@methodSignatureNames, "page") if $needsPage; + unshift(@parameters, "*page") if $needsPage; + push(@methodSignatureNames, "outExceptionString") if $needsExceptionString; push(@parameters, "&exceptionString") if $needsExceptionString; diff --git a/Source/WebKit/WebProcess/Extensions/Interfaces/WebExtensionAPIMenus.idl b/Source/WebKit/WebProcess/Extensions/Interfaces/WebExtensionAPIMenus.idl index d30394b36e01f..0531a57b37e8e 100644 --- a/Source/WebKit/WebProcess/Extensions/Interfaces/WebExtensionAPIMenus.idl +++ b/Source/WebKit/WebProcess/Extensions/Interfaces/WebExtensionAPIMenus.idl @@ -28,8 +28,8 @@ MainWorldOnly, ] interface WebExtensionAPIMenus { - [RaisesException, ImplementedAs=createMenu, NeedsPage] any create([NSDictionary] any properties, [Optional, CallbackHandler] function callback); - [RaisesException, ReturnsPromiseWhenCallbackIsOmitted, NeedsPage] void update([NSObject] any identifier, [NSDictionary] any properties, [Optional, CallbackHandler] function callback); + [RaisesException, ImplementedAs=createMenu, NeedsPage, NeedsFrame] any create([NSDictionary=NullAllowed] any properties, [Optional, CallbackHandler] function callback); + [RaisesException, ReturnsPromiseWhenCallbackIsOmitted, NeedsPage, NeedsFrame] void update([NSObject] any identifier, [NSDictionary=NullAllowed] any properties, [Optional, CallbackHandler] function callback); [RaisesException, ReturnsPromiseWhenCallbackIsOmitted] void remove([NSObject] any identifier, [Optional, CallbackHandler] function callback); [ReturnsPromiseWhenCallbackIsOmitted] void removeAll([Optional, CallbackHandler] function callback); diff --git a/Tools/TestWebKitAPI/Tests/WebKitCocoa/WKWebExtensionAPIAction.mm b/Tools/TestWebKitAPI/Tests/WebKitCocoa/WKWebExtensionAPIAction.mm index 25556b8c23d30..aa71511ef6950 100644 --- a/Tools/TestWebKitAPI/Tests/WebKitCocoa/WKWebExtensionAPIAction.mm +++ b/Tools/TestWebKitAPI/Tests/WebKitCocoa/WKWebExtensionAPIAction.mm @@ -838,7 +838,7 @@ [manager loadAndRun]; } -TEST(WKWebExtensionAPIAction, SetIconWithSVGDataURL) +TEST(WKWebExtensionAPIAction, SetIconWithDataURL) { auto *backgroundScript = Util::constructScript(@[ @"const canvas = document.createElement('canvas')", @@ -926,6 +926,261 @@ [manager loadAndRun]; } +#if ENABLE(WK_WEB_EXTENSIONS_ICON_VARIANTS) +TEST(WKWebExtensionAPIAction, SetIconWithVariants) +{ + auto *backgroundScript = Util::constructScript(@[ + @"await browser.test.assertSafeResolve(() => browser.action.setIcon({", + @" 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' ] }", + @" ]", + @"}))", + + @"browser.action.openPopup()" + ]); + + 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 = @{ + @"background.js": backgroundScript, + @"popup.html": @"Hello world!", + @"action-dark-32.png": dark32Icon, + @"action-dark-64.png": dark64Icon, + @"action-light-32.png": light32Icon, + @"action-light-64.png": light64Icon, + }; + + auto extension = adoptNS([[WKWebExtension alloc] _initWithManifestDictionary:actionPopupManifest resources:resources]); + auto manager = adoptNS([[TestWebExtensionManager alloc] initForExtension:extension.get()]); + + manager.get().internalDelegate.presentPopupForAction = ^(WKWebExtensionAction *action) { + auto *icon32 = [action iconForSize:CGSizeMake(32, 32)]; + EXPECT_NOT_NULL(icon32); + EXPECT_TRUE(CGSizeEqualToSize(icon32.size, CGSizeMake(32, 32))); + + auto *icon64 = [action iconForSize:CGSizeMake(64, 64)]; + EXPECT_NOT_NULL(icon64); + EXPECT_TRUE(CGSizeEqualToSize(icon64.size, CGSizeMake(64, 64))); + + Util::performWithAppearance(Util::Appearance::Dark, ^{ + EXPECT_TRUE(Util::compareColors(Util::pixelColor(icon32), [CocoaColor whiteColor])); + EXPECT_TRUE(Util::compareColors(Util::pixelColor(icon64), [CocoaColor whiteColor])); + }); + + Util::performWithAppearance(Util::Appearance::Light, ^{ + EXPECT_TRUE(Util::compareColors(Util::pixelColor(icon32), [CocoaColor blackColor])); + EXPECT_TRUE(Util::compareColors(Util::pixelColor(icon64), [CocoaColor blackColor])); + }); + + [manager done]; + }; + + [manager loadAndRun]; +} + +TEST(WKWebExtensionAPIAction, SetIconWithImageDataAndVariants) +{ + auto *backgroundScript = Util::constructScript(@[ + @"const createImageData = (size, color) => {", + @" const context = new OffscreenCanvas(size, size).getContext('2d')", + @" context.fillStyle = color", + @" context.fillRect(0, 0, size, size)", + + @" return context.getImageData(0, 0, size, size)", + @"}", + + @"const imageDataDark32 = createImageData(32, 'white')", + @"const imageDataDark64 = createImageData(64, 'white')", + @"const imageDataLight32 = createImageData(32, 'black')", + @"const imageDataLight64 = createImageData(64, 'black')", + + @"await browser.test.assertSafeResolve(() => browser.action.setIcon({", + @" variants: [", + @" { 32: imageDataDark32, 64: imageDataDark64, 'color_schemes': [ 'dark' ] },", + @" { 32: imageDataLight32, 64: imageDataLight64, 'color_schemes': [ 'light' ] }", + @" ]", + @"}))", + + @"browser.action.openPopup()" + ]); + + auto *resources = @{ + @"background.js": backgroundScript, + @"popup.html": @"Hello world!", + }; + + auto extension = adoptNS([[WKWebExtension alloc] _initWithManifestDictionary:actionPopupManifest resources:resources]); + auto manager = adoptNS([[TestWebExtensionManager alloc] initForExtension:extension.get()]); + + manager.get().internalDelegate.presentPopupForAction = ^(WKWebExtensionAction *action) { + auto *icon32 = [action iconForSize:CGSizeMake(32, 32)]; + auto *icon64 = [action iconForSize:CGSizeMake(64, 64)]; + + EXPECT_NOT_NULL(icon32); + EXPECT_TRUE(CGSizeEqualToSize(icon32.size, CGSizeMake(32, 32))); + + EXPECT_NOT_NULL(icon64); + EXPECT_TRUE(CGSizeEqualToSize(icon64.size, CGSizeMake(64, 64))); + + Util::performWithAppearance(Util::Appearance::Dark, ^{ + EXPECT_TRUE(Util::compareColors(Util::pixelColor(icon32), [CocoaColor whiteColor])); + EXPECT_TRUE(Util::compareColors(Util::pixelColor(icon64), [CocoaColor whiteColor])); + }); + + Util::performWithAppearance(Util::Appearance::Light, ^{ + EXPECT_TRUE(Util::compareColors(Util::pixelColor(icon32), [CocoaColor blackColor])); + EXPECT_TRUE(Util::compareColors(Util::pixelColor(icon64), [CocoaColor blackColor])); + }); + + [manager done]; + }; + + [manager loadAndRun]; +} + +TEST(WKWebExtensionAPIAction, SetIconThrowsWithNoValidVariants) +{ + auto *backgroundScript = Util::constructScript(@[ + @"const createImageData = (size, color) => {", + @" const context = new OffscreenCanvas(size, size).getContext('2d')", + @" context.fillStyle = color", + @" context.fillRect(0, 0, size, size)", + + @" return context.getImageData(0, 0, size, size)", + @"}", + + @"const invalidImageData = createImageData(32, 'white')", + + @"await browser.test.assertThrows(() => browser.action.setIcon({", + @" variants: [ { 'thirtytwo': invalidImageData, 'color_schemes': [ 'light' ] } ]", + @"}), /'variants\\[0\\]' value is invalid, because 'thirtytwo' is not a valid dimension/s)", + + @"await browser.test.assertThrows(() => browser.action.setIcon({", + @" variants: [ { 32: invalidImageData, 'color_schemes': [ 'bad' ] } ]", + @"}), /'variants\\[0\\]\\['color_schemes'\\]' value is invalid, because it must specify either 'light' or 'dark'/s)", + + @"browser.test.notifyPass()" + ]); + + auto *resources = @{ + @"background.js": backgroundScript, + @"popup.html": @"Hello world!", + }; + + auto extension = adoptNS([[WKWebExtension alloc] _initWithManifestDictionary:actionPopupManifest resources:resources]); + auto manager = adoptNS([[TestWebExtensionManager alloc] initForExtension:extension.get()]); + + [manager loadAndRun]; +} + +TEST(WKWebExtensionAPIAction, SetIconWithMixedValidAndInvalidVariants) +{ + auto *backgroundScript = Util::constructScript(@[ + @"const createImageData = (size, color) => {", + @" const context = new OffscreenCanvas(size, size).getContext('2d')", + @" context.fillStyle = color", + @" context.fillRect(0, 0, size, size)", + + @" return context.getImageData(0, 0, size, size)", + @"}", + + @"const imageDataLight32 = createImageData(32, 'black')", + @"const invalidImageData = createImageData(32, 'white')", + + @"await browser.test.assertSafeResolve(() => browser.action.setIcon({", + @" variants: [", + @" { '32': imageDataLight32, 'color_schemes': ['light'] },", + @" { '32.5': invalidImageData, 'color_schemes': ['dark'] }", + @" ]", + @"}))", + + @"browser.action.openPopup()" + ]); + + auto *resources = @{ + @"background.js": backgroundScript, + @"popup.html": @"Hello world!", + }; + + auto extension = adoptNS([[WKWebExtension alloc] _initWithManifestDictionary:actionPopupManifest resources:resources]); + auto manager = adoptNS([[TestWebExtensionManager alloc] initForExtension:extension.get()]); + + manager.get().internalDelegate.presentPopupForAction = ^(WKWebExtensionAction *action) { + auto *icon32 = [action iconForSize:CGSizeMake(32, 32)]; + EXPECT_NOT_NULL(icon32); + EXPECT_TRUE(CGSizeEqualToSize(icon32.size, CGSizeMake(32, 32))); + + Util::performWithAppearance(Util::Appearance::Light, ^{ + EXPECT_TRUE(Util::compareColors(Util::pixelColor(icon32), [CocoaColor blackColor])); + }); + + Util::performWithAppearance(Util::Appearance::Dark, ^{ + // Should still be black, as light variant is used. + EXPECT_TRUE(Util::compareColors(Util::pixelColor(icon32), [CocoaColor blackColor])); + }); + + [manager done]; + }; + + [manager loadAndRun]; +} + +TEST(WKWebExtensionAPIAction, SetIconWithAnySizeVariantAndSVGDataURL) +{ + auto *backgroundScript = Util::constructScript(@[ + @"const whiteSVGData = 'data:image/svg+xml;base64,' + btoa(`", + @" ", + @" ", + @" `)", + + @"const blackSVGData = 'data:image/svg+xml;base64,' + btoa(`", + @" ", + @" ", + @" `)", + + @"await browser.test.assertSafeResolve(() => browser.action.setIcon({", + @" variants: [", + @" { any: whiteSVGData, 'color_schemes': [ 'dark' ] },", + @" { any: blackSVGData, 'color_schemes': [ 'light' ] }", + @" ]", + @"}))", + + @"browser.action.openPopup()" + ]); + + auto *resources = @{ + @"background.js": backgroundScript, + @"popup.html": @"Hello World!", + }; + + auto extension = adoptNS([[WKWebExtension alloc] _initWithManifestDictionary:actionPopupManifest resources:resources]); + auto manager = adoptNS([[TestWebExtensionManager alloc] initForExtension:extension.get()]); + + manager.get().internalDelegate.presentPopupForAction = ^(WKWebExtensionAction *action) { + auto *iconAnySize = [action iconForSize:CGSizeMake(48, 48)]; + + EXPECT_NOT_NULL(iconAnySize); + EXPECT_TRUE(CGSizeEqualToSize(iconAnySize.size, CGSizeMake(48, 48))); + + Util::performWithAppearance(Util::Appearance::Dark, ^{ + EXPECT_TRUE(Util::compareColors(Util::pixelColor(iconAnySize), [CocoaColor whiteColor])); + }); + + Util::performWithAppearance(Util::Appearance::Light, ^{ + EXPECT_TRUE(Util::compareColors(Util::pixelColor(iconAnySize), [CocoaColor blackColor])); + }); + + [manager done]; + }; + + [manager loadAndRun]; +} +#endif // ENABLE(WK_WEB_EXTENSIONS_ICON_VARIANTS) + TEST(WKWebExtensionAPIAction, BrowserAction) { auto *browserActionManifest = @{ diff --git a/Tools/TestWebKitAPI/Tests/WebKitCocoa/WKWebExtensionAPIMenus.mm b/Tools/TestWebKitAPI/Tests/WebKitCocoa/WKWebExtensionAPIMenus.mm index 0bf71f204fb60..cd2b1cae11223 100644 --- a/Tools/TestWebKitAPI/Tests/WebKitCocoa/WKWebExtensionAPIMenus.mm +++ b/Tools/TestWebKitAPI/Tests/WebKitCocoa/WKWebExtensionAPIMenus.mm @@ -100,10 +100,10 @@ @"browser.test.assertThrows(() => browser.menus.create({ visible: 'bad', title: 'Test' }), /'visible' is expected to be a boolean, but a string was provided/i)", @"browser.test.assertThrows(() => browser.menus.create({ enabled: 'bad', title: 'Test' }), /'enabled' is expected to be a boolean, but a string was provided/i)", - @"browser.test.assertThrows(() => browser.menus.create({ icons: 123, title: 'Test' }), /'icons' is expected to be a string or an object, but a number was provided/i)", + @"browser.test.assertThrows(() => browser.menus.create({ icons: 123, title: 'Test' }), /'icons' is expected to be a string or an object or null, but a number was provided/i)", @"browser.test.assertThrows(() => browser.menus.create({ icons: { 16: 123 }, title: 'Test' }), /'icons\\[16]' value is invalid, because a string is expected, but a number was provided/i)", @"browser.test.assertThrows(() => browser.menus.create({ icons: { '16': 123 }, title: 'Test' }), /'icons\\[16]' value is invalid, because a string is expected, but a number was provided/i)", - @"browser.test.assertThrows(() => browser.menus.create({ icons: { '1.2': 'test.png' }, title: 'Test' }), /'icons' value is invalid, because '1.2' in not a valid dimension/i)", + @"browser.test.assertThrows(() => browser.menus.create({ icons: { '1.2': 'test.png' }, title: 'Test' }), /'icons' value is invalid, because '1.2' is not a valid dimension/i)", @"browser.test.assertThrows(() => browser.menus.create({ onclick: 'bad', title: 'Test' }), /'onclick' is expected to be a value, but a string was provided/i)", @"browser.test.assertThrows(() => browser.menus.create({ onclick: { }, title: 'Test' }), /'onclick' is expected to be a value, but an object was provided/i)", @@ -752,6 +752,552 @@ static inline void performMenuItemAction(auto *menuItem) [manager run]; } +#if ENABLE(WK_WEB_EXTENSIONS_ICON_VARIANTS) +TEST(WKWebExtensionAPIMenus, MenuItemWithIconVariants) +{ + auto *backgroundScript = Util::constructScript(@[ + @"browser.test.assertSafe(() => browser.menus.create({", + @" id: 'top-level-item',", + @" title: 'Top Level Menu',", + @" contexts: [ 'action' ]", + @"}))", + + @"browser.test.assertSafe(() => browser.menus.create({", + @" id: 'submenu-item-with-icon-variants',", + @" parentId: 'top-level-item',", + @" title: 'Submenu Item with Icon Variants',", + @" icon_variants: [", + @" { 16: 'icon-dark-16.png', 'color_schemes': [ 'dark' ] },", + @" { 16: 'icon-light-16.png', 'color_schemes': [ 'light' ] }", + @" ],", + @" contexts: [ 'action' ]", + @"}))", + + @"browser.test.yield('Menus Created')", + ]); + + auto *darkIcon16 = Util::makePNGData(CGSizeMake(16, 16), @selector(whiteColor)); + auto *lightIcon16 = Util::makePNGData(CGSizeMake(16, 16), @selector(blackColor)); + + auto *resources = @{ + @"background.js": backgroundScript, + @"icon-dark-16.png": darkIcon16, + @"icon-light-16.png": lightIcon16, + }; + + auto manager = Util::loadAndRunExtension(menusManifest, resources); + + EXPECT_NS_EQUAL(manager.get().yieldMessage, @"Menus Created"); + + auto *action = [manager.get().context actionForTab:manager.get().defaultTab]; + auto *menuItems = action.menuItems; + + EXPECT_EQ(menuItems.count, 1lu); + + auto *topLevelMenuItem = dynamic_objc_cast(menuItems.firstObject); + EXPECT_TRUE([topLevelMenuItem isKindOfClass:[CocoaMenuItem class]]); + EXPECT_NS_EQUAL(topLevelMenuItem.title, @"Top Level Menu"); + +#if USE(APPKIT) + EXPECT_EQ(topLevelMenuItem.submenu.itemArray.count, 1lu); + + auto *submenuItem = dynamic_objc_cast(topLevelMenuItem.submenu.itemArray.firstObject); + EXPECT_NS_EQUAL(submenuItem.title, @"Submenu Item with Icon Variants"); +#else + auto *parentMenu = dynamic_objc_cast(topLevelMenuItem); + EXPECT_EQ(parentMenu.children.count, 1lu); + + auto *submenuItem = dynamic_objc_cast(parentMenu.children.firstObject); + EXPECT_NS_EQUAL(submenuItem.title, @"Submenu Item with Icon Variants"); +#endif + + Util::performWithAppearance(Util::Appearance::Dark, ^{ + EXPECT_TRUE(CGSizeEqualToSize(submenuItem.image.size, CGSizeMake(16, 16))); + EXPECT_TRUE(Util::compareColors(Util::pixelColor(submenuItem.image), [CocoaColor whiteColor])); + }); + + Util::performWithAppearance(Util::Appearance::Light, ^{ + EXPECT_TRUE(CGSizeEqualToSize(submenuItem.image.size, CGSizeMake(16, 16))); + EXPECT_TRUE(Util::compareColors(Util::pixelColor(submenuItem.image), [CocoaColor blackColor])); + }); +} + +TEST(WKWebExtensionAPIMenus, MenuItemWithImageDataVariants) +{ + auto *backgroundScript = Util::constructScript(@[ + @"const createImageData = (size, color) => {", + @" const context = new OffscreenCanvas(size, size).getContext('2d')", + @" context.fillStyle = color", + @" context.fillRect(0, 0, size, size)", + + @" return context.getImageData(0, 0, size, size)", + @"}", + + @"browser.test.assertSafe(() => browser.menus.create({", + @" id: 'top-level-item',", + @" title: 'Top Level Menu',", + @" contexts: [ 'action' ]", + @"}))", + + @"const darkImageData = createImageData(16, 'white')", + @"const lightImageData = createImageData(16, 'black')", + + @"browser.test.assertSafe(() => browser.menus.create({", + @" id: 'submenu-item-with-icon-variants',", + @" parentId: 'top-level-item',", + @" title: 'Submenu Item with ImageData Variants',", + @" icon_variants: [", + @" { 16: darkImageData, 'color_schemes': [ 'dark' ] },", + @" { 16: lightImageData, 'color_schemes': [ 'light' ] }", + @" ],", + @" contexts: [ 'action' ]", + @"}))", + + @"browser.test.yield('Menus Created')", + ]); + + auto *resources = @{ + @"background.js": backgroundScript, + }; + + auto manager = Util::loadAndRunExtension(menusManifest, resources); + + EXPECT_NS_EQUAL(manager.get().yieldMessage, @"Menus Created"); + + auto *action = [manager.get().context actionForTab:manager.get().defaultTab]; + auto *menuItems = action.menuItems; + + EXPECT_EQ(menuItems.count, 1lu); + + auto *topLevelMenuItem = dynamic_objc_cast(menuItems.firstObject); + EXPECT_TRUE([topLevelMenuItem isKindOfClass:[CocoaMenuItem class]]); + EXPECT_NS_EQUAL(topLevelMenuItem.title, @"Top Level Menu"); + +#if USE(APPKIT) + EXPECT_EQ(topLevelMenuItem.submenu.itemArray.count, 1lu); + + auto *submenuItem = dynamic_objc_cast(topLevelMenuItem.submenu.itemArray.firstObject); + EXPECT_NS_EQUAL(submenuItem.title, @"Submenu Item with ImageData Variants"); +#else + auto *parentMenu = dynamic_objc_cast(topLevelMenuItem); + EXPECT_EQ(parentMenu.children.count, 1lu); + + auto *submenuItem = dynamic_objc_cast(parentMenu.children.firstObject); + EXPECT_NS_EQUAL(submenuItem.title, @"Submenu Item with ImageData Variants"); +#endif + + Util::performWithAppearance(Util::Appearance::Dark, ^{ + EXPECT_TRUE(CGSizeEqualToSize(submenuItem.image.size, CGSizeMake(16, 16))); + EXPECT_TRUE(Util::compareColors(Util::pixelColor(submenuItem.image), [CocoaColor whiteColor])); + }); + + Util::performWithAppearance(Util::Appearance::Light, ^{ + EXPECT_TRUE(CGSizeEqualToSize(submenuItem.image.size, CGSizeMake(16, 16))); + EXPECT_TRUE(Util::compareColors(Util::pixelColor(submenuItem.image), [CocoaColor blackColor])); + }); +} + +TEST(WKWebExtensionAPIMenus, MenuItemWithWithNoValidVariants) +{ + auto *backgroundScript = Util::constructScript(@[ + @"const createImageData = (size, color) => {", + @" const context = new OffscreenCanvas(size, size).getContext('2d')", + @" context.fillStyle = color", + @" context.fillRect(0, 0, size, size)", + + @" return context.getImageData(0, 0, size, size)", + @"}", + + @"const validImageData = createImageData(16, 'white')", + + @"await browser.test.assertThrows(() => browser.menus.create({", + @" id: 'submenu-item-invalid-dimension',", + @" parentId: 'top-level-item',", + @" title: 'Submenu with Invalid Dimension Key',", + @" icon_variants: [", + @" { 'sixteen': validImageData, 'color_schemes': [ 'light' ] }", + @" ],", + @" contexts: [ 'action' ]", + @"}), /'icon_variants\\[0\\]' value is invalid, because 'sixteen' is not a valid dimension/)", + + @"await browser.test.assertThrows(() => browser.menus.create({", + @" id: 'submenu-item-invalid-color-scheme',", + @" parentId: 'top-level-item',", + @" title: 'Submenu with Invalid Color Scheme',", + @" icon_variants: [", + @" { '16': validImageData, 'color_schemes': [ 'bad' ] }", + @" ],", + @" contexts: [ 'action' ]", + @"}), /'icon_variants\\[0\\]\\['color_schemes'\\]' value is invalid, because it must specify either 'light' or 'dark'/)", + + @"browser.test.notifyPass()" + ]); + + auto *resources = @{ + @"background.js": backgroundScript, + }; + + Util::loadAndRunExtension(menusManifest, resources); +} + +TEST(WKWebExtensionAPIMenus, MenuItemWithMixedValidAndInvalidIconVariants) +{ + auto *backgroundScript = Util::constructScript(@[ + @"const createImageData = (size, color) => {", + @" const context = new OffscreenCanvas(size, size).getContext('2d')", + @" context.fillStyle = color", + @" context.fillRect(0, 0, size, size)", + + @" return context.getImageData(0, 0, size, size)", + @"}", + + @"const validImageData = createImageData(16, 'black')", + @"const invalidImageData = createImageData(16, 'white')", + + @"browser.test.assertSafe(() => browser.menus.create({", + @" id: 'top-level-item',", + @" title: 'Top Level Menu',", + @" contexts: [ 'action' ]", + @"}))", + + @"browser.test.assertSafe(() => browser.menus.create({", + @" id: 'submenu-item-mixed',", + @" parentId: 'top-level-item',", + @" title: 'Submenu Item with Mixed Variants',", + @" icon_variants: [", + @" { 'sixteen': invalidImageData, 'color_schemes': [ 'dark' ] },", + @" { '16': validImageData, 'color_schemes': [ 'light' ] }", + @" ],", + @" contexts: [ 'action' ]", + @"}))", + + @"browser.test.yield('Menus Created')", + ]); + + auto *resources = @{ + @"background.js": backgroundScript, + }; + + auto manager = Util::loadAndRunExtension(menusManifest, resources); + + EXPECT_NS_EQUAL(manager.get().yieldMessage, @"Menus Created"); + + auto *action = [manager.get().context actionForTab:manager.get().defaultTab]; + auto *menuItems = action.menuItems; + + EXPECT_EQ(menuItems.count, 1lu); + + auto *topLevelMenuItem = dynamic_objc_cast(menuItems.firstObject); + EXPECT_TRUE([topLevelMenuItem isKindOfClass:[CocoaMenuItem class]]); + EXPECT_NS_EQUAL(topLevelMenuItem.title, @"Top Level Menu"); + +#if USE(APPKIT) + EXPECT_EQ(topLevelMenuItem.submenu.itemArray.count, 1lu); + + auto *submenuItem = dynamic_objc_cast(topLevelMenuItem.submenu.itemArray.firstObject); + EXPECT_NS_EQUAL(submenuItem.title, @"Submenu Item with Mixed Variants"); +#else + auto *parentMenu = dynamic_objc_cast(topLevelMenuItem); + EXPECT_EQ(parentMenu.children.count, 1lu); + + auto *submenuItem = dynamic_objc_cast(parentMenu.children.firstObject); + EXPECT_NS_EQUAL(submenuItem.title, @"Submenu Item with Mixed Variants"); +#endif + + EXPECT_TRUE(CGSizeEqualToSize(submenuItem.image.size, CGSizeMake(16, 16))); + + Util::performWithAppearance(Util::Appearance::Light, ^{ + EXPECT_TRUE(Util::compareColors(Util::pixelColor(submenuItem.image), [CocoaColor blackColor])); + }); + + Util::performWithAppearance(Util::Appearance::Dark, ^{ + // Should still be black, as light variant is used. + EXPECT_TRUE(Util::compareColors(Util::pixelColor(submenuItem.image), [CocoaColor blackColor])); + }); +} + +TEST(WKWebExtensionAPIMenus, MenuItemWithAnySizeVariantAndSVGDataURL) +{ + auto *backgroundScript = Util::constructScript(@[ + @"const whiteSVGData = 'data:image/svg+xml;base64,' + btoa(`", + @" ", + @" ", + @" `)", + + @"const blackSVGData = 'data:image/svg+xml;base64,' + btoa(`", + @" ", + @" ", + @" `)", + + @"browser.test.assertSafe(() => browser.menus.create({", + @" id: 'top-level-item',", + @" title: 'Top Level Menu',", + @" contexts: [ 'all' ]", + @"}))", + + @"browser.test.assertSafe(() => browser.menus.create({", + @" id: 'submenu-item-with-icon-variants',", + @" parentId: 'top-level-item',", + @" title: 'Submenu Item with SVG Icon Variants',", + @" icon_variants: [", + @" { any: whiteSVGData, 'color_schemes': [ 'dark' ] },", + @" { any: blackSVGData, 'color_schemes': [ 'light' ] }", + @" ],", + @" contexts: [ 'all' ]", + @"}))", + + @"browser.test.yield('Menus Created')" + ]); + + auto *resources = @{ + @"background.js": backgroundScript, + }; + + auto manager = Util::loadAndRunExtension(menusManifest, resources); + + EXPECT_NS_EQUAL(manager.get().yieldMessage, @"Menus Created"); + + auto *action = [manager.get().context actionForTab:manager.get().defaultTab]; + auto *menuItems = action.menuItems; + + EXPECT_EQ(menuItems.count, 1lu); + + auto *topLevelMenuItem = dynamic_objc_cast(menuItems.firstObject); + EXPECT_TRUE([topLevelMenuItem isKindOfClass:[CocoaMenuItem class]]); + EXPECT_NS_EQUAL(topLevelMenuItem.title, @"Top Level Menu"); + +#if USE(APPKIT) + EXPECT_EQ(topLevelMenuItem.submenu.itemArray.count, 1lu); + + auto *submenuItem = dynamic_objc_cast(topLevelMenuItem.submenu.itemArray.firstObject); + EXPECT_NS_EQUAL(submenuItem.title, @"Submenu Item with SVG Icon Variants"); +#else + auto *parentMenu = dynamic_objc_cast(topLevelMenuItem); + EXPECT_EQ(parentMenu.children.count, 1lu); + + auto *submenuItem = dynamic_objc_cast(parentMenu.children.firstObject); + EXPECT_NS_EQUAL(submenuItem.title, @"Submenu Item with SVG Icon Variants"); +#endif + + EXPECT_TRUE(CGSizeEqualToSize(submenuItem.image.size, CGSizeMake(16, 16))); + + Util::performWithAppearance(Util::Appearance::Dark, ^{ + EXPECT_TRUE(Util::compareColors(Util::pixelColor(submenuItem.image), [CocoaColor whiteColor])); + }); + + Util::performWithAppearance(Util::Appearance::Light, ^{ + EXPECT_TRUE(Util::compareColors(Util::pixelColor(submenuItem.image), [CocoaColor blackColor])); + }); +} + +TEST(WKWebExtensionAPIMenus, UpdateMenuItemWithIconVariants) +{ + auto *backgroundScript = Util::constructScript(@[ + @"browser.test.assertSafe(() => browser.menus.create({", + @" id: 'top-level-item',", + @" title: 'Top Level Menu',", + @" contexts: [ 'action' ]", + @"}))", + + @"browser.test.assertSafe(() => browser.menus.create({", + @" id: 'submenu-item-without-icon-variants',", + @" parentId: 'top-level-item',", + @" title: 'Submenu Item without Icon Variants',", + @" contexts: [ 'action' ]", + @"}))", + + @"browser.test.assertSafe(() => browser.menus.update('submenu-item-without-icon-variants', {", + @" title: 'Submenu Item with Icon Variants',", + @" icon_variants: [", + @" { 16: 'icon-dark-16.png', 'color_schemes': [ 'dark' ] },", + @" { 16: 'icon-light-16.png', 'color_schemes': [ 'light' ] }", + @" ]", + @"}))", + + @"browser.test.yield('Menus Updated')", + ]); + + auto *darkIcon16 = Util::makePNGData(CGSizeMake(16, 16), @selector(whiteColor)); + auto *lightIcon16 = Util::makePNGData(CGSizeMake(16, 16), @selector(blackColor)); + + auto *resources = @{ + @"background.js": backgroundScript, + @"icon-dark-16.png": darkIcon16, + @"icon-light-16.png": lightIcon16, + }; + + auto manager = Util::loadAndRunExtension(menusManifest, resources); + + EXPECT_NS_EQUAL(manager.get().yieldMessage, @"Menus Updated"); + + auto *action = [manager.get().context actionForTab:manager.get().defaultTab]; + auto *menuItems = action.menuItems; + + EXPECT_EQ(menuItems.count, 1lu); + + auto *topLevelMenuItem = dynamic_objc_cast(menuItems.firstObject); + EXPECT_TRUE([topLevelMenuItem isKindOfClass:[CocoaMenuItem class]]); + EXPECT_NS_EQUAL(topLevelMenuItem.title, @"Top Level Menu"); + +#if USE(APPKIT) + EXPECT_EQ(topLevelMenuItem.submenu.itemArray.count, 1lu); + + auto *submenuItem = dynamic_objc_cast(topLevelMenuItem.submenu.itemArray.firstObject); + EXPECT_NS_EQUAL(submenuItem.title, @"Submenu Item with Icon Variants"); +#else + auto *parentMenu = dynamic_objc_cast(topLevelMenuItem); + EXPECT_EQ(parentMenu.children.count, 1lu); + + auto *submenuItem = dynamic_objc_cast(parentMenu.children.firstObject); + EXPECT_NS_EQUAL(submenuItem.title, @"Submenu Item with Icon Variants"); +#endif + + EXPECT_TRUE(CGSizeEqualToSize(submenuItem.image.size, CGSizeMake(16, 16))); + + Util::performWithAppearance(Util::Appearance::Dark, ^{ + EXPECT_TRUE(Util::compareColors(Util::pixelColor(submenuItem.image), [CocoaColor whiteColor])); + }); + + Util::performWithAppearance(Util::Appearance::Light, ^{ + EXPECT_TRUE(Util::compareColors(Util::pixelColor(submenuItem.image), [CocoaColor blackColor])); + }); +} + +TEST(WKWebExtensionAPIMenus, ClearMenuItemIconVariantsWithNull) +{ + auto *backgroundScript = Util::constructScript(@[ + @"browser.test.assertSafe(() => browser.menus.create({", + @" id: 'top-level-item',", + @" title: 'Top Level Menu',", + @" contexts: [ 'action' ]", + @"}))", + + @"browser.test.assertSafe(() => browser.menus.create({", + @" id: 'submenu-item-with-icon-variants',", + @" parentId: 'top-level-item',", + @" title: 'Submenu Item with Icon Variants',", + @" icon_variants: [", + @" { 16: 'icon-dark-16.png', 'color_schemes': [ 'dark' ] },", + @" { 16: 'icon-light-16.png', 'color_schemes': [ 'light' ] }", + @" ],", + @" contexts: [ 'action' ]", + @"}))", + + @"browser.test.assertSafe(() => browser.menus.update('submenu-item-with-icon-variants', {", + @" icon_variants: null,", + @" title: 'Submenu Item without Icon Variants'", + @"}))", + + @"browser.test.yield('Menus Updated')", + ]); + + auto *darkIcon16 = Util::makePNGData(CGSizeMake(16, 16), @selector(whiteColor)); + auto *lightIcon16 = Util::makePNGData(CGSizeMake(16, 16), @selector(blackColor)); + + auto *resources = @{ + @"background.js": backgroundScript, + @"icon-dark-16.png": darkIcon16, + @"icon-light-16.png": lightIcon16, + }; + + auto manager = Util::loadAndRunExtension(menusManifest, resources); + + EXPECT_NS_EQUAL(manager.get().yieldMessage, @"Menus Updated"); + + auto *action = [manager.get().context actionForTab:manager.get().defaultTab]; + auto *menuItems = action.menuItems; + + EXPECT_EQ(menuItems.count, 1lu); + + auto *topLevelMenuItem = dynamic_objc_cast(menuItems.firstObject); + EXPECT_TRUE([topLevelMenuItem isKindOfClass:[CocoaMenuItem class]]); + EXPECT_NS_EQUAL(topLevelMenuItem.title, @"Top Level Menu"); + +#if USE(APPKIT) + EXPECT_EQ(topLevelMenuItem.submenu.itemArray.count, 1lu); + + auto *submenuItem = dynamic_objc_cast(topLevelMenuItem.submenu.itemArray.firstObject); + EXPECT_NS_EQUAL(submenuItem.title, @"Submenu Item without Icon Variants"); +#else + auto *parentMenu = dynamic_objc_cast(topLevelMenuItem); + EXPECT_EQ(parentMenu.children.count, 1lu); + + auto *submenuItem = dynamic_objc_cast(parentMenu.children.firstObject); + EXPECT_NS_EQUAL(submenuItem.title, @"Submenu Item without Icon Variants"); +#endif + + // Icon should be null after clearing. + EXPECT_NULL(submenuItem.image); +} + +TEST(WKWebExtensionAPIMenus, ClearMenuItemIconVariantsWithEmpty) +{ + auto *backgroundScript = Util::constructScript(@[ + @"browser.test.assertSafe(() => browser.menus.create({", + @" id: 'top-level-item',", + @" title: 'Top Level Menu',", + @" contexts: [ 'action' ]", + @"}))", + + @"browser.test.assertSafe(() => browser.menus.create({", + @" id: 'submenu-item-with-icon-variants',", + @" parentId: 'top-level-item',", + @" title: 'Submenu Item with Icon Variants',", + @" icon_variants: [", + @" { 16: 'icon-dark-16.png', 'color_schemes': [ 'dark' ] },", + @" { 16: 'icon-light-16.png', 'color_schemes': [ 'light' ] }", + @" ],", + @" contexts: [ 'action' ]", + @"}))", + + @"browser.test.assertSafe(() => browser.menus.update('submenu-item-with-icon-variants', {", + @" icon_variants: [ ],", + @" title: 'Submenu Item without Icon Variants'", + @"}))", + + @"browser.test.yield('Menus Updated')", + ]); + + auto *darkIcon16 = Util::makePNGData(CGSizeMake(16, 16), @selector(whiteColor)); + auto *lightIcon16 = Util::makePNGData(CGSizeMake(16, 16), @selector(blackColor)); + + auto *resources = @{ + @"background.js": backgroundScript, + @"icon-dark-16.png": darkIcon16, + @"icon-light-16.png": lightIcon16, + }; + + auto manager = Util::loadAndRunExtension(menusManifest, resources); + + EXPECT_NS_EQUAL(manager.get().yieldMessage, @"Menus Updated"); + + auto *action = [manager.get().context actionForTab:manager.get().defaultTab]; + auto *menuItems = action.menuItems; + + EXPECT_EQ(menuItems.count, 1lu); + + auto *topLevelMenuItem = dynamic_objc_cast(menuItems.firstObject); + EXPECT_TRUE([topLevelMenuItem isKindOfClass:[CocoaMenuItem class]]); + EXPECT_NS_EQUAL(topLevelMenuItem.title, @"Top Level Menu"); + +#if USE(APPKIT) + EXPECT_EQ(topLevelMenuItem.submenu.itemArray.count, 1lu); + + auto *submenuItem = dynamic_objc_cast(topLevelMenuItem.submenu.itemArray.firstObject); + EXPECT_NS_EQUAL(submenuItem.title, @"Submenu Item without Icon Variants"); +#else + auto *parentMenu = dynamic_objc_cast(topLevelMenuItem); + EXPECT_EQ(parentMenu.children.count, 1lu); + + auto *submenuItem = dynamic_objc_cast(parentMenu.children.firstObject); + EXPECT_NS_EQUAL(submenuItem.title, @"Submenu Item without Icon Variants"); +#endif + + // Icon should be null after clearing. + EXPECT_NULL(submenuItem.image); +} +#endif // ENABLE(WK_WEB_EXTENSIONS_ICON_VARIANTS) + TEST(WKWebExtensionAPIMenus, ToggleCheckboxMenuItems) { auto *backgroundScript = Util::constructScript(@[