Skip to content

Commit

Permalink
Merge branch 'open-package-api' into api
Browse files Browse the repository at this point in the history
  • Loading branch information
memo33 committed Dec 21, 2024
2 parents 6771c34 + 555f30d commit 0fd6605
Show file tree
Hide file tree
Showing 7 changed files with 166 additions and 14 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
### Changed
- API upgrade to 2.1:
- `/update` accepts a new parameter `refreshChannels` to clear cached data ([#14][gui14]).
- New `/packages.open` endpoint for externally instructing the GUI to open a specific package page (#21).
The main channel website now shows an "Open in App" button for each package.


[gui3]: https://github.com/memo33/sc4pac-gui/issues/3
Expand Down
25 changes: 24 additions & 1 deletion api.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ POST /profile.init?profile=id {plugins: "<path>", cache: "<path>", temp:
GET /packages.list?profile=id
GET /packages.info?pkg=<pkg>&profile=id
GET /packages.search?q=<text>&profile=id
POST /packages.open [{package: "<pkg>", channelUrl: "<url>"}]
GET /plugins.added.list?profile=id
GET /plugins.installed.list?profile=id
Expand Down Expand Up @@ -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: "<pkg>", channelUrl: "<url>"}]`

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

Expand Down Expand Up @@ -454,9 +471,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": "<pkg>", "channelUrl": "<url>"}] }
```

## profiles.list

Get the list of all existing profiles, each corresponding to a Plugins folder.
Expand Down
33 changes: 26 additions & 7 deletions src/main/scala/sc4pac/api/api.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 =
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions src/main/scala/sc4pac/api/message.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion src/main/scala/sc4pac/cli.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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("")
Expand Down Expand Up @@ -578,6 +578,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)
Expand All @@ -598,6 +599,7 @@ object Commands {
}

class ServerFiber(val promise: zio.Promise[Nothing, zio.Fiber[Throwable, Nothing]])
class ServerConnection(val currentChannel: Option[zio.http.WebSocketChannel])
}

}
Expand Down
59 changes: 57 additions & 2 deletions web/channel/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -19,10 +19,50 @@ a.btn {
text-decoration: none;
display: inline-block;
}
a.btn:hover {
.btn:hover {
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: #fff;
padding: 12px 36px;
transition-duration: 0.4s;
overflow: hidden;
box-shadow: 0 4px 8px rgba(0,0,0,0.25);
border-radius: 5px;
}
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;
Expand Down Expand Up @@ -86,6 +126,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;
Expand Down
51 changes: 48 additions & 3 deletions web/src/main/scala/sc4pac/web/ChannelPage.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
),
),
),
)
}

Expand Down

0 comments on commit 0fd6605

Please sign in to comment.