diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..3589a1def37a4e813b1a6d7e2b0b6a50e504a87b --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# bloop and metals +.bloop +.bsp +.metals +project/metals.sbt + +# vs code +.vscode + +# scala 3 +.tasty + +# sbt +project/project/ +project/target/ +target/ + +# eclipse +build/ +.classpath +.project +.settings +.worksheet +bin/ +.cache + +# intellij idea +*.log +*.iml +*.ipr +*.iws +.idea + +# mac +.DS_Store + +# other? +.history +.scala_dependencies +.cache-main + +# general +*.class diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..44c759fbb94395af3c6388f7509b7993c24b3c8e --- /dev/null +++ b/LICENSE @@ -0,0 +1,15 @@ +© 2023, 2024 EPFL. All rights reserved. + +“Course” below refers to CS-214 Software Construction at EPFL. + +“Course materials” below includes slides, notes, recordings, lab assignments +(write-ups, code, and accompanying material), exercises, exams, and any other +materials created or prepared by the course staff, except where attributed to +someone else. + +Permission is granted to make copies and derivatives of these course materials +*only* in the context of course activities. These materials, or derivatives +thereof, *may not* be shared with anyone except the course staff, nor distributed +in any way, publicly or privately, including but not limited to in public git +repositories or Google Drive folders, except where permission has been explicitly +granted in writing by a course instructor. diff --git a/build.sbt b/build.sbt new file mode 100644 index 0000000000000000000000000000000000000000..deb2988e5e131df7c1ddfa68dc19ace74ea7b05e --- /dev/null +++ b/build.sbt @@ -0,0 +1,62 @@ +import org.scalajs.sbtplugin.ScalaJSPlugin.autoImport.* +import sbt.Keys.libraryDependencies + +lazy val shared = crossProject(JSPlatform, JVMPlatform).in(file("./shared")) + .jsConfigure(_.enablePlugins(JSDependenciesPlugin)) + .settings( + name := "shared", + scalaVersion := "3.5.0", + libraryDependencies += dependencies.ujson, + scalacOptions ++= Seq("-deprecation", "-feature", "-language:fewerBraces", "-Xfatal-warnings") + ).jsSettings( + test / aggregate := false, + Test / test := {}, + Test / testOnly := {} + ).jvmSettings( + ) + +lazy val client = (project in file("./client")) + .dependsOn(shared.js) + .enablePlugins(ScalaJSPlugin) + .settings( + name := "client", + version := "0.1.0-SNAPSHOT", + scalaVersion := "3.5.0", + scalacOptions ++= Seq("-deprecation", "-feature", "-language:fewerBraces", "-Xfatal-warnings"), + libraryDependencies ++= Seq( + "org.scala-js" %%% "scalajs-dom" % "2.8.0", + "com.lihaoyi" %%% "scalatags" % "0.12.0", + ), + // Add support for the DOM in `run` and `test` + jsEnv := new org.scalajs.jsenv.jsdomnodejs.JSDOMNodeJSEnv(), + testFrameworks += new TestFramework("utest.runner.Framework"), + test / aggregate := false, + Test / test := {}, + Test / testOnly := {} + ) + +lazy val server = (project in file("./server")) + .dependsOn(shared.jvm) + .settings( + name := "server", + version := "0.1.0-SNAPSHOT", + scalaVersion := "3.5.0", + scalacOptions ++= Seq("-deprecation", "-feature", "-language:fewerBraces", "-Xfatal-warnings"), + libraryDependencies ++= Seq( + dependencies.websocket, + dependencies.cask, + dependencies.slf4j, + dependencies.reflect, + ), + ) + +/// Dependencies + +lazy val dependencies = new { + val caskVersion = "0.9.4" + val websocket = "org.java-websocket" % "Java-WebSocket" % "1.5.4" + val cask = "com.lihaoyi" %% "cask" % caskVersion + val ujson = "com.lihaoyi" %% "ujson" % "3.3.1" + val slf4j = "org.slf4j" % "slf4j-nop" % "2.0.5" + val reflect = "org.reflections" % "reflections" % "0.10.2" +} diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000000000000000000000000000000000000..8adfb5dc9835905802341b2a17ab1b3d0a5af5e8 --- /dev/null +++ b/client/package.json @@ -0,0 +1,5 @@ +{ + "devDependencies": { + "jsdom": "^22.1.0" + } +} diff --git a/client/src/main/scala/cs214/webapp/client/ApplicationJS.scala b/client/src/main/scala/cs214/webapp/client/ApplicationJS.scala new file mode 100644 index 0000000000000000000000000000000000000000..3bbf15971a2f72ff7bc3d484d4d25abb992eb834 --- /dev/null +++ b/client/src/main/scala/cs214/webapp/client/ApplicationJS.scala @@ -0,0 +1,6 @@ +package cs214.webapp.client + +class ApplicationJS: + + def start(): Unit = + WebClient.start() diff --git a/client/src/main/scala/cs214/webapp/client/ClientApp.scala b/client/src/main/scala/cs214/webapp/client/ClientApp.scala new file mode 100644 index 0000000000000000000000000000000000000000..22873d0cbe34e3bb1ca1ac0089005aa651fecf05 --- /dev/null +++ b/client/src/main/scala/cs214/webapp/client/ClientApp.scala @@ -0,0 +1,15 @@ +package cs214.webapp +package client + +import org.scalajs.dom + +/** The ClientApp interface is used by clients to connect to a server. */ +trait ClientApp: + def appId: AppId + def init(instanceId: InstanceId, userId: UserId, endpoint: String, target: dom.Element): ClientAppInstance + +/** The ClientAppInstance interface is used by the server to store the state of + * a client UI. + */ +trait ClientAppInstance: + def onMessage(msg: util.Try[ujson.Value]): Unit diff --git a/client/src/main/scala/cs214/webapp/client/Pages.scala b/client/src/main/scala/cs214/webapp/client/Pages.scala new file mode 100644 index 0000000000000000000000000000000000000000..b8cf36d93f7c1101ec976a883826d6240a87212b --- /dev/null +++ b/client/src/main/scala/cs214/webapp/client/Pages.scala @@ -0,0 +1,244 @@ +package cs214.webapp +package client + +import java.net.{URLDecoder, URLEncoder} +import scala.concurrent.ExecutionContext.Implicits.global +import org.scalajs.dom +import org.scalajs.dom.{Element, KeyCode} +import org.scalajs.dom.html.{Input, Select, TextArea} +import scalatags.JsDom.all.* + +/** Parent class to web pages */ +abstract class Page: + val classList: String + def renderInto(target: dom.Element): Unit + + def pageHeader(subtitle: String) = + frag(h1(cls := "title", "ScalApp"), h2(subtitle)) + + def replaceChildren(target: dom.Element)(frag: Frag): Unit = + target.replaceChildren(frag.render) + target.setAttribute("class", classList) + + private def path: Seq[String] = + this match + case HomePage => + Nil + case InstanceCreationPage(appId) => + List("app", appId) + case JoinPageLoader(appId, instanceId) => + List("app", appId, instanceId) + case AppPage(appId, instanceId, userId) => + List("app", appId, instanceId, userId) + case _ => + throw IllegalArgumentException(f"No path for $this!") + + def url = + f"/${path.map(s => URLEncoder.encode(s, "UTF-8")).mkString("/")}" + +object Page: + /** Creates a component from a URL path + * @param appInfos + * retrieves the app information given its id + */ + def from(url: String): Page = + val components = url.stripPrefix("/").stripSuffix("/").split("/") + val decoded = components.map(c => URLDecoder.decode(c, "UTF-8")).toList + decoded match + case List("") => + HomePage + case List("app", appId) => + InstanceCreationPage(appId) + case List("app", appId, instanceId) => + JoinPageLoader(appId, instanceId) + case List("app", appId, instanceId, userId) => + AppPage(appId, instanceId, userId) + case _ => + throw IllegalArgumentException(f"Unknown url $url!") + +/** The initial home page which fetches the list of apps */ +object HomePage extends Page: + val classList = "HomePage" + + def renderInto(target: Element) = + Requests.listApps.map: response => + AppSelectionPage(response.apps).renderInto(target) + +/** The app selection menu, where the user can create a new app. */ +case class AppSelectionPage(apps: Seq[AppInfo]) extends Page: + val classList = "AppSelectionPage" + + def selectApp(appId: AppId): Unit = + WebClient.navigateTo(InstanceCreationPage(appId)) + + def renderInto(target: Element) = replaceChildren(target): + frag( + pageHeader("Select an app"), + div( + id := "app-list", + apps.groupBy(_.year).toList.map: (year, appsOfYear) => + frag( + h5(year.toString), + div( + cls := "app-grid", + appsOfYear.sortBy(_.name).map: appInfo => + div( + cls := "app-option", + onclick := (() => selectApp(appInfo.id)), + img(src := s"./static/${appInfo.id}.jpg", cls := "app-thumbnail"), + p(cls := "app-option-name", appInfo.name), + p(cls := "app-option-description", appInfo.description) + ) + ) + ) + ) + ) + +/** The instance creation menu, where you enter the player names */ +case class InstanceCreationPage(appId: AppId) extends Page: + val classList = "InstanceCreationPage" + + def submit(e: dom.Event): Unit = + e.preventDefault() + val userIdStr = getElementById[Input]("user-ids").value + val userIds = userIdStr.split("[;,]").map(_.strip).to(Seq).distinct + Requests.createInstance(appId, userIds).map: resp => + WebClient.navigateTo(JoinPageLoader(appId, resp.instanceId)) + + def renderInto(target: Element) = replaceChildren(target): + frag( + pageHeader(s"Create a new $appId instance"), + form( + onsubmit := submit, + div( + cls := "grid-form", + label(`for` := "user-ids", "User IDs: "), + input( + `type` := "text", + id := "user-ids", + placeholder := "user1; user2; …", + required := true, + autofocus := true + ) + ), + input(`type` := "submit", value := "Start!") + ) + ) + +/** The pre-connection menu, which fetches the user list. */ +case class JoinPageLoader(appId: String, instanceId: InstanceId) extends Page: + val classList = "JoinPageLoader" + def renderInto(target: Element) = + Requests.instanceInfo(instanceId).map: resp => + JoinPage(appId, instanceId, resp.userIds).renderInto(target) + +/** The connection menu, where a user joins an existing app. */ +case class JoinPage(appId: AppId, instanceId: InstanceId, userIds: Seq[UserId]) + extends Page: + val classList = "JoinPage" + + private def cssId(idx: Int) = f"user-$idx" + + private def handleKeyboardEvent(e: dom.KeyboardEvent): Unit = + println(s"Print ${e.keyCode}, ${KeyCode.Enter}") + if e.keyCode == KeyCode.Enter then + e.preventDefault() + dom.window.removeEventListener("keydown", (e: dom.KeyboardEvent) => handleKeyboardEvent(e)) + joinAppInstance() + + private def handleFormSubmission(e: dom.Event): Unit = + e.preventDefault() + joinAppInstance() + + private def getSelected = + userIds.zipWithIndex.find { (u, i) => + getElementById[Input](cssId(i)).checked + }.map(_._1) + + private def joinAppInstance(): Unit = + getSelected.map: userId => + WebClient.navigateTo(AppPage(appId, instanceId, userId)) + + def renderInto(target: Element): Unit = replaceChildren(target): + dom.window.addEventListener("keydown", (e: dom.KeyboardEvent) => handleKeyboardEvent(e)) + frag( + pageHeader("Join app instance"), + form( + onsubmit := handleFormSubmission, + fieldset( + legend("Select your username"), + for (userId, idx) <- userIds.zipWithIndex + yield div( + input( + `type` := "radio", + id := cssId(idx), + if idx == 0 then checked := "checked" else frag(), + name := "user", + value := userId, + required := true + ), + label(`for` := cssId(idx), userId) + ) + ), + input(`type` := "submit", value := "Join!") + ) + ) + +/** The actual app content page */ +case class AppPage(appId: String, instanceId: InstanceId, userId: UserId) extends Page: + val classList = f"app $appId" + + private val app: ClientApp = + val maybeApp = WebClient.getApp(appId) + require( + maybeApp.isDefined, + s"Could not find an app UI corresponding to app id \"$appId\". There may be a " + + s"mismatch between the client UI's appId and server logic's appId, or you may have " + + s"forgotten to annotate your UI with @JSExportTopLevel(\"<yourAppId>\")" + ) + maybeApp.get + + def renderInto(target: Element) = + replaceChildren(target): + frag(header(id := "banner"), tag("section")(id := "app")) + Requests.instanceInfo(instanceId).map: appInfo => + val hostName = dom.window.location.hostname + val endpoint = appInfo.wsEndpoint + .replace("{{hostName}}", hostName) + .replace("{{userId}}", URLEncoder.encode(userId, "UTF-8")) + IpBanner(appInfo.shareUrl).renderInto(target.querySelector("#banner")) + app.init(instanceId, userId, endpoint, target.querySelector("#app")) + +/** The top-most share banner */ +case class IpBanner(shareUrl: String) extends Page: + val classList = "IpBanner" + + def renderInto(target: Element) = + replaceChildren(target): + val copyToClipboardButtonText = "📋 Copy to clipboard." + frag( + span("Share ", a(href := shareUrl, shareUrl), " with your friends to let them join!"), + div( + id := "banner-actions", + button( + onclick := (() => WebClient.navigateTo(HomePage)), + "🏠 Back to home page" + ), + button( + id := "copy-share-url", + onclick := (() => + dom.window.navigator.clipboard.writeText(shareUrl) + dom.document.getElementById("copy-share-url").innerText = "Copied!" + + dom.window.setTimeout( + () => dom.document.getElementById("copy-share-url").innerText = copyToClipboardButtonText, + 1000 + ) + ), + copyToClipboardButtonText + ) + ) + ) + +def getElementById[T](id: String) = + dom.document.getElementById(id).asInstanceOf[T] diff --git a/client/src/main/scala/cs214/webapp/client/StateMachineClientApp.scala b/client/src/main/scala/cs214/webapp/client/StateMachineClientApp.scala new file mode 100644 index 0000000000000000000000000000000000000000..a1271a4e00a4a2561514fafae6881f134660bd6d --- /dev/null +++ b/client/src/main/scala/cs214/webapp/client/StateMachineClientApp.scala @@ -0,0 +1,138 @@ +package cs214.webapp +package client + +import scala.collection.mutable +import scala.scalajs.js.timers.{SetTimeoutHandle, setTimeout} +import scala.util.{Failure, Success} +import org.scalajs.dom +import scalatags.JsDom.all.Frag + +abstract class WSClientApp extends ClientApp: + WebClient.register(this) + + protected def init(userId: UserId, sendMessage: ujson.Value => Unit, target: dom.Element): ClientAppInstance + + def init(instanceId: InstanceId, userId: UserId, endpoint: String, target: dom.Element): ClientAppInstance = + val socket = new dom.WebSocket(endpoint) + socket.onopen = (event: dom.Event) => println("WebSocket connection opened") + socket.onclose = (event: dom.CloseEvent) => println(s"WebSocket connection closed: ${event.reason}") + socket.onerror = (event: dom.Event) => println(s"WebSocket error: ${event.`type`}") + + val sendMessage = (js: ujson.Value) => socket.send(js.toString) + val client = init(userId, sendMessage, target) + socket.onmessage = msg => + println(msg) + val js = ujson.read(msg.data.toString) + client.onMessage(SocketResponseWire.decode(js).flatten) + + client + +/** Instance of a client-side state machine application. + * + * @param userId + * the user for which creating the client application + * @param sendMessage + * how to send a json message + * @param target + * the element where to draw the application + */ +abstract class StateMachineClientAppInstance[Event, View]( + userId: UserId, + sendMessage: ujson.Value => Unit, + target: dom.Element +) extends ClientAppInstance: + + /** Optional CSS style sheet to apply to the application page. When + * overriding, do not forget to append to the parent definition. + */ + def css: String = "" + if !css.isEmpty then + // Add <style> tag in the header of the page + val styleTag = dom.document.createElement("style") + styleTag.textContent = css + dom.document.head.appendChild(styleTag) + + /** Provides serialization methods for events and views */ + val wire: AppWire[Event, View] + + /** Renders a [[View]] received from the server. The method also takes a + * [[UserId]] to get information on the context and an [[onEvent]] callback + * which it uses to send server events when specific actions are triggered on + * the UI (click on a button for example) + * @param userId + * The user id used by the client. + * @param view + * The view to render. + * @return + * The rendered view. + */ + def render(userId: UserId, view: View): Frag + + /** Renders a non-fatal error as a browser alert */ + private def renderError(msg: String): Unit = + dom.window.alert(msg) + + protected def sendEvent(event: Event) = + sendMessage(wire.eventFormat.encode(event)) + + /** @inheritdoc */ + override def onMessage(msg: util.Try[ujson.Value]): Unit = + msg.flatMap(EventResponse.Wire.decode) match + case Failure(msg) => WebClient.crash(Exception(msg)) + case Success(Failure(msg)) => renderError(msg.getMessage) + case Success(Success(actions)) => actionsQueue.enqueueAll(actions) + tryProcessNextAction() + + /** The views to render */ + private val actionsQueue: mutable.Queue[Action[ujson.Value]] = mutable.Queue() + + /** Minimal cooldown time between the render of two views */ + private var actionCooldownDelay = 0 + + /** The timestamp for the last render of a view */ + private var lastActionAppliedMs = System.currentTimeMillis() + + /** A call to the [[tryProcessNextAction()]] function scheduled */ + private var timeout: Option[SetTimeoutHandle] = None + + /** Sets the [[actionCooldownDelay]] of the client to the specified value */ + private def setCooldown(ms: Int): Unit = actionCooldownDelay = ms + + /** Apply the next action from [[actionsQueue]] if [[actionCooldownDelay]] + * permits it. + */ + private def tryProcessNextAction(): Unit = + // If there are still views to render and if no call to this function is scheduled + if actionsQueue.nonEmpty && timeout.isEmpty then + // Then check if we are out of the cooldown delay + if System.currentTimeMillis() - lastActionAppliedMs > actionCooldownDelay then + // We can render the view, and dequeue it + processAction(actionsQueue.dequeue()) + // Continue try emptying the queue + tryProcessNextAction() + else + // We still have to wait, put a timeout to call the function later + timeout = Some(setTimeout(actionCooldownDelay) { + // First remove the timeout so that, if necessary, + // the next function call can create a new one + timeout = None + // Then try to render next views + tryProcessNextAction() + }) + + /** Execute a single action sent by the server. */ + private def processAction(jsonAction: Action[ujson.Value]): Unit = + lastActionAppliedMs = System.currentTimeMillis() + setCooldown(0) + jsonAction match + case Action.Alert(msg) => + dom.window.alert(msg) + case Action.Pause(durationMs) => + setCooldown(durationMs) + case Action.Render(js) => + // Step1: The client receives the view sent by the server here + wire.viewFormat.decode(js) match + case Failure(exception) => renderError(exception.getMessage) + case Success(jsonView) => + target.replaceChildren: + this.render(userId, jsonView).render diff --git a/client/src/main/scala/cs214/webapp/client/WebClient.scala b/client/src/main/scala/cs214/webapp/client/WebClient.scala new file mode 100644 index 0000000000000000000000000000000000000000..17fc58c6b0992ef29ed56a51688513debb10fa83 --- /dev/null +++ b/client/src/main/scala/cs214/webapp/client/WebClient.scala @@ -0,0 +1,92 @@ +package cs214.webapp +package client + +import scala.collection.mutable +import scala.util.{Failure, Success} +import org.scalajs.dom + +object WebClient: + private val appLibrary: mutable.Map[AppId, ClientApp] = mutable.Map.empty + + val getApp = appLibrary.get + + def register(clientApp: ClientApp): Unit = + println(s"Registered ${clientApp.appId}'s UI") + appLibrary.put(clientApp.appId, clientApp) + + def start(): Unit = + start(dom.document.getElementById("root")) + + def start(root: dom.Element): Unit = + println(f"Registered apps: ${appLibrary.keys.toSeq.sorted}.") + try + Page.from(dom.document.location.pathname) + .renderInto(root) + catch case t => crash(t) + + def navigateTo(page: Page) = + dom.window.location.pathname = page.url + + def crash(t: Throwable) = + dom.document.querySelector("body").prepend: + import scalatags.JsDom.all.* + tag("dialog")(id := "fatal", b("Fatal error: "), t.getMessage).render + dom.document.querySelector("#fatal").asInstanceOf[dom.html.Dialog].showModal() + throw t + +object Requests: + import org.scalajs.dom.{Fetch, Headers} + + import concurrent.ExecutionContext.Implicits.global + import scala.concurrent.Future + import scala.scalajs.js.Promise + + /** Sends a POST request to create a new instance including the given users of + * the app with the given id + */ + def createInstance(appId: AppId, userIds: Seq[UserId]): Future[CreateInstanceResponse] = + sendPostRequestWith(Endpoints.Api.createInstance)(CreateInstanceRequest.Wire, CreateInstanceResponse.Wire): + CreateInstanceRequest(appId, userIds) + + /** Sends a GET request to enumerate available apps */ + def listApps: Future[ListAppsResponse] = + Fetch.fetch(Endpoints.Api.listApps.toString) + .decodeResponse(ListAppsResponse.Wire) + .logErrors + + /** Sends a GET request to retrieve information on an app. */ + def instanceInfo(instanceId: InstanceId): Future[InstanceInfoResponse] = + Fetch.fetch(f"${Endpoints.Api.instanceInfo.toString}?instanceId=$instanceId") + .decodeResponse(InstanceInfoResponse.Wire) + .logErrors + + /** Sends a POST request with the given payload to the specified endpoint */ + private def sendPostRequestWith[R, S](endpoint: Endpoints.Api)( + reqWire: WireFormat[R], + respWire: WireFormat[S] + )(payload: R) = + val requestHeaders = dom.Headers() + requestHeaders.append("Content-Type", "application/json") + + val requestOptions = new dom.RequestInit: + method = dom.HttpMethod.POST + headers = requestHeaders + body = reqWire.encode(payload).toString + + Fetch.fetch(endpoint.toString, requestOptions) + .decodeResponse(respWire) + .logErrors + + extension [T](p: Future[T]) + private def logErrors = + p.andThen { case Failure(t) => WebClient.crash(t) } + + case class FailedRequestException(status: Int, msg: String) extends Exception(msg) + + extension (p: Promise[dom.Response]) + private def decodeResponse[R](wire: WireFormat[R]) = + p.toFuture.flatMap: response => + response.text().toFuture.flatMap: txt => + if !response.ok then + throw FailedRequestException(response.status, txt) + Future.fromTry(wire.decode(ujson.read(txt))) diff --git a/client/src/main/scala/cs214/webapp/client/graphics/MouseButton.scala b/client/src/main/scala/cs214/webapp/client/graphics/MouseButton.scala new file mode 100644 index 0000000000000000000000000000000000000000..08cc7f2a7c5e86e91e3053387ac8c57339905c6f --- /dev/null +++ b/client/src/main/scala/cs214/webapp/client/graphics/MouseButton.scala @@ -0,0 +1,14 @@ +package cs214.webapp.client.graphics + +enum MouseButton: + case Left + case Middle + case Right + +object MouseButton: + def from(htmlButtonIdentifier: Int): MouseButton = + htmlButtonIdentifier match + case 0 => Left + case 1 => Middle + case 2 => Right + case i => throw IllegalStateException(s"Unsupported mouse button: $i") diff --git a/client/src/main/scala/cs214/webapp/client/graphics/TextClientAppInstance.scala b/client/src/main/scala/cs214/webapp/client/graphics/TextClientAppInstance.scala new file mode 100644 index 0000000000000000000000000000000000000000..b8ae9384e678189eeeeebefb43988d8876a967ed --- /dev/null +++ b/client/src/main/scala/cs214/webapp/client/graphics/TextClientAppInstance.scala @@ -0,0 +1,178 @@ +package cs214.webapp.client.graphics + +import cs214.webapp.UserId +import cs214.webapp.client.StateMachineClientAppInstance +import org.scalajs.dom +import org.scalajs.dom.HTMLInputElement +import scalatags.JsDom +import scalatags.JsDom.all.* + +enum HTMLAttribute: + case cls + case style + +type CSSProperties = Map[String, String] +extension (cssProps: CSSProperties) + def toCSS(): String = cssProps + .map((k, v) => f"${k}: ${v}") + .mkString("; ") + +/** Text-based UI. Detects mouse events and has an integrated text field */ +abstract class TextClientAppInstance[Event, View]( + userId: UserId, + sendMessage: ujson.Value => Unit, + target: dom.Element +) extends StateMachineClientAppInstance[Event, View](userId, sendMessage, target): + + enum MouseEvent: + case Click(button: MouseButton) + case MouseDown(button: MouseButton) + case MouseUp(button: MouseButton) + case HoverEnter + case HoverLeave + + /** The console is segmented with [[TextSegment]]s. Each segment represents a + * piece of text with its own modifiers and event listeners. + * + * If multiple parts of your text need to respond independently to mouse + * events or need a different style, you should consider separating them in + * different [[TextSegment]]s + * + * @param text + * The text to be displayed + * @param onMouseEvent + * How to handle mouse events happening on this text segment + * @param cssProperties + * The css properties to inline with this text segment (html style + * attribute) + * @param htmlAttributes + * The html attributes to add to this text segment + * @param modifiers + * Any additional `JsDom.Modifier`s you may want to add to the element + */ + case class TextSegment( + text: String, + onMouseEvent: MouseEvent => Unit = _ => (), + cssProperties: CSSProperties = Map(), + htmlAttributes: Map[HTMLAttribute, String] = Map(), + modifiers: scalatags.JsDom.Modifier* + ) + + /** Transforms a text input from the user into an optional app [[Event]] + * @param view + * The current view + * @param text + * The input text typed by the user + * @return + * An optional application event + */ + def handleTextInput(view: View, text: String): Option[Event] + + /** Transforms a view received by the server into a segmented console display. + * Each [[TextSegment]] responds independently to mouse events and defines + * its own style. + * + * @param userId + * The id of the user using this client + * @param view + * The view of the application received by the server + * @return + * The different text segments of the console + */ + def renderView(userId: UserId, view: View): Vector[TextSegment] + + override def render(userId: UserId, view: View): JsDom.all.Frag = + currView = Some(view) + + frag( + div( + cls := "textapp", + div( + onclick := (() => focusConsoleInput()), + cls := "console", + pre( + style := "white-space: pre-wrap;", + for textSegment <- renderView(userId, view) + yield span( + textSegment.text, + style := textSegment.htmlAttributes.getOrElse(HTMLAttribute.style, textSegment.cssProperties.toCSS()), + cls := textSegment.htmlAttributes.getOrElse(HTMLAttribute.cls, ""), + textSegment.modifiers, + margin := "0", + height := "1em", + onclick := { + (e: dom.MouseEvent) => + textSegment.onMouseEvent(MouseEvent.Click(MouseButton.from(e.button))) + }, + onmousedown := { + (e: dom.MouseEvent) => textSegment.onMouseEvent(MouseEvent.MouseDown(MouseButton.from(e.button))) + }, + onmouseup := { (e: dom.MouseEvent) => + textSegment.onMouseEvent(MouseEvent.MouseUp(MouseButton.from(e.button))) + }, + onmouseover := { (_: dom.MouseEvent) => textSegment.onMouseEvent(MouseEvent.HoverEnter) }, + onmouseout := { (_: dom.MouseEvent) => textSegment.onMouseEvent(MouseEvent.HoverLeave) } + ) + ) + ), + input( + `type` := "text", + autofocus := true, + id := "console-input", + tabindex := 0, + onkeydown := handleKeyDown, + onblur := { + dom.window.setTimeout( + () => + focusConsoleInput(), + 0 + ) + } + ) + ) + ) + + private var currView: Option[View] = None + + private def focusConsoleInput(): Unit = + dom.document.getElementById("console-input").asInstanceOf[HTMLInputElement].focus() + + private def handleKeyDown(e: dom.KeyboardEvent): Unit = currView + .flatMap(view => + if e.keyCode == dom.KeyCode.Enter then + e.target match + case inputElement: HTMLInputElement => + e.preventDefault() + handleTextInput(view, inputElement.value) + else + None + ).foreach(sendEvent) + + override def css: String = super.css + + """ + | .textapp { + | font-size: 1rem; + | } + | + | .textapp input { + | height: 1em; + | width: 100%; + | border: solid 1px black; + | box-sizing: border-box; + | } + | + | .textapp .console { + | overflow: hidden; + | padding: 0.5em; + | font-size: 1em; + | display: flex; + | flex-direction: column; + | justify-content: center; + | align-items: start; + | border: solid 1px black; + | } + | + | .textapp .console span { + | box-sizing: border-box; + | } + """.stripMargin diff --git a/client/src/main/scala/cs214/webapp/client/graphics/WebClientAppInstance.scala b/client/src/main/scala/cs214/webapp/client/graphics/WebClientAppInstance.scala new file mode 100644 index 0000000000000000000000000000000000000000..fe27bd59cf3e067078e727628c96f67a7a05d239 --- /dev/null +++ b/client/src/main/scala/cs214/webapp/client/graphics/WebClientAppInstance.scala @@ -0,0 +1,12 @@ +package cs214.webapp.client.graphics + +import cs214.webapp.UserId +import cs214.webapp.client.StateMachineClientAppInstance +import org.scalajs.dom + +/** General web graphics UI, basically a wrapper around raw ScalaJS API */ +abstract class WebClientAppInstance[Event, View]( + userId: UserId, + sendMessage: ujson.Value => Unit, + target: dom.Element +) extends StateMachineClientAppInstance[Event, View](userId, sendMessage, target) diff --git a/project/build.properties b/project/build.properties new file mode 100644 index 0000000000000000000000000000000000000000..ee4c672cd0d79d58199c731ea043b4c54eac8744 --- /dev/null +++ b/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.10.1 diff --git a/project/plugins.sbt b/project/plugins.sbt new file mode 100644 index 0000000000000000000000000000000000000000..2357ba0f76c5fb6f42c31f5d853fd4c8b6e33f21 --- /dev/null +++ b/project/plugins.sbt @@ -0,0 +1,5 @@ +addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.16.0") +addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.3.2") +addSbtPlugin("org.scala-js" % "sbt-jsdependencies" % "1.0.0") + +libraryDependencies += "org.scala-js" %% "scalajs-env-jsdom-nodejs" % "1.1.0" diff --git a/server/src/main/resources/www/static/main.css b/server/src/main/resources/www/static/main.css new file mode 100644 index 0000000000000000000000000000000000000000..0cfa226f80fff3d6b8c1361cb566bdc131f6fbd7 --- /dev/null +++ b/server/src/main/resources/www/static/main.css @@ -0,0 +1,248 @@ +@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:ital,wght@0,400;0,700;1,400&family=Noto+Color+Emoji&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@300..700&display=swap'); + +:root { + --rouge: #DA291C; + --groseille: #B51F1F; + --taupe: #413D3A; + --canard: #007480; + --leman: #00A79F; + --perle: #CAC7C7; +} + +* { + box-sizing: border-box; +} + +body, +html { + box-sizing:border-box +} + +html { + font-family: 'IBM Plex Sans', sans-serif; + font-size: 18px; + line-height: 1.3; + padding: 0 1rem; +} + +input, select, button { + font-size: unset; + font-family: unset; +} + +body { + max-width:34rem; + margin: 1rem auto; +} + +dialog { + border: none; + outline: thick solid var(--groseille); + text-align: center; + z-index: 1; +} + +dialog::backdrop { + backdrop-filter: blur(0.25rem); +} + +pre, code, kbd, samp { + font-family: 'IBM Plex Mono', monospace; + line-height: 1.0; +} + +h1, h2, h3, h4, h5, h6 { + margin: 1rem 0; +} + +article, aside, details, footer, header, main, nav, section, +figure, form, ol, p, pre, ul, video, table { + margin: 1rem 0; +} + +ol, ul { + padding-left: 1em; +} + +header, fieldset, h1.title { + border-radius: 0.5rem; + padding: 0.5rem; +} + +h1.title { + background: var(--rouge); + color: white; +} + +h2 { + color: var(--canard); +} + +footer h3 { + margin-top: 2rem; +} + +p.finished { + background: var(--leman); + border: thick solid var(--canard); + color:white; + font-size: 2rem; + font-weight: bold; + text-align: center; +} + +form { + display: flex; + flex-direction: column; +} + +fieldset, fieldset:first-child:first-child { + margin-top: -0.5rem; + align-self: stretch; +} + +fieldset input[type="radio"] { + margin: 0.5rem; +} + +fieldset label { + padding: 0.25rem; +} + +label { + display: inline-block; +} + +select, input, button { + padding: 0.5rem; +} + +input[type="submit"], button { + padding: 0.5rem 1rem; + margin-top: 1rem; +} + +input[type="submit"] { + align-self: end; +} + +form > *:first-child { + margin-top: 0; +} + +form > *:last-child { + margin-bottom: 0; +} + +.grid-form { + display: grid; + grid-template-columns: max-content auto; + align-items: center; + gap: 0.5rem; +} + +.grid-form label { + text-align: right; +} + +.IpBanner { + border: thin solid var(--taupe); + word-wrap: break-word; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +#banner-actions { + width: 100%; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; +} + +#app-list { + width: 100%; + max-height: 65vh; + overflow: auto; +} + +#app-list h5::after { + content: ""; + display: block; + width: 100%; + height: 1px; + background-color: rgba(0, 0, 0, 0.1); +} + +.app-grid { + display: grid; + grid-gap: 8px; + + padding: 8px; +} + +.app-option { + min-height: 0; + min-width: 0; + + display: flex; + flex-direction: column; + justify-content: start; + align-items: start; + + overflow: hidden; + word-wrap: break-word; + text-overflow: ellipsis; + + border-radius: 8px; + + box-shadow: rgba(0, 0, 0, 0.2) 0 2px 1px -1px, rgba(0, 0, 0, 0.14) 0 1px 1px 0, rgba(0, 0, 0, 0.12) 0 1px 3px 0; + background-color: rgba(0, 0, 0, 0.05); + + font-size: larger; + cursor: pointer; +} + +@media (min-width:1025px) { + .app-grid { + grid-template-columns: repeat(3, 1fr); + } + .app-option { + aspect-ratio: 11/18; + } +} + + +@media (max-width:1024px) { + .app-grid { + grid-template-columns: repeat(2, 1fr); + } + .app-option { + aspect-ratio: 11/22; + } +} + +.app-option p { + margin: 0; + padding: 0 8px; + width: 100%; +} + +.app-option-name { + font-size: 1rem; +} + +.app-option-description { + font-size: 0.7rem; + opacity: 0.7; +} + + +.app-thumbnail { + width: 100%; + aspect-ratio: 11/11; + object-fit: cover; +} diff --git a/server/src/main/resources/www/static/webapp.html b/server/src/main/resources/www/static/webapp.html new file mode 100644 index 0000000000000000000000000000000000000000..3d6d26ed47be82388eec800a78d878db79c97e02 --- /dev/null +++ b/server/src/main/resources/www/static/webapp.html @@ -0,0 +1,13 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Scalapp</title> + <link rel="stylesheet" href="/static/main.css" /> + </head> + <body> + <main id="root"></main> + <script type="text/javascript" src="/static/main.js"></script> + </body> +</html> diff --git a/server/src/main/scala/cs214/webapp/server/ApplicationJVM.scala b/server/src/main/scala/cs214/webapp/server/ApplicationJVM.scala new file mode 100644 index 0000000000000000000000000000000000000000..d102f6c98ba3bb1811167192c1bea5885b76865e --- /dev/null +++ b/server/src/main/scala/cs214/webapp/server/ApplicationJVM.scala @@ -0,0 +1,35 @@ +package cs214.webapp.server + +import web.WebServer +import cs214.webapp.server.utils.AppsLoader +import cs214.webapp.Config.HTTP_PORT + +/** Main entry point of the server of an application. Starts the server on + * construction, call `start()` to initialize the apps. + */ +abstract class ApplicationJVM extends cask.Main: + + override def host: String = "0.0.0.0" + override def port: Int = HTTP_PORT + val allRoutes = Seq(web.WebServerRoutes()) + println(s"[WebServer] started on ${host}:${port}.") + + def start(): Unit = + // Find all apps and register them. + val classes = AppsLoader.loadApps() + + classes.foreach: candidateClass => + val appLogicInstance = candidateClass + .getDeclaredConstructor() + .newInstance() + .asInstanceOf[StateMachine[?, ?, ?]] + + if !WebServer.isRegistered(appLogicInstance) then + val appPackageName = appLogicInstance.getClass.getPackageName.replaceFirst("apps\\.", "") + require( + appPackageName.contains(appLogicInstance.appInfo.id), + s"Found invalid appId: Package name is \"$appPackageName\"" + + s" but id specified in app logic is \"${appLogicInstance.appInfo.id}\"." + + s" These values should match!" + ) + WebServer.register(appLogicInstance) diff --git a/server/src/main/scala/cs214/webapp/server/StateMachine.scala b/server/src/main/scala/cs214/webapp/server/StateMachine.scala new file mode 100644 index 0000000000000000000000000000000000000000..82f774827de1fe52d944ed50892470daff150833 --- /dev/null +++ b/server/src/main/scala/cs214/webapp/server/StateMachine.scala @@ -0,0 +1,96 @@ +package cs214.webapp +package server + +import scala.util.Try + +/** A state machine describes the core logic of an application. + * + * @tparam Event + * The type of the events that this application expects. + * @tparam State + * The type of internal states of this application. + * @tparam View + * The type of the views sent to the clients. + */ +abstract class StateMachine[Event, State, View]: + /** An identifier for this app type. */ + val appInfo: cs214.webapp.AppInfo + + /** Provides serialization methods for events and views. */ + val wire: AppWire[Event, View] + + /** Initializes a new application. */ + def init(clients: Seq[UserId]): State + + /** Simulates a transition of the state machine. + * + * @param state + * The current [[State]] of the application. + * @param userId + * The [[UserId]] of the user triggering the event + * @param event + * The [[Event]] received + * @return + * A sequence of commands to be sent to clients, or [[scala.util.Failure]] + * if the event is illegal given the current state. + */ + def transition(state: State)(userId: UserId, event: Event): Try[Seq[Action[State]]] + + /** Projects an application state to produce a user-specific view. + * + * This function hides any details of the application's state that the user + * should not have access to. + * + * @param state + * A [[State]] of the application. + * @param userId + * The [[UserId]] of a user. + * @return + * A view for user [[uid]]. + */ + def project(state: State)(userId: UserId): View + +/** Extend this class if you want your state machine to receive a [[Tick]] event + * in addition to your own custom events. This event will be received every + * [[clockPeriodMs]] milliseconds + * + * @tparam Event + * The type of the events that this application expects. + * @tparam State + * The type of internal states of this application. + * @tparam View + * The type of the views sent to the clients. + */ +abstract class ClockDrivenStateMachine[Event, State, View] + extends StateMachine[Tick | Event, State, View]: + + /** The [[Tick]] event will be received by the state machine every + * [[clockPeriodMs]] milliseconds. + */ + val clockPeriodMs: Int + + /** Provides serialization methods for events and views of a + * [[ClockDrivenStateMachine]]. + */ + val clockDrivenWire: AppWire[Event, View] + + override object wire extends AppWire[Tick | Event, View]: + override object eventFormat extends WireFormat[Tick | Event]: + override def decode(json: ujson.Value): Try[Tick | Event] = + Try: + val isClockEvent = + json.objOpt + .flatMap(_.get(ClockDrivenStateMachine.CLOCK_EVENT_HEADER)) + .exists(_.bool) + + if isClockEvent then TickEventFormat.decode(json.obj.get("tick").get).get + else clockDrivenWire.eventFormat.decode(json).get + + override def encode(t: Tick | Event): ujson.Value = + throw IllegalStateException("This shouldn't be used. Please use the ScalApp's wire instead.") + + override val viewFormat: WireFormat[View] = clockDrivenWire.viewFormat + +object ClockDrivenStateMachine: + val CLOCK_EVENT_HEADER = "X-Clock" + val CLOCK_EVENT_TICK_HEADER = "tick" diff --git a/server/src/main/scala/cs214/webapp/server/utils/AppsLoader.scala b/server/src/main/scala/cs214/webapp/server/utils/AppsLoader.scala new file mode 100644 index 0000000000000000000000000000000000000000..6c47e35adfdb7447a72a361835ce7151f8dde512 --- /dev/null +++ b/server/src/main/scala/cs214/webapp/server/utils/AppsLoader.scala @@ -0,0 +1,20 @@ +package cs214.webapp.server +package utils + +import java.lang.reflect.Modifier +import scala.jdk.CollectionConverters.* +import org.reflections.Reflections + +/** Finds all apps under the `apps` package. A class extending `StateMachine` is + * considered to be an app. + */ +object AppsLoader: + private val APPS_PACKAGE = "apps" + + def loadApps(): List[Class[?]] = + val reflections = new Reflections(APPS_PACKAGE) + reflections + .getSubTypesOf(classOf[StateMachine[?, ?, ?]]) + .asScala + .toList + .filterNot(cls => Modifier.isAbstract(cls.getModifiers) || cls.isInterface()) diff --git a/server/src/main/scala/cs214/webapp/server/web/ServerApp.scala b/server/src/main/scala/cs214/webapp/server/web/ServerApp.scala new file mode 100644 index 0000000000000000000000000000000000000000..5b9b699eb453b7fd8a8a93ad9d1a92693c4b9deb --- /dev/null +++ b/server/src/main/scala/cs214/webapp/server/web/ServerApp.scala @@ -0,0 +1,67 @@ +package cs214.webapp +package server +package web + +import scala.util.Try + +/** Server-side apps definition and abstractions */ + +/** A server-side app, allows creating new `ServerAppInstance` */ +private[web] sealed trait ServerApp: + def appInfo: AppInfo + def init(clients: Seq[UserId]): ServerAppInstance + +/** A server-side app instance */ +private[web] sealed trait ServerAppInstance: + val appInfo: AppInfo + val registeredUsers: Seq[String] + + /** Compute setup actions to send to client. */ + def respondToNewClient(userId: UserId): ujson.Value + + /** Simulate one step of the underlying state machine. */ + def transition(clients: Seq[UserId])( + userId: UserId, + jsEvent: ujson.Value + ): Try[(Map[UserId, Seq[Action[ujson.Value]]], ServerAppInstance)] + +/** Wraps a state machine into a server-side app. */ +private[web] case class StateMachineServerApp[E, S, V](sm: StateMachine[E, S, V]) extends ServerApp: + def appInfo: AppInfo = sm.appInfo + + def init(clients: Seq[UserId]): ServerAppInstance = + StateMachineServerAppInstance(sm = sm, registeredUsers = clients, state = sm.init(clients)) + +/** Wraps a state machine and its state into a server-side app instance. */ +private[web] case class StateMachineServerAppInstance[E, S, V]( + sm: StateMachine[E, S, V], + registeredUsers: Seq[UserId], + state: S +) extends ServerAppInstance: + + val appInfo: AppInfo = sm.appInfo + + def respondToNewClient(userId: UserId): ujson.Value = + sm.wire.viewFormat.encode(sm.project(state)(userId)) + + def transition(clients: Seq[UserId])( + uid: UserId, + eventJs: ujson.Value + ): Try[(Map[UserId, Seq[Action[ujson.Value]]], ServerAppInstance)] = Try: + val event = sm.wire.eventFormat.decode(eventJs).get + if WebServer.debug then + println(f"[debug] got event: $event") + + val actions = sm.transition(state)(uid, event).get + if WebServer.debug then + println(s"[debug] transition produced actions ${actions.mkString(", ")}") + + val clientActions = clients.map: uid => + uid -> projectFor(uid)(actions).map(_.map(sm.wire.viewFormat.encode)) + val nextState = + actions.collect { case Action.Render(t) => t }.lastOption.getOrElse(state) + + (clientActions.toMap, copy(state = nextState)) + + private def projectFor(userId: UserId)(actions: Seq[Action[S]]): Seq[Action[V]] = + actions.map(_.map(st => sm.project(st)(userId))) diff --git a/server/src/main/scala/cs214/webapp/server/web/WebServer.scala b/server/src/main/scala/cs214/webapp/server/web/WebServer.scala new file mode 100644 index 0000000000000000000000000000000000000000..79c41625a8a5f8622751f1455767c4fcb3317f0a --- /dev/null +++ b/server/src/main/scala/cs214/webapp/server/web/WebServer.scala @@ -0,0 +1,143 @@ +package cs214.webapp +package server +package web + +import java.util.concurrent.atomic.AtomicBoolean +import scala.collection.immutable.Map +import scala.collection.{concurrent, immutable, mutable} +import scala.util.{Failure, Success, Try} + +/** Contains the web server state and functionalities */ +object WebServer: + private[web] val debug = false + + private[web] case class RunningClock( + clockId: UserId, + running: AtomicBoolean + ) + + private[web] case class RunningServerAppInstance( + instance: ServerAppInstance, + connectedUsersCount: Int + ) + + /** Mapping from app ids to their app class */ + private[web] val appDirectory: mutable.Map[AppId, ServerApp] = mutable.Map() + + /** Mapping from app instance ids to their running instances */ + private[web] val apps: concurrent.Map[InstanceId, RunningServerAppInstance] = concurrent.TrieMap() + + /** Mapping from app instance ids to the attached running clocks */ + private[web] val clocks: concurrent.Map[InstanceId, RunningClock] = concurrent.TrieMap() + + private[web] lazy val webSocketServer: WebSocketsCollection = new WebSocketsCollection(Config.WS_PORT) + + /** Registers the given state-machine based app. */ + def register[E, V, S](sm: StateMachine[E, V, S]): Unit = + register(StateMachineServerApp(sm)) + + def isRegistered(smInstance: StateMachine[?, ?, ?]): Boolean = + appDirectory.contains(smInstance.appInfo.id) + + /** Registers the given app */ + private def register(app: ServerApp): Unit = + // Populate `appDirectory` + val appId = app.appInfo.id + this.appDirectory.put(appId, app) + println(f"[$appId] registered") + + private[web] def createInstance(appId: AppId, clients: Seq[UserId]): InstanceId = + // Populate `apps` + val instanceId = java.util.UUID.randomUUID.toString + apps(instanceId) = RunningServerAppInstance(appDirectory(appId).init(clients), 0) + + apps(instanceId).instance match + case StateMachineServerAppInstance(sm: ClockDrivenStateMachine[?, ?, ?], _, _) => + startClockFor(appId, instanceId, sm.clockPeriodMs) + case StateMachineServerAppInstance(_, _, _) => () + + webSocketServer.initializeApp(instanceId) + println(f"[$appId] instance created $instanceId") + instanceId + + private[web] def shutdownApp(instanceId: InstanceId): Unit = + // Remove references of this app + val appId = apps.remove(instanceId).map(_.instance.appInfo.id) + clocks.get(instanceId).map(_._2).foreach(_.set(false)) + clocks.remove(instanceId) + if appId.isDefined then + println(s"[${appId.get}][$instanceId] shut down") + + private def startClockFor(appId: String, instanceId: InstanceId, clockResolutionMs: Int): Unit = + object Clock extends Thread: + override def run(): Unit = + val clockName = s"clock-$instanceId" + val running = AtomicBoolean(true) + clocks.put(instanceId, RunningClock(clockName, running)) + setName(clockName) + println(f"[$appId][$instanceId] clock started") + while running.get() do + handleMessage( + instanceId, + clockName, + ujson.Obj( + ClockDrivenStateMachine.CLOCK_EVENT_HEADER -> true, + ClockDrivenStateMachine.CLOCK_EVENT_TICK_HEADER -> TickEventFormat.encode(Tick(System.currentTimeMillis)) + ) + ) + Thread.sleep(clockResolutionMs) + + println(s"[$appId][$instanceId] clock stopped") + + Clock.start() + + /** Creates a transition from the current state of the application to the next + * state of the application. + * + * Given a [[Seq]] of [[UserId]], corresponding to the clients connected to + * the app, accepts and event and the event's author's id. The [[apps]] is + * then used to handle the event and gives a sequence of actions for each + * user. + * + * @param clients + * The set of clients currently connected in the application. + * @param uid + * The user id of the user who triggered the event. + * @param event + * The event triggered by the user. + * @return + * A map which links each user to a queue of views they should receive. + */ + private def transition(clients: Seq[UserId], instanceId: InstanceId)( + uid: UserId, + event: ujson.Value + ): Try[immutable.Map[UserId, Seq[Action[ujson.Value]]]] = + val instance = apps(instanceId).instance + instance.synchronized { + instance.transition(clients)(uid, event).map { + case (views, newApp) => + apps.update(instanceId, apps(instanceId).copy(instance = newApp)) + views + } + } + + /** Dispatches the given `msg`, sent by `userId` for app instance `instanceId` + * appropriately to clients + */ + private[web] def handleMessage(instanceId: InstanceId, userId: UserId, msg: ujson.Value): Unit = + val transitionResult = transition(webSocketServer.connectedClients(instanceId), instanceId)(userId, msg) + val serverResponse = transitionResult match + case Failure(exception) => + // If sender is clock, send exception to all clients + val senderIsClock = clocks.exists((clockId, _) => clockId == userId) // clocks use their id as userId + val recipients = + if senderIsClock then webSocketServer.connectedClients(instanceId) + else Seq(userId) + + for recipient <- recipients do + webSocketServer.send(instanceId, recipient): + EventResponse.Wire.encode(Failure(exception)) + case Success(userActions) => + for (uid, actions) <- userActions do + webSocketServer.send(instanceId, uid): + EventResponse.Wire.encode(Success(actions)) diff --git a/server/src/main/scala/cs214/webapp/server/web/WebServerRoutes.scala b/server/src/main/scala/cs214/webapp/server/web/WebServerRoutes.scala new file mode 100644 index 0000000000000000000000000000000000000000..ae1ada98224502fb4716564065c56ba74be72b62 --- /dev/null +++ b/server/src/main/scala/cs214/webapp/server/web/WebServerRoutes.scala @@ -0,0 +1,76 @@ +package cs214.webapp +package server +package web + +import java.net.InetAddress +import scala.jdk.CollectionConverters.* +import scala.util.Try +import cask.endpoints.JsonData + +/** HTTP routes of the WebServer */ +private[server] final case class WebServerRoutes()(using cc: castor.Context, log: cask.Logger) extends cask.Routes: + /** Paths where the static content served by the server is stored */ + private val WEB_SRC_PATH = "www/static/" + + /** HTML page to serve when accessing the server `/` and `/app/...` path */ + private def HTML_STATIC_FILE = + cask.model.StaticResource( + WEB_SRC_PATH + "webapp.html", + classOf[cask.staticResources].getClassLoader, + Seq("Content-Type" -> "text/html") + ) + + lazy val hostAddress: String = + val addresses = + for + intf <- java.net.NetworkInterface.getNetworkInterfaces.asScala + if intf.isUp + _ = if WebServer.debug then println(f"[debug] found interface $intf") + addr <- intf.getInetAddresses.asScala + if (addr.isInstanceOf[java.net.Inet4Address] + && !addr.isLinkLocalAddress + && !addr.isLoopbackAddress) + _ = if WebServer.debug then println(f"[debug] found address ${addr.getHostAddress}") + yield addr.getHostAddress + Try(addresses.toList.head).getOrElse(InetAddress.getLocalHost.getHostAddress) + + @cask.get("/") + def getIndexFile() = HTML_STATIC_FILE + + @cask.staticResources("/static") + def getStaticResources() = WEB_SRC_PATH + + // For all /app subsegments, provide the HTML page + @cask.get("/app") + def getApp(segments: cask.RemainingPathSegments) = HTML_STATIC_FILE + + @cask.getJson(f"/${Endpoints.Api.listApps}") + def getListApps() = + ListAppsResponse.Wire.encode(ListAppsResponse(WebServer.appDirectory.values.map(_.appInfo).toSeq)) + + @cask.getJson(f"/${Endpoints.Api.instanceInfo}") + def getAppInfo(instanceId: InstanceId) = + val response: cask.Response[JsonData] = + if WebServer.apps.contains(instanceId) then + val app = WebServer.apps(instanceId).instance + val shareUrl = f"http://$hostAddress:${Config.HTTP_PORT}/app/${app.appInfo.id}/$instanceId/" + val wsEndpoint = f"ws://{{hostName}}:${Config.WS_PORT}/$instanceId/{{userId}}" + val response = InstanceInfoResponse(instanceId, app.registeredUsers, wsEndpoint, shareUrl) + InstanceInfoResponse.Wire.encode(response) + else + cask.Response(f"Unknown instance id $instanceId", 400) + response + + @cask.post(f"/${Endpoints.Api.createInstance}") + def postInitApp(request: cask.Request) = + val response: cask.Response[JsonData] = + val req = CreateInstanceRequest.Wire.decode(ujson.read(request.text())) + if req.isFailure then + cask.Response(f"Unable to decode data: ${request.text()}", 400) + else + val appId = WebServer.createInstance(req.get.appName, req.get.userIds) + CreateInstanceResponse.Wire.encode(CreateInstanceResponse(appId)) + response + + WebServer.webSocketServer.run() + initialize() diff --git a/server/src/main/scala/cs214/webapp/server/web/WebSocketsCollection.scala b/server/src/main/scala/cs214/webapp/server/web/WebSocketsCollection.scala new file mode 100644 index 0000000000000000000000000000000000000000..ca2f9ab3f0b0e473205279677f257d79e4a6508d --- /dev/null +++ b/server/src/main/scala/cs214/webapp/server/web/WebSocketsCollection.scala @@ -0,0 +1,111 @@ +package cs214.webapp +package server.web + +import org.java_websocket.WebSocket +import org.java_websocket.handshake.ClientHandshake +import org.java_websocket.server.WebSocketServer +import java.net.{InetSocketAddress, URLDecoder} +import scala.collection.mutable +import scala.util.{Success, Failure, Try} + +/** A collection of websockets organized by app Id and client */ +private[web] final class WebSocketsCollection(val port: Int): + def onMessageReceive(appId: InstanceId, uid: UserId, msg: ujson.Value): Unit = + WebServer.handleMessage(appId, uid, msg) + + // When the user joins the app, the projection of the current state is sent to them + def onClientConnect(instanceId: InstanceId, userId: UserId): Unit = + val instance = WebServer.apps(instanceId) + val newInstance = instance.copy(connectedUsersCount = instance.connectedUsersCount + 1) + WebServer.apps(instanceId) = newInstance + val js = newInstance.instance.respondToNewClient(userId) + send(instanceId, userId): + EventResponse.Wire.encode(Success(List(Action.Render(js)))) + println(f"[${newInstance.instance.appInfo.id}][$instanceId] client \"$userId\" connected") + + def onClientDisconnect(instanceId: InstanceId, userId: UserId): Unit = + val instance = WebServer.apps(instanceId) + val newInstance = instance.copy(connectedUsersCount = instance.connectedUsersCount - 1) + WebServer.apps(instanceId) = newInstance + + val appId = newInstance.instance.appInfo.id + println(f"[$appId][$instanceId] client \"$userId\" disconnected") + if WebServer.apps(instanceId).connectedUsersCount <= 0 then + println(s"[$appId][$instanceId] 0 connected client") + WebServer.shutdownApp(instanceId) + + /** All the sessions currently in use, mapping instance ids to an actual + * socket object that a client owns + */ + private val sessions: mutable.Map[InstanceId, Seq[(UserId, WebSocket)]] = + mutable.Map() + + /** Initialize an empty session list for the given app instance */ + def initializeApp(instanceId: InstanceId) = + require(!sessions.contains(instanceId)) + sessions(instanceId) = Seq.empty + + /** Runs k with parameters parsed from the websocket connection path. [[k]] is + * run while synchronizing on [[sessions]] + * + * The connection should be on "ws://…/[app_instance_id]/[user_id]" for the + * parsing to function properly. + */ + private def withSessionParams[T](socket: WebSocket)(k: (InstanceId, UserId) => T) = + sessions.synchronized: + try + val components = socket.getResourceDescriptor.split("/").takeRight(2) + val decoded = components.map(s => URLDecoder.decode(s, "UTF-8")) + decoded match + case Array(appId, userId) => + if !sessions.contains(appId) then + throw IllegalArgumentException("Error: Invalid app ID") + k(appId, userId) + case _ => throw Exception("Error: Invalid path") + catch + case t => + socket.send(SocketResponseWire.encode(util.Failure(t)).toString) + socket.close() + + /** A single websocket server handling multiple apps and clients. */ + private val server: WebSocketServer = new WebSocketServer(InetSocketAddress("0.0.0.0", port)): + override def onOpen(socket: WebSocket, handshake: ClientHandshake): Unit = + withSessionParams(socket): (appId, clientId) => + sessions(appId) = sessions(appId) :+ (clientId, socket) + onClientConnect(appId, clientId) + + override def onClose(socket: WebSocket, code: Int, reason: String, remote: Boolean): Unit = + withSessionParams(socket): (appId, clientId) => + sessions(appId) = sessions(appId).filter(_._2 != socket) // Unregister the session + onClientDisconnect(appId, clientId) + + override def onMessage(socket: WebSocket, message: String): Unit = + withSessionParams(socket): (appId, clientId) => + onMessageReceive(appId, clientId, ujson.read(message)) + + override def onError(socket: WebSocket, ex: Exception): Unit = + // Only report the error, onClosed is called even when an error occurs + throw new RuntimeException(ex) + + override def onStart(): Unit = + val addr = server.getAddress + println(s"[WebSocket] server started on ${addr.getHostName}:${addr.getPort}.") + + /** Starts the server asynchronously. */ + def run(): Unit = + server.setReuseAddr(true) // Ignore leftover connections from pending processes + Thread(() => server.run(), "Socket Thread").start() + + /** Enumerates clients connected to [[appId]]. */ + def connectedClients(appId: InstanceId): Seq[UserId] = + sessions.get(appId).map(_.map(_._1).distinct).getOrElse(Seq()) + + /** Sends a message to a specific client. */ + def send(appId: InstanceId, clientId: UserId)(message: ujson.Value) = + val wrapped = SocketResponseWire.encode(util.Success(message)).toString + sessions.synchronized: + for + (userId, socket) <- sessions.getOrElse(appId, Seq.empty) + if userId == clientId + do + socket.send(wrapped) diff --git a/shared/shared/src/main/scala/cs214/webapp/Apps.scala b/shared/shared/src/main/scala/cs214/webapp/Apps.scala new file mode 100644 index 0000000000000000000000000000000000000000..c5f0f7dd327272608cf2a67bc331c63d3b1f7891 --- /dev/null +++ b/shared/shared/src/main/scala/cs214/webapp/Apps.scala @@ -0,0 +1,44 @@ +package cs214.webapp + +import ujson.Value + +import scala.util.Try + +case class AppInfo( + /** A lowercase letters ([a-z]) only app identifier (for example + * "tictactoe") + */ + id: AppId, + /** The display name of the application (for example "Tic Tac Toe"), less + * than 32 characters + */ + name: String, + /** A description of the application, less than 160 characters */ + description: String, + /** The current year for the course */ + year: Int +): + private val MAX_APP_DESC_LEN = 160 + private val MAX_APP_NAME_LEN = 32 + private val ID_PATTERN = "^[a-z-_]+$" + + require(id.matches(ID_PATTERN), f"App id \"$id\" is invalid. It should match this pattern: $ID_PATTERN") + require(name.length <= MAX_APP_NAME_LEN, s"App name \"$name\" is too long (> $MAX_APP_NAME_LEN chars)") + require( + description.length <= MAX_APP_DESC_LEN, + s"App description \"${description}\" is too long (> $MAX_APP_DESC_LEN chars)" + ) + +/** Provides all the necessary encoding/decoding methods for an application. */ +trait AppWire[Event, View]: + val eventFormat: WireFormat[Event] + val viewFormat: WireFormat[View] + +case class Tick(systemMillis: Long) + +object TickEventFormat extends WireFormat[Tick]: + override def decode(json: Value): Try[Tick] = + Try: + Tick(json.obj.get("millis").get.num.toLong) + + override def encode(t: Tick): Value = ujson.Obj("type" -> "Tick", "millis" -> ujson.Num(t.systemMillis.toDouble)) diff --git a/shared/shared/src/main/scala/cs214/webapp/Common.scala b/shared/shared/src/main/scala/cs214/webapp/Common.scala new file mode 100644 index 0000000000000000000000000000000000000000..49d2bc94826ac2c25e5922171dc8782cb6d940fc --- /dev/null +++ b/shared/shared/src/main/scala/cs214/webapp/Common.scala @@ -0,0 +1,47 @@ +package cs214.webapp + +object Config: + /** Which port the websocket server uses */ + val WS_PORT = 9090 + + /** Which port the HTTP server uses */ + val HTTP_PORT = 8080 + +/** Unique identifier for an app */ +type AppId = String + +/** Unique identifier for an instance of an app */ +type InstanceId = String + +/** Unique identifier for users */ +type UserId = String + +/** Encodes an object of type [[T]] to a [[ujson.Value]] */ +trait Encoder[T]: + def encode(t: T): ujson.Value + +/** Decodes an object of type [[T]] from a [[ujson.Value]] */ +trait Decoder[T]: + def decode(json: ujson.Value): util.Try[T] + +/** Provides a way to decode and encode an object of type [[T]] to [[Value]] */ +trait WireFormat[T] extends Encoder[T] with Decoder[T] + +/** HTTP endpoints */ +object Endpoints: + sealed abstract case class Api(path: String): + val root = "/api" + override def toString = f"$root/$path" + + object Api: + object listApps extends Api("list-apps") + object createInstance extends Api("create-instance") + object instanceInfo extends Api("instance-info") + +trait RegistrationProvider: + def register(): Unit + +/** Using ??? in a val breaks worksheet evaluation, so below we use TODO + * instead. This TODO value is a hack! + */ +def TODO[A]: A = null.asInstanceOf[A] diff --git a/shared/shared/src/main/scala/cs214/webapp/Exceptions.scala b/shared/shared/src/main/scala/cs214/webapp/Exceptions.scala new file mode 100644 index 0000000000000000000000000000000000000000..b7552ed292ccab746608c7e89d4a9c94a1f960dc --- /dev/null +++ b/shared/shared/src/main/scala/cs214/webapp/Exceptions.scala @@ -0,0 +1,19 @@ +package cs214.webapp + +/** An exception generated by an application */ +class AppException(message: String) extends Exception(message) + +/** The app was waiting for a different user. */ +case class NotYourTurnException() + extends AppException("It is not your turn!") + +/** User attempted illegal move. + * @param reason + * Why the move is illegal + */ +case class IllegalMoveException(reason: String) + extends AppException(s"Invalid move: $reason!") + +/** There was an error during the parsing of a JSON message. */ +case class DecodingException(msg: String) + extends AppException(f"Error while trying to decode JSON message: $msg.") diff --git a/shared/shared/src/main/scala/cs214/webapp/Messages.scala b/shared/shared/src/main/scala/cs214/webapp/Messages.scala new file mode 100644 index 0000000000000000000000000000000000000000..7457ee2e395c31f8d79041bcab7394747501a7b2 --- /dev/null +++ b/shared/shared/src/main/scala/cs214/webapp/Messages.scala @@ -0,0 +1,113 @@ +package cs214.webapp + +import ujson.* + +import java.util.UUID +import scala.util.{Failure, Success, Try} + +/** An action that client should perform */ +enum Action[+StateOrView]: + /** Show a message box. */ + case Alert(msg: String) + + /** Wait before processing the next action. */ + case Pause(durationMs: Int) + + /** Redraw the UI with new data. */ + case Render(st: StateOrView) + + def map[T](f: StateOrView => T): Action[T] = + this match + case Alert(msg) => Alert(msg) + case Pause(durationMs) => Pause(durationMs) + case Render(st) => Render(f(st)) + +object Action: + object Wire extends WireFormat[Action[Value]]: + def encode(t: Action[Value]): Value = + t match + case Alert(msg) => Obj("type" -> "Alert", "msg" -> msg) + case Pause(durationMs) => Obj("type" -> "Pause", "durationMs" -> durationMs) + case Render(js) => Obj("type" -> "Render", "js" -> js) + + def decode(json: Value): Try[Action[Value]] = Try: + json("type").str match + case "Alert" => Alert(json("msg").str) + case "Pause" => Pause(json("durationMs").num.toInt) + case "Render" => Render(json("js")) + case _ => throw DecodingException(f"Unexpected action: $json") + +/** A response to an event */ +type EventResponse = Try[Seq[Action[ujson.Value]]] +object EventResponse: + val Wire: WireFormat[EventResponse] = TryWire(SeqWire(Action.Wire)) + +/** A response to a socket message */ +type SocketResponse = Try[ujson.Value] +val SocketResponseWire = TryWire(IdentityWire) + +/** A response to the list-apps query */ +case class ListAppsResponse(apps: Seq[AppInfo]) + +object ListAppsResponse: + object Wire extends WireFormat[ListAppsResponse]: + def encode(t: ListAppsResponse): ujson.Value = + ujson.Obj("apps" -> Arr(t.apps.map { + case AppInfo(id, name, description, year) => + Obj("id" -> id, "name" -> name, "desc" -> description, "year" -> year) + }*)) + def decode(js: ujson.Value): Try[ListAppsResponse] = Try: + ListAppsResponse( + js("apps").arr + .map(_.obj) + .map(js => + AppInfo( + id = js("id").str, + name = js("name").str, + description = js("desc").str, + year = js("year").num.toInt + ) + ) + .to(Seq) + ) + +/** An HTTP request sent to create a new application instance for the server */ +case class CreateInstanceRequest(appName: String, userIds: Seq[UserId]) + +object CreateInstanceRequest: + object Wire extends WireFormat[CreateInstanceRequest]: + val strsWire = SeqWire(StringWire) + def encode(t: CreateInstanceRequest): ujson.Value = + ujson.Obj("appName" -> Str(t.appName), "userIds" -> strsWire.encode(t.userIds)) + def decode(js: ujson.Value): Try[CreateInstanceRequest] = Try: + CreateInstanceRequest(js("appName").str, strsWire.decode(js("userIds")).get) + +case class CreateInstanceResponse(instanceId: InstanceId) + +object CreateInstanceResponse: + object Wire extends WireFormat[CreateInstanceResponse]: + def encode(t: CreateInstanceResponse): ujson.Value = + ujson.Obj("instanceId" -> Str(t.instanceId)) + def decode(js: ujson.Value): Try[CreateInstanceResponse] = Try: + CreateInstanceResponse(js("instanceId").str) + +/** The response to an AppInfo query */ +case class InstanceInfoResponse(instanceId: InstanceId, userIds: Seq[UserId], wsEndpoint: String, shareUrl: String) + +object InstanceInfoResponse: + val strsWire = SeqWire(StringWire) + object Wire extends WireFormat[InstanceInfoResponse]: + def encode(t: InstanceInfoResponse): ujson.Value = + ujson.Obj( + "instanceId" -> t.instanceId, + "userIds" -> strsWire.encode(t.userIds), + "wsEndpoint" -> t.wsEndpoint, + "shareUrl" -> t.shareUrl + ) + def decode(js: ujson.Value): Try[InstanceInfoResponse] = Try: + InstanceInfoResponse( + js("instanceId").str, + strsWire.decode(js("userIds")).get, + js("wsEndpoint").str, + js("shareUrl").str + ) diff --git a/shared/shared/src/main/scala/cs214/webapp/Wires.scala b/shared/shared/src/main/scala/cs214/webapp/Wires.scala new file mode 100644 index 0000000000000000000000000000000000000000..2c7fe460056165a13995de050f68ca9826fe0ce4 --- /dev/null +++ b/shared/shared/src/main/scala/cs214/webapp/Wires.scala @@ -0,0 +1,84 @@ +package cs214.webapp + +import ujson.* + +import scala.util.{Failure, Success, Try} + +object IdentityWire extends WireFormat[ujson.Value]: + def encode(t: ujson.Value): Value = t + def decode(js: Value): Try[ujson.Value] = Success(js) + +object BooleanWire extends WireFormat[Boolean]: + def encode(t: Boolean): Value = Bool(t) + def decode(js: Value): Try[Boolean] = Try(js.bool) + +object StringWire extends WireFormat[String]: + def encode(t: String): Value = Str(t) + def decode(js: Value): Try[String] = Try(js.str) + +object IntWire extends WireFormat[Int]: + def encode(t: Int): Value = Num(t) + def decode(js: Value): Try[Int] = Try(js.num.toInt) + +case class OptionWire[T](wt: WireFormat[T]) extends WireFormat[Option[T]]: + def encode(o: Option[T]): ujson.Value = + o match + case None => Obj() + case Some(t) => Obj("get" -> wt.encode(t)) + def decode(js: ujson.Value): Try[Option[T]] = Try: + js.obj.get("get").map(wt.decode(_).get) + +case class TryWire[T](wt: WireFormat[T]) extends WireFormat[Try[T]]: + def encode(t: Try[T]): Value = + t match + case Failure(exn) => + Obj( + "type" -> "failure", + "msg" -> Str(exn.getMessage), + "stacktrace" -> exn.getStackTrace.mkString("StackTrace(", ", ", ")") + ) + case Success(t) => + Obj("type" -> "success", "get" -> wt.encode(t)) + + def decode(json: Value): Try[Try[T]] = Try: + json("type").str match + case "failure" => + Failure(Exception(json("msg").strOpt.getOrElse("Unkown error"))) + case "success" => + Success(wt.decode(json("get")).get) + case _ => + throw DecodingException(f"Unexpected try: $json") + +case class SeqWire[T](wt: WireFormat[T]) extends WireFormat[Seq[T]]: + def encode(s: Seq[T]): ujson.Value = + Arr(s.map(wt.encode)*) + def decode(js: ujson.Value): Try[Seq[T]] = Try: + js.arr.map(wt.decode(_).get).toSeq + +case class CastWire[T1, T2](w2: WireFormat[T2], enc: T1 => T2, dec: T2 => Try[T1]) + extends WireFormat[T1]: + def encode(t1: T1): ujson.Value = + w2.encode(enc(t1)) + def decode(js: ujson.Value): Try[T1] = + w2.decode(js).flatMap(dec) + +class SetWire[T](wt: WireFormat[T]) + extends CastWire[Set[T], Seq[T]]( + SeqWire(wt), + (s: Set[T]) => s.toSeq, + (s: Seq[T]) => Success(s.toSet) + ) + +case class PairWire[T1, T2](w1: WireFormat[T1], w2: WireFormat[T2]) + extends WireFormat[(T1, T2)]: + def encode(p: (T1, T2)): ujson.Value = + Arr(w1.encode(p._1), w2.encode(p._2)) + def decode(js: ujson.Value): Try[(T1, T2)] = Try: + (w1.decode(js.arr(0)).get, w2.decode(js.arr(1)).get) + +class MapWire[K, V](wk: WireFormat[K], wv: WireFormat[V]) + extends CastWire[Map[K, V], Seq[(K, V)]]( + SeqWire(PairWire(wk, wv)), + (m: Map[K, V]) => m.toSeq, + (s: Seq[(K, V)]) => Success(s.toMap) + )