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 @@
+
+
+
+
+
+
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;
+ }