blob: ec1881db5511b233b439d3064e0870923522d8c5 [file] [log] [blame]
// Copyright 2022 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "third_party/blink/renderer/modules/mediastream/capture_controller.h"
#include <algorithm>
#include <cmath>
#include <optional>
#include "base/numerics/safe_conversions.h"
#include "base/types/expected.h"
#include "base/unguessable_token.h"
#include "build/build_config.h"
#include "third_party/blink/public/common/page/page_zoom.h"
#include "third_party/blink/public/platform/browser_interface_broker_proxy.h"
#include "third_party/blink/renderer/bindings/core/v8/idl_types.h"
#include "third_party/blink/renderer/bindings/core/v8/script_promise.h"
#include "third_party/blink/renderer/bindings/modules/v8/v8_captured_wheel_action.h"
#include "third_party/blink/renderer/bindings/modules/v8/v8_media_stream_track_state.h"
#include "third_party/blink/renderer/core/dom/dom_exception.h"
#include "third_party/blink/renderer/core/dom/events/event.h"
#include "third_party/blink/renderer/core/dom/events/native_event_listener.h"
#include "third_party/blink/renderer/core/event_type_names.h"
#include "third_party/blink/renderer/core/events/wheel_event.h"
#include "third_party/blink/renderer/core/execution_context/execution_context.h"
#include "third_party/blink/renderer/core/geometry/dom_rect.h"
#include "third_party/blink/renderer/core/html/html_element.h"
#include "third_party/blink/renderer/modules/mediastream/media_stream_track.h"
#include "third_party/blink/renderer/modules/mediastream/media_stream_video_track.h"
#include "third_party/blink/renderer/modules/mediastream/user_media_client.h"
#include "third_party/blink/renderer/platform/bindings/exception_code.h"
#include "third_party/blink/renderer/platform/bindings/exception_state.h"
#include "third_party/blink/renderer/platform/heap/garbage_collected.h"
#include "third_party/blink/renderer/platform/heap/persistent.h"
#include "third_party/blink/renderer/platform/mediastream/media_stream_component.h"
#include "third_party/blink/renderer/platform/mediastream/media_stream_source.h"
#include "third_party/blink/renderer/platform/wtf/functional.h"
#include "third_party/blink/renderer/platform/wtf/vector.h"
using ::blink::mojom::blink::CapturedSurfaceControlResult;
namespace blink {
namespace {
using SurfaceType = media::mojom::DisplayCaptureSurfaceType;
bool IsCaptureType(const MediaStreamTrack* track,
const std::vector<SurfaceType>& types) {
DCHECK(track);
const MediaStreamVideoTrack* video_track =
MediaStreamVideoTrack::From(track->Component());
if (!video_track) {
return false;
}
MediaStreamTrackPlatform::Settings settings;
video_track->GetSettings(settings);
const std::optional<SurfaceType> display_surface = settings.display_surface;
return std::ranges::any_of(
types, [display_surface](SurfaceType t) { return t == display_surface; });
}
#if !BUILDFLAG(IS_ANDROID) && !BUILDFLAG(IS_IOS)
bool ShouldFocusCapturedSurface(V8CaptureStartFocusBehavior focus_behavior) {
switch (focus_behavior.AsEnum()) {
case V8CaptureStartFocusBehavior::Enum::kFocusCapturedSurface:
return true;
case V8CaptureStartFocusBehavior::Enum::kFocusCapturingApplication:
case V8CaptureStartFocusBehavior::Enum::kNoFocusChange:
return false;
}
NOTREACHED();
}
std::optional<int> GetInitialZoomLevel(MediaStreamTrack* video_track) {
const MediaStreamVideoSource* native_source =
MediaStreamVideoSource::GetVideoSource(
video_track->Component()->Source());
if (!native_source) {
return std::nullopt;
}
const media::mojom::DisplayMediaInformationPtr& display_media_info =
native_source->device().display_media_info;
if (!display_media_info ||
display_media_info->display_surface != SurfaceType::BROWSER) {
return std::nullopt;
}
return display_media_info->initial_zoom_level;
}
std::optional<base::UnguessableToken> GetCaptureSessionId(
MediaStreamTrack* track) {
if (!track) {
return std::nullopt;
}
MediaStreamComponent* component = track->Component();
if (!component) {
return std::nullopt;
}
MediaStreamSource* source = component->Source();
if (!source) {
return std::nullopt;
}
WebPlatformMediaStreamSource* platform_source = source->GetPlatformSource();
if (!platform_source) {
return std::nullopt;
}
return platform_source->device().serializable_session_id();
}
DOMException* CscResultToDOMException(CapturedSurfaceControlResult result) {
switch (result) {
case CapturedSurfaceControlResult::kSuccess:
return nullptr;
case CapturedSurfaceControlResult::kUnknownError:
return MakeGarbageCollected<DOMException>(DOMExceptionCode::kUnknownError,
"Unknown error.");
case CapturedSurfaceControlResult::kNoPermissionError:
return MakeGarbageCollected<DOMException>(
DOMExceptionCode::kNotAllowedError, "No permission.");
case CapturedSurfaceControlResult::kCapturerNotFoundError:
return MakeGarbageCollected<DOMException>(
DOMExceptionCode::kNotFoundError, "Capturer not found.");
case CapturedSurfaceControlResult::kCapturedSurfaceNotFoundError:
return MakeGarbageCollected<DOMException>(
DOMExceptionCode::kNotFoundError, "Captured surface not found.");
case CapturedSurfaceControlResult::kDisallowedForSelfCaptureError:
return MakeGarbageCollected<DOMException>(
DOMExceptionCode::kInvalidStateError,
"API not supported for self-capture.");
case CapturedSurfaceControlResult::kCapturerNotFocusedError:
return MakeGarbageCollected<DOMException>(
DOMExceptionCode::kInvalidStateError,
"Capturing application not focused.");
case CapturedSurfaceControlResult::kMinZoomLevel:
return MakeGarbageCollected<DOMException>(
DOMExceptionCode::kInvalidStateError,
"Cannot decrease zoom level beyond the minimum value.");
case CapturedSurfaceControlResult::kMaxZoomLevel:
return MakeGarbageCollected<DOMException>(
DOMExceptionCode::kInvalidStateError,
"Cannot increase zoom level beyond the maximum value.");
}
NOTREACHED();
}
void OnCapturedSurfaceControlResult(
ScriptPromiseResolver<IDLUndefined>* resolver,
CapturedSurfaceControlResult result) {
if (auto* exception = CscResultToDOMException(result)) {
resolver->Reject(exception);
} else {
resolver->Resolve();
}
}
#endif // !BUILDFLAG(IS_ANDROID) && !BUILDFLAG(IS_IOS)
} // namespace
#if !BUILDFLAG(IS_ANDROID) && !BUILDFLAG(IS_IOS)
class CaptureController::WheelEventListener : public NativeEventListener {
public:
WheelEventListener(ScriptState* script_state, CaptureController* controller)
: script_state_(script_state), controller_(controller) {}
void ListenTo(HTMLElement* element) {
if (element_) {
element_->removeEventListener(event_type_names::kWheel, this,
/*use_capture=*/false);
}
element_ = element;
if (element_) {
element_->addEventListener(event_type_names::kWheel, this);
}
}
void StopListening() { ListenTo(nullptr); }
// NativeEventListener
void Invoke(ExecutionContext* context, Event* event) override {
CHECK(element_);
CHECK(controller_);
WheelEvent* wheel_event = DynamicTo<WheelEvent>(event);
if (!wheel_event || !wheel_event->isTrusted()) {
return;
}
DOMRect* element_rect = element_->GetBoundingClientRect();
double relative_x =
static_cast<double>(wheel_event->offsetX()) / element_rect->width();
double relative_y =
static_cast<double>(wheel_event->offsetY()) / element_rect->height();
if (relative_x < 0.0 || relative_x >= 1.0 || relative_y < 0.0 ||
relative_y >= 1.0) {
return;
}
controller_->SendWheel(relative_x, relative_y, -wheel_event->deltaX(),
-wheel_event->deltaY());
}
void Trace(Visitor* visitor) const override {
NativeEventListener::Trace(visitor);
visitor->Trace(script_state_);
visitor->Trace(controller_);
visitor->Trace(element_);
}
private:
Member<ScriptState> script_state_;
Member<CaptureController> controller_;
Member<HTMLElement> element_;
};
#endif // !BUILDFLAG(IS_ANDROID) && !BUILDFLAG(IS_IOS)
CaptureController::ValidationResult::ValidationResult(DOMExceptionCode code,
String message)
: code(code), message(message) {}
Vector<int> CaptureController::getSupportedZoomLevelsForTabs() {
const wtf_size_t kSize =
static_cast<wtf_size_t>(kPresetBrowserZoomFactors.size());
// If later developers modify `kPresetBrowserZoomFactors` to include many more
// entries than original intended, they should consider modifying this
// Web-exposed API to either:
// * Allow the Web application provide the max levels it wishes to receive.
// * Do some UA-determined trimming.
CHECK_LE(kSize, 100u) << "Excessive zoom levels.";
CHECK_EQ(kMinimumBrowserZoomFactor, kPresetBrowserZoomFactors.front());
CHECK_EQ(kMaximumBrowserZoomFactor, kPresetBrowserZoomFactors.back());
Vector<int> result(kSize);
if (kSize == 0) {
return result;
}
result[0] = base::ClampCeil(100 * kPresetBrowserZoomFactors[0]);
for (wtf_size_t i = 1; i < kSize; ++i) {
result[i] = base::ClampFloor(100 * kPresetBrowserZoomFactors[i]);
CHECK_LT(result[i - 1], result[i]) << "Must be monotonically increasing.";
}
return result;
}
CaptureController* CaptureController::Create(ExecutionContext* context) {
return MakeGarbageCollected<CaptureController>(context);
}
CaptureController::CaptureController(ExecutionContext* context)
: ExecutionContextClient(context)
#if !BUILDFLAG(IS_ANDROID) && !BUILDFLAG(IS_IOS)
,
media_stream_dispatcher_host_(context)
#endif
{
}
void CaptureController::setFocusBehavior(
V8CaptureStartFocusBehavior focus_behavior,
ExceptionState& exception_state) {
DCHECK(IsMainThread());
if (!GetExecutionContext()) {
return;
}
if (focus_decision_finalized_) {
exception_state.ThrowDOMException(
DOMExceptionCode::kInvalidStateError,
"The window of opportunity for focus-decision is closed.");
return;
}
if (!video_track_) {
focus_behavior_ = focus_behavior;
return;
}
if (video_track_->readyState() != V8MediaStreamTrackState::Enum::kLive) {
exception_state.ThrowDOMException(DOMExceptionCode::kInvalidStateError,
"The video track must be live.");
return;
}
if (!IsCaptureType(video_track_,
{SurfaceType::BROWSER, SurfaceType::WINDOW})) {
exception_state.ThrowDOMException(
DOMExceptionCode::kInvalidStateError,
"The captured display surface must be either a tab or a window.");
return;
}
focus_behavior_ = focus_behavior;
FinalizeFocusDecision();
}
ScriptPromise<IDLUndefined> CaptureController::forwardWheel(
ScriptState* script_state,
HTMLElement* element) {
DCHECK(IsMainThread());
auto* resolver =
MakeGarbageCollected<ScriptPromiseResolver<IDLUndefined>>(script_state);
const auto promise = resolver->Promise();
#if BUILDFLAG(IS_ANDROID) || BUILDFLAG(IS_IOS)
resolver->RejectWithDOMException(DOMExceptionCode::kNotSupportedError,
"Unsupported.");
return promise;
#else
if (!element) {
if (wheel_listener_) {
wheel_listener_->StopListening();
}
resolver->Resolve();
return promise;
}
std::optional<base::UnguessableToken> session_id =
GetCaptureSessionId(video_track_);
if (!session_id.has_value()) {
resolver->RejectWithDOMException(DOMExceptionCode::kInvalidStateError,
"Invalid capture.");
return promise;
}
ValidationResult validation_result = ValidateCapturedSurfaceControlCall();
if (validation_result.code != DOMExceptionCode::kNoError) {
resolver->RejectWithDOMException(validation_result.code,
validation_result.message);
return promise;
}
GetMediaStreamDispatcherHost()->RequestCapturedSurfaceControlPermission(
*session_id, BindOnce(&CaptureController::OnForwardWheelPermissionResult,
WrapWeakPersistent(this), WrapPersistent(resolver),
WrapWeakPersistent(element)));
return promise;
#endif // BUILDFLAG(IS_ANDROID) || BUILDFLAG(IS_IOS)
}
Vector<int> CaptureController::getSupportedZoomLevels(
ExceptionState& exception_state) {
if (!video_track_ || video_track_->Ended()) {
exception_state.ThrowDOMException(DOMExceptionCode::kInvalidStateError,
"Not actively capturing.");
return Vector<int>();
}
if (!IsCaptureType(video_track_, {SurfaceType::BROWSER})) {
exception_state.ThrowDOMException(DOMExceptionCode::kNotSupportedError,
"Not a supported surface type.");
return Vector<int>();
}
return getSupportedZoomLevelsForTabs();
}
std::optional<int> CaptureController::zoomLevel() const {
return zoom_level_;
}
ScriptPromise<IDLUndefined> CaptureController::increaseZoomLevel(
ScriptState* script_state) {
return UpdateZoomLevel(script_state,
mojom::blink::ZoomLevelAction::kIncrease);
}
ScriptPromise<IDLUndefined> CaptureController::decreaseZoomLevel(
ScriptState* script_state) {
return UpdateZoomLevel(script_state,
mojom::blink::ZoomLevelAction::kDecrease);
}
ScriptPromise<IDLUndefined> CaptureController::resetZoomLevel(
ScriptState* script_state) {
return UpdateZoomLevel(script_state, mojom::blink::ZoomLevelAction::kReset);
}
void CaptureController::SetVideoTrack(MediaStreamTrack* video_track,
std::string descriptor_id) {
DCHECK(IsMainThread());
DCHECK(video_track);
DCHECK(!video_track_);
DCHECK(!descriptor_id.empty());
DCHECK(descriptor_id_.empty());
video_track_ = video_track;
// The CaptureController-Source mapping cannot change after having been set
// up, and the observer remains until either object is garbage collected. No
// explicit deregistration of the observer is necessary.
video_track_->Component()->AddSourceObserver(this);
descriptor_id_ = std::move(descriptor_id);
#if !BUILDFLAG(IS_ANDROID) && !BUILDFLAG(IS_IOS)
zoom_level_ = GetInitialZoomLevel(video_track_);
#endif
}
const AtomicString& CaptureController::InterfaceName() const {
return event_target_names::kCaptureController;
}
ExecutionContext* CaptureController::GetExecutionContext() const {
return ExecutionContextClient::GetExecutionContext();
}
void CaptureController::FinalizeFocusDecision() {
DCHECK(IsMainThread());
if (focus_decision_finalized_) {
return;
}
focus_decision_finalized_ = true;
if (!video_track_ || !IsCaptureType(video_track_, {SurfaceType::BROWSER,
SurfaceType::WINDOW})) {
return;
}
UserMediaClient* client = UserMediaClient::From(DomWindow());
if (!client) {
return;
}
if (!focus_behavior_) {
return;
}
#if !BUILDFLAG(IS_ANDROID) && !BUILDFLAG(IS_IOS)
client->FocusCapturedSurface(
String(descriptor_id_),
ShouldFocusCapturedSurface(focus_behavior_.value()));
#endif
}
#if !BUILDFLAG(IS_ANDROID) && !BUILDFLAG(IS_IOS)
void CaptureController::SourceChangedZoomLevel(int zoom_level) {
DCHECK(IsMainThread());
if (!video_track_ || video_track_->Ended() ||
!IsCaptureType(video_track_, {SurfaceType::BROWSER}) ||
zoom_level_ == zoom_level) {
return;
}
zoom_level_ = zoom_level;
DispatchEvent(*Event::Create(event_type_names::kZoomlevelchange));
}
void CaptureController::OnForwardWheelPermissionResult(
ScriptPromiseResolver<IDLUndefined>* resolver,
HTMLElement* element,
CapturedSurfaceControlResult result) {
DCHECK(IsMainThread());
DOMException* exception = CscResultToDOMException(result);
if (exception) {
resolver->Reject(exception);
return;
}
if (!wheel_listener_) {
wheel_listener_ = MakeGarbageCollected<WheelEventListener>(
resolver->GetScriptState(), this);
}
wheel_listener_->ListenTo(element);
resolver->Resolve();
}
mojom::blink::MediaStreamDispatcherHost*
CaptureController::GetMediaStreamDispatcherHost() {
DCHECK(IsMainThread());
CHECK(GetExecutionContext());
if (!media_stream_dispatcher_host_.is_bound()) {
GetExecutionContext()->GetBrowserInterfaceBroker().GetInterface(
media_stream_dispatcher_host_.BindNewPipeAndPassReceiver(
GetExecutionContext()->GetTaskRunner(
TaskType::kInternalMediaRealTime)));
}
return media_stream_dispatcher_host_.get();
}
void CaptureController::SetMediaStreamDispatcherHostForTesting(
mojo::PendingRemote<mojom::blink::MediaStreamDispatcherHost> host) {
media_stream_dispatcher_host_.Bind(
std::move(host),
GetExecutionContext()->GetTaskRunner(TaskType::kInternalMediaRealTime));
}
#endif
void CaptureController::Trace(Visitor* visitor) const {
visitor->Trace(video_track_);
#if !BUILDFLAG(IS_ANDROID) && !BUILDFLAG(IS_IOS)
visitor->Trace(wheel_listener_);
visitor->Trace(media_stream_dispatcher_host_);
#endif
EventTarget::Trace(visitor);
ExecutionContextClient::Trace(visitor);
}
CaptureController::ValidationResult
CaptureController::ValidateCapturedSurfaceControlCall() const {
if (!is_bound_) {
return ValidationResult(DOMExceptionCode::kInvalidStateError,
"getDisplayMedia() not called yet.");
}
if (!video_track_) {
return ValidationResult(DOMExceptionCode::kInvalidStateError,
"Capture-session not started.");
}
if (video_track_->readyState() == V8MediaStreamTrackState::Enum::kEnded) {
return ValidationResult(DOMExceptionCode::kInvalidStateError,
"Video track ended.");
}
if (!IsCaptureType(video_track_, {SurfaceType::BROWSER})) {
return ValidationResult(DOMExceptionCode::kNotSupportedError,
"Action only supported for tab-capture.");
}
return ValidationResult(DOMExceptionCode::kNoError, "");
}
ScriptPromise<IDLUndefined> CaptureController::UpdateZoomLevel(
ScriptState* script_state,
mojom::blink::ZoomLevelAction action) {
DCHECK(IsMainThread());
auto* resolver =
MakeGarbageCollected<ScriptPromiseResolver<IDLUndefined>>(script_state);
const auto promise = resolver->Promise();
#if BUILDFLAG(IS_ANDROID) || BUILDFLAG(IS_IOS)
resolver->RejectWithDOMException(DOMExceptionCode::kNotSupportedError,
"Unsupported.");
return promise;
#else
ValidationResult validation_result = ValidateCapturedSurfaceControlCall();
if (validation_result.code != DOMExceptionCode::kNoError) {
resolver->RejectWithDOMException(validation_result.code,
validation_result.message);
return promise;
}
const std::optional<base::UnguessableToken>& session_id =
GetCaptureSessionId(video_track_);
if (!session_id.has_value()) {
resolver->RejectWithDOMException(DOMExceptionCode::kUnknownError,
"Invalid capture");
return promise;
}
GetMediaStreamDispatcherHost()->UpdateZoomLevel(
session_id.value(), action,
BindOnce(&OnCapturedSurfaceControlResult, WrapPersistent(resolver)));
return promise;
#endif // BUILDFLAG(IS_ANDROID) || BUILDFLAG(IS_IOS)
}
#if !BUILDFLAG(IS_ANDROID) && !BUILDFLAG(IS_IOS)
void CaptureController::SendWheel(double relative_x,
double relative_y,
int32_t wheel_delta_x,
int32_t wheel_delta_y) {
const std::optional<base::UnguessableToken>& session_id =
GetCaptureSessionId(video_track_);
if (!session_id.has_value()) {
return;
}
GetMediaStreamDispatcherHost()->SendWheel(
*session_id, blink::mojom::blink::CapturedWheelAction::New(
relative_x, relative_y, wheel_delta_x, wheel_delta_y));
}
#endif // !BUILDFLAG(IS_ANDROID) && !BUILDFLAG(IS_IOS)
} // namespace blink