Using Web Share API preceded by an AJAX call
authorcdumez@apple.com <cdumez@apple.com@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Wed, 8 Jan 2020 01:07:25 +0000 (01:07 +0000)
committercdumez@apple.com <cdumez@apple.com@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Wed, 8 Jan 2020 01:07:25 +0000 (01:07 +0000)
https://bugs.webkit.org/show_bug.cgi?id=197779
<rdar://problem/50708309>

Reviewed by Dean Jackson.

Source/WebCore:

As per the Web Share specification, navigator.share() is supposed to reject the promise with a
"NotAllowedError" DOMException if the relevant global object of this does not have transient
activation [1]. However, our implementation was stricter and would reject the promise if we
are not currently processing a user-gesture. This behavior did not match Chrome and does not
appear to be Web compatible.

To address the issue, this patch introduces the concept of transient activation [2] in WebKit
and uses it in navigator.share() to match the specification more closely. Note that we are
still a bit stricter than the specification because calling navigator.share() will currently
"consume the activation" [3] to prevent the JS from presenting the share sheet more than once
based on a single activation. However, our new behavior is still more permissive and more aligned
with Chrome.

[1] https://w3c.github.io/web-share/#dom-navigator-share
[2] https://html.spec.whatwg.org/multipage/interaction.html#transient-activation
[3] https://html.spec.whatwg.org/multipage/interaction.html#consume-user-activation

Tests: fast/web-share/share-transient-activation-expired.html
       fast/web-share/share-transient-activation.html

* dom/UserGestureIndicator.cpp:
* dom/UserGestureIndicator.h:
(WebCore::UserGestureToken::startTime const):
* page/DOMWindow.cpp:
(WebCore::transientActivationDurationOverrideForTesting):
(WebCore::transientActivationDuration):
(WebCore::DOMWindow::origin const):
(WebCore::DOMWindow::securityOrigin const):
(WebCore::DOMWindow::overrideTransientActivationDurationForTesting):
(WebCore::DOMWindow::hasTransientActivation const):
(WebCore::DOMWindow::consumeTransientActivation):
(WebCore::DOMWindow::notifyActivated):
* page/DOMWindow.h:
* page/Navigator.cpp:
(WebCore::Navigator::share):
* testing/Internals.cpp:
(WebCore::Internals::resetToConsistentState):
(WebCore::Internals::setTransientActivationDuration):
* testing/Internals.h:
* testing/Internals.idl:

LayoutTests:

Add layout test coverage.

* fast/web-share/share-transient-activation-expected.txt: Added.
* fast/web-share/share-transient-activation-expired-expected.txt: Added.
* fast/web-share/share-transient-activation-expired.html: Added.
* fast/web-share/share-transient-activation.html: Added.

git-svn-id: http://svn.webkit.org/repository/webkit/trunk@254178 268f45cc-cd09-0410-ab3c-d52691b4dbfc

14 files changed:
LayoutTests/ChangeLog
LayoutTests/fast/web-share/share-transient-activation-expected.txt [new file with mode: 0644]
LayoutTests/fast/web-share/share-transient-activation-expired-expected.txt [new file with mode: 0644]
LayoutTests/fast/web-share/share-transient-activation-expired.html [new file with mode: 0644]
LayoutTests/fast/web-share/share-transient-activation.html [new file with mode: 0644]
Source/WebCore/ChangeLog
Source/WebCore/dom/UserGestureIndicator.cpp
Source/WebCore/dom/UserGestureIndicator.h
Source/WebCore/page/DOMWindow.cpp
Source/WebCore/page/DOMWindow.h
Source/WebCore/page/Navigator.cpp
Source/WebCore/testing/Internals.cpp
Source/WebCore/testing/Internals.h
Source/WebCore/testing/Internals.idl

index e056ff8..7f76f8d 100644 (file)
@@ -1,3 +1,18 @@
+2020-01-07  Chris Dumez  <cdumez@apple.com>
+
+        Using Web Share API preceded by an AJAX call
+        https://bugs.webkit.org/show_bug.cgi?id=197779
+        <rdar://problem/50708309>
+
+        Reviewed by Dean Jackson.
+
+        Add layout test coverage.
+
+        * fast/web-share/share-transient-activation-expected.txt: Added.
+        * fast/web-share/share-transient-activation-expired-expected.txt: Added.
+        * fast/web-share/share-transient-activation-expired.html: Added.
+        * fast/web-share/share-transient-activation.html: Added.
+
 2020-01-07  Daniel Bates  <dabates@apple.com>
 
         Fix up layout tests results following r254160
diff --git a/LayoutTests/fast/web-share/share-transient-activation-expected.txt b/LayoutTests/fast/web-share/share-transient-activation-expected.txt
new file mode 100644 (file)
index 0000000..c67d83e
--- /dev/null
@@ -0,0 +1,2 @@
+PASS: Share sheet invoked.
+
diff --git a/LayoutTests/fast/web-share/share-transient-activation-expired-expected.txt b/LayoutTests/fast/web-share/share-transient-activation-expired-expected.txt
new file mode 100644 (file)
index 0000000..3b54cb8
--- /dev/null
@@ -0,0 +1,2 @@
+PASS: Share promise was rejected: NotAllowedError: The request is not allowed by the user agent or the platform in the current context, possibly because the user denied permission.
+
diff --git a/LayoutTests/fast/web-share/share-transient-activation-expired.html b/LayoutTests/fast/web-share/share-transient-activation-expired.html
new file mode 100644 (file)
index 0000000..024b617
--- /dev/null
@@ -0,0 +1,61 @@
+<!DOCTYPE html> <!-- webkit-test-runner [ useFlexibleViewport=true ] -->
+
+<html>
+<meta name="viewport" content="initial-scale=5, width=device-width">
+<head>
+
+    <script src="../../resources/ui-helper.js"></script>
+    <script>
+        
+        if (window.testRunner) {
+            testRunner.dumpAsText();
+            testRunner.waitUntilDone();
+        }
+
+        if (window.internals)
+            internals.setTransientActivationDuration(0.01); // Use very short 10ms transient activation duration for testing.
+    
+        let write = (message) => output.innerHTML += (message + "<br>");
+
+        function runTest()
+        {
+            document.getElementById("target").addEventListener("click", () => {
+                fetch("../files/resources/abe.png").then(() => {
+                    // Cause the transient activation to expire with a setTimeout.
+                    setTimeout(() => {
+                        navigator.share({ title: "Example Page", url: "url", text: "text" }).then((result) => {
+                            write("FAIL: Share sheet invoked.");
+                            testRunner.notifyDone();
+                        }, (exception) => {
+                            write("PASS: Share promise was rejected: " + exception);
+                            testRunner.notifyDone();
+                        });
+                    }, 100);
+                }, (exception) => {
+                    write("FAIL: Fetch promise was rejected");
+                    testRunner.notifyDone();
+                });
+            });
+            
+            UIHelper.setShareSheetCompletesImmediatelyWithResolution(true).then(() => {
+                UIHelper.activateAt(50, 50);
+            });
+        }
+
+    </script>
+    <style>
+        #target {
+            height: 100px;
+            width: 100px;
+            background-color: silver;
+        }
+    </style>
+</head>
+<body onload="runTest()">
+<pre id="output"></pre>
+<button id="target">
+</button>
+
+</body>
+</html>
+
diff --git a/LayoutTests/fast/web-share/share-transient-activation.html b/LayoutTests/fast/web-share/share-transient-activation.html
new file mode 100644 (file)
index 0000000..a231fc5
--- /dev/null
@@ -0,0 +1,55 @@
+<!DOCTYPE html> <!-- webkit-test-runner [ useFlexibleViewport=true ] -->
+
+<html>
+<meta name="viewport" content="initial-scale=5, width=device-width">
+<head>
+
+    <script src="../../resources/ui-helper.js"></script>
+    <script>
+        
+        if (window.testRunner) {
+            testRunner.dumpAsText();
+            testRunner.waitUntilDone();
+        }
+    
+        let write = (message) => output.innerHTML += (message + "<br>");
+
+        function runTest()
+        {
+            document.getElementById("target").addEventListener("click", () => {
+                fetch("../files/resources/abe.png").then(() => {
+                    navigator.share({ title: "Example Page", url: "url", text: "text" }).then((result) => {
+                        write("PASS: Share sheet invoked.");
+                        testRunner.notifyDone();
+                    }, (exception) => {
+                        write("FAIL: Share promise was rejected");
+                        testRunner.notifyDone();
+                    });
+                }, (exception) => {
+                    write("FAIL: Fetch promise was rejected");
+                    testRunner.notifyDone();
+                });
+            });
+            
+            UIHelper.setShareSheetCompletesImmediatelyWithResolution(true).then(() => {
+                UIHelper.activateAt(50, 50);
+            });
+        }
+
+    </script>
+    <style>
+        #target {
+            height: 100px;
+            width: 100px;
+            background-color: silver;
+        }
+    </style>
+</head>
+<body onload="runTest()">
+<pre id="output"></pre>
+<button id="target">
+</button>
+
+</body>
+</html>
+
index 9f3c057..ca3fd82 100644 (file)
@@ -1,3 +1,52 @@
+2020-01-07  Chris Dumez  <cdumez@apple.com>
+
+        Using Web Share API preceded by an AJAX call
+        https://bugs.webkit.org/show_bug.cgi?id=197779
+        <rdar://problem/50708309>
+
+        Reviewed by Dean Jackson.
+
+        As per the Web Share specification, navigator.share() is supposed to reject the promise with a
+        "NotAllowedError" DOMException if the relevant global object of this does not have transient
+        activation [1]. However, our implementation was stricter and would reject the promise if we
+        are not currently processing a user-gesture. This behavior did not match Chrome and does not
+        appear to be Web compatible.
+
+        To address the issue, this patch introduces the concept of transient activation [2] in WebKit
+        and uses it in navigator.share() to match the specification more closely. Note that we are
+        still a bit stricter than the specification because calling navigator.share() will currently
+        "consume the activation" [3] to prevent the JS from presenting the share sheet more than once
+        based on a single activation. However, our new behavior is still more permissive and more aligned
+        with Chrome.
+
+        [1] https://w3c.github.io/web-share/#dom-navigator-share
+        [2] https://html.spec.whatwg.org/multipage/interaction.html#transient-activation
+        [3] https://html.spec.whatwg.org/multipage/interaction.html#consume-user-activation
+
+        Tests: fast/web-share/share-transient-activation-expired.html
+               fast/web-share/share-transient-activation.html
+
+        * dom/UserGestureIndicator.cpp:
+        * dom/UserGestureIndicator.h:
+        (WebCore::UserGestureToken::startTime const):
+        * page/DOMWindow.cpp:
+        (WebCore::transientActivationDurationOverrideForTesting):
+        (WebCore::transientActivationDuration):
+        (WebCore::DOMWindow::origin const):
+        (WebCore::DOMWindow::securityOrigin const):
+        (WebCore::DOMWindow::overrideTransientActivationDurationForTesting):
+        (WebCore::DOMWindow::hasTransientActivation const):
+        (WebCore::DOMWindow::consumeTransientActivation):
+        (WebCore::DOMWindow::notifyActivated):
+        * page/DOMWindow.h:
+        * page/Navigator.cpp:
+        (WebCore::Navigator::share):
+        * testing/Internals.cpp:
+        (WebCore::Internals::resetToConsistentState):
+        (WebCore::Internals::setTransientActivationDuration):
+        * testing/Internals.h:
+        * testing/Internals.idl:
+
 2020-01-07  Simon Fraser  <simon.fraser@apple.com>
 
         Add some more Animations logging
index 0ec20b1..4c0c63e 100644 (file)
@@ -56,8 +56,8 @@ UserGestureIndicator::UserGestureIndicator(Optional<ProcessingUserGestureState>
     if (state)
         currentToken() = UserGestureToken::create(state.value(), gestureType);
 
-    if (document && currentToken()->processingUserGesture()) {
-        document->updateLastHandledUserGestureTimestamp(MonotonicTime::now());
+    if (document && currentToken()->processingUserGesture() && state) {
+        document->updateLastHandledUserGestureTimestamp(currentToken()->startTime());
         if (processInteractionStyle == ProcessInteractionStyle::Immediate)
             ResourceLoadObserver::shared().logUserInteractionWithReducedTimeResolution(document->topDocument());
         document->topDocument().setUserDidInteractWithPage(true);
@@ -67,6 +67,9 @@ UserGestureIndicator::UserGestureIndicator(Optional<ProcessingUserGestureState>
                     frame->setHasHadUserInteraction();
             }
         }
+
+        if (auto* window = document->domWindow())
+            window->notifyActivated(currentToken()->startTime());
     }
 }
 
index cca4240..8583bfc 100644 (file)
@@ -88,6 +88,8 @@ public:
         return m_startTime + expirationInterval < MonotonicTime::now();
     }
 
+    MonotonicTime startTime() const { return m_startTime; }
+
 private:
     UserGestureToken(ProcessingUserGestureState state, UserGestureType gestureType)
         : m_state(state)
index a53f3ec..400ded4 100644 (file)
 namespace WebCore {
 using namespace Inspector;
 
+static const Seconds defaultTransientActivationDuration { 2_s };
+
+static Optional<Seconds>& transientActivationDurationOverrideForTesting()
+{
+    static NeverDestroyed<Optional<Seconds>> overrideForTesting;
+    return overrideForTesting;
+}
+
+static Seconds transientActivationDuration()
+{
+    if (auto override = transientActivationDurationOverrideForTesting())
+        return *override;
+    return defaultTransientActivationDuration;
+}
+
 WTF_MAKE_ISO_ALLOCATED_IMPL(DOMWindow);
 
 class PostMessageTimer : public TimerBase {
@@ -1488,15 +1503,80 @@ WindowProxy* DOMWindow::top() const
 
 String DOMWindow::origin() const
 {
-    auto document = this->document();
+    auto* document = this->document();
     return document ? document->securityOrigin().toString() : emptyString();
 }
 
+SecurityOrigin* DOMWindow::securityOrigin() const
+{
+    auto* document = this->document();
+    return document ? &document->securityOrigin() : nullptr;
+}
+
 Document* DOMWindow::document() const
 {
     return downcast<Document>(ContextDestructionObserver::scriptExecutionContext());
 }
 
+void DOMWindow::overrideTransientActivationDurationForTesting(Optional<Seconds>&& override)
+{
+    transientActivationDurationOverrideForTesting() = WTFMove(override);
+}
+
+// When the current high resolution time is greater than or equal to the last activation timestamp in W, and
+// less than the last activation timestamp in W plus the transient activation duration, then W is said to
+// have transient activation. (https://html.spec.whatwg.org/multipage/interaction.html#transient-activation)
+bool DOMWindow::hasTransientActivation() const
+{
+    auto now = MonotonicTime::now();
+    return now >= m_lastActivationTimestamp && now < (m_lastActivationTimestamp + transientActivationDuration());
+}
+
+// https://html.spec.whatwg.org/multipage/interaction.html#consume-user-activation
+bool DOMWindow::consumeTransientActivation()
+{
+    if (!hasTransientActivation())
+        return false;
+
+    for (Frame* frame = this->frame() ? &this->frame()->tree().top() : nullptr; frame; frame = frame->tree().traverseNext()) {
+        auto* window = frame->window();
+        if (!window || window->lastActivationTimestamp() != MonotonicTime::infinity())
+            window->setLastActivationTimestamp(-MonotonicTime::infinity());
+    }
+
+    return true;
+}
+
+// https://html.spec.whatwg.org/multipage/interaction.html#activation-notification
+void DOMWindow::notifyActivated(MonotonicTime activationTime)
+{
+    setLastActivationTimestamp(activationTime);
+    if (!frame())
+        return;
+
+    for (Frame* ancestor = frame() ? frame()->tree().parent() : nullptr; ancestor; ancestor = ancestor->tree().parent()) {
+        if (auto* window = ancestor->window())
+            window->setLastActivationTimestamp(activationTime);
+    }
+
+    auto* securityOrigin = this->securityOrigin();
+    if (!securityOrigin)
+        return;
+
+    auto* descendant = frame();
+    while ((descendant = descendant->tree().traverseNext(frame()))) {
+        auto* descendantWindow = descendant->window();
+        if (!descendantWindow)
+            continue;
+
+        auto* descendantSecurityOrigin = descendantWindow->securityOrigin();
+        if (!descendantSecurityOrigin || !descendantSecurityOrigin->isSameOriginAs(*securityOrigin))
+            continue;
+
+        descendantWindow->setLastActivationTimestamp(activationTime);
+    }
+}
+
 StyleMedia& DOMWindow::styleMedia()
 {
     if (!m_media)
index 8f0d9c9..f7e0728 100644 (file)
@@ -41,6 +41,7 @@
 #include <JavaScriptCore/Strong.h>
 #include <wtf/Function.h>
 #include <wtf/HashSet.h>
+#include <wtf/MonotonicTime.h>
 #include <wtf/WeakPtr.h>
 
 namespace JSC {
@@ -176,6 +177,13 @@ public:
     Navigator* optionalNavigator() const { return m_navigator.get(); }
     Navigator& clientInformation() { return navigator(); }
 
+    WEBCORE_EXPORT static void overrideTransientActivationDurationForTesting(Optional<Seconds>&&);
+    void setLastActivationTimestamp(MonotonicTime lastActivationTimestamp) { m_lastActivationTimestamp = lastActivationTimestamp; }
+    MonotonicTime lastActivationTimestamp() const { return m_lastActivationTimestamp; }
+    void notifyActivated(MonotonicTime);
+    bool hasTransientActivation() const;
+    bool consumeTransientActivation();
+
     WEBCORE_EXPORT Location& location();
     void setLocation(DOMWindow& activeWindow, const URL& completedURL, SetLocationLocking = LockHistoryBasedOnGestureState);
 
@@ -234,6 +242,7 @@ public:
     WindowProxy* top() const;
 
     String origin() const;
+    SecurityOrigin* securityOrigin() const;
 
     // DOM Level 2 AbstractView Interface
 
@@ -463,6 +472,12 @@ private:
 
     mutable RefPtr<Performance> m_performance;
 
+    // For the purpose of tracking user activation, each Window W has a last activation timestamp. This is a number indicating the last time W got
+    // an activation notification. It corresponds to a DOMHighResTimeStamp value except for two cases: positive infinity indicates that W has never
+    // been activated, while negative infinity indicates that a user activation-gated API has consumed the last user activation of W. The initial
+    // value is positive infinity.
+    MonotonicTime m_lastActivationTimestamp { MonotonicTime::infinity() };
+
 #if ENABLE(USER_MESSAGE_HANDLERS)
     mutable RefPtr<WebKitNamespace> m_webkitNamespace;
 #endif
index 1b27ae9..28484de 100644 (file)
@@ -127,7 +127,9 @@ void Navigator::share(ScriptExecutionContext& context, ShareData data, Ref<Defer
         }
     }
     
-    if (!UserGestureIndicator::processingUserGesture()) {
+    auto* window = this->window();
+    // Note that the specification does not indicate we should consume user activation. We are intentionally stricter here.
+    if (!window || !window->consumeTransientActivation()) {
         promise->reject(NotAllowedError);
         return;
     }
index ba9418c..5735f62 100644 (file)
@@ -553,6 +553,7 @@ void Internals::resetToConsistentState(Page& page)
 #endif
 
     HTMLCanvasElement::setMaxPixelMemoryForTesting(0); // This means use the default value.
+    DOMWindow::overrideTransientActivationDurationForTesting(WTF::nullopt);
 }
 
 Internals::Internals(Document& document)
@@ -5280,6 +5281,11 @@ void Internals::setXHRMaximumIntervalForUserGestureForwarding(XMLHttpRequest& re
     request.setMaximumIntervalForUserGestureForwarding(interval);
 }
 
+void Internals::setTransientActivationDuration(double seconds)
+{
+    DOMWindow::overrideTransientActivationDurationForTesting(Seconds { seconds });
+}
+
 void Internals::setIsPlayingToAutomotiveHeadUnit(bool isPlaying)
 {
 #if ENABLE(VIDEO) || ENABLE(WEB_AUDIO)
index 02a2ea2..3b1902e 100644 (file)
@@ -869,6 +869,7 @@ public:
     void testDictionaryLogging();
 
     void setXHRMaximumIntervalForUserGestureForwarding(XMLHttpRequest&, double);
+    void setTransientActivationDuration(double seconds);
 
     void setIsPlayingToAutomotiveHeadUnit(bool);
     
index 9f83c32..b598218 100644 (file)
@@ -812,6 +812,7 @@ enum CompositingPolicy {
     void testDictionaryLogging();
 
     void setXHRMaximumIntervalForUserGestureForwarding(XMLHttpRequest xhr, double interval);
+    void setTransientActivationDuration(double seconds);
 
     void setIsPlayingToAutomotiveHeadUnit(boolean value);