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))
}