All Downloads are FREE. Search and download functionalities are using the official Maven repository.

io.taig.taigless.chrome.WebSocketChromeChannel.scala Maven / Gradle / Ivy

The newest version!
package io.taig.taigless.chrome

import java.net.URI

import scala.concurrent.duration.{DurationInt, FiniteDuration}
import scala.sys.process.{Process, ProcessLogger}

import cats.effect.std.Dispatcher
import cats.effect.syntax.all._
import cats.effect.{Async, Deferred, Ref, Resource}
import cats.syntax.all._
import fs2.Stream
import fs2.concurrent.Topic
import fs2.io.file.{Files, Path}
import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder}
import io.circe.parser.decode
import io.circe.syntax._
import io.circe.{Decoder, Encoder, Json, Printer}
import io.taig.taigless.ws.{Connection, WebSocket}

final class WebSocketChromeChannel[F[_]](connection: Connection[F, String], messages: Topic[F, String], ids: F[Long])(
    printer: Printer,
    timeout: FiniteDuration
)(implicit F: Async[F]) {
  def send(method: WebSocketChromeChannel.Method): F[Json] = ids.flatMap { id =>
    subscribe(id, method)
      .collectFirst { case WebSocketChromeChannel.Message.Response(`id`, payload) => payload }
      .compile
      .lastOrError
      .rethrow
      .timeout(timeout)
  }

  def subscribe(method: WebSocketChromeChannel.Method): Stream[F, WebSocketChromeChannel.Message] =
    Stream.eval(ids).flatMap(subscribe(_, method))

  private def subscribe(id: Long, method: WebSocketChromeChannel.Method): Stream[F, WebSocketChromeChannel.Message] =
    connection
      .send(printer.print(WebSocketChromeChannel.Request(id, method).asJson))
      .evalMap(value => F.fromEither(decode[WebSocketChromeChannel.Message](value)))

  def logs(maxQueued: Int): Resource[F, Stream[F, String]] = messages.subscribeAwait(maxQueued)
}

object WebSocketChromeChannel {
  sealed abstract class Method extends Product with Serializable

  object Method {
    sealed abstract class Browser extends Method

    object Browser {
      case object Close extends Browser
    }

    sealed abstract class Emulation extends Method

    object Emulation {
      final case class SetDeviceMetricsOverride(
          session: String,
          width: Int,
          height: Int,
          deviceScaleFactor: Double,
          mobile: Boolean
      ) extends Emulation
    }

    sealed abstract class Page extends Method

    object Page {
      final case class CaptureScreenshot(
          session: String,
          format: Option[String],
          quality: Option[Int],
          clip: Option[Viewport],
          fromSurface: Option[Boolean],
          captureBeyondViewport: Option[Boolean]
      ) extends Page
      final case class Enable(session: String) extends Page
      final case class Navigate(session: String, url: String) extends Page
      final case class SetLifecycleEventsEnabled(session: String, enabled: Boolean) extends Page

      final case class Viewport(x: Double, y: Double, width: Double, height: Double, scale: Double)

      object Viewport {
        implicit val encoder: Encoder[Viewport] = deriveEncoder
      }
    }

    sealed abstract class Runtime extends Method

    object Runtime {
      final case class AwaitPromise(session: String, promise: String) extends Runtime
      final case class Evaluate(session: String, expression: String) extends Runtime
    }

    sealed abstract class Target extends Method

    object Target {
      final case class Activate(target: String) extends Target
      final case class Attach(target: String) extends Target
      final case class Close(target: String) extends Target
      final case class Create(url: String) extends Target
    }

    implicit val encoder: Encoder[Method] = Encoder.instance {
      case Browser.Close => Json.obj("method" := "Browser.close")
      case Emulation.SetDeviceMetricsOverride(session, width, height, deviceScaleFactor, mobile) =>
        Json.obj(
          "sessionId" := session,
          "method" := "Emulation.setDeviceMetricsOverride",
          "params" := Json.obj(
            "width" := width,
            "height" := height,
            "deviceScaleFactor" := deviceScaleFactor,
            "mobile" := mobile
          )
        )
      case Page.CaptureScreenshot(session, format, quality, clip, fromSurface, captureBeyondViewport) =>
        Json.obj(
          "sessionId" := session,
          "method" := "Page.captureScreenshot",
          "params" := Json.obj(
            "format" := format,
            "quality" := quality,
            "clip" := clip,
            "fromSurface" := fromSurface,
            "captureBeyondViewport" := captureBeyondViewport
          )
        )
      case Page.Enable(session) => Json.obj("sessionId" := session, "method" := "Page.enable")
      case Page.Navigate(session, url) =>
        Json.obj(
          "sessionId" := session,
          "method" := "Page.navigate",
          "params" := Json.obj("url" := url)
        )
      case Page.SetLifecycleEventsEnabled(session, enabled) =>
        Json.obj(
          "sessionId" := session,
          "method" := "Page.setLifecycleEventsEnabled",
          "params" := Json.obj("enabled" := enabled)
        )
      case Runtime.AwaitPromise(session, promise) =>
        Json.obj(
          "sessionId" := session,
          "method" := "Runtime.awaitPromise",
          "params" := Json.obj("promiseObjectId" := promise)
        )
      case Runtime.Evaluate(session, expression) =>
        Json.obj(
          "sessionId" := session,
          "method" := "Runtime.evaluate",
          "params" := Json.obj("expression" := expression)
        )
      case Target.Activate(target) =>
        Json.obj("method" := "Target.activateTarget", "params" := Json.obj("targetId" := target))
      case Target.Attach(target) =>
        Json.obj("method" := "Target.attachToTarget", "params" := Json.obj("targetId" := target, "flatten" := true))
      case Target.Close(target) =>
        Json.obj("method" := "Target.closeTarget", "params" := Json.obj("targetId" := target))
      case Target.Create(url) => Json.obj("method" := "Target.createTarget", "params" := Json.obj("url" := url))
    }
  }

  sealed abstract class Message extends Product with Serializable

  object Message {
    final case class Response(id: Long, payload: Either[Error, Json]) extends Message

    final case class Event(method: String, params: Json) extends Message

    object Event {
      implicit val decoder: Decoder[Event] = deriveDecoder
    }

    implicit val decoder: Decoder[Message] = Decoder.instance { cursor =>
      cursor
        .get[Long]("id")
        .flatMap { id =>
          cursor
            .get[Error]("error")
            .map(_.asLeft)
            .orElse(cursor.get[Json]("result").map(_.asRight))
            .map(Response(id, _))
        }
        .orElse(Decoder[Event].apply(cursor))
    }
  }

  final case class Request(id: Long, method: Method)

  object Request {
    implicit val encoder: Encoder[Request] = Encoder.instance { command =>
      Json.obj("id" := command.id).deepMerge(command.method.asJson)
    }
  }

  final case class Error(code: Int, message: String) extends RuntimeException(message)

  object Error {
    implicit val decoder: Decoder[Error] = deriveDecoder
  }

  final case class JavaScriptExecutionFailure(exception: Json) extends RuntimeException(exception.spaces2)

  object JavaScriptExecutionFailure {
    implicit val decoder: Decoder[JavaScriptExecutionFailure] = Decoder.instance { cursor =>
      cursor.get[Json]("exceptionDetails").map(JavaScriptExecutionFailure(_))
    }
  }

  val DefaultExecutables: List[String] =
    "/usr/bin/chromium" ::
      "/usr/bin/chromium-browser" ::
      "/usr/bin/google-chrome-stable" ::
      "/usr/bin/google-chrome" ::
      "/snap/bin/chromium" ::
      "/Applications/Chromium.app/Contents/MacOS/Chromium" ::
      "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" ::
      "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary" ::
      "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe" ::
      "C:/Program Files/Google/Chrome/Application/chrome.exe" ::
      Nil

  val DefaultArguments: List[String] =
    "--headless" ::
      "--no-sandbox" ::
      "--no-first-run" ::
      "--no-default-browser-check" ::
      "--disable-background-networking" ::
      "--disable-background-timer-throttling" ::
      "--disable-client-side-phishing-detection" ::
      "--disable-default-apps" ::
      "--disable-extensions" ::
      "--disable-hang-monitor" ::
      "--disable-popup-blocking" ::
      "--disable-prompt-on-repost" ::
      "--disable-sync" ::
      "--disable-translate" ::
      "--metrics-recording-only" ::
      "--force-gpu-mem-available-mb=4096" ::
      "--hide-scrollbars" ::
      "--use-gl=swiftshader" ::
      "--disable-software-rasterizer" ::
      "--disable-dev-shm-usage" ::
      "--enable-logging" ::
      "--v=1" ::
      Nil

  val DefaultTimeout: FiniteDuration = 10.seconds

  def apply[F[_]: Async](connection: Connection[F, String], messages: Topic[F, String])(
      timeout: FiniteDuration
  ): F[WebSocketChromeChannel[F]] =
    Ref[F].of(1L).map(_.getAndUpdate(_ + 1)).map { ids =>
      val printer = Printer(dropNullValues = true, indent = "")
      new WebSocketChromeChannel[F](connection, messages, ids)(printer, timeout)
    }

  def default[F[_]](
      dispatcher: Dispatcher[F],
      executable: String,
      arguments: List[String] = DefaultArguments,
      timeout: FiniteDuration = DefaultTimeout
  )(implicit
      F: Async[F]
  ): Resource[F, WebSocketChromeChannel[F]] = {
    // Std out message to look for that indicates a successful start of the chrome application
    val needle = "DevTools listening on"

    val topic = Resource.make(Topic[F, String])(_.close.void)
    val deferred = Resource.eval(Deferred[F, Either[Throwable, Resource[F, WebSocketChromeChannel[F]]]])

    (topic, deferred).tupled.flatMap { case (topic, deferred) =>
      val builder = Process(executable :: "--remote-debugging-port=0" :: arguments)

      val enqueue: String => Unit = { value =>
        dispatcher.unsafeRunAndForget {
          value.split('\n').toList.map(_.trim).filter(_.nonEmpty).traverse_(topic.publish1)
        }
      }

      val logger = ProcessLogger(enqueue, enqueue)
      val process = Resource.make(F.blocking(builder.run(logger)))(process => F.blocking(process.destroy()))

      process *> topic
        .subscribeAwait(maxQueued = 10)
        .use { messages =>
          messages
            .filter(!_.matches("\\[.+\\] .*"))
            .take(10)
            .mapFilter { value =>
              Option.when(value.startsWith(needle)) {
                WebSocket
                  .default[F](new URI(value.substring(needle.length + 1)), maxQueued = 100)
                  .evalMap(WebSocketChromeChannel[F](_, topic)(timeout))
              }
            }
            .head
            .compile
            .last
            .flatMap {
              case Some(channel) => deferred.complete(Right(channel))
              case None =>
                val exception = new IllegalStateException("Failed to start chrome. Is the process already running?")
                deferred.complete(Left(exception))
            }
        }
        .background *> Resource.eval(deferred.get).rethrow.flatten
    }
  }

  def auto[F[_]: Async](
      dispatcher: Dispatcher[F],
      arguments: List[String] = DefaultArguments,
      timeout: FiniteDuration = DefaultTimeout
  ): Resource[F, WebSocketChromeChannel[F]] =
    Resource
      .eval {
        DefaultExecutables
          .map(Path(_))
          .findM { path =>
            for {
              regular <- Files[F].isRegularFile(path)
              readable <- Files[F].isReadable(path)
              executable <- Files[F].isExecutable(path)
            } yield regular && readable && executable
          }
          .flatMap(_.liftTo[F](new IllegalStateException("Could not find a chrome executable")))
      }
      .flatMap(executable => default(dispatcher, executable.toString, arguments, timeout))
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy