From af3bfcb2216ec101d2a660b16e928388bd43b46f Mon Sep 17 00:00:00 2001 From: memo Date: Sun, 15 Dec 2024 14:47:39 +0100 Subject: [PATCH 1/3] implement /packages.open endpoint for externally-triggered opening of packages in the GUI application. --- api.md | 25 ++++++++++++++++++- src/main/scala/sc4pac/api/api.scala | 33 +++++++++++++++++++------ src/main/scala/sc4pac/api/message.scala | 6 +++++ src/main/scala/sc4pac/cli.scala | 4 ++- 4 files changed, 59 insertions(+), 9 deletions(-) diff --git a/api.md b/api.md index a3da370..bfb40e5 100644 --- a/api.md +++ b/api.md @@ -11,6 +11,7 @@ POST /profile.init?profile=id {plugins: "", cache: "", temp: GET /packages.list?profile=id GET /packages.info?pkg=&profile=id GET /packages.search?q=&profile=id +POST /packages.open [{package: "", channelUrl: ""}] GET /plugins.added.list?profile=id GET /plugins.installed.list?profile=id @@ -197,6 +198,22 @@ Returns: ``` The `status` field contains the local installation status if the package has been explicitly added or actually installed. +## packages.open + +Tell the server to tell the client to show a particular package, using the current profile. +This endpoint is intended for external invocation, such as an "Open in app" button on a website. + +Synopsis: `POST /packages.open [{package: "", channelUrl: ""}]` + +Returns: +- 200 `{"$type": "/result", "ok": true}` +- 503 if GUI is not connected to API server + +Example: +```sh +curl -X POST -d '[{"package": "cyclone-boom:save-warning", "channelUrl": "https://memo33.github.io/sc4pac/channel/"}]' http://localhost:51515/packages.open +``` +The client will be informed by the `/server.connect` websocket. ## plugins.added.list @@ -452,9 +469,15 @@ Returns: `{"sc4pacVersion": "0.4.x"}` ## server.connect Monitor whether the server is still running by opening a websocket at this endpoint. -No particular messages are exchanged, but if either client or server terminates, +Usually, no particular messages are exchanged, but if either client or server terminates, the other side will be informed about it as the websocket closes. +Triggered by an external `/packages.open` request, +the server may send the following message to tell the client to show a particular package, using the current profile: +``` +{ "$type": "/prompt/open/package", "packages": [{"package": "", "channelUrl": ""}] } +``` + ## profiles.list Get the list of all existing profiles, each corresponding to a Plugins folder. diff --git a/src/main/scala/sc4pac/api/api.scala b/src/main/scala/sc4pac/api/api.scala index fc5ffff..daefb8a 100644 --- a/src/main/scala/sc4pac/api/api.scala +++ b/src/main/scala/sc4pac/api/api.scala @@ -4,12 +4,12 @@ package api import zio.http.* import zio.http.ChannelEvent.{Read, Unregistered, UserEvent, UserEventTriggered} -import zio.{ZIO, IO, URIO} +import zio.{ZIO, IO, URIO, Ref} import upickle.default as UP import sc4pac.JsonData as JD import JD.{bareModuleRw, uriRw} -import sc4pac.cli.Commands.Server.ServerFiber +import sc4pac.cli.Commands.Server.{ServerFiber, ServerConnection} class Api(options: sc4pac.cli.Commands.ServerOptions) { @@ -314,7 +314,7 @@ class Api(options: sc4pac.cli.Commands.ServerOptions) { pluginsData <- readPluginsOr409 pac <- Sc4pac.init(pluginsData.config) remoteData <- pac.infoJson(mod).someOrFail( // TODO avoid decoding/encoding json - jsonResponse(ErrorMessage.PackageNotFound("Package not found.", pkg)).status(Status.NotFound) + jsonResponse(ErrorMessage.PackageNotFound("Package not found in any of your channels.", pkg)).status(Status.NotFound) ) explicit = pluginsData.explicit.toSet installed <- JD.PluginsLock.listInstalled2 @@ -438,7 +438,7 @@ class Api(options: sc4pac.cli.Commands.ServerOptions) { ) - def routes(webAppDir: Option[os.Path]): Routes[ProfilesDir & ServerFiber & Client, Nothing] = { + def routes(webAppDir: Option[os.Path]): Routes[ProfilesDir & ServerFiber & Client & Ref[ServerConnection], Nothing] = { // Extract profile ID from URL query parameter and add it to environment. // 400 error if "profile" parameter is absent. val profileRoutes2 = @@ -454,7 +454,7 @@ class Api(options: sc4pac.cli.Commands.ServerOptions) { }) // profile-independent routes - val genericRoutes = Routes[ProfilesDir & ServerFiber & Client, Throwable]( + val genericRoutes = Routes[ProfilesDir & ServerFiber & Client & Ref[ServerConnection], Throwable]( // 200 Method.GET / "server.status" -> handler { @@ -467,7 +467,7 @@ class Api(options: sc4pac.cli.Commands.ServerOptions) { Method.GET / "server.connect" -> handler { val num = connectionCount.incrementAndGet() Handler.webSocket { wsChannel => - for { + val wsTask = for { logger <- ZIO.service[Logger] _ <- ZIO.succeed(logger.log(s"Registered websocket connection $num.")) _ <- wsChannel.receiveAll { @@ -489,9 +489,28 @@ class Api(options: sc4pac.cli.Commands.ServerOptions) { _ <- fiber.interrupt.fork } yield () } yield () - }.provideSomeLayer(httpLogger).toResponse: zio.URIO[ProfilesDir & ServerFiber, Response] + + val serverConnection = ServerConnection(currentChannel = Some(wsChannel)) + ZIO.acquireReleaseWith + (acquire = ZIO.serviceWithZIO[Ref[ServerConnection]](_.set(serverConnection))) + (release = (_) => ZIO.serviceWithZIO[Ref[ServerConnection]](_.updateSome { case s if s == serverConnection => ServerConnection(currentChannel = None) })) + (use = (_) => wsTask) + }.provideSomeLayer(httpLogger).toResponse }, + // 200, 400, 503 + Method.POST / "packages.open" -> handler((req: Request) => wrapHttpEndpoint { + for { + packages <- parseOr400[Seq[OpenPackageMessage.Item]](req.body, ErrorMessage.BadRequest("Malformed package list.", "Pass an array of package items.")) + wsChannel <- ZIO.serviceWithZIO[Ref[ServerConnection]](_.get.map(_.currentChannel)) + .someOrFail(jsonResponse(ErrorMessage.ServerError( + "The sc4pac GUI is not opened. Make sure the GUI is running correctly.", + "Connection between API server and GUI client is not available." + )).status(Status.ServiceUnavailable)) + _ <- wsChannel.send(Read(jsonFrame(OpenPackageMessage(packages)))) + } yield jsonOk + }), + // 200 Method.GET / "profiles.list" -> handler { wrapHttpEndpoint { diff --git a/src/main/scala/sc4pac/api/message.scala b/src/main/scala/sc4pac/api/message.scala index aeff928..503102d 100644 --- a/src/main/scala/sc4pac/api/message.scala +++ b/src/main/scala/sc4pac/api/message.scala @@ -77,6 +77,12 @@ object PromptMessage { @upickle.implicits.key("/prompt/response") case class ResponseMessage(token: String, body: String) extends Message derives UP.ReadWriter +@upickle.implicits.key("/prompt/open/package") +case class OpenPackageMessage(packages: Seq[OpenPackageMessage.Item]) extends Message derives UP.ReadWriter +object OpenPackageMessage { + case class Item(`package`: BareModule, channelUrl: String) derives UP.ReadWriter +} + sealed trait ErrorMessage extends Message derives UP.ReadWriter { def title: String def detail: String diff --git a/src/main/scala/sc4pac/cli.scala b/src/main/scala/sc4pac/cli.scala index 20a6914..dc489b5 100644 --- a/src/main/scala/sc4pac/cli.scala +++ b/src/main/scala/sc4pac/cli.scala @@ -242,7 +242,7 @@ object Commands { } yield { val (found, notFound) = infoResults.zip(mods).partition(_._1.isDefined) if (notFound.nonEmpty) { - error(caseapp.core.Error.Other("Package not found: " + notFound.map(_._2.orgName).mkString(" "))) + error(caseapp.core.Error.Other("Package not found in any of your channels: " + notFound.map(_._2.orgName).mkString(" "))) } else { for ((infoResultOpt, idx) <- found.zipWithIndex) { if (idx > 0) logger.log("") @@ -569,6 +569,7 @@ object Commands { .map(_.update[zio.http.Client](_.updateHeaders(_.addHeader("User-Agent", Constants.userAgent)) @@ followRedirects)), zio.ZLayer.succeed(ProfilesDir(profilesDir)), zio.ZLayer.succeed(ServerFiber(promise)), + zio.ZLayer(zio.Ref.make(ServerConnection(None))), ) .fork _ <- promise.succeed(fiber) @@ -589,6 +590,7 @@ object Commands { } class ServerFiber(val promise: zio.Promise[Nothing, zio.Fiber[Throwable, Nothing]]) + class ServerConnection(val currentChannel: Option[zio.http.WebSocketChannel]) } } From fae63637efc756f2967448078d21628ea7efccfe Mon Sep 17 00:00:00 2001 From: memo Date: Sun, 15 Dec 2024 17:15:24 +0100 Subject: [PATCH 2/3] add "Open in App" button --- web/channel/styles.css | 29 ++++++++++- .../main/scala/sc4pac/web/ChannelPage.scala | 51 +++++++++++++++++-- 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/web/channel/styles.css b/web/channel/styles.css index ab1770d..7fcf90b 100644 --- a/web/channel/styles.css +++ b/web/channel/styles.css @@ -8,7 +8,7 @@ h2 { margin-bottom: 0.5em; border-bottom: 1px solid #888; } -a.btn { +.btn { margin-left: 0.5em; margin-bottom: 0.5em; background-color: #555; @@ -19,10 +19,20 @@ a.btn { text-decoration: none; display: inline-block; } -a.btn:hover { +.btn:hover { background-color: #707070; text-decoration: none; } +button.open-app-btn { + background-color: #3469f0; + color: white; + padding: 12px 36px; + border-radius: 5px; + font-size: 1.0em; +} +button.open-app-btn:hover { + background-color: #6b90ef; +} ul.unstyled-list { list-style: none; margin: 0; @@ -86,6 +96,21 @@ code { border-radius: 2px; } +.card { + box-shadow: 0 4px 8px 0 rgba(0,0,0,0.2); + transition: 0.3s; + padding: 2px 16px; + border: 1px solid #ddd; + margin-top: 1.5em; + margin-bottom: 1.5em; +} +.card:hover { + box-shadow: 0 8px 16px 0 rgba(0,0,0,0.2); +} +.card > ul { + padding-left: 1.0em; +} + a { text-decoration: none; word-break: break-word; diff --git a/web/src/main/scala/sc4pac/web/ChannelPage.scala b/web/src/main/scala/sc4pac/web/ChannelPage.scala index ea7fa7e..bb05829 100644 --- a/web/src/main/scala/sc4pac/web/ChannelPage.scala +++ b/web/src/main/scala/sc4pac/web/ChannelPage.scala @@ -14,7 +14,7 @@ import sttp.client4.{basicRequest, Request, UriContext, Response, ResponseExcept import sttp.client4.upicklejson.asJson import scalatags.JsDom.all as H // html tags -import scalatags.JsDom.all.{stringFrag, stringAttr, SeqFrag, intPixelStyle, stringStyle} +import scalatags.JsDom.all.{stringFrag, stringAttr, SeqFrag, intPixelStyle, stringStyle, bindNode} object JsonData extends SharedData { opaque type Instant = String @@ -64,8 +64,10 @@ object ChannelPage { // val channelUrl = "http://localhost:8090/channel/" val channelUrl = "" // relative to current host + val channelUrlMain = "https://memo33.github.io/sc4pac/channel/" // val sc4pacUrl = "https://github.com/memo33/sc4pac-tools#sc4pac" val sc4pacUrl = "https://memo33.github.io/sc4pac/#/" + val sc4pacGuiUrl = "https://github.com/memo33/sc4pac-gui/releases" val issueUrl = "https://github.com/memo33/sc4pac/issues" lazy val backend = sttp.client4.fetch.FetchBackend() @@ -128,6 +130,36 @@ object ChannelPage { def add(label: String, child: H.Frag): Unit = b += H.tr(H.th(label), H.td(child)) + lazy val openButton = + H.button(H.cls := "btn open-app-btn", + { + import scalatags.JsDom.all.bindJsAnyLike + H.onclick := openInApp // not sure how this implicit conversion works exactly + }, + )("Open in App").render + lazy val openButtonResult = H.div(H.color := "#ff0077").render + + def openInApp(e: dom.Event): Unit = { + val port: Int = 51515 + val url = sttp.model.Uri(java.net.URI.create(s"http://localhost:$port/packages.open")) + val msg = Seq(Map("package" -> module.orgName, "channelUrl" -> channelUrlMain)) + basicRequest + .body(UP.write(msg)) + .contentType("application/json") + .post(url) + .send(backend) + .onComplete { + case scala.util.Success(response) if response.is200 => + openButton.textContent = "Opened in App" + openButtonResult.textContent = "" + case _ => + if (openButtonResult.textContent.isEmpty) + openButtonResult.textContent = s"Hold on, mayor! First launch the app before pressing this button." + else + openButtonResult.textContent = s"Make sure the GUI is running (on port $port) before pressing this button. Requires at least version 0.2.1." + } + } + // add("Name", pkg.name) // add("Group", pkg.group) add("Version", pkg.version) @@ -179,8 +211,21 @@ object ChannelPage { ), H.h2(H.clear := "right")(module.orgName), H.table(H.id := "pkginfo")(H.tbody(b.result())), - H.p("Install this package with ", H.a(H.href := sc4pacUrl)(H.code("sc4pac")), ":"), - H.pre(H.cls := "codebox")(s"sc4pac add ${module.orgName}\nsc4pac update") + H.div(H.cls := "card")( + H.h3("Installing this packageā€¦"), + H.ul( + H.li( + H.p("with the ", H.a(H.href := sc4pacGuiUrl)("sc4pac GUI"), ":", + openButton, + openButtonResult, + ), + ), + H.li( + H.p("with the ", H.a(H.href := sc4pacUrl)("sc4pac CLI"), ":"), + H.pre(H.cls := "codebox")(s"sc4pac add ${module.orgName}\nsc4pac update") + ), + ), + ), ) } From 555f30da773b91b1a2a3e33fb6bb9915ac80635a Mon Sep 17 00:00:00 2001 From: memo Date: Sun, 15 Dec 2024 22:31:45 +0100 Subject: [PATCH 3/3] tweak "Open in App" button style --- web/channel/styles.css | 34 +++++++++++++++++-- .../main/scala/sc4pac/web/ChannelPage.scala | 2 +- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/web/channel/styles.css b/web/channel/styles.css index 7fcf90b..542f127 100644 --- a/web/channel/styles.css +++ b/web/channel/styles.css @@ -23,16 +23,46 @@ h2 { background-color: #707070; text-decoration: none; } + button.open-app-btn { + text-align: center; + /* text-transform: uppercase; */ + cursor: pointer; + font-size: 1.0em; + letter-spacing: 3px; + position: relative; background-color: #3469f0; - color: white; + color: #fff; padding: 12px 36px; + transition-duration: 0.4s; + overflow: hidden; + box-shadow: 0 4px 8px rgba(0,0,0,0.25); border-radius: 5px; - font-size: 1.0em; } button.open-app-btn:hover { background-color: #6b90ef; + color: #fff; + box-shadow: 0 8px 16px 0 rgba(0,0,0,0.25); } +button.open-app-btn:after { + content: ""; + background: #1459d0; + display: block; + position: absolute; + padding-top: 300%; + padding-left: 350%; + margin-left: -36px !important; + margin-top: -120%; + opacity: 0; + transition: all 0.8s +} +button.open-app-btn:active:after { + padding: 0; + margin: 0; + opacity: 1; + transition: 0s +} + ul.unstyled-list { list-style: none; margin: 0; diff --git a/web/src/main/scala/sc4pac/web/ChannelPage.scala b/web/src/main/scala/sc4pac/web/ChannelPage.scala index bb05829..34b3b39 100644 --- a/web/src/main/scala/sc4pac/web/ChannelPage.scala +++ b/web/src/main/scala/sc4pac/web/ChannelPage.scala @@ -150,7 +150,7 @@ object ChannelPage { .send(backend) .onComplete { case scala.util.Success(response) if response.is200 => - openButton.textContent = "Opened in App" + // openButton.textContent = "Opened in App" openButtonResult.textContent = "" case _ => if (openButtonResult.textContent.isEmpty)