feat: embed optional apps in player server
This commit is contained in:
parent
acd71c84f6
commit
8d46ff7ff8
@ -1,2 +1,3 @@
|
||||
ARCAST_DESKTOP_ADDITIONAL_CHROME_ARGS=
|
||||
ARCAST_DESKTOP_INSTANCE_ID=
|
||||
ARCAST_DESKTOP_INSTANCE_ID=
|
||||
ARCAST_DESKTOP_APPS=true
|
7
Makefile
7
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
|
||||
|
19
apps/home/app.js
Normal file
19
apps/home/app.js
Normal file
@ -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);
|
||||
});
|
16
apps/home/index.html
Normal file
16
apps/home/index.html
Normal file
@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Arcast - Home</title>
|
||||
<link rel="stylesheet" href="/apps/lib/style.css">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<script type="text/javascript" src="app.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="main" class="container">
|
||||
<p class="text-center">Loading...</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
10
apps/home/manifest.json
Normal file
10
apps/home/manifest.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"title": {
|
||||
"fr": "Accueil",
|
||||
"en": "Home"
|
||||
},
|
||||
"description": {
|
||||
"fr": "Voir la liste des applications",
|
||||
"en": "See apps list"
|
||||
}
|
||||
}
|
23
apps/home/style.css
Normal file
23
apps/home/style.css
Normal file
@ -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;
|
||||
}
|
46
apps/lib/arcast.js
Normal file
46
apps/lib/arcast.js
Normal file
@ -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 || {}));
|
3
apps/lib/manifest.json
Normal file
3
apps/lib/manifest.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"hidden": true
|
||||
}
|
77
apps/lib/style.css
Normal file
77
apps/lib/style.css
Normal file
@ -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%;
|
||||
}
|
66
apps/remote-control/app.js
Normal file
66
apps/remote-control/app.js
Normal file
@ -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()
|
36
apps/remote-control/index.html
Normal file
36
apps/remote-control/index.html
Normal file
@ -0,0 +1,36 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Arcast - Remote Control</title>
|
||||
<link rel="stylesheet" href="/apps/lib/style.css" />
|
||||
<script type="text/javascript" src="../lib/arcast.js" defer></script>
|
||||
<script type="text/javascript" src="app.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="main" class="container">
|
||||
<div class="panel">
|
||||
<h1>Remote control</h1>
|
||||
<table class="fullwidth">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Title</th>
|
||||
<th>URL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr id="status" class="text-centered">
|
||||
<td colspan="3">Refreshing...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<input class="text-input" id="url-input" placeholder />
|
||||
<button onclick="castUrl()">Cast</button>
|
||||
<button onclick="reset()">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
10
apps/remote-control/manifest.json
Normal file
10
apps/remote-control/manifest.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
200
apps/screen-sharing/app.js
Normal file
200
apps/screen-sharing/app.js
Normal file
@ -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();
|
30
apps/screen-sharing/index.html
Normal file
30
apps/screen-sharing/index.html
Normal file
@ -0,0 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Arcast - Screen sharing</title>
|
||||
<link rel="stylesheet" href="/apps/lib/style.css" />
|
||||
<script type="text/javascript" src="/apps/lib/arcast.js" defer></script>
|
||||
<script type="text/javascript" src="app.js" defer></script>
|
||||
<style>
|
||||
#screenplay {
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
object-fit: contain;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
object-fit: fill;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="main" class="container">
|
||||
<div class="panel">
|
||||
<h1>Screen sharing</h1>
|
||||
<button onclick="shareScreen()">Start</button>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
10
apps/screen-sharing/manifest.json
Normal file
10
apps/screen-sharing/manifest.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"title": {
|
||||
"fr": "Partage d'écran",
|
||||
"en": "Screen sharing"
|
||||
},
|
||||
"description": {
|
||||
"fr": "Partager son écran",
|
||||
"en": "Share your screen"
|
||||
}
|
||||
}
|
@ -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)))
|
||||
|
70
default_apps.go
Normal file
70
default_apps.go
Normal file
@ -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
|
||||
}
|
2
go.mod
2
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
|
||||
|
4
go.sum
4
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=
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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")
|
||||
|
@ -1,5 +1,6 @@
|
||||
**/*.go
|
||||
pkg/server/templates/**.gotmpl
|
||||
apps/**
|
||||
modd.conf
|
||||
.env {
|
||||
prep: make build-client
|
||||
|
@ -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()
|
120
pkg/selfsigned/cert.go
Normal file
120
pkg/selfsigned/cert.go
Normal file
@ -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
|
||||
}
|
||||
}
|
118
pkg/server/api.go
Normal file
118
pkg/server/api.go
Normal file
@ -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,
|
||||
})
|
||||
}
|
63
pkg/server/apps.go
Normal file
63
pkg/server/apps.go
Normal file
@ -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)
|
||||
}
|
106
pkg/server/broadcast.go
Normal file
106
pkg/server/broadcast.go
Normal file
@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
@ -76,13 +76,18 @@
|
||||
.text-small {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.mt {
|
||||
margin-top: 1em;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="panel">
|
||||
<h1>Arcast</h1>
|
||||
<h1>Arcast - Idle</h1>
|
||||
<p>Instance ID:</p>
|
||||
<p class="text-centered text-small">
|
||||
<code>{{ .ID }}</code>
|
||||
@ -94,6 +99,15 @@
|
||||
<li><code>{{ . }}:{{ $port }}</code></li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{if .Apps }}
|
||||
<p>Apps:</p>
|
||||
<ul class="text-italic text-small">
|
||||
{{ $tlsPort := .TLSPort }}
|
||||
{{range .IPs}}
|
||||
<li><a href="https://{{ . }}:{{ $tlsPort }}/apps">https://{{ . }}:{{ $tlsPort }}/apps</a></li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
Loading…
Reference in New Issue
Block a user