chromium/content/browser/media/capture/screen_capture_kit_fullscreen_module.mm

// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#include "content/browser/media/capture/screen_capture_kit_fullscreen_module.h"

#include <array>

#include "base/metrics/histogram_functions.h"
#include "base/task/bind_post_task.h"
#import "base/task/single_thread_task_runner.h"
#include "content/public/common/content_features.h"

namespace content {
namespace {

BASE_FEATURE(kScreenCaptureKitFullscreenModule,
             "ScreenCaptureKitFullscreenModule",
             base::FEATURE_ENABLED_BY_DEFAULT);

static NSString* const kApplicationNameKeynote = @"Keynote";
static NSString* const kApplicationNameLibreOffice = @"LibreOffice";
static NSString* const kApplicationNamePowerPoint = @"Microsoft PowerPoint";
static NSString* const kApplicationNameOpenOffice = @"OpenOffice";

static NSString* const kEditorWindowNameOpenOffice = @" OpenOffice Impress";

bool IsPowerPointSlideShow(NSString* window_title) {
  // Localized strings of the title name that identifies the PowerPoint slide
  // show. This is needed in order to distinguish between the slide show window
  // and the presenter's view.
  static NSArray<NSString*>* kPowerPointSlideShowTitles = @[
    @"PowerPoint-Bildschirmpräsentation",
    @"Προβολή παρουσίασης PowerPoint",
    @"PowerPoint スライド ショー",
    @"PowerPoint Slide Show",
    @"PowerPoint 幻灯片放映",
    @"Presentación de PowerPoint",
    @"PowerPoint-slideshow",
    @"Presentazione di PowerPoint",
    @"Prezentácia programu PowerPoint",
    @"Apresentação do PowerPoint",
    @"PowerPoint-bildspel",
    @"Prezentace v aplikaci PowerPoint",
    @"PowerPoint 슬라이드 쇼",
    @"PowerPoint-lysbildefremvisning",
    @"PowerPoint-vetítés",
    @"PowerPoint Slayt Gösterisi",
    @"Pokaz slajdów programu PowerPoint",
    @"PowerPoint 投影片放映",
    @"Демонстрация PowerPoint",
    @"Diaporama PowerPoint",
    @"PowerPoint-diaesitys",
    @"Peragaan Slide PowerPoint",
    @"PowerPoint-diavoorstelling",
    @"การนำเสนอสไลด์ PowerPoint",
    @"Apresentação de slides do PowerPoint",
    @"הצגת שקופיות של PowerPoint",
    @"عرض شرائح في PowerPoint"
  ];

  for (NSString* pp_slide_title in kPowerPointSlideShowTitles) {
    if ([window_title hasPrefix:pp_slide_title]) {
      return true;
    }
  }
  return false;
}

bool IsOpenOfficeImpressWindow(NSString* window_title) {
  return [window_title hasSuffix:kEditorWindowNameOpenOffice];
}

bool API_AVAILABLE(macos(12.3))
    IsWindowFullscreen(SCWindow* window, NSArray<SCDisplay*>* displays) {
  for (SCDisplay* display : displays) {
    if (CGRectEqualToRect(window.frame, display.frame)) {
      return true;
    }
  }
  return false;
}

void API_AVAILABLE(macos(12.3))
    LogModeToUma(ScreenCaptureKitFullscreenModule::Mode mode) {
  base::UmaHistogramEnumeration("Media.ScreenCaptureKit.FullscreenModuleMode",
                                mode);
}

}  // namespace

std::unique_ptr<ScreenCaptureKitFullscreenModule>
MaybeCreateScreenCaptureKitFullscreenModule(
    scoped_refptr<base::SingleThreadTaskRunner> device_task_runner,
    ScreenCaptureKitResetStreamInterface& reset_stream_interface,
    SCWindow* original_window) {
  if (base::FeatureList::IsEnabled(kScreenCaptureKitFullscreenModule)) {
    // Check if we should enable the fullscreen module for this window and what
    // mode to use.
    if ([kApplicationNamePowerPoint
            isEqualToString:original_window.owningApplication
                                .applicationName]) {
      return std::make_unique<ScreenCaptureKitFullscreenModule>(
          device_task_runner, reset_stream_interface, original_window.windowID,
          original_window.owningApplication.processID,
          ScreenCaptureKitFullscreenModule::Mode::kPowerPoint);
    }
    if ([kApplicationNameKeynote
            isEqualToString:original_window.owningApplication
                                .applicationName]) {
      return std::make_unique<ScreenCaptureKitFullscreenModule>(
          device_task_runner, reset_stream_interface, original_window.windowID,
          original_window.owningApplication.processID,
          ScreenCaptureKitFullscreenModule::Mode::kKeynote);
    }
    if ([kApplicationNameOpenOffice
            isEqualToString:original_window.owningApplication
                                .applicationName] &&
        IsOpenOfficeImpressWindow(original_window.title)) {
      return std::make_unique<ScreenCaptureKitFullscreenModule>(
          device_task_runner, reset_stream_interface, original_window.windowID,
          original_window.owningApplication.processID,
          ScreenCaptureKitFullscreenModule::Mode::kOpenOffice);
    }
    if ([kApplicationNameLibreOffice
            isEqualToString:original_window.owningApplication
                                .applicationName]) {
      // TODO(crbug.com/40233195): Implement support for LibreOffice.
      LogModeToUma(ScreenCaptureKitFullscreenModule::Mode::kLibreOffice);
      return nullptr;
    }
  }
  LogModeToUma(ScreenCaptureKitFullscreenModule::Mode::kUnsupported);
  return nullptr;
}

ScreenCaptureKitFullscreenModule::ScreenCaptureKitFullscreenModule(
    scoped_refptr<base::SingleThreadTaskRunner> device_task_runner,
    ScreenCaptureKitResetStreamInterface& reset_stream_interface,
    CGWindowID original_window_id,
    pid_t original_window_pid,
    Mode mode)
    : device_task_runner_(device_task_runner),
      reset_stream_interface_(reset_stream_interface),
      original_window_id_(original_window_id),
      original_window_pid_(original_window_pid),
      mode_(mode) {
  CHECK_NE(mode, Mode::kUnsupported);
  LogModeToUma(mode);
}

ScreenCaptureKitFullscreenModule::~ScreenCaptureKitFullscreenModule() = default;

void ScreenCaptureKitFullscreenModule::Start() {
  // Create a timer to periodically check if a new fullscreen window has been
  // created. The delay is set to 800 ms to give a response time that is less
  // than 1 second. Reducing the delay would increase the responsiveness at the
  // cost of a higher CPU load.
  timer_.Start(
      FROM_HERE, base::Milliseconds(800), this,
      &ScreenCaptureKitFullscreenModule::CheckForFullscreenPresentation);
}

void ScreenCaptureKitFullscreenModule::Reset() {
  timer_.Stop();
  fullscreen_mode_active_ = false;
  fullscreen_window_id_ = 0;
}

void ScreenCaptureKitFullscreenModule::CheckForFullscreenPresentation() {
  DCHECK(device_task_runner_->RunsTasksInCurrentSequence());
  auto content_callback = base::BindPostTask(
      device_task_runner_,
      base::BindRepeating(&ScreenCaptureKitFullscreenModule::
                              OnFullscreenShareableContentCreated,
                          weak_factory_.GetWeakPtr()));

  if (get_shareable_content_for_test_) {
    get_shareable_content_for_test_.Run(content_callback);
  } else {
    auto handler = ^(SCShareableContent* content, NSError* error) {
      content_callback.Run(content);
    };
    [SCShareableContent getShareableContentExcludingDesktopWindows:true
                                               onScreenWindowsOnly:false
                                                 completionHandler:handler];
  }
}

void ScreenCaptureKitFullscreenModule::OnFullscreenShareableContentCreated(
    SCShareableContent* content) {
  DCHECK(device_task_runner_->RunsTasksInCurrentSequence());
  if (!content || !timer_.IsRunning()) {
    return;
  }
  SCWindow* editor_window = nullptr;
  int number_of_impress_editor_windows = 0;
  for (SCWindow* window in content.windows) {
    if (window.windowID == original_window_id_) {
      editor_window = window;
    }
    if (mode_ == Mode::kOpenOffice &&
        window.owningApplication.processID == original_window_pid_ &&
        window.isOnScreen && !IsWindowFullscreen(window, [content displays]) &&
        IsOpenOfficeImpressWindow(window.title)) {
      ++number_of_impress_editor_windows;
    }
  }

  if (fullscreen_mode_active_) {
    // Verify that the fullscreen window is on screen. Reset to the original
    // window otherwise.
    for (SCWindow* window : [content windows]) {
      if (window.windowID == fullscreen_window_id_) {
        if (!window.isOnScreen) {
          fullscreen_mode_active_ = false;
          reset_stream_interface_->ResetStreamTo(editor_window);
        }
        break;
      }
    }
  } else {
    SCWindow* fullscreen_window =
        editor_window ? GetFullscreenWindow(content, editor_window,
                                            number_of_impress_editor_windows)
                      : nullptr;
    if (fullscreen_window) {
      // Fullscreen window detected, update stream to capture this window
      // instead.
      fullscreen_mode_active_ = true;
      fullscreen_window_id_ = fullscreen_window.windowID;
      reset_stream_interface_->ResetStreamTo(fullscreen_window);
    }
  }
}

SCWindow* ScreenCaptureKitFullscreenModule::GetFullscreenWindow(
    SCShareableContent* content,
    SCWindow* editor_window,
    int number_of_impress_editor_windows) const {
  DCHECK(device_task_runner_->RunsTasksInCurrentSequence());
  SCWindow* fullscreen_window = nullptr;
  int fullscreenWindowLayer = 0;
  for (SCWindow* window in content.windows) {
    // Only check windows that belong to the same application as the original
    // window.
    if (window.owningApplication.processID == original_window_pid_ &&
        window.windowID != original_window_id_ && window.onScreen &&
        [window.owningApplication.applicationName
            isEqualToString:editor_window.owningApplication.applicationName] &&
        IsWindowFullscreen(window, content.displays)) {
      switch (mode_) {
        case Mode::kPowerPoint:
          if ([window.title containsString:editor_window.title]) {
            if (IsPowerPointSlideShow(window.title)) {
              fullscreen_window = window;
            }
          }
          break;
        case Mode::kOpenOffice:
          // Since the window title is empty, we cannot make a certain match to
          // determine what presentation is fullscreen if there are more than
          // one Impress editor window open.
          if (window.title.length == 0 &&
              number_of_impress_editor_windows == 1) {
            fullscreen_window = window;
          }
          break;
        case Mode::kKeynote:
          // For Keynote we must select the window with the highest layer.
          if ([window.title isEqualToString:editor_window.title] &&
              !editor_window.onScreen &&
              window.windowLayer > fullscreenWindowLayer) {
            fullscreen_window = window;
            fullscreenWindowLayer = window.windowLayer;
          }
          break;
        case Mode::kLibreOffice:
        // TODO(crbug.com/40233195): Implement support for LibreOffice.
        case Mode::kUnsupported:
          NOTREACHED_IN_MIGRATION();
      }
    }
  }
  return fullscreen_window;
}

}  // namespace content