diff --git a/Package.resolved b/Package.resolved index 2e0bdcb..76cfaf2 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,6 +1,51 @@ { "object": { "pins": [ + { + "package": "async-http-client", + "repositoryURL": "https://github.com/swift-server/async-http-client.git", + "state": { + "branch": null, + "revision": "7a4dfe026f6ee0f8ad741b58df74c60af296365d", + "version": "1.9.0" + } + }, + { + "package": "async-kit", + "repositoryURL": "https://github.com/vapor/async-kit.git", + "state": { + "branch": null, + "revision": "e2f741640364c1d271405da637029ea6a33f754e", + "version": "1.11.1" + } + }, + { + "package": "console-kit", + "repositoryURL": "https://github.com/vapor/console-kit.git", + "state": { + "branch": null, + "revision": "75ea3b627d88221440b878e5dfccc73fd06842ed", + "version": "4.2.7" + } + }, + { + "package": "leaf", + "repositoryURL": "https://github.com/vapor/leaf.git", + "state": { + "branch": null, + "revision": "41741b782ac49959af2f7612661154326ac24d00", + "version": "4.1.5" + } + }, + { + "package": "leaf-kit", + "repositoryURL": "https://github.com/vapor/leaf-kit.git", + "state": { + "branch": null, + "revision": "983fcbe89e7153c4d5870bdc76bf57a56817225f", + "version": "1.4.0" + } + }, { "package": "MessagePack", "repositoryURL": "https://github.com/Flight-School/MessagePack.git", @@ -10,6 +55,24 @@ "version": "1.2.4" } }, + { + "package": "multipart-kit", + "repositoryURL": "https://github.com/vapor/multipart-kit.git", + "state": { + "branch": null, + "revision": "2dd9368a3c9580792b77c7ef364f3735909d9996", + "version": "4.5.1" + } + }, + { + "package": "routing-kit", + "repositoryURL": "https://github.com/vapor/routing-kit.git", + "state": { + "branch": null, + "revision": "5603b81ceb744b8318feab1e60943704977a866b", + "version": "4.3.1" + } + }, { "package": "swift-argument-parser", "repositoryURL": "https://github.com/apple/swift-argument-parser.git", @@ -19,6 +82,24 @@ "version": "1.0.3" } }, + { + "package": "swift-backtrace", + "repositoryURL": "https://github.com/swift-server/swift-backtrace.git", + "state": { + "branch": null, + "revision": "d3e04a9d4b3833363fb6192065b763310b156d54", + "version": "1.3.1" + } + }, + { + "package": "swift-crypto", + "repositoryURL": "https://github.com/apple/swift-crypto.git", + "state": { + "branch": null, + "revision": "a8911e0fadc25aef1071d582355bd1037a176060", + "version": "2.0.4" + } + }, { "package": "swift-log", "repositoryURL": "https://github.com/apple/swift-log.git", @@ -28,6 +109,15 @@ "version": "1.4.2" } }, + { + "package": "swift-metrics", + "repositoryURL": "https://github.com/apple/swift-metrics.git", + "state": { + "branch": null, + "revision": "3edd2f57afc4e68e23c3e4956bc8b65ca6b5b2ff", + "version": "2.2.0" + } + }, { "package": "swift-nio", "repositoryURL": "https://github.com/apple/swift-nio.git", @@ -37,6 +127,24 @@ "version": "2.38.0" } }, + { + "package": "swift-nio-extras", + "repositoryURL": "https://github.com/apple/swift-nio-extras.git", + "state": { + "branch": null, + "revision": "f73ca5ee9c6806800243f1ac415fcf82de9a4c91", + "version": "1.10.2" + } + }, + { + "package": "swift-nio-http2", + "repositoryURL": "https://github.com/apple/swift-nio-http2.git", + "state": { + "branch": null, + "revision": "000ca94f9de92c95b9ac85d44600b7b0fe25a3e5", + "version": "1.19.2" + } + }, { "package": "swift-nio-ssl", "repositoryURL": "https://github.com/apple/swift-nio-ssl.git", @@ -46,6 +154,24 @@ "version": "2.17.2" } }, + { + "package": "swift-nio-transport-services", + "repositoryURL": "https://github.com/apple/swift-nio-transport-services.git", + "state": { + "branch": null, + "revision": "8ab824b140d0ebcd87e9149266ddc353e3705a3e", + "version": "1.11.4" + } + }, + { + "package": "vapor", + "repositoryURL": "https://github.com/vapor/vapor.git", + "state": { + "branch": null, + "revision": "18e9419cae5049e43ca1e8002ca3cf0449f2c8ed", + "version": "4.55.0" + } + }, { "package": "websocket-kit", "repositoryURL": "https://github.com/vapor/websocket-kit.git", diff --git a/Package.swift b/Package.swift index 84c280a..737fe9e 100644 --- a/Package.swift +++ b/Package.swift @@ -10,12 +10,16 @@ let package = Package( // Products define the executables and libraries a package produces, and make them visible to other packages. .library( name: "LighthouseClient", - targets: ["LighthouseClient"] + targets: ["LighthouseProtocol", "LighthouseClient"] ), .executable( name: "LighthouseDemo", targets: ["LighthouseDemo"] - ) + ), + .executable( + name: "LighthouseTestServer", + targets: ["LighthouseTestServer"] + ), ], dependencies: [ // Dependencies declare other packages that this package depends on. @@ -23,26 +27,47 @@ let package = Package( .package(url: "https://github.com/Flight-School/MessagePack.git", from: "1.2.4"), .package(url: "https://github.com/apple/swift-log.git", from: "1.4.2"), .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.0.3"), + .package(url: "https://github.com/vapor/vapor.git", from: "4.0.0"), + .package(url: "https://github.com/vapor/leaf.git", from: "4.0.0"), ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. // Targets can depend on other targets in this package, and on products in packages this package depends on. + .target( + name: "LighthouseProtocol", + dependencies: [ + .product(name: "Logging", package: "swift-log"), + ] + ), .target( name: "LighthouseClient", dependencies: [ - .product(name: "WebSocketKit", package: "websocket-kit"), - .product(name: "MessagePack", package: "MessagePack"), + .target(name: "LighthouseProtocol"), .product(name: "Logging", package: "swift-log"), + .product(name: "MessagePack", package: "MessagePack"), + .product(name: "WebSocketKit", package: "websocket-kit"), ] ), .executableTarget( name: "LighthouseDemo", dependencies: [ + .target(name: "LighthouseProtocol"), .target(name: "LighthouseClient"), .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "Logging", package: "swift-log"), ] ), + .executableTarget( + name: "LighthouseTestServer", + dependencies: [ + .target(name: "LighthouseProtocol"), + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "Logging", package: "swift-log"), + .product(name: "MessagePack", package: "MessagePack"), + .product(name: "Vapor", package: "vapor"), + .product(name: "Leaf", package: "leaf"), + ] + ), // .testTarget( // name: "LighthouseClientTests", // dependencies: [ diff --git a/Public/scripts.js b/Public/scripts.js new file mode 100644 index 0000000..035014a --- /dev/null +++ b/Public/scripts.js @@ -0,0 +1,96 @@ +const lighthouseRows = 14; +const lighthouseCols = 28; +const xScale = 14; +const yScale = 2 * xScale; + +let connection = null; +let auth = null; +let display = null; + +function updateDisplay(rgb) { + const ctx = display.getContext("2d"); + + for (let i = 0; i < (rgb.length / 3); i++) { + const r = rgb[3 * i]; + const g = rgb[3 * i + 1]; + const b = rgb[3 * i + 2]; + + const y = Math.floor(i / lighthouseCols); + const x = i % lighthouseCols; + + ctx.fillStyle = `rgb(${r},${g},${b})`; + ctx.fillRect(x * xScale, y * yScale, xScale, yScale); + } +} + +function setUpDisplay() { + display = document.getElementById("display"); + display.width = xScale * lighthouseCols; + display.height = yScale * lighthouseRows; + updateDisplay(new Uint8Array(3 * lighthouseRows * lighthouseCols)); +} + +function setUpConnection() { + connection = new WebSocket(`${location.origin.replace(/^http/, "ws")}/websocket`); + connection.binaryType = "arraybuffer"; + + connection.addEventListener("open", () => { + console.log("Connected!"); + }); + + connection.addEventListener("message", event => { + try { + const message = MessagePack.decode(new Uint8Array(event.data)); + + if (message.PAYL instanceof Uint8Array) { + updateDisplay(message.PAYL); + } else { + console.log(`Something else: ${message.PAYL instanceof Uint8Array}`); + } + } catch (e) { + console.log(`Error while decoding message from WebSocket: ${e}`); + } + }); +} + +function setUpFormListener() { + const form = document.getElementById("auth-form"); + const fieldset = document.getElementById("auth-form-fieldset"); + const usernameField = document.getElementById("username"); + const tokenField = document.getElementById("token"); + + form.addEventListener("submit", event => { + event.preventDefault(); + + if (auth) { + alert("You are already authenticated!"); + return; + } + + const username = usernameField.value; + const token = tokenField.value; + + if (!username) { + alert("Please provide a username!"); + return; + } + + auth = { USER: username, TOKEN: token }; + fieldset.disabled = true; + + connection.send(MessagePack.encode({ + VERB: "STREAM", + PATH: ["user", username, "model"], + AUTH: auth, + META: {}, + REID: 0, + PAYL: null, + })); + }); +} + +window.addEventListener("load", () => { + setUpDisplay(); + setUpConnection(); + setUpFormListener(); +}); diff --git a/README.md b/README.md index 0d682cf..91ca6b5 100644 --- a/README.md +++ b/README.md @@ -7,26 +7,38 @@ An API client for a light installation at the University of Kiel using Swift 5.5 ## Example ```swift -// Prepare connection -let conn = Connection(authentication: Authentication( - username: "[your username]", - token: "[your token]" -)) - -// Handle incoming input events -conn.onInput { input in - print("Got input \(input)") +import LighthouseClient +import LighthouseProtocol +import Dispatch + +func runApp() async throws { + // Prepare connection + let conn = Connection(authentication: Authentication( + username: "[your username]", + token: "[your token]" + )) + + // Handle incoming input events + conn.onInput { input in + print("Got input \(input)") + } + + // Connect to the lighthouse server and request events + try await conn.connect() + try await conn.requestStream() + + // Repeatedly send colored displays to the lighthouse + while true { + try await conn.send(display: Display(fill: .random())) + try await Task.sleep(nanoseconds: 1_000_000_000) + } } -// Connect to the lighthouse server and request events -try await conn.connect() -try await conn.requestStream() - -// Repeatedly send colored displays to the lighthouse -while true { - try await conn.send(display: Display(fill: .random())) - try await Task.sleep(nanoseconds: 1_000_000_000) +Task { + try! await runApp() } + +dispatchMain() ``` ## Usage diff --git a/Resources/Views/index.leaf b/Resources/Views/index.leaf new file mode 100644 index 0000000..79081e4 --- /dev/null +++ b/Resources/Views/index.leaf @@ -0,0 +1,23 @@ + + + + Lighthouse Test + + + + +
+

Lighthouse Test

+
+
+ + + +
+
+

+ +

+
+ + diff --git a/Sources/LighthouseClient/Connection.swift b/Sources/LighthouseClient/Connection.swift index 1233284..a571ce6 100644 --- a/Sources/LighthouseClient/Connection.swift +++ b/Sources/LighthouseClient/Connection.swift @@ -3,6 +3,7 @@ import Logging import MessagePack import NIO import WebSocketKit +import LighthouseProtocol private let log = Logger(label: "LighthouseClient.Connection") diff --git a/Sources/LighthouseClient/Constants.swift b/Sources/LighthouseClient/Constants.swift index 301e1f5..04a7f10 100644 --- a/Sources/LighthouseClient/Constants.swift +++ b/Sources/LighthouseClient/Constants.swift @@ -1,6 +1,3 @@ import Foundation -public let lighthouseRows: Int = 14 -public let lighthouseCols: Int = 28 -public let lighthouseSize: Int = lighthouseRows * lighthouseCols public let lighthouseUrl: URL = URL(string: "wss://lighthouse.uni-kiel.de/websocket")! diff --git a/Sources/LighthouseDemo/LighthouseDemo.swift b/Sources/LighthouseDemo/LighthouseDemo.swift index 80fc455..33921ae 100644 --- a/Sources/LighthouseDemo/LighthouseDemo.swift +++ b/Sources/LighthouseDemo/LighthouseDemo.swift @@ -2,10 +2,11 @@ import ArgumentParser import Dispatch import Foundation import Logging +import LighthouseProtocol import LighthouseClient -let log = Logger(label: "LighthouseDemo") -let env = ProcessInfo.processInfo.environment +private let log = Logger(label: "LighthouseDemo") +private let env = ProcessInfo.processInfo.environment @main struct LighthouseDemo: ParsableCommand { @@ -24,7 +25,7 @@ struct LighthouseDemo: ParsableCommand { // Prepare connection let auth = Authentication(username: username, token: token) - let conn = Connection(authentication: auth) + let conn = Connection(authentication: auth, url: url) // Handle incoming input events conn.onInput { input in diff --git a/Sources/LighthouseClient/Authentication.swift b/Sources/LighthouseProtocol/Authentication.swift similarity index 100% rename from Sources/LighthouseClient/Authentication.swift rename to Sources/LighthouseProtocol/Authentication.swift diff --git a/Sources/LighthouseClient/Color.swift b/Sources/LighthouseProtocol/Color.swift similarity index 100% rename from Sources/LighthouseClient/Color.swift rename to Sources/LighthouseProtocol/Color.swift diff --git a/Sources/LighthouseProtocol/Constants.swift b/Sources/LighthouseProtocol/Constants.swift new file mode 100644 index 0000000..44b6f92 --- /dev/null +++ b/Sources/LighthouseProtocol/Constants.swift @@ -0,0 +1,3 @@ +public let lighthouseRows: Int = 14 +public let lighthouseCols: Int = 28 +public let lighthouseSize: Int = lighthouseRows * lighthouseCols diff --git a/Sources/LighthouseClient/Display.swift b/Sources/LighthouseProtocol/Display.swift similarity index 100% rename from Sources/LighthouseClient/Display.swift rename to Sources/LighthouseProtocol/Display.swift diff --git a/Sources/LighthouseClient/Protocol.swift b/Sources/LighthouseProtocol/Protocol.swift similarity index 73% rename from Sources/LighthouseClient/Protocol.swift rename to Sources/LighthouseProtocol/Protocol.swift index d83f9ef..e576cce 100644 --- a/Sources/LighthouseClient/Protocol.swift +++ b/Sources/LighthouseProtocol/Protocol.swift @@ -56,9 +56,25 @@ public enum Protocol { public var requestId: Int public var verb: String public var path: [String] - public var meta: [String: String] = [:] + public var meta: [String: String] public var authentication: Authentication - public var payload: Payload = .other + public var payload: Payload + + public init( + requestId: Int, + verb: String, + path: [String], + meta: [String: String] = [:], + authentication: Authentication, + payload: Payload + ) { + self.requestId = requestId + self.verb = verb + self.path = path + self.meta = meta + self.authentication = authentication + self.payload = payload + } } /// A message originating from the lighthouse server. @@ -76,5 +92,19 @@ public enum Protocol { public var warnings: [String]? public var response: String? public var payload: Payload + + public init( + code: Int, + requestId: Int, + warnings: [String]? = nil, + response: String? = nil, + payload: Payload = .other + ) { + self.code = code + self.requestId = requestId + self.warnings = warnings + self.response = response + self.payload = payload + } } } diff --git a/Sources/LighthouseTestServer/ConnectionHandler.swift b/Sources/LighthouseTestServer/ConnectionHandler.swift new file mode 100644 index 0000000..6c95ccd --- /dev/null +++ b/Sources/LighthouseTestServer/ConnectionHandler.swift @@ -0,0 +1,103 @@ +import NIO +import MessagePack +import Vapor +import LighthouseProtocol + +private let log = Logger(label: "LighthouseTestServer.MessagingHandler") + +/// Handles the server side of the LighthouseTestServer's web sockets. +class ConnectionHandler { + private var clients: [UUID: ClientState] = [:] + + private class ClientState { + private let ws: WebSocket + var receivesStream: Bool = false + var display: Display = .init() + var name: String? = nil + + init(ws: WebSocket) { + self.ws = ws + } + + func respond(to requestId: Int, code: Int, response: String? = nil) throws { + log.info("Responding with \(code) (id: \(requestId))") + try send(message: Protocol.ServerMessage( + code: code, + requestId: requestId, + response: response + )) + } + + func send(display: Display) throws { + try send(message: Protocol.ServerMessage( + code: 200, + requestId: 0, // TODO: Set this to some value? + payload: .display(display) + )) + } + + func send(message: Message) throws where Message: Encodable { + let data = try MessagePackEncoder().encode(message) + send(data: data) + } + + func send(data: Data) { + ws.send(Array(data)) + } + } + + func connect(_ ws: WebSocket) { + let uuid = UUID() + clients[uuid] = ClientState(ws: ws) + log.info("Opened connection to \(uuid)") + + ws.onBinary { [weak self] _, buf in + guard let data = buf.getData(at: 0, length: buf.readableBytes) else { + log.warning("Could not read data from WebSocket") + return + } + self?.handleReceive(of: data, for: uuid) + } + + ws.onClose.whenComplete { [weak self] _ in + self?.clients[uuid] = nil + log.info("Closed connection to \(uuid)") + } + } + + func handleReceive(of data: Data, for uuid: UUID) { + do { + guard let client = clients[uuid] else { + log.warning("No client associated with \(uuid)") + return + } + + let message = try MessagePackDecoder().decode(Protocol.ClientMessage.self, from: data) + log.info("Got \(message.verb) \(message.path.joined(separator: "/")) (id: \(message.requestId))") + + let name = message.authentication.username + client.name = name + + switch (message.verb, message.payload) { + case ("PUT", .display(let display)): + log.info("Updating \(name)'s display and notifying streaming clients...") + client.display = display + try client.respond(to: message.requestId, code: 200) + + for rcvClient in clients.values where rcvClient.receivesStream { + try rcvClient.send(display: client.display) + } + case ("STREAM", _): + client.receivesStream = true + log.info("Enabled stream for \(name)") + try client.respond(to: message.requestId, code: 200) + default: + log.warning("Got unknown request \(message.verb) with payload \(message.payload)") + try client.respond(to: message.requestId, code: 400, response: "Bad Request") + break + } + } catch { + log.warning("Error while handling message: \(error)") + } + } +} diff --git a/Sources/LighthouseTestServer/configure.swift b/Sources/LighthouseTestServer/configure.swift new file mode 100644 index 0000000..79da61f --- /dev/null +++ b/Sources/LighthouseTestServer/configure.swift @@ -0,0 +1,12 @@ +import Vapor +import Leaf + +public func configure(_ app: Application) throws { + // Serve from /Public + app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory)) + + app.views.use(.leaf) + + // Register routes + try routes(app) +} diff --git a/Sources/LighthouseTestServer/main.swift b/Sources/LighthouseTestServer/main.swift new file mode 100644 index 0000000..a291b28 --- /dev/null +++ b/Sources/LighthouseTestServer/main.swift @@ -0,0 +1,8 @@ +import Vapor + +var env = try Environment.detect() +try LoggingSystem.bootstrap(from: &env) +let app = Application(env) +defer { app.shutdown() } +try configure(app) +try app.run() diff --git a/Sources/LighthouseTestServer/routes.swift b/Sources/LighthouseTestServer/routes.swift new file mode 100644 index 0000000..44e69af --- /dev/null +++ b/Sources/LighthouseTestServer/routes.swift @@ -0,0 +1,12 @@ +import Vapor + +func routes(_ app: Application) throws { + app.get { req in + req.view.render("index") + } + + let handler = ConnectionHandler() + app.webSocket("websocket") { req, ws in + handler.connect(ws) + } +}