From 8d46ff7ff89ab7c1acf0772b2de26eb09cd788a3 Mon Sep 17 00:00:00 2001 From: William Petit Date: Tue, 16 Jan 2024 09:27:04 +0100 Subject: [PATCH] feat: embed optional apps in player server --- .env.dist | 3 +- Makefile | 7 +- apps/home/app.js | 19 +++ apps/home/index.html | 16 +++ apps/home/manifest.json | 10 ++ apps/home/style.css | 23 +++ apps/lib/arcast.js | 46 ++++++ apps/lib/manifest.json | 3 + apps/lib/style.css | 77 ++++++++++ apps/remote-control/app.js | 66 +++++++++ apps/remote-control/index.html | 36 +++++ apps/remote-control/manifest.json | 10 ++ apps/screen-sharing/app.js | 200 ++++++++++++++++++++++++++ apps/screen-sharing/index.html | 30 ++++ apps/screen-sharing/manifest.json | 10 ++ cmd/mobile/main.go | 16 ++- default_apps.go | 70 +++++++++ go.mod | 2 + go.sum | 4 + internal/command/client/util.go | 4 +- internal/command/player/run.go | 35 ++++- modd.conf | 1 + pkg/{server => network}/network.go | 4 +- pkg/selfsigned/cert.go | 120 ++++++++++++++++ pkg/server/api.go | 118 +++++++++++++++ pkg/server/apps.go | 63 ++++++++ pkg/server/broadcast.go | 106 ++++++++++++++ pkg/server/http.go | 187 +++++++++++++----------- pkg/server/mdns.go | 3 +- pkg/server/options.go | 57 ++++++-- pkg/server/server.go | 36 +++-- pkg/server/templates/idle.html.gotmpl | 16 ++- 32 files changed, 1285 insertions(+), 113 deletions(-) create mode 100644 apps/home/app.js create mode 100644 apps/home/index.html create mode 100644 apps/home/manifest.json create mode 100644 apps/home/style.css create mode 100644 apps/lib/arcast.js create mode 100644 apps/lib/manifest.json create mode 100644 apps/lib/style.css create mode 100644 apps/remote-control/app.js create mode 100644 apps/remote-control/index.html create mode 100644 apps/remote-control/manifest.json create mode 100644 apps/screen-sharing/app.js create mode 100644 apps/screen-sharing/index.html create mode 100644 apps/screen-sharing/manifest.json create mode 100644 default_apps.go rename pkg/{server => network}/network.go (94%) create mode 100644 pkg/selfsigned/cert.go create mode 100644 pkg/server/api.go create mode 100644 pkg/server/apps.go create mode 100644 pkg/server/broadcast.go diff --git a/.env.dist b/.env.dist index df10f3e..f8a5fca 100644 --- a/.env.dist +++ b/.env.dist @@ -1,2 +1,3 @@ ARCAST_DESKTOP_ADDITIONAL_CHROME_ARGS= -ARCAST_DESKTOP_INSTANCE_ID= \ No newline at end of file +ARCAST_DESKTOP_INSTANCE_ID= +ARCAST_DESKTOP_APPS=true \ No newline at end of file diff --git a/Makefile b/Makefile index ca19aca..c0e2d4e 100644 --- a/Makefile +++ b/Makefile @@ -36,9 +36,14 @@ build-client: deps ## Build executable build-android: tools/gogio/bin/gogio deps ## Build executable mkdir -p dist - GOOS=android CGO_CFLAGS="-I${JDK_PATH}/include -I${JDK_PATH}/include/linux -w" tools/gogio/bin/gogio -target android -buildmode archive -o android/app/libs/mobile.aar -x ./cmd/mobile + CGO_ENABLED=1 GOOS=android CGO_CFLAGS="-I${JDK_PATH}/include -I${JDK_PATH}/include/linux -w" tools/gogio/bin/gogio -target android -buildmode archive -o android/app/libs/mobile.aar -x ./cmd/mobile ( cd android && ./gradlew assembleDebug ) +release-android: tools/gogio/bin/gogio deps ## Build executable + mkdir -p dist + CGO_ENABLED=1 GOOS=android CGO_CFLAGS="-I${JDK_PATH}/include -I${JDK_PATH}/include/linux -w" tools/gogio/bin/gogio -target android -buildmode archive -o android/app/libs/mobile.aar -x ./cmd/mobile + ( cd android && ./gradlew assemble ) + install-android: build-android adb install android/app/build/outputs/apk/debug/app-debug.apk adb shell monkey -p com.cadoles.arcast_player -c android.intent.category.LAUNCHER 1 diff --git a/apps/home/app.js b/apps/home/app.js new file mode 100644 index 0000000..4520fcd --- /dev/null +++ b/apps/home/app.js @@ -0,0 +1,19 @@ +fetch("/api/v1/apps") + .then((res) => res.json()) + .then((res) => { + const defaultApp = res.data.defaultApp; + const apps = res.data.apps; + + const container = document.createElement("div"); + container.className = "container"; + apps.forEach((app) => { + if (app.id === defaultApp || app.hidden) return; + const appLink = document.createElement("a"); + appLink.className = "app-link"; + appLink.href = "/apps/" + app.id + "/"; + appLink.innerText = app.title["fr"]; + container.appendChild(appLink); + }); + + document.getElementById("main").replaceWith(container); + }); diff --git a/apps/home/index.html b/apps/home/index.html new file mode 100644 index 0000000..2671835 --- /dev/null +++ b/apps/home/index.html @@ -0,0 +1,16 @@ + + + + + + Arcast - Home + + + + + +
+

Loading...

+
+ + \ No newline at end of file diff --git a/apps/home/manifest.json b/apps/home/manifest.json new file mode 100644 index 0000000..c132d33 --- /dev/null +++ b/apps/home/manifest.json @@ -0,0 +1,10 @@ +{ + "title": { + "fr": "Accueil", + "en": "Home" + }, + "description": { + "fr": "Voir la liste des applications", + "en": "See apps list" + } +} \ No newline at end of file diff --git a/apps/home/style.css b/apps/home/style.css new file mode 100644 index 0000000..dc151dd --- /dev/null +++ b/apps/home/style.css @@ -0,0 +1,23 @@ +.mt { + margin-top: 1em; + display: block; +} + +.app-link { + display: block; + background: white; + border-radius: 5px; + width: 100px; + height: 100px; + box-shadow: 1px 1px 3px #ccc; + color: #333; + text-decoration: none; + text-align: center; + padding-top: 30px; +} + +.app-link:hover { + background-color: #abdbdb; + box-shadow: 1px 1px 3px #aaa; + text-shadow: 1px 1px white; +} \ No newline at end of file diff --git a/apps/lib/arcast.js b/apps/lib/arcast.js new file mode 100644 index 0000000..fe23264 --- /dev/null +++ b/apps/lib/arcast.js @@ -0,0 +1,46 @@ +(function (Arcast) { + Arcast.getInfo = function () { + return fetch("/api/v1/info") + .then((res) => res.json()) + .then((res) => res.data); + }; + + Arcast.getBroadcastingChannel = function (id, onmessage) { + var scheme = "wss"; + if (window.location.protocol === "http:") { + scheme = "ws"; + } + var ws = new WebSocket( + `${scheme}://${window.location.host}/api/v1/broadcast/${id}` + ); + + var channel = { + opened: false, + send: (message) => { + ws.send(message); + }, + close: () => { + ws.close(); + }, + }; + + ws.onmessage = (evt) => { + if (typeof onmessage !== "function") { + return; + } + + onmessage(evt.data); + }; + ws.onclose = () => { + ws.onmessage = null; + ws.onclose = null; + ws.onopen = null; + channel.opened = false; + }; + ws.onopen = () => { + channel.opened = true; + }; + + return channel; + }; +})((window.Arcast = window.Arcast || {})); diff --git a/apps/lib/manifest.json b/apps/lib/manifest.json new file mode 100644 index 0000000..9279f8f --- /dev/null +++ b/apps/lib/manifest.json @@ -0,0 +1,3 @@ +{ + "hidden": true +} \ No newline at end of file diff --git a/apps/lib/style.css b/apps/lib/style.css new file mode 100644 index 0000000..4121a75 --- /dev/null +++ b/apps/lib/style.css @@ -0,0 +1,77 @@ +html { + box-sizing: border-box; + font-size: 16px; + width: 100%; + height: 100%; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + width: 100%; + height: 100%; + background-color: #e1e1e1; +} + +*, +*:before, +*:after { + box-sizing: inherit; +} + +ol, +ul { + list-style: none; +} + +body, +h1, +h2, +h3, +h4, +h5, +h6, +p, +ol, +ul { + margin: 0; + padding: 0; + font-weight: normal; +} + +.container { + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + flex-wrap: wrap; + gap: 10px; +} + +.panel { + display: block; + background-color: #fff; + border-radius: 5px; + padding: 10px 20px; + box-shadow: 2px 2px #3333331d; +} + +.panel p, .panel ul { + margin-top: 10px; +} + +.text-centered { + text-align: center; +} + +.text-italic { + font-style: italic; +} + +.text-small { + font-size: 0.8em; +} + +.fullwidth { + width: 100%; +} \ No newline at end of file diff --git a/apps/remote-control/app.js b/apps/remote-control/app.js new file mode 100644 index 0000000..d1efeb3 --- /dev/null +++ b/apps/remote-control/app.js @@ -0,0 +1,66 @@ +function main() { + refreshStatus(); + setInterval(refreshStatus, 10000) +} + +function refreshStatus() { + return fetch("/api/v1/status") + .then(res => res.json()) + .then(res => { + const newStatus = document.createElement("tr") + newStatus.id = "status" + + let td = document.createElement("td") + td.innerText = res.data.status + newStatus.appendChild(td) + + td = document.createElement("td") + td.innerText = res.data.title + newStatus.appendChild(td) + + td = document.createElement("td") + td.innerText = res.data.url + document.getElementById("url-input").placeholder = res.data.url + newStatus.appendChild(td) + + document.getElementById("status").replaceWith(newStatus) + }) + .catch(err => { + console.error(err); + window.location.reload() + }) +} + +function castUrl() { + const urlInput = document.getElementById("url-input") + const url = urlInput.value + if (url === "") return Promise.resolve() + return fetch("/api/v1/cast", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + "url": url, + }) + }) + .then(res => res.json()) + .then(() => refreshStatus()) + .then(() => { + urlInput.value = "" + }) +} + +function reset() { + const urlInput = document.getElementById("url-input") + return fetch("/api/v1/cast", { + method: "DELETE", + }) + .then(res => res.json()) + .then(() => refreshStatus()) + .then(() => { + urlInput.value = "" + }) +} + +main() \ No newline at end of file diff --git a/apps/remote-control/index.html b/apps/remote-control/index.html new file mode 100644 index 0000000..bdde8c6 --- /dev/null +++ b/apps/remote-control/index.html @@ -0,0 +1,36 @@ + + + + + + Arcast - Remote Control + + + + + +
+
+

Remote control

+ + + + + + + + + + + + + +
StatusTitleURL
Refreshing...
+ + + + +
+
+ + diff --git a/apps/remote-control/manifest.json b/apps/remote-control/manifest.json new file mode 100644 index 0000000..65837ea --- /dev/null +++ b/apps/remote-control/manifest.json @@ -0,0 +1,10 @@ +{ + "title": { + "fr": "Contrôle à distance", + "en": "Remote control" + }, + "description": { + "fr": "Contrôler l'afficheur numérique", + "en": "Control the cast player" + } +} \ No newline at end of file diff --git a/apps/screen-sharing/app.js b/apps/screen-sharing/app.js new file mode 100644 index 0000000..a1f2a01 --- /dev/null +++ b/apps/screen-sharing/app.js @@ -0,0 +1,200 @@ +const displayMediaOptions = { + video: { + displaySurface: "browser", + }, + audio: { + suppressLocalAudioPlayback: false, + }, + preferCurrentTab: false, + selfBrowserSurface: "exclude", + systemAudio: "include", + surfaceSwitching: "include", + monitorTypeSurfaces: "include", +}; + +const peerConnection = new RTCPeerConnection(); +const signaling = Arcast.getBroadcastingChannel( + "screen-sharing", + onSignalReceived +); + +var secret = window.crypto.randomUUID(); +var receivedAnswer = false; +var receivedOffer = false; +var isOffering = false; + +peerConnection.onconnectionstatechange = (evt) => { + console.log("Connection state changed", evt); +}; + +peerConnection.oniceconnectionstatechange = () => { + console.log("ICE state: ", peerConnection.iceConnectionState); +}; + +peerConnection.onicecandidate = (evt) => { + signaling.send( + JSON.stringify({ + type: "icecandidate", + data: { + candidate: evt.candidate, + }, + }) + ); +}; + +peerConnection.ontrack = function (e) { + var screenplay = document.getElementById("screenplay"); + if (!screenplay) { + screenplay = document.createElement("video"); + screenplay.id = "screenplay"; + document.body.appendChild(screenplay); + } + + screenplay.autoplay = true; + screenplay.playsInline = true; + screenplay.srcObject = e.streams[0]; + screenplay.muted = true; + + e.track.onended = (e) => (screenplay.srcObject = screenplay.srcObject); +}; + +function main() { + const urlParams = new URLSearchParams(window.location.search); + if (urlParams.has("secret")) { + secret = urlParams.get("secret"); + } +} + +function shareScreen() { + return Arcast.getInfo().then((info) => { + return navigator.mediaDevices + .getDisplayMedia(displayMediaOptions) + .then((captureStream) => { + isOffering = true; + const videoTrack = captureStream.getVideoTracks()[0]; + peerConnection.addTrack(videoTrack, captureStream); + + peerConnection.createOffer().then((offer) => { + peerConnection.setLocalDescription(offer).then(() => { + const intervalId = setInterval(() => { + if (receivedAnswer) { + clearInterval(intervalId); + return; + } + signaling.send( + JSON.stringify({ + type: "offer", + data: { + secret: secret, + offer: offer, + }, + }) + ); + }, 1000); + + const url = + "http://127.0.0.1:" + + info.port + + "/apps/screen-sharing/?secret=" + + encodeURIComponent(secret); + fetch("/api/v1/cast", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + url: url, + }), + }); + }); + }); + }); + }); +} + +function onSignalReceived(data) { + const message = JSON.parse(data); + switch (message.type) { + case "offer": + if (message.data.secret !== secret) { + return; + } + + if (receivedOffer || isOffering) { + return; + } + + peerConnection.setRemoteDescription( + new RTCSessionDescription(message.data.offer), + () => { + peerConnection.createAnswer(function (answer) { + peerConnection.setLocalDescription( + answer, + function () { + signaling.send( + JSON.stringify({ + type: "answer", + data: { + secret: secret, + answer: answer, + }, + }) + ); + }, + error + ); + }, error); + }, + error + ); + + receivedOffer = true; + + break; + + case "answer": + if (receivedAnswer || !isOffering) { + return; + } + + if (message.data.secret !== secret) { + return; + } + + peerConnection.setRemoteDescription( + new RTCSessionDescription(message.data.answer), + () => {}, + error + ); + + receivedAnswer = true; + + break; + + case "icecandidate": + if (message.data.candidate) { + peerConnection.addIceCandidate(message.data.candidate); + } + + break; + + default: + console.log("Received unhandled message", message); + } +} + +function endCall() { + var videos = document.getElementsByTagName("video"); + for (var i = 0; i < videos.length; i++) { + videos[i].pause(); + } + + peerConnection.close(); +} + +function error(err) { + console.error(err); + endCall(); +} + +main(); diff --git a/apps/screen-sharing/index.html b/apps/screen-sharing/index.html new file mode 100644 index 0000000..53257c5 --- /dev/null +++ b/apps/screen-sharing/index.html @@ -0,0 +1,30 @@ + + + + + + Arcast - Screen sharing + + + + + + +
+
+

Screen sharing

+ +
+
+ + diff --git a/apps/screen-sharing/manifest.json b/apps/screen-sharing/manifest.json new file mode 100644 index 0000000..a621713 --- /dev/null +++ b/apps/screen-sharing/manifest.json @@ -0,0 +1,10 @@ +{ + "title": { + "fr": "Partage d'écran", + "en": "Screen sharing" + }, + "description": { + "fr": "Partager son écran", + "en": "Share your screen" + } +} \ No newline at end of file diff --git a/cmd/mobile/main.go b/cmd/mobile/main.go index ca85e5a..4f2fcf7 100644 --- a/cmd/mobile/main.go +++ b/cmd/mobile/main.go @@ -8,7 +8,9 @@ import ( "os" "sync" + "forge.cadoles.com/arcad/arcast" "forge.cadoles.com/arcad/arcast/pkg/browser/gioui" + "forge.cadoles.com/arcad/arcast/pkg/selfsigned" "forge.cadoles.com/arcad/arcast/pkg/server" "gioui.org/app" "gioui.org/io/system" @@ -77,7 +79,19 @@ func main() { logger.Fatal(ctx, "could not retrieve instance id", logger.CapturedE(errors.WithStack(err))) } - server := server.New(browser, server.WithInstanceID(instanceID)) + cert, err := selfsigned.NewLANCert() + if err != nil { + logger.Fatal(ctx, "could not generate self signed certificate", logger.CapturedE(errors.WithStack(err))) + } + + server := server.New( + browser, + server.WithInstanceID(instanceID), + server.WithAppsEnabled(true), + server.WithDefaultApp("home"), + server.WithApps(arcast.DefaultApps...), + server.WithTLSCertificate(cert), + ) if err := server.Start(); err != nil { logger.Fatal(ctx, "could not start server", logger.CapturedE(errors.WithStack(err))) diff --git a/default_apps.go b/default_apps.go new file mode 100644 index 0000000..fb9a7a6 --- /dev/null +++ b/default_apps.go @@ -0,0 +1,70 @@ +package arcast + +import ( + "embed" + "encoding/json" + "io/fs" + "path/filepath" + + "forge.cadoles.com/arcad/arcast/pkg/server" + "github.com/pkg/errors" +) + +var ( + DefaultApps []server.App + //go:embed apps/** + appsFS embed.FS +) + +func init() { + defaultApps, err := loadApps("apps/*") + if err != nil { + panic(errors.WithStack(err)) + } + + DefaultApps = defaultApps +} + +func loadApps(dirPattern string) ([]server.App, error) { + apps := make([]server.App, 0) + + files, err := fs.Glob(appsFS, dirPattern) + if err != nil { + return nil, errors.WithStack(err) + } + + for _, f := range files { + stat, err := fs.Stat(appsFS, f) + if err != nil { + return nil, errors.WithStack(err) + } + + if !stat.IsDir() { + continue + } + + rawManifest, err := fs.ReadFile(appsFS, filepath.Join(f, "manifest.json")) + if err != nil { + return nil, errors.WithStack(err) + } + + var app server.App + + if err := json.Unmarshal(rawManifest, &app); err != nil { + return nil, errors.WithStack(err) + } + + app.ID = filepath.Base(f) + + fs, err := fs.Sub(appsFS, "apps/"+app.ID) + if err != nil { + return nil, errors.WithStack(err) + } + + app.FS = fs + + apps = append(apps, app) + } + + return apps, nil +} diff --git a/go.mod b/go.mod index 69a75ec..5c96510 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.21.4 require ( gioui.org v0.4.1 github.com/gioui-plugins/gio-plugins v0.0.0-20230625001848-8f18aae6c91c + github.com/go-chi/cors v1.2.1 github.com/grandcat/zeroconf v1.0.1-0.20230119201135-e4f60f8407b1 github.com/jaevor/go-nanoid v1.3.0 github.com/pkg/errors v0.9.1 @@ -24,6 +25,7 @@ require ( github.com/go-playground/locales v0.14.0 // indirect github.com/go-playground/universal-translator v0.18.0 // indirect github.com/go-text/typesetting v0.0.0-20230803102845-24e03d8b5372 // indirect + github.com/gorilla/websocket v1.5.1 // indirect github.com/inkeliz/go_inkwasm v0.0.0-20220912074516-049d3472c98a // indirect github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect github.com/jedib0t/go-pretty/v6 v6.4.9 // indirect diff --git a/go.sum b/go.sum index 49184b7..7da9c7d 100644 --- a/go.sum +++ b/go.sum @@ -36,6 +36,8 @@ github.com/gioui-plugins/gio-plugins v0.0.0-20230625001848-8f18aae6c91c h1:naFDa github.com/gioui-plugins/gio-plugins v0.0.0-20230625001848-8f18aae6c91c/go.mod h1:nBuRsi6udr2x6eorarLHtRkoRaWBICt+WzaE7zQXgYY= github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk= github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= +github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY= github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -52,6 +54,8 @@ github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= +github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= github.com/grandcat/zeroconf v1.0.1-0.20230119201135-e4f60f8407b1 h1:cNb52t5fkWv8ZiicKWnc2eZnhsCCoH7WmRBMIbMp04Q= github.com/grandcat/zeroconf v1.0.1-0.20230119201135-e4f60f8407b1/go.mod h1:I6CSXU4zCGL08JOk9NbcT0ofAgnIkS/fVXbYzfSoDic= github.com/inkeliz/go_inkwasm v0.0.0-20220912074516-049d3472c98a h1:uZklbtdSPrDL/d1EUKd9s8a0Byla2TT01Wg/GZ4xj0w= diff --git a/internal/command/client/util.go b/internal/command/client/util.go index d43a565..5065763 100644 --- a/internal/command/client/util.go +++ b/internal/command/client/util.go @@ -6,7 +6,7 @@ import ( "time" "forge.cadoles.com/arcad/arcast/pkg/client" - "forge.cadoles.com/arcad/arcast/pkg/server" + "forge.cadoles.com/arcad/arcast/pkg/network" "github.com/pkg/errors" "github.com/urfave/cli/v2" ) @@ -57,7 +57,7 @@ func forEachPlayer(ctx *cli.Context, fn func(cl *client.Client, playerAddr strin } for _, p := range players { - preferredIP, err := server.FindPreferredLocalAddress(p.IPs...) + preferredIP, err := network.FindPreferredLocalAddress(p.IPs...) if err != nil { return errors.Errorf("could not retrieve player '%s' preferred address", p.ID) } diff --git a/internal/command/player/run.go b/internal/command/player/run.go index f5c1eae..e1578f9 100644 --- a/internal/command/player/run.go +++ b/internal/command/player/run.go @@ -4,7 +4,9 @@ import ( "fmt" "os" + "forge.cadoles.com/arcad/arcast" "forge.cadoles.com/arcad/arcast/pkg/browser/lorca" + "forge.cadoles.com/arcad/arcast/pkg/selfsigned" "forge.cadoles.com/arcad/arcast/pkg/server" "github.com/pkg/errors" "github.com/urfave/cli/v2" @@ -26,11 +28,26 @@ func Run() *cli.Command { EnvVars: []string{"ARCAST_DESKTOP_INSTANCE_ID"}, Value: "", }, + &cli.StringFlag{ + Name: "address", + EnvVars: []string{"ARCAST_DESKTOP_ADDRESS"}, + Value: ":", + }, + &cli.StringFlag{ + Name: "tls-address", + EnvVars: []string{"ARCAST_DESKTOP_TLS_ADDRESS"}, + Value: ":", + }, &cli.IntFlag{ Name: "window-height", EnvVars: []string{"ARCAST_DESKTOP_WINDOW_HEIGHT"}, Value: defaults.Height, }, + &cli.BoolFlag{ + Name: "apps", + EnvVars: []string{"ARCAST_DESKTOP_APPS"}, + Value: false, + }, &cli.IntFlag{ Name: "window-width", EnvVars: []string{"ARCAST_DESKTOP_WINDOW_WIDTH"}, @@ -41,6 +58,9 @@ func Run() *cli.Command { windowHeight := ctx.Int("window-height") windowWidth := ctx.Int("window-width") chromeArgs := addFlagsPrefix(ctx.StringSlice("additional-chrome-arg")...) + enableApps := ctx.Bool("apps") + serverAddress := ctx.String("address") + serverTLSAddress := ctx.String("tls-address") browser := lorca.NewBrowser( lorca.WithAdditionalChromeArgs(chromeArgs...), @@ -69,7 +89,20 @@ func Run() *cli.Command { instanceID = server.NewRandomInstanceID() } - server := server.New(browser, server.WithInstanceID(instanceID)) + cert, err := selfsigned.NewLANCert() + if err != nil { + return errors.Wrap(err, "could not generate self signed certificate") + } + + server := server.New(browser, + server.WithInstanceID(instanceID), + server.WithAppsEnabled(enableApps), + server.WithDefaultApp("home"), + server.WithApps(arcast.DefaultApps...), + server.WithAddress(serverAddress), + server.WithTLSAddress(serverTLSAddress), + server.WithTLSCertificate(cert), + ) if err := server.Start(); err != nil { return errors.Wrap(err, "could not start server") diff --git a/modd.conf b/modd.conf index 199c707..3062cfa 100644 --- a/modd.conf +++ b/modd.conf @@ -1,5 +1,6 @@ **/*.go pkg/server/templates/**.gotmpl +apps/** modd.conf .env { prep: make build-client diff --git a/pkg/server/network.go b/pkg/network/network.go similarity index 94% rename from pkg/server/network.go rename to pkg/network/network.go index e33ba1c..0ce99a9 100644 --- a/pkg/server/network.go +++ b/pkg/network/network.go @@ -1,4 +1,4 @@ -package server +package network import ( "net" @@ -13,7 +13,7 @@ var ( _, lanC, _ = net.ParseCIDR("192.168.0.0/16") ) -func getLANIPv4Addrs() ([]string, error) { +func GetLANIPv4Addrs() ([]string, error) { ips := make([]string, 0) addrs, err := anet.InterfaceAddrs() diff --git a/pkg/selfsigned/cert.go b/pkg/selfsigned/cert.go new file mode 100644 index 0000000..4789f19 --- /dev/null +++ b/pkg/selfsigned/cert.go @@ -0,0 +1,120 @@ +package selfsigned + +import ( + "bytes" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "net" + "time" + + "forge.cadoles.com/arcad/arcast/pkg/network" + "github.com/pkg/errors" +) + +func NewLANCert() (*tls.Certificate, error) { + hosts, err := network.GetLANIPv4Addrs() + if err != nil { + return nil, errors.WithStack(err) + } + + hosts = append(hosts, "127.0.0.1") + + rawCert, rawKey, err := NewCertKeyPair(hosts...) + if err != nil { + return nil, errors.WithStack(err) + } + + cert, err := tls.X509KeyPair(rawCert, rawKey) + if err != nil { + return nil, errors.WithStack(err) + } + + return &cert, nil +} + +func NewCertKeyPair(hosts ...string) ([]byte, []byte, error) { + priv, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + if err != nil { + return nil, nil, errors.WithStack(err) + } + + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + if err != nil { + return nil, nil, errors.WithStack(err) + } + + keyUsage := x509.KeyUsageDigitalSignature + + notBefore := time.Now() + notAfter := notBefore.Add(365 * 24 * time.Hour) + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + Organization: []string{"Arcast Org"}, + }, + NotBefore: notBefore, + NotAfter: notAfter, + + KeyUsage: keyUsage, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + + for _, h := range hosts { + if ip := net.ParseIP(h); ip != nil { + template.IPAddresses = append(template.IPAddresses, ip) + } else { + template.DNSNames = append(template.DNSNames, h) + } + } + + template.IsCA = true + template.KeyUsage |= x509.KeyUsageCertSign + + derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, publicKey(priv), priv) + if err != nil { + return nil, nil, errors.WithStack(err) + } + + var cert bytes.Buffer + + if err := pem.Encode(&cert, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil { + return nil, nil, errors.WithStack(err) + } + + var key bytes.Buffer + + privBytes, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + return nil, nil, errors.WithStack(err) + } + + if err := pem.Encode(&key, &pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}); err != nil { + return nil, nil, errors.WithStack(err) + } + + return cert.Bytes(), key.Bytes(), nil +} + +func publicKey(priv any) any { + switch k := priv.(type) { + case *rsa.PrivateKey: + return &k.PublicKey + case *ecdsa.PrivateKey: + return &k.PublicKey + case ed25519.PrivateKey: + return k.Public().(ed25519.PublicKey) + default: + return nil + } +} diff --git a/pkg/server/api.go b/pkg/server/api.go new file mode 100644 index 0000000..f9b4b39 --- /dev/null +++ b/pkg/server/api.go @@ -0,0 +1,118 @@ +package server + +import ( + "fmt" + "net/http" + + "forge.cadoles.com/arcad/arcast/pkg/network" + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/api" + "gitlab.com/wpetit/goweb/logger" +) + +type InfoResponse struct { + IPs []string `json:"ips"` + Port int `json:"port"` + TLSPort int `json:"tlsPort"` + InstanceID string `json:"instanceId"` + AppsEnabled bool `json:"appsEnabled"` + ServiceDiscoveryEnabled bool `json:"serviceDiscoveryEnabled"` +} + +func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) { + ips, err := network.GetLANIPv4Addrs() + if err != nil { + logger.Error(r.Context(), "could not retrieve lan ip addresses", logger.CapturedE(errors.WithStack(err))) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + api.DataResponse(w, http.StatusOK, &InfoResponse{ + IPs: ips, + TLSPort: s.tlsPort, + Port: s.port, + InstanceID: s.instanceID, + AppsEnabled: s.appsEnabled, + ServiceDiscoveryEnabled: s.serviceDiscoveryEnabled, + }) +} + +type CastRequest struct { + URL string `json:"url" validate:"required"` +} + +func (s *Server) handleCast(w http.ResponseWriter, r *http.Request) { + req := &CastRequest{} + if ok := api.Bind(w, r, req); !ok { + return + } + + if err := s.browser.Load(req.URL); err != nil { + logger.Error(r.Context(), "could not load url", logger.CapturedE(errors.WithStack(err))) + api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil) + + return + } + + http.Redirect(w, r, "/api/v1/status", http.StatusSeeOther) +} + +func (s *Server) handleReset(w http.ResponseWriter, r *http.Request) { + if err := s.resetBrowser(); err != nil { + logger.Error(r.Context(), "could not unload url", logger.CapturedE(errors.WithStack(err))) + api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil) + + return + } + + http.Redirect(w, r, "/api/v1/status", http.StatusSeeOther) +} + +func (s *Server) resetBrowser() error { + idleURL := fmt.Sprintf("http://localhost:%d", s.port) + if err := s.browser.Reset(idleURL); err != nil { + return errors.WithStack(err) + } + + return nil +} + +type StatusResponse struct { + ID string `json:"id"` + URL string `json:"url"` + Status string `json:"status"` + Title string `json:"title"` +} + +func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { + url, err := s.browser.URL() + if err != nil { + logger.Error(r.Context(), "could not retrieve browser url", logger.CapturedE(errors.WithStack(err))) + api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil) + + return + } + + status, err := s.browser.Status() + if err != nil { + logger.Error(r.Context(), "could not retrieve browser status", logger.CapturedE(errors.WithStack(err))) + api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil) + + return + } + + title, err := s.browser.Title() + if err != nil { + logger.Error(r.Context(), "could not retrieve browser page title", logger.CapturedE(errors.WithStack(err))) + api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil) + + return + } + + api.DataResponse(w, http.StatusOK, &StatusResponse{ + ID: s.instanceID, + URL: url, + Status: status.String(), + Title: title, + }) +} diff --git a/pkg/server/apps.go b/pkg/server/apps.go new file mode 100644 index 0000000..a455c98 --- /dev/null +++ b/pkg/server/apps.go @@ -0,0 +1,63 @@ +package server + +import ( + "io/fs" + "net/http" + "strings" + "sync" + + "github.com/go-chi/chi/v5" + "gitlab.com/wpetit/goweb/api" + + _ "embed" +) + +type App struct { + ID string `json:"id"` + Title map[string]string `json:"title"` + Description map[string]string `json:"description"` + Icon string `json:"icon"` + FS fs.FS `json:"-"` + Hidden bool `json:"hidden"` +} + +func (s *Server) handleDefaultApp(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/apps/"+s.defaultApp+"/", http.StatusTemporaryRedirect) +} + +func (s *Server) handleApps(w http.ResponseWriter, r *http.Request) { + api.DataResponse(w, http.StatusOK, struct { + DefaultApp string `json:"defaultApp"` + Apps []App `json:"apps"` + }{ + DefaultApp: s.defaultApp, + Apps: s.apps, + }) +} + +var ( + indexedAppFilesystems map[string]fs.FS + indexAppsOnce sync.Once +) + +func (s *Server) handleAppFilesystem(w http.ResponseWriter, r *http.Request) { + indexAppsOnce.Do(func() { + indexedAppFilesystems = make(map[string]fs.FS, len(s.apps)) + for _, app := range s.apps { + indexedAppFilesystems[app.ID] = app.FS + } + }) + + appID := chi.URLParam(r, "appID") + + fs, exists := indexedAppFilesystems[appID] + if !exists { + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + + return + } + + name := strings.TrimPrefix(r.URL.Path, "/apps/"+appID) + + http.ServeFileFS(w, r, fs, name) +} diff --git a/pkg/server/broadcast.go b/pkg/server/broadcast.go new file mode 100644 index 0000000..ffcf51e --- /dev/null +++ b/pkg/server/broadcast.go @@ -0,0 +1,106 @@ +package server + +import ( + "context" + "log" + "net/http" + "sync" + + "github.com/go-chi/chi/v5" + "github.com/gorilla/websocket" + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/logger" +) + +var ( + upgrader = websocket.Upgrader{} + channels = &channelMap{ + index: make(map[string]map[*websocket.Conn]struct{}), + } +) + +func (s *Server) handleBroadcast(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + c, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Print("upgrade:", err) + return + } + + channelID := chi.URLParam(r, "channelID") + channels.Add(channelID, c) + + defer func() { + channels.Remove(channelID, c) + + if err := c.Close(); err != nil && !websocket.IsCloseError(err, 1001) { + logger.Error(ctx, "could not close connection", logger.E(errors.WithStack(err))) + } + }() + + for { + messageType, message, err := c.ReadMessage() + if err != nil { + logger.Error(ctx, "could not read message", logger.E(errors.WithStack(err))) + break + } + + logger.Debug(ctx, "broadcasting message", logger.F("message", message), logger.F("messageType", messageType)) + + channels.Send(ctx, channelID, messageType, message, c) + } +} + +type channelMap struct { + mutex sync.RWMutex + index map[string]map[*websocket.Conn]struct{} +} + +func (m *channelMap) Remove(channelID string, conn *websocket.Conn) { + m.mutex.Lock() + defer m.mutex.Unlock() + + conns, exists := m.index[channelID] + if !exists { + return + } + + delete(conns, conn) + if len(conns) == 0 { + delete(m.index, channelID) + } +} + +func (m *channelMap) Add(channelID string, conn *websocket.Conn) { + m.mutex.Lock() + defer m.mutex.Unlock() + + conns, exists := m.index[channelID] + if !exists { + conns = make(map[*websocket.Conn]struct{}) + } + + conns[conn] = struct{}{} + m.index[channelID] = conns +} + +func (m *channelMap) Send(ctx context.Context, channelID string, messageType int, message []byte, except *websocket.Conn) { + m.mutex.RLock() + defer m.mutex.RUnlock() + + conns, exists := m.index[channelID] + if !exists { + return + } + + for c := range conns { + if except == c { + continue + } + + if err := c.WriteMessage(messageType, message); err != nil { + logger.Error(ctx, "could not write message", logger.E(errors.WithStack(err))) + } + } +} diff --git a/pkg/server/http.go b/pkg/server/http.go index 36ffdc0..54129e1 100644 --- a/pkg/server/http.go +++ b/pkg/server/http.go @@ -2,15 +2,17 @@ package server import ( "context" + "crypto/tls" "fmt" "html/template" "net" "net/http" "strconv" + "forge.cadoles.com/arcad/arcast/pkg/network" "github.com/go-chi/chi/v5" + "github.com/go-chi/cors" "github.com/pkg/errors" - "gitlab.com/wpetit/goweb/api" "gitlab.com/wpetit/goweb/logger" _ "embed" @@ -31,14 +33,61 @@ func init() { idleTemplate = tmpl } -func (s *Server) startHTTPServer(ctx context.Context) error { +func (s *Server) startWebServers(ctx context.Context) error { router := chi.NewRouter() + if s.appsEnabled { + ips, err := network.GetLANIPv4Addrs() + if err != nil { + return errors.WithStack(err) + } + + allowedOrigins := make([]string, len(ips)) + for idx, ip := range ips { + allowedOrigins[idx] = fmt.Sprintf("http://%s:%d", ip, s.port) + } + + router.Use(cors.Handler(cors.Options{ + AllowedOrigins: allowedOrigins, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"}, + AllowCredentials: false, + })) + } + router.Get("/", s.handleHome) + router.Get("/api/v1/info", s.handleInfo) router.Post("/api/v1/cast", s.handleCast) router.Delete("/api/v1/cast", s.handleReset) router.Get("/api/v1/status", s.handleStatus) + if s.appsEnabled { + router.Get("/apps", s.handleDefaultApp) + router.Get("/api/v1/apps", s.handleApps) + router.Handle("/apps/{appID}/*", http.HandlerFunc(s.handleAppFilesystem)) + router.Handle("/api/v1/broadcast/{channelID}", http.HandlerFunc(s.handleBroadcast)) + } + + if err := s.startHTTPServer(ctx, router); err != nil { + return errors.WithStack(err) + } + + if s.tlsCert != nil { + if err := s.startHTTPSServer(ctx, router); err != nil { + return errors.WithStack(err) + } + } else { + logger.Info(ctx, "no tls certificate configured, not starting https server") + } + + if err := s.resetBrowser(); err != nil { + return errors.WithStack(err) + } + + return nil +} + +func (s *Server) startHTTPServer(ctx context.Context, router chi.Router) error { server := http.Server{ Addr: s.address, Handler: router, @@ -78,101 +127,67 @@ func (s *Server) startHTTPServer(ctx context.Context) error { } }() - if err := s.resetBrowser(); err != nil { - return errors.WithStack(err) - } - return nil } -type CastRequest struct { - URL string `json:"url" validate:"required"` -} - -func (s *Server) handleCast(w http.ResponseWriter, r *http.Request) { - req := &CastRequest{} - if ok := api.Bind(w, r, req); !ok { - return +func (s *Server) startHTTPSServer(ctx context.Context, router chi.Router) error { + server := http.Server{ + Addr: s.address, + Handler: router, + TLSConfig: &tls.Config{ + GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) { + return s.tlsCert, nil + }, + }, } - if err := s.browser.Load(req.URL); err != nil { - logger.Error(r.Context(), "could not load url", logger.CapturedE(errors.WithStack(err))) - api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil) - - return - } - - http.Redirect(w, r, "/api/v1/status", http.StatusSeeOther) -} - -func (s *Server) handleReset(w http.ResponseWriter, r *http.Request) { - if err := s.resetBrowser(); err != nil { - logger.Error(r.Context(), "could not unload url", logger.CapturedE(errors.WithStack(err))) - api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil) - - return - } - - http.Redirect(w, r, "/api/v1/status", http.StatusSeeOther) -} - -func (s *Server) resetBrowser() error { - idleURL := fmt.Sprintf("http://localhost:%d", s.port) - if err := s.browser.Reset(idleURL); err != nil { + listener, err := net.Listen("tcp", s.tlsAddress) + if err != nil { return errors.WithStack(err) } + host, rawPort, err := net.SplitHostPort(listener.Addr().String()) + if err != nil { + return errors.WithStack(err) + } + + port, err := strconv.ParseInt(rawPort, 10, 32) + if err != nil { + return errors.Wrapf(err, "could not parse listening port '%v'", rawPort) + } + + logger.Debug(ctx, "listening for tls tcp connections", logger.F("port", port), logger.F("host", host)) + + s.tlsPort = int(port) + + go func() { + logger.Debug(ctx, "starting https server") + if err := server.ServeTLS(listener, "", ""); err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.Error(ctx, "could not listen", logger.CapturedE(errors.WithStack(err))) + } + }() + + go func() { + <-ctx.Done() + logger.Debug(ctx, "closing https server") + if err := server.Close(); err != nil { + logger.Error(ctx, "could not close https server", logger.CapturedE(errors.WithStack(err))) + } + }() + return nil } -type StatusResponse struct { - ID string `json:"id"` - URL string `json:"url"` - Status string `json:"status"` - Title string `json:"title"` -} - -func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { - url, err := s.browser.URL() - if err != nil { - logger.Error(r.Context(), "could not retrieve browser url", logger.CapturedE(errors.WithStack(err))) - api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil) - - return - } - - status, err := s.browser.Status() - if err != nil { - logger.Error(r.Context(), "could not retrieve browser status", logger.CapturedE(errors.WithStack(err))) - api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil) - - return - } - - title, err := s.browser.Title() - if err != nil { - logger.Error(r.Context(), "could not retrieve browser page title", logger.CapturedE(errors.WithStack(err))) - api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil) - - return - } - - api.DataResponse(w, http.StatusOK, &StatusResponse{ - ID: s.instanceID, - URL: url, - Status: status.String(), - Title: title, - }) -} - func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) { type templateData struct { - IPs []string - Port int - ID string + IPs []string + Port int + TLSPort int + ID string + Apps bool } - ips, err := getLANIPv4Addrs() + ips, err := network.GetLANIPv4Addrs() if err != nil { logger.Error(r.Context(), "could not retrieve lan ip addresses", logger.CapturedE(errors.WithStack(err))) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -180,9 +195,11 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) { } d := templateData{ - ID: s.instanceID, - IPs: ips, - Port: s.port, + ID: s.instanceID, + IPs: ips, + Port: s.port, + TLSPort: s.tlsPort, + Apps: s.appsEnabled, } if err := idleTemplate.Execute(w, d); err != nil { diff --git a/pkg/server/mdns.go b/pkg/server/mdns.go index 5e67d4a..9a16f6e 100644 --- a/pkg/server/mdns.go +++ b/pkg/server/mdns.go @@ -3,6 +3,7 @@ package server import ( "context" + "forge.cadoles.com/arcad/arcast/pkg/network" "github.com/grandcat/zeroconf" "github.com/pkg/errors" "github.com/wlynxg/anet" @@ -21,7 +22,7 @@ func (s *Server) startMDNServer(ctx context.Context) error { return errors.WithStack(err) } - ips, err := getLANIPv4Addrs() + ips, err := network.GetLANIPv4Addrs() if err != nil { return errors.WithStack(err) } diff --git a/pkg/server/options.go b/pkg/server/options.go index 4e0f632..03140da 100644 --- a/pkg/server/options.go +++ b/pkg/server/options.go @@ -1,6 +1,8 @@ package server import ( + "crypto/tls" + "github.com/jaevor/go-nanoid" "github.com/pkg/errors" ) @@ -17,18 +19,27 @@ func init() { } type Options struct { - InstanceID string - Address string - DisableServiceDiscovery bool + InstanceID string + Address string + TLSAddress string + TLSCertificate *tls.Certificate + EnableServiceDiscovery bool + EnableApps bool + DefaultApp string + Apps []App } type OptionFunc func(opts *Options) func NewOptions(funcs ...OptionFunc) *Options { opts := &Options{ - InstanceID: NewRandomInstanceID(), - Address: ":", - DisableServiceDiscovery: false, + InstanceID: NewRandomInstanceID(), + Address: ":", + TLSAddress: ":", + EnableServiceDiscovery: true, + EnableApps: false, + DefaultApp: "", + Apps: make([]App, 0), } for _, fn := range funcs { @@ -38,21 +49,51 @@ func NewOptions(funcs ...OptionFunc) *Options { return opts } +func WithAppsEnabled(enabled bool) OptionFunc { + return func(opts *Options) { + opts.EnableApps = enabled + } +} + +func WithDefaultApp(defaultApp string) OptionFunc { + return func(opts *Options) { + opts.DefaultApp = defaultApp + } +} + +func WithApps(apps ...App) OptionFunc { + return func(opts *Options) { + opts.Apps = apps + } +} + func WithAddress(addr string) OptionFunc { return func(opts *Options) { opts.Address = addr } } +func WithTLSAddress(addr string) OptionFunc { + return func(opts *Options) { + opts.TLSAddress = addr + } +} + +func WithTLSCertificate(cert *tls.Certificate) OptionFunc { + return func(opts *Options) { + opts.TLSCertificate = cert + } +} + func WithInstanceID(id string) OptionFunc { return func(opts *Options) { opts.InstanceID = id } } -func WithServiceDiscoveryDisabled(disabled bool) OptionFunc { +func WithServiceDiscoveryEnabled(enabled bool) OptionFunc { return func(opts *Options) { - opts.DisableServiceDiscovery = disabled + opts.EnableServiceDiscovery = enabled } } diff --git a/pkg/server/server.go b/pkg/server/server.go index 3ea403c..ca4df02 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -2,6 +2,7 @@ package server import ( "context" + "crypto/tls" "forge.cadoles.com/arcad/arcast/pkg/browser" "github.com/pkg/errors" @@ -11,10 +12,20 @@ import ( type Server struct { browser browser.Browser - instanceID string - address string - port int - disableServiceDiscovery bool + instanceID string + + address string + port int + + tlsAddress string + tlsPort int + tlsCert *tls.Certificate + + serviceDiscoveryEnabled bool + + appsEnabled bool + defaultApp string + apps []App ctx context.Context cancel context.CancelFunc @@ -26,16 +37,16 @@ func (s *Server) Start() error { s.cancel = cancelServer s.ctx = serverCtx - httpServerCtx, cancelHTTPServer := context.WithCancel(serverCtx) - if err := s.startHTTPServer(httpServerCtx); err != nil { - cancelHTTPServer() + webServersCtx, cancelWebServers := context.WithCancel(serverCtx) + if err := s.startWebServers(webServersCtx); err != nil { + cancelWebServers() return errors.WithStack(err) } - if !s.disableServiceDiscovery { + if s.serviceDiscoveryEnabled { mdnsServerCtx, cancelMDNSServer := context.WithCancel(serverCtx) if err := s.startMDNServer(mdnsServerCtx); err != nil { - cancelHTTPServer() + cancelWebServers() cancelMDNSServer() return errors.WithStack(err) } @@ -71,6 +82,11 @@ func New(browser browser.Browser, funcs ...OptionFunc) *Server { browser: browser, instanceID: opts.InstanceID, address: opts.Address, - disableServiceDiscovery: opts.DisableServiceDiscovery, + tlsAddress: opts.TLSAddress, + tlsCert: opts.TLSCertificate, + appsEnabled: opts.EnableApps, + defaultApp: opts.DefaultApp, + apps: opts.Apps, + serviceDiscoveryEnabled: opts.EnableServiceDiscovery, } } diff --git a/pkg/server/templates/idle.html.gotmpl b/pkg/server/templates/idle.html.gotmpl index 3ea0931..767b079 100644 --- a/pkg/server/templates/idle.html.gotmpl +++ b/pkg/server/templates/idle.html.gotmpl @@ -76,13 +76,18 @@ .text-small { font-size: 0.8em; } + + .mt { + margin-top: 1em; + display: block; + }
-

Arcast

+

Arcast - Idle

Instance ID:

{{ .ID }} @@ -94,6 +99,15 @@

  • {{ . }}:{{ $port }}
  • {{end}} + {{if .Apps }} +

    Apps:

    + + {{end}}