| /* |
| * Copyright (C) 2020 Apple Inc. All rights reserved. |
| * |
| * Redistribution and use in source and binary forms, with or without |
| * modification, are permitted provided that the following conditions |
| * are met: |
| * 1. Redistributions of source code must retain the above copyright |
| * notice, this list of conditions and the following disclaimer. |
| * 2. Redistributions in binary form must reproduce the above copyright |
| * notice, this list of conditions and the following disclaimer in the |
| * documentation and/or other materials provided with the distribution. |
| * |
| * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS'' |
| * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, |
| * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR |
| * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS |
| * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR |
| * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF |
| * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS |
| * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN |
| * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) |
| * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF |
| * THE POSSIBILITY OF SUCH DAMAGE. |
| */ |
| |
| #import "config.h" |
| |
| #if PLATFORM(MAC) && ENABLE(MEDIA_SESSION) |
| |
| #import "PlatformUtilities.h" |
| #import "Test.h" |
| #import "TestWKWebView.h" |
| #import <WebKit/WKPreferencesPrivate.h> |
| #import <WebKit/WKWebViewConfigurationPrivate.h> |
| #import <WebKit/WKWebViewPrivate.h> |
| #import <WebKit/WebViewPrivate.h> |
| #import <pal/spi/mac/MediaRemoteSPI.h> |
| #import <ranges> |
| #import <wtf/Function.h> |
| #import <wtf/HashSet.h> |
| #import <wtf/NeverDestroyed.h> |
| #import <wtf/SoftLinking.h> |
| #import <wtf/cocoa/VectorCocoa.h> |
| #import <wtf/darwin/DispatchExtras.h> |
| #import <wtf/text/MakeString.h> |
| #import <wtf/text/StringHash.h> |
| #import <wtf/text/WTFString.h> |
| |
| SOFT_LINK_PRIVATE_FRAMEWORK(MediaRemote) |
| |
| SOFT_LINK(MediaRemote, MRMediaRemoteSendCommandToApp, Boolean, (MRMediaRemoteCommand command, CFDictionaryRef options, MROriginRef origin, CFStringRef appDisplayID, MRSendCommandAppOptions appOptions, dispatch_queue_t replyQ, void(^completion)(MRSendCommandError err, CFArrayRef handlerReturnStatuses)), (command, options, origin, appDisplayID, appOptions, replyQ, completion)); |
| #define MRMediaRemoteSendCommandToApp softLinkMRMediaRemoteSendCommandToApp |
| |
| SOFT_LINK(MediaRemote, MRMediaRemoteGetLocalOrigin, MROriginRef, (), ()); |
| #define MRMediaRemoteGetLocalOrigin softLinkMRMediaRemoteGetLocalOrigin |
| |
| SOFT_LINK(MediaRemote, MRMediaRemoteGetSupportedCommandsForOrigin, void, (MROriginRef origin, dispatch_queue_t queue, void(^completion)(CFArrayRef commands)), (origin, queue, completion)); |
| #define MRMediaRemoteGetSupportedCommandsForOrigin softLinkMRMediaRemoteGetSupportedCommandsForOrigin |
| |
| SOFT_LINK(MediaRemote, MRMediaRemoteGetNowPlayingClient, void, (dispatch_queue_t queue, void(^completion)(MRNowPlayingClientRef, CFErrorRef)), (queue, completion)) |
| #define MRMediaRemoteGetNowPlayingClient softLinkMRMediaRemoteGetNowPlayingClient |
| |
| SOFT_LINK(MediaRemote, MRNowPlayingClientGetProcessIdentifier, pid_t, (MRNowPlayingClientRef client), (client)) |
| #define MRNowPlayingClientGetProcessIdentifier softLinkMRNowPlayingClientGetProcessIdentifier |
| |
| SOFT_LINK_CONSTANT(MediaRemote, kMRMediaRemoteOptionSkipInterval, CFStringRef) |
| #define kMRMediaRemoteOptionSkipInterval getkMRMediaRemoteOptionSkipIntervalSingleton() |
| |
| SOFT_LINK_CONSTANT(MediaRemote, kMRMediaRemoteOptionPlaybackPosition, CFStringRef) |
| #define kMRMediaRemoteOptionPlaybackPosition getkMRMediaRemoteOptionPlaybackPositionSingleton() |
| |
| #if !USE(APPLE_INTERNAL_SDK) |
| @interface MRCommandInfo : NSObject |
| @property (nonatomic, readonly) MRMediaRemoteCommand command; |
| @property (nonatomic, readonly, getter=isEnabled) BOOL enabled; |
| @property (nonatomic, readonly, copy) NSDictionary *options; |
| @end |
| #endif |
| |
| namespace TestWebKitAPI { |
| |
| class MediaSessionTest : public testing::Test { |
| public: |
| void SetUp() final |
| { |
| _configuration = adoptNS([[WKWebViewConfiguration alloc] init]); |
| [_configuration setMediaTypesRequiringUserActionForPlayback:WKAudiovisualMediaTypeAudio]; |
| |
| _webView = adoptNS([[TestWKWebView alloc] initWithFrame:NSMakeRect(0, 0, 300, 300) configuration:_configuration.get() addToWindow:YES]); |
| |
| WKPreferences *preferences = [_webView configuration].preferences; |
| preferences._mediaSessionEnabled = YES; |
| |
| _messageHandlers = adoptNS([[NSMutableArray alloc] init]); |
| } |
| |
| void TearDown() override |
| { |
| [_webView clearMessageHandlers:_messageHandlers.get()]; |
| } |
| |
| TestWKWebView* webView() { return _webView.get(); } |
| |
| pid_t gpuProcessPID() { return [_webView _gpuProcessIdentifier]; } |
| |
| RetainPtr<MRNowPlayingClientRef> getNowPlayingClient() |
| { |
| bool gotNowPlaying = false; |
| RetainPtr<MRNowPlayingClientRef> nowPlayingClient; |
| MRMediaRemoteGetNowPlayingClient(mainDispatchQueueSingleton(), [&] (MRNowPlayingClientRef player, CFErrorRef error) { |
| if (!error && player) |
| nowPlayingClient = player; |
| gotNowPlaying = true; |
| }); |
| TestWebKitAPI::Util::run(&gotNowPlaying); |
| return nowPlayingClient; |
| } |
| |
| pid_t getNowPlayingClientPid() |
| { |
| return MRNowPlayingClientGetProcessIdentifier(getNowPlayingClient().get()); |
| } |
| |
| void loadPageAndBecomeNowPlaying(NSString *pageName) |
| { |
| [_webView synchronouslyLoadTestPageNamed:pageName]; |
| |
| bool canplaythrough = false; |
| [webView() performAfterReceivingMessage:@"canplaythrough event" action:[&] { |
| canplaythrough = true; |
| }]; |
| runScriptWithUserGesture(@"load()"); |
| Util::run(&canplaythrough); |
| |
| play(); |
| pause(); |
| ASSERT_EQ(gpuProcessPID(), getNowPlayingClientPid()); |
| } |
| |
| void runScriptWithUserGesture(NSString *script) |
| { |
| bool complete = false; |
| [_webView evaluateJavaScript:script completionHandler:[&] (id, NSError *) { complete = true; }]; |
| TestWebKitAPI::Util::run(&complete); |
| } |
| |
| void play() |
| { |
| bool playing = false; |
| [_webView performAfterReceivingMessage:@"play event" action:[&] { playing = true; }]; |
| runScriptWithUserGesture(@"audio.play()"); |
| Util::run(&playing); |
| } |
| |
| void pause() |
| { |
| bool paused = false; |
| [_webView performAfterReceivingMessage:@"pause event" action:[&] { paused = true; }]; |
| runScriptWithUserGesture(@"audio.pause()"); |
| Util::run(&paused); |
| } |
| |
| bool sendMediaRemoteCommand(MRMediaRemoteCommand command, CFDictionaryRef options = nullptr) |
| { |
| bool completed = false; |
| bool success; |
| |
| MRMediaRemoteSendCommandToApp(command, options, NULL, NULL, static_cast<MRSendCommandAppOptions>(0), NULL, [&] (MRSendCommandError error, CFArrayRef) { |
| success = !error; |
| completed = true; |
| }); |
| TestWebKitAPI::Util::run(&completed); |
| |
| return success; |
| } |
| |
| bool sendMediaRemoteSeekCommand(MRMediaRemoteCommand command, double interval) |
| { |
| CFStringRef seekInterval = (command == MRMediaRemoteCommandSeekToPlaybackPosition) ? kMRMediaRemoteOptionPlaybackPosition : kMRMediaRemoteOptionSkipInterval; |
| NSDictionary *options = @{(__bridge NSString *)seekInterval : @(interval)}; |
| return sendMediaRemoteCommand(command, (__bridge CFDictionaryRef)options); |
| } |
| |
| void listenForEventMessages(std::initializer_list<ASCIILiteral> events) |
| { |
| for (auto event : events) { |
| auto eventMessage = makeString(event, " event"_s); |
| RetainPtr nsEventMessage = eventMessage.createNSString(); |
| [_messageHandlers addObject:nsEventMessage.get()]; |
| [webView() performAfterReceivingMessage:nsEventMessage.get() action:[this, eventMessage = WTF::move(eventMessage)] { |
| _eventListenersCalled.add(eventMessage); |
| }]; |
| } |
| } |
| |
| bool eventListenerWasCalled(StringView event) |
| { |
| return _eventListenersCalled.contains(makeString(event, " event"_s)); |
| } |
| |
| void clearEventListenerState() |
| { |
| _eventListenersCalled.clear(); |
| } |
| |
| void waitForEventListenerToBeCalled(StringView event) |
| { |
| int tries = 0; |
| do { |
| if (eventListenerWasCalled(event)) |
| return; |
| Util::runFor(0.1_s); |
| } while (++tries <= 50); |
| |
| return; |
| } |
| |
| void listenForSessionHandlerMessages(std::initializer_list<ASCIILiteral> handlers) |
| { |
| for (auto handler : handlers) { |
| auto handlerMessage = makeString(handler, " handler"_s); |
| RetainPtr nsHandleMessage = handlerMessage.createNSString(); |
| [_messageHandlers addObject:nsHandleMessage.get()]; |
| [webView() performAfterReceivingMessage:nsHandleMessage.get() action:[this, handlerMessage = WTF::move(handlerMessage)] { |
| _mediaSessionHandlersCalled.add(handlerMessage); |
| }]; |
| } |
| } |
| |
| bool sessionHandlerWasCalled(StringView handler) |
| { |
| return _mediaSessionHandlersCalled.contains(makeString(handler, " handler"_s)); |
| } |
| |
| void waitForSessionHandlerToBeCalled(StringView handler) |
| { |
| int tries = 0; |
| do { |
| if (sessionHandlerWasCalled(handler)) |
| return; |
| Util::runFor(0.1_s); |
| } while (++tries <= 50); |
| |
| return; |
| } |
| |
| RetainPtr<NSArray> getSupportedCommands() |
| { |
| bool completed = false; |
| RetainPtr<NSArray> result; |
| |
| MRMediaRemoteGetSupportedCommandsForOrigin(MRMediaRemoteGetLocalOrigin(), mainDispatchQueueSingleton(), [&] (CFArrayRef commands) { |
| result = (__bridge NSArray *)commands; |
| completed = true; |
| }); |
| |
| TestWebKitAPI::Util::run(&completed); |
| |
| return result; |
| } |
| |
| private: |
| RetainPtr<WKWebViewConfiguration> _configuration; |
| RetainPtr<TestWKWebView> _webView; |
| |
| HashSet<String> _mediaSessionHandlersCalled; |
| HashSet<String> _eventListenersCalled; |
| RetainPtr<NSMutableArray> _messageHandlers; |
| }; |
| |
| TEST_F(MediaSessionTest, DISABLED_OnlyOneHandler) |
| { |
| loadPageAndBecomeNowPlaying(@"media-remote"); |
| |
| [webView() objectByEvaluatingJavaScript:@"setEmptyActionHandlers([ 'play' ])"]; |
| |
| listenForSessionHandlerMessages({ "play"_s, "pause"_s, "seekto"_s, "seekforward"_s, "seekbackward"_s, "previoustrack"_s, "nexttrack"_s }); |
| listenForEventMessages({ "play"_s, "pause"_s, "seeked"_s }); |
| |
| #if __MAC_OS_X_VERSION_MIN_REQUIRED > 101500 |
| static Vector<MRMediaRemoteCommand> registeredCommands = { MRMediaRemoteCommandPlay }; |
| auto currentCommands = getSupportedCommands(); |
| for (MRCommandInfo *command in currentCommands.get()) { |
| if (!command.enabled) |
| continue; |
| |
| ASSERT_TRUE(registeredCommands.contains(command.command)); |
| } |
| #endif |
| |
| ASSERT_TRUE(sendMediaRemoteCommand(MRMediaRemoteCommandPlay)); |
| waitForSessionHandlerToBeCalled("play"_s); |
| ASSERT_TRUE(sessionHandlerWasCalled("play"_s)); |
| ASSERT_FALSE(eventListenerWasCalled("play"_s)); |
| |
| // The media session only registered for Play, but no other commands should reach HTMLMediaElement. |
| ASSERT_TRUE(sendMediaRemoteSeekCommand(MRMediaRemoteCommandSkipForward, 1)); |
| ASSERT_FALSE(sessionHandlerWasCalled("seekforward"_s)); |
| ASSERT_FALSE(eventListenerWasCalled("seeked"_s)); |
| |
| ASSERT_TRUE(sendMediaRemoteSeekCommand(MRMediaRemoteCommandSkipBackward, 10)); |
| ASSERT_FALSE(sessionHandlerWasCalled("seekbackward"_s)); |
| ASSERT_FALSE(eventListenerWasCalled("seeked"_s)); |
| |
| ASSERT_TRUE(sendMediaRemoteSeekCommand(MRMediaRemoteCommandSeekToPlaybackPosition, 6)); |
| ASSERT_FALSE(sessionHandlerWasCalled("seekto"_s)); |
| ASSERT_FALSE(eventListenerWasCalled("seeked"_s)); |
| |
| ASSERT_TRUE(sendMediaRemoteCommand(MRMediaRemoteCommandNextTrack)); |
| ASSERT_FALSE(sessionHandlerWasCalled("nexttrack"_s)); |
| |
| ASSERT_TRUE(sendMediaRemoteCommand(MRMediaRemoteCommandPreviousTrack)); |
| ASSERT_FALSE(sessionHandlerWasCalled("previoustrack"_s)); |
| } |
| |
| TEST_F(MediaSessionTest, DISABLED_RemoteCommands) |
| { |
| loadPageAndBecomeNowPlaying(@"media-remote"); |
| |
| [webView() objectByEvaluatingJavaScript:@"setEmptyActionHandlers([ 'play', 'pause', 'seekto', 'seekforward', 'seekbackward', 'previoustrack', 'nexttrack' ])"]; |
| |
| listenForSessionHandlerMessages({ "play"_s, "pause"_s, "seekto"_s, "seekforward"_s, "seekbackward"_s, "previoustrack"_s, "nexttrack"_s }); |
| listenForEventMessages({ "play"_s, "pause"_s, "seeked"_s }); |
| |
| #if __MAC_OS_X_VERSION_MIN_REQUIRED > 101500 |
| static Vector<MRMediaRemoteCommand> registeredCommands = { MRMediaRemoteCommandPlay, MRMediaRemoteCommandPause, MRMediaRemoteCommandSeekToPlaybackPosition, MRMediaRemoteCommandSkipForward, MRMediaRemoteCommandSkipBackward, MRMediaRemoteCommandPreviousTrack, MRMediaRemoteCommandNextTrack }; |
| auto currentCommands = getSupportedCommands(); |
| for (MRCommandInfo *command in currentCommands.get()) { |
| if (!command.enabled) |
| continue; |
| |
| ASSERT_TRUE(registeredCommands.contains(command.command)); |
| } |
| #endif |
| |
| ASSERT_TRUE(sendMediaRemoteCommand(MRMediaRemoteCommandPlay)); |
| waitForSessionHandlerToBeCalled("play"_s); |
| ASSERT_TRUE(sessionHandlerWasCalled("play"_s)); |
| ASSERT_FALSE(eventListenerWasCalled("play"_s)); |
| |
| ASSERT_TRUE(sendMediaRemoteCommand(MRMediaRemoteCommandPause)); |
| waitForSessionHandlerToBeCalled("pause"_s); |
| ASSERT_TRUE(sessionHandlerWasCalled("pause"_s)); |
| ASSERT_FALSE(eventListenerWasCalled("pause"_s)); |
| |
| ASSERT_TRUE(sendMediaRemoteSeekCommand(MRMediaRemoteCommandSkipForward, 1)); |
| waitForSessionHandlerToBeCalled("seekforward"_s); |
| ASSERT_TRUE(sessionHandlerWasCalled("seekforward"_s)); |
| ASSERT_FALSE(eventListenerWasCalled("seeked"_s)); |
| |
| ASSERT_TRUE(sendMediaRemoteSeekCommand(MRMediaRemoteCommandSkipBackward, 10)); |
| waitForSessionHandlerToBeCalled("seekbackward"_s); |
| ASSERT_TRUE(sessionHandlerWasCalled("seekbackward"_s)); |
| ASSERT_FALSE(eventListenerWasCalled("seeked"_s)); |
| |
| ASSERT_TRUE(sendMediaRemoteSeekCommand(MRMediaRemoteCommandSeekToPlaybackPosition, 6)); |
| waitForSessionHandlerToBeCalled("seekto"_s); |
| ASSERT_TRUE(sessionHandlerWasCalled("seekto"_s)); |
| ASSERT_FALSE(eventListenerWasCalled("seeked"_s)); |
| |
| ASSERT_TRUE(sendMediaRemoteCommand(MRMediaRemoteCommandNextTrack)); |
| waitForSessionHandlerToBeCalled("nexttrack"_s); |
| ASSERT_TRUE(sessionHandlerWasCalled("nexttrack"_s)); |
| |
| ASSERT_TRUE(sendMediaRemoteCommand(MRMediaRemoteCommandPreviousTrack)); |
| waitForSessionHandlerToBeCalled("previoustrack"_s); |
| ASSERT_TRUE(sessionHandlerWasCalled("previoustrack"_s)); |
| |
| // Unregister action handlers, supported commands should go to HTMLMediaElement. |
| [webView() objectByEvaluatingJavaScript:@"clearActionHandlers()"]; |
| clearEventListenerState(); |
| |
| ASSERT_TRUE(sendMediaRemoteCommand(MRMediaRemoteCommandPlay)); |
| waitForEventListenerToBeCalled("play"_s); |
| ASSERT_TRUE(eventListenerWasCalled("play"_s)); |
| |
| ASSERT_TRUE(sendMediaRemoteCommand(MRMediaRemoteCommandPause)); |
| waitForEventListenerToBeCalled("pause"_s); |
| ASSERT_TRUE(eventListenerWasCalled("pause"_s)); |
| |
| ASSERT_TRUE(sendMediaRemoteSeekCommand(MRMediaRemoteCommandSkipForward, 1)); |
| waitForEventListenerToBeCalled("seeked"_s); |
| ASSERT_TRUE(eventListenerWasCalled("seeked"_s)); |
| clearEventListenerState(); |
| |
| ASSERT_TRUE(sendMediaRemoteSeekCommand(MRMediaRemoteCommandSkipBackward, 10)); |
| waitForEventListenerToBeCalled("seeked"_s); |
| ASSERT_TRUE(eventListenerWasCalled("seeked"_s)); |
| clearEventListenerState(); |
| |
| ASSERT_TRUE(sendMediaRemoteSeekCommand(MRMediaRemoteCommandSeekToPlaybackPosition, 6)); |
| waitForEventListenerToBeCalled("seeked"_s); |
| ASSERT_TRUE(eventListenerWasCalled("seeked"_s)); |
| } |
| |
| TEST_F(MediaSessionTest, MinimalCommands) |
| { |
| loadPageAndBecomeNowPlaying(@"media-remote"); |
| |
| [webView() objectByEvaluatingJavaScript:@"setEmptyActionHandlers([ 'seekforward' ])"]; |
| |
| listenForSessionHandlerMessages({ "seekforward"_s }); |
| listenForEventMessages({ "play"_s, "pause"_s, "seeked"_s }); |
| |
| ASSERT_TRUE(sendMediaRemoteSeekCommand(MRMediaRemoteCommandSkipForward, 1)); |
| waitForSessionHandlerToBeCalled("seekforward"_s); |
| ASSERT_TRUE(sessionHandlerWasCalled("seekforward"_s)); |
| ASSERT_FALSE(eventListenerWasCalled("seeked"_s)); |
| |
| ASSERT_TRUE(sendMediaRemoteCommand(MRMediaRemoteCommandPlay)); |
| waitForEventListenerToBeCalled("play"_s); |
| ASSERT_TRUE(eventListenerWasCalled("play"_s)); |
| ASSERT_FALSE(sessionHandlerWasCalled("play"_s)); |
| |
| ASSERT_TRUE(sendMediaRemoteCommand(MRMediaRemoteCommandPause)); |
| waitForEventListenerToBeCalled("pause"_s); |
| ASSERT_TRUE(eventListenerWasCalled("pause"_s)); |
| ASSERT_FALSE(sessionHandlerWasCalled("pause"_s)); |
| |
| Vector<MRMediaRemoteCommand> expectedCommands { |
| MRMediaRemoteCommandPlay, |
| MRMediaRemoteCommandPause, |
| MRMediaRemoteCommandSkipForward, |
| }; |
| std::ranges::sort(expectedCommands); |
| |
| Vector actualCommands = makeVector(getSupportedCommands().get(), [] (MRCommandInfo *command) -> std::optional<MRMediaRemoteCommand> { |
| if (!command.enabled) |
| return std::nullopt; |
| return command.command; |
| }); |
| std::ranges::sort(actualCommands); |
| |
| EXPECT_EQ(expectedCommands, actualCommands); |
| } |
| |
| } // namespace TestWebKitAPI |
| |
| #endif // PLATFORM(MAC) && ENABLE(MEDIA_SESSION) |