feat: rewrite app system
arcad/arcast/pipeline/head There was a failure building this commit Details

This commit is contained in:
wpetit 2024-04-24 17:32:01 +02:00
parent 7b8165a0ec
commit e642604312
62 changed files with 19692 additions and 610 deletions

View File

@ -1,3 +1,4 @@
ARCAST_DESKTOP_ADDITIONAL_CHROME_ARGS= ARCAST_DESKTOP_ADDITIONAL_CHROME_ARGS=
ARCAST_DESKTOP_INSTANCE_ID= ARCAST_DESKTOP_INSTANCE_ID=
ARCAST_DESKTOP_APPS=true ARCAST_DESKTOP_APPS=true
ARCAST_DESKTOP_ALLOWED_ORIGINS="*"

5
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,5 @@
{
"java.project.sourcePaths": [
"apps/main/src"
]
}

6
Jenkinsfile vendored
View File

@ -6,6 +6,7 @@ standardMakePipeline([
'baseImage': 'reg.cadoles.com/proxy_cache/library/ubuntu:24.04', 'baseImage': 'reg.cadoles.com/proxy_cache/library/ubuntu:24.04',
'dockerfileExtension': ''' 'dockerfileExtension': '''
ARG GOLANG_VERSION=1.22.0 ARG GOLANG_VERSION=1.22.0
ARG NODEJS_VERSION=20.x
ENV ANDROID_HOME=/opt/android-sdk-linux ENV ANDROID_HOME=/opt/android-sdk-linux
ENV ANDROID_SDK_ROOT=${ANDROID_HOME} ENV ANDROID_SDK_ROOT=${ANDROID_HOME}
@ -20,6 +21,11 @@ standardMakePipeline([
RUN locale-gen en_US.UTF-8 RUN locale-gen en_US.UTF-8
ENV LANG='en_US.UTF-8' LANGUAGE='en_US:en' LC_ALL='en_US.UTF-8' ENV LANG='en_US.UTF-8' LANGUAGE='en_US:en' LC_ALL='en_US.UTF-8'
# Install NodeJS
RUN wget -O- https://deb.nodesource.com/setup_${NODEJS_VERSION} | bash - \
&& apt-get update -y \
&& apt-get install -y nodejs
# Install Golang # Install Golang
RUN wget -O golang.tar.gz https://golang.org/dl/go${GOLANG_VERSION}.linux-amd64.tar.gz \ RUN wget -O golang.tar.gz https://golang.org/dl/go${GOLANG_VERSION}.linux-amd64.tar.gz \
&& tar -C /usr/local -xzf golang.tar.gz && tar -C /usr/local -xzf golang.tar.gz

View File

@ -18,6 +18,8 @@ ANDROID_KEYSTORE_KEY_VALIDITY ?= 365000
ANDROID_BUILD_TOOLS_VERSION ?= 34.0.0 ANDROID_BUILD_TOOLS_VERSION ?= 34.0.0
APPS := main
watch: tools/modd/bin/modd deps ## Watching updated files - live reload watch: tools/modd/bin/modd deps ## Watching updated files - live reload
( set -o allexport && source .env && set +o allexport && tools/modd/bin/modd ) ( set -o allexport && source .env && set +o allexport && tools/modd/bin/modd )
@ -27,7 +29,12 @@ test: test-go ## Executing tests
test-go: deps test-go: deps
( set -o allexport && source .env && set +o allexport && go test -v -count=1 $(GOTEST_ARGS) ./... ) ( set -o allexport && source .env && set +o allexport && go test -v -count=1 $(GOTEST_ARGS) ./... )
build: build-desktop build-android build-client ## Build artefacts build: build-apps build-desktop build-android build-client ## Build artefacts
build-apps: $(foreach name,$(APPS),build-app-$(name))
build-app-%:
$(MAKE) -C apps/$* build
build-desktop: deps ## Build executable build-desktop: deps ## Build executable
CGO_ENABLED=0 GOARCH=$(GOARCH) go build \ CGO_ENABLED=0 GOARCH=$(GOARCH) go build \
@ -69,7 +76,7 @@ release-android: $(ANDROID_KEYSTORE_FILE) tools/gogio/bin/gogio deps ## Build ex
--in android/app/build/outputs/apk/release/app-release-unsigned-aligned.apk \ --in android/app/build/outputs/apk/release/app-release-unsigned-aligned.apk \
--out android/app/build/outputs/apk/release/app-release.apk --out android/app/build/outputs/apk/release/app-release.apk
install-android: build-android install-android: build-apps build-android
adb uninstall com.cadoles.arcast_player adb uninstall com.cadoles.arcast_player
adb install android/app/build/outputs/apk/debug/app-debug.apk adb install android/app/build/outputs/apk/debug/app-debug.apk
$(MAKE) run-android $(MAKE) run-android

View File

@ -12,12 +12,14 @@ import (
var ( var (
DefaultApps []server.App DefaultApps []server.App
//go:embed apps/** //go:embed apps/main/build/**
appsFS embed.FS appsFS embed.FS
) )
func init() { func init() {
defaultApps, err := loadApps("apps/*") defaultApps, err := loadApps(
"apps/main/build",
)
if err != nil { if err != nil {
panic(errors.WithStack(err)) panic(errors.WithStack(err))
} }
@ -25,25 +27,11 @@ func init() {
DefaultApps = defaultApps DefaultApps = defaultApps
} }
func loadApps(dirPattern string) ([]server.App, error) { func loadApps(appDirs ...string) ([]server.App, error) {
apps := make([]server.App, 0) apps := make([]server.App, 0)
files, err := fs.Glob(appsFS, dirPattern) for _, dir := range appDirs {
if err != nil { rawManifest, err := fs.ReadFile(appsFS, filepath.Join(dir, "arcast-app.json"))
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 { if err != nil {
return nil, errors.WithStack(err) return nil, errors.WithStack(err)
} }
@ -54,9 +42,7 @@ func loadApps(dirPattern string) ([]server.App, error) {
return nil, errors.WithStack(err) return nil, errors.WithStack(err)
} }
app.ID = filepath.Base(f) fs, err := fs.Sub(appsFS, dir)
fs, err := fs.Sub(appsFS, "apps/"+app.ID)
if err != nil { if err != nil {
return nil, errors.WithStack(err) return nil, errors.WithStack(err)
} }

View File

@ -1,19 +0,0 @@
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);
});

View File

@ -1,16 +0,0 @@
<!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>

View File

@ -1,23 +0,0 @@
.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;
}

View File

@ -1,46 +0,0 @@
(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 || {}));

View File

@ -1,3 +0,0 @@
{
"hidden": true
}

View File

@ -1,77 +0,0 @@
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%;
}

23
apps/main/.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

12
apps/main/Makefile Normal file
View File

@ -0,0 +1,12 @@
.PHONY: build
build: node_modules
npm run build
node_modules:
npm ci
.env.development.local:
echo "REACT_APP_ARCAST_SERVER_BASE_URL=http://127.0.0.1:45555" > .env.development.local
dev: .env.development.local
npm run start

18262
apps/main/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

47
apps/main/package.json Normal file
View File

@ -0,0 +1,47 @@
{
"name": "main",
"version": "0.1.0",
"private": true,
"dependencies": {
"@tanstack/react-query": "^5.32.0",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2",
"@types/node": "^16.18.96",
"@types/react": "^18.2.79",
"@types/react-dom": "^18.2.25",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.23.0",
"react-scripts": "5.0.1",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4",
"webrtc-adapter": "^9.0.1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"homepage": "/apps/main"
}

View File

@ -1,4 +1,5 @@
{ {
"id": "main",
"title": { "title": {
"fr": "Accueil", "fr": "Accueil",
"en": "Home" "en": "Home"

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Main | Apps | Arcast</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

0
apps/main/src/App.css Normal file
View File

36
apps/main/src/App.tsx Normal file
View File

@ -0,0 +1,36 @@
import React, { FunctionComponent } from "react";
import { createHashRouter, RouterProvider } from "react-router-dom";
import { Layout } from "./components/Layout/Layout";
import { HomePage } from "./pages/HomePage/HomePage";
import { ScreenSharingPage } from "./pages/ScreenSharingPage/ScreenSharingPage";
const router = createHashRouter([
{
path: "/",
element: <Layout />,
children: [
{
path: "",
element: <HomePage />,
},
{
path: "/screen-sharing",
element: <ScreenSharingPage />,
children: [
{
path: "sessions/:sessionId",
element: <ScreenSharingPage />,
},
],
},
],
},
]);
export const App: FunctionComponent = () => {
return <RouterProvider router={router} />;
};
export default App;

164
apps/main/src/api/arcast.ts Normal file
View File

@ -0,0 +1,164 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
const BASE_URL = process.env.REACT_APP_ARCAST_SERVER_BASE_URL ?? ""
export interface AppInfos {
defaultApp: string;
apps: App[];
}
export interface App {
id: string;
title: { [lang: string]: string };
description: { [lang: string]: string };
icon: string;
hidden: boolean;
}
export const usePlayerAppInfos = () => {
return useQuery({
queryKey: ["appsInfos"],
queryFn: getAppInfos,
select: (appInfos) => appInfos ?? { apps: [], defaultApp: "main" }
})
}
export async function getAppInfos() {
const response = await fetch(`${BASE_URL}/api/v1/apps`);
const result = await response.json();
const appInfos = result.data as AppInfos;
return appInfos;
}
export interface Status {
id: string
status: string
title: string
url: string
}
export const usePlayerStatus = () => {
return useQuery({
queryKey: ["playerStatus"],
queryFn: getStatus,
select: (status) => status ?? {}
})
}
export async function getStatus() {
const response = await fetch(`${BASE_URL}/api/v1/status`);
const result = await response.json();
const appInfos = result.data as Status;
return appInfos;
}
export interface BroadcastChannel {
opened: boolean
send: (data: string | ArrayBufferLike | Blob | ArrayBufferView) => void
close: () => void
}
export function getBroadcastChannel(channelId: string, onmessage?: (data: any) => void): BroadcastChannel {
let location: Location | URL = window.location
if (BASE_URL !== "") {
location = new URL(BASE_URL)
}
let scheme = "wss";
if (location.protocol === "http:") {
scheme = "ws";
}
var ws = new WebSocket(
`${scheme}://${location.host}/api/v1/broadcast/${channelId}`
);
const pending: (string | ArrayBufferLike | Blob | ArrayBufferView)[] = []
var channel = {
opened: false,
send: (message: string | ArrayBufferLike | Blob | ArrayBufferView) => {
if (channel.opened) {
ws.send(message);
} else {
pending.push(message)
}
},
close: () => {
ws.close();
},
};
ws.onmessage = (evt: MessageEvent<any>) => {
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;
while (pending.length > 0) {
const message = pending.shift()
if (message) ws.send(message)
}
};
return channel;
}
export async function cast(url: string): Promise<Status> {
const response = await fetch(`${BASE_URL}/api/v1/cast`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ url })
});
const result = await response.json();
const status = result.data as Status;
return status;
}
export function useCast() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (params: { url: string }) => cast(params.url),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['playerStatus'] })
}
})
}
export async function reset(): Promise<Status> {
const response = await fetch(`${BASE_URL}/api/v1/cast`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
});
const result = await response.json();
const status = result.data as Status;
return status;
}
export function useReset() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: () => reset(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['playerStatus'] })
}
})
}

275
apps/main/src/api/webrtc.ts Normal file
View File

@ -0,0 +1,275 @@
import { BroadcastChannel, cast, getBroadcastChannel } from "./arcast"
export class ScreenSharing extends EventTarget {
signaling: BroadcastChannel
handlers: { [messageType: string]: (data: { type: string, data: any }) => void }
constructor() {
super()
this.handlers = {}
this.signaling = getBroadcastChannel("arcast.screen-sharing", this.onSignal.bind(this))
}
onSignal(data: any) {
let message: any
try {
message = JSON.parse(data)
} catch (err) {
console.warn(err)
return
}
if (typeof message.type !== "string") return
const handler = this.handlers[message.type]
if (!handler) return
handler(message.data)
}
send(type: string, data: any) {
this.signaling.send(JSON.stringify({ type, data }));
}
}
export class ScreenSharingClient extends ScreenSharing {
sessionId: string
clientId: string
peerConnection: RTCPeerConnection | undefined
constructor(sessionId: string) {
super()
this.sessionId = sessionId
this.clientId = window.crypto.randomUUID()
this.handlers['server-offer'] = this.onServerOffer.bind(this)
this.handlers['ice-candidate'] = this.onIceCandidate.bind(this)
}
connect() {
this.send("client-request", { clientId: this.clientId, sessionId: this.sessionId })
}
close() {
this.signaling.close()
}
async onServerOffer(message: any) {
if (!this.checkMessage(message)) return
const conn = new RTCPeerConnection()
conn.onicecandidate = this.sendCandidate.bind(this)
conn.ontrack = this.onTrack.bind(this)
conn.onconnectionstatechange = (evt: Event) => {
const state = conn.connectionState
this.dispatchEvent(new StateChangedEvent(state))
}
await conn.setRemoteDescription(new RTCSessionDescription(message.offer))
const answer = await conn.createAnswer()
await conn.setLocalDescription(answer)
this.peerConnection = conn
this.send("client-answer", { clientId: this.clientId, sessionId: this.sessionId, answer })
}
sendCandidate(evt: RTCPeerConnectionIceEvent) {
this.send("ice-candidate", {
clientId: this.clientId,
sessionId: this.sessionId,
candidate: evt.candidate
})
}
onTrack(evt: RTCTrackEvent) {
if (!evt.streams || evt.streams.length < 1) return;
const stream = evt.streams[0]
this.dispatchEvent(new StreamChangedEvent(stream))
}
async onIceCandidate(message: any) {
if (!this.checkMessage(message)) return
if (!this.peerConnection) return
if (!message.candidate) return
await this.peerConnection.addIceCandidate(message.candidate)
}
checkMessage(message: any) {
const sessionId = message.sessionId;
if (!this.sessionId || sessionId !== this.sessionId) return false;
if (this.clientId !== message.clientId) return false;
return true
}
}
export enum ScreenSharingServerState {
Idle = "idle",
Sharing = "sharing"
}
export class ScreenSharingServer extends ScreenSharing {
stream: MediaStream | undefined
sessionId: string | undefined
peerConnections: {
[key: string]: RTCPeerConnection
}
state: ScreenSharingServerState
constructor() {
super()
this.peerConnections = {}
this.state = ScreenSharingServerState.Idle
this.handlers['client-request'] = this.onClientRequest.bind(this)
this.handlers['client-answer'] = this.onClientAnswer.bind(this)
this.handlers['ice-candidate'] = this.onIceCandidate.bind(this)
}
async shareScreen(options?: DisplayMediaStreamOptions) {
try {
this.stream = await navigator.mediaDevices.getDisplayMedia(options)
} catch (err) {
console.warn(err)
return
}
this.sessionId = window.crypto.randomUUID()
const url = `http://127.0.0.1:45555/apps/main/#screen-sharing/sessions/${this.sessionId}`
cast(url)
if (process.env.NODE_ENV === "development") {
window.open(`http://localhost:3000/apps/main/#screen-sharing/sessions/${this.sessionId}`, "_blank")
}
this.state = ScreenSharingServerState.Sharing
this.dispatchEvent(new StateChangedEvent(ScreenSharingServerState.Sharing))
}
onClientRequest(message: any) {
if (!this.checkMessage(message)) return
this.addNewClient(message.clientId)
}
async onIceCandidate(message: any) {
if (!this.checkMessage(message)) return
const conn = this.peerConnections[message.clientId]
if (!conn) return
if (!message.candidate) return
await conn.addIceCandidate(message.candidate)
}
async onClientAnswer(message: any) {
if (!this.checkMessage(message)) return
const conn = this.peerConnections[message.clientId]
if (!conn) return
await conn.setRemoteDescription(new RTCSessionDescription(message.answer));
}
checkMessage(message: any) {
const sessionId = message.sessionId;
if (!this.sessionId || sessionId !== this.sessionId) return false;
const clientId = message.clientId;
if (!clientId) return false;
return true
}
async addNewClient(clientId: string) {
if (!this.stream) return
const conn = new RTCPeerConnection();
const tracks = this.stream?.getVideoTracks()
if (!tracks || tracks.length === 0) {
return
}
conn.addTrack(tracks[0], this.stream)
const offer = await conn.createOffer()
await conn.setLocalDescription(offer);
conn.onicecandidate = this.sendCandidate.bind(this, clientId)
conn.onconnectionstatechange = (evt: Event) => {
const isClosed = conn.connectionState === "disconnected" ||
conn.connectionState === "failed"
if (!isClosed) return;
conn.onicecandidate = null;
conn.onconnectionstatechange = null;
conn.close();
console.log("Removing client", clientId);
delete this.peerConnections[clientId];
}
console.log("Adding client", clientId);
this.peerConnections[clientId] = conn;
this.send("server-offer", { sessionId: this.sessionId, clientId, offer })
}
sendCandidate(clientId: string, evt: RTCPeerConnectionIceEvent) {
this.send("ice-candidate", {
clientId,
sessionId: this.sessionId,
candidate: evt.candidate
})
}
close() {
this.signaling.close()
Object.keys(this.peerConnections).forEach(clientId => {
const conn = this.peerConnections[clientId];
conn.onicecandidate = null;
conn.onconnectionstatechange = null;
conn.close();
delete this.peerConnections[clientId]
})
const tracks = this.stream?.getTracks()
if (!tracks) return
tracks.forEach(track => track.stop())
this.state = ScreenSharingServerState.Idle
this.dispatchEvent(new StateChangedEvent(ScreenSharingServerState.Idle))
}
}
export const StreamChangedEventType = "stream-changed"
export class StreamChangedEvent extends Event {
stream: MediaStream
constructor(stream: MediaStream) {
super(StreamChangedEventType)
this.stream = stream
}
}
export const StateChangedEventType = "state-changed"
export class StateChangedEvent extends Event {
state: string
constructor(state: string) {
super(StateChangedEventType)
this.state = state
}
}

View File

@ -0,0 +1,20 @@
.root {
display: inline-block;
background-color: hsl(208, 46%, 52%);
cursor: pointer;
padding: 10px 15px;
border-radius: 5px;
color: hsl(208, 46%, 15%);;
font-weight: bolder;
font-size: 1.2em;
}
.root:hover {
background-color: hsl(208, 60%, 52%);
color: hsl(208, 60%, 15%);;;
}
.root > a {
text-decoration: none;
color: inherit;
}

View File

@ -0,0 +1,16 @@
import { FunctionComponent, PropsWithChildren } from "react";
import styles from "./Button.module.css";
export interface ButtonProps extends PropsWithChildren {
onClick?: React.MouseEventHandler<HTMLDivElement> | undefined;
}
export const Button: FunctionComponent<ButtonProps> = ({
children,
onClick,
}) => {
return (
<div className={styles.root} onClick={onClick}>
{children}
</div>
);
};

View File

@ -0,0 +1,17 @@
.root {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.container {
width: 90%;
}
@media only screen and (max-width: 768px) {
.container {
width: 90%;
height: 90%;
}
}

View File

@ -0,0 +1,15 @@
import { FunctionComponent, PropsWithChildren } from "react";
import styles from "./Layout.module.css";
import { Outlet } from "react-router-dom";
export interface LayoutProps extends PropsWithChildren {}
export const Layout: FunctionComponent<LayoutProps> = ({ children }) => {
return (
<div className={styles.root}>
<div className={styles.container}>
<Outlet />
</div>
</div>
);
};

View File

@ -0,0 +1,10 @@
.root {
display: block;
background-color: #fff;
border-radius: 15px;
box-shadow: 10px 10px 10px #33333361;
position: relative;
padding: 30px 30px 30px 30px;
min-width: 33%;
color: #333;
}

View File

@ -0,0 +1,7 @@
import { FunctionComponent, PropsWithChildren } from "react";
import styles from "./Panel.module.css";
export interface PanelProps extends PropsWithChildren {}
export const Panel: FunctionComponent<PanelProps> = ({ children }) => {
return <div className={styles.root}>{children}</div>;
};

View File

@ -0,0 +1,3 @@
.root {
}

View File

@ -0,0 +1,7 @@
import { FunctionComponent, PropsWithChildren } from "react";
import styles from "./Tabs.module.css";
export interface TabsProps extends PropsWithChildren {}
export const Tabs: FunctionComponent<TabsProps> = ({ children }) => {
return <div className={styles.root}>Tabs</div>;
};

105
apps/main/src/index.css Normal file
View File

@ -0,0 +1,105 @@
html {
box-sizing: border-box;
font-size: 16px;
width: 100%;
height: 100%;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans",
"Helvetica Neue", sans-serif;
width: 100%;
height: 100%;
background: rgb(76, 96, 188);
background: linear-gradient(
415deg,
rgba(4, 168, 243, 1),
rgb(76, 136, 188, 1),
rgba(76, 96, 188, 1),
rgb(115, 76, 188, 1),
rgb(87, 76, 188, 1)
);
background-size: 400% 400%;
animation: gradient 15s ease infinite;
}
@keyframes gradient {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
#root {
height: 100%;
}
*,
*: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%;
}

18
apps/main/src/index.tsx Normal file
View File

@ -0,0 +1,18 @@
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./App";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient();
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement
);
root.render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>
);

View File

@ -0,0 +1,77 @@
.root {
height: 100%;
}
.title {
margin-top: 10px;
margin-bottom: 20px;
}
.columns {
display: flex;
flex-direction: row;
}
.statusBlock {
margin-top: 10px;
}
@media only screen and (max-width: 768px) {
.columns {
flex-direction: column;
}
}
.columns > * {
flex-grow: 1;
height: 100%;
overflow: auto;
}
.separator {
margin: 20px 0 10px 0;
}
.castField {
width: 100%;
display: flex;
flex-direction: row;
height: 40px;
overflow: auto;
}
.castField > input {
flex-grow: 1;
height: 100%;
border-right: 0;
border-top: 1px solid #ccc;
border-bottom: 1px solid #ccc;
border-left: 1px solid #ccc;
border-top-left-radius: 5px;
border-bottom-left-radius: 5px;
font-size: 100%;
padding-left: 10px;
}
.castField > button {
flex-shrink: 1;
height: 100%;
padding: 0 10px;
border-top: 1px solid #ccc;
border-bottom: 1px solid #ccc;
border-right: 0;
border-left: 1px solid #ccc;
border-radius: 0;
font-weight: bold;
cursor: pointer;
}
.castField > button:hover {
background-color: #96b4ff;
}
.castField > button:last-child {
border-right: 1px solid #ccc;
border-top-right-radius: 5px;
border-bottom-right-radius: 5px;
}

View File

@ -0,0 +1,90 @@
import { ChangeEvent, FunctionComponent, useCallback, useState } from "react";
import styles from "./HomePage.module.css";
import { Panel } from "../../components/Panel/Panel";
import { useCast, usePlayerStatus, useReset } from "../../api/arcast";
import { Link } from "react-router-dom";
import { Button } from "../../components/Button/Button";
export const HomePage: FunctionComponent = () => {
const playerStatusQuery = usePlayerStatus();
const [castUrl, setCastUrl] = useState<string>("");
const castMutation = useCast();
const resetMutation = useReset();
const onCastUrlChange = useCallback(
(evt: ChangeEvent<HTMLInputElement>) => {
setCastUrl(evt.target.value);
},
[setCastUrl]
);
const onCastClick = useCallback(() => {
if (!castUrl) return;
castMutation.mutate({ url: castUrl });
setCastUrl("");
}, [castUrl]);
const onResetClick = useCallback(() => {
resetMutation.mutate();
}, []);
return (
<div className={styles.root}>
<Panel>
<h1 className={styles.title}>
<abbr title="Arcast Player Manager">A.P.M.</abbr>
</h1>
<div className={styles.columns}>
<div>
<h2 className={styles.title}>Status</h2>
<p className={styles.statusBlock}>
<b>Instance ID: </b>
<br />
<code>{playerStatusQuery.data?.id}</code>
</p>
<p className={styles.statusBlock}>
<b>Current status: </b>
<br />
<code>{playerStatusQuery.data?.status}</code>
</p>
<p className={styles.statusBlock}>
<b>Current page title: </b>
<br />
<span>"{playerStatusQuery.data?.title}"</span>
</p>
<p className={styles.statusBlock}>
<b>Current page URL: </b>
<br />
<a target="_blank" href={playerStatusQuery.data?.url ?? "#"}>
{playerStatusQuery.data?.url
? playerStatusQuery.data?.url.slice(0, 32) + "..."
: "--"}
</a>
</p>
</div>
<div>
<h2 className={styles.title}>Control</h2>
<div className={styles.controlBlock}>
<div className={styles.castField}>
<input
type="url"
placeholder="http://example.net"
value={castUrl}
onChange={onCastUrlChange}
/>
<button onClick={onCastClick}>Cast</button>
<button onClick={onResetClick}>Reset</button>
</div>
</div>
</div>
</div>
<hr className={styles.separator} />
<h2 className={styles.title}>Tools</h2>
<Button>
<Link to="/screen-sharing">Screen sharing</Link>
</Button>
</Panel>
</div>
);
};

View File

@ -0,0 +1,8 @@
.root {
}
.panelTitle {
margin-bottom: 20px;
margin-top: 10px;
}

View File

@ -0,0 +1,74 @@
import { FunctionComponent, useCallback, useEffect, useState } from "react";
import styles from "./ScreenSharingPage.module.css";
import { Panel } from "../../components/Panel/Panel";
import { Link, useParams } from "react-router-dom";
import { ScreenViewer } from "./ScreenViewer";
import { Button } from "../../components/Button/Button";
import {
ScreenSharingServer,
ScreenSharingServerState,
StateChangedEvent,
StateChangedEventType,
} from "../../api/webrtc";
export const ScreenSharingPage: FunctionComponent = () => {
const { sessionId } = useParams<{ sessionId: string }>();
const [server, setServer] = useState<ScreenSharingServer | null>(null);
const [state, setState] = useState<string | null>(null);
const onServerStateChanged = useCallback((evt: Event) => {
setState((evt as StateChangedEvent).state);
}, []);
console.log("Server state", state);
const onShareScreenClick = useCallback(() => {
const server = new ScreenSharingServer();
server.addEventListener(StateChangedEventType, onServerStateChanged);
server.shareScreen({
video: true,
audio: false,
});
setServer(server);
return () => {
server.removeEventListener(StateChangedEventType, onServerStateChanged);
server.close();
setServer(null);
};
}, [onServerStateChanged]);
const onStopSharingClick = useCallback(() => {
if (!server) return;
server.close();
setServer(null);
setState(null);
}, [server]);
useEffect(() => {
if (!server) return;
return () => {
if (!server) return;
console.log("Closing screen sharing server");
server.close();
setServer(null);
};
}, [server]);
if (sessionId !== undefined) {
return <ScreenViewer sessionId={sessionId} />;
}
return (
<div className={styles.root}>
<Panel>
<Link to="/">Back to homepage</Link>
<h1 className={styles.panelTitle}>Screen sharing</h1>
{state === ScreenSharingServerState.Sharing ? (
<Button onClick={onStopSharingClick}>Stop sharing</Button>
) : (
<Button onClick={onShareScreenClick}>Share screen</Button>
)}
</Panel>
</div>
);
};

View File

@ -0,0 +1,21 @@
.root {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
display: flex;
justify-content: center;
align-items: center;
}
.video {
object-fit: cover;
width: 100%;
height: auto;
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
}

View File

@ -0,0 +1,113 @@
import {
FunctionComponent,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import styles from "./ScreenViewer.module.css";
import {
ScreenSharingClient,
StateChangedEvent,
StateChangedEventType,
StreamChangedEvent,
StreamChangedEventType,
} from "../../api/webrtc";
export interface ScreenViewerProps {
sessionId: string;
}
export const ScreenViewer: FunctionComponent<ScreenViewerProps> = ({
sessionId,
}) => {
const videoRef = useRef<HTMLVideoElement>(null);
const [client, setClient] = useState<ScreenSharingClient | null>(null);
const [stream, setStream] = useState<MediaStream | null>(null);
const [state, setState] = useState<string | null>(null);
const onStreamChanged = useCallback((evt: Event) => {
setStream((evt as StreamChangedEvent).stream);
}, []);
const onStateChanged = useCallback((evt: Event) => {
setState((evt as StateChangedEvent).state);
}, []);
useEffect(() => {
const client = new ScreenSharingClient(sessionId);
client.addEventListener(StreamChangedEventType, onStreamChanged);
client.addEventListener(StateChangedEventType, onStateChanged);
client.connect();
setClient(client);
return () => {
client.removeEventListener(StreamChangedEventType, onStreamChanged);
client.removeEventListener(StateChangedEventType, onStateChanged);
client.close();
setClient(null);
};
}, [sessionId, onStreamChanged, onStateChanged]);
useEffect(() => {
if (!videoRef.current) return;
const video = videoRef.current;
console.log("Changing video stream", video, stream);
video.autoplay = true;
video.playsInline = true;
video.srcObject = stream;
video.muted = true;
}, [videoRef, stream]);
const isVideoReady = state === "connected" && stream !== null;
console.log("isVideoReady", isVideoReady);
return (
<div className={styles.root}>
<h1
style={{
display: `${isVideoReady ? "none" : "block"}`,
}}
>
<ConnectionState state={state} />
</h1>
<video
ref={videoRef}
className={styles.video}
style={{
display: `${isVideoReady ? "block" : "none"}`,
}}
></video>
</div>
);
};
export interface ConnectionStateProps {
state: string | null;
}
export const ConnectionState: FunctionComponent<ConnectionStateProps> = ({
state,
}) => {
switch (state) {
case null:
return <span>Connecting...</span>;
case "Connecté":
return <span>Connection established !</span>;
case "disconnected":
return <span>Connection lost !</span>;
case "failed":
return <span>Connection failed !</span>;
default:
return (
<span>
État inconnu (<code>{state}</code>)
</span>
);
}
};

1
apps/main/src/react-app-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="react-scripts" />

View File

@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

26
apps/main/tsconfig.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}

View File

@ -1,66 +0,0 @@
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()

View File

@ -1,36 +0,0 @@
<!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>

View File

@ -1,10 +0,0 @@
{
"title": {
"fr": "Contrôle à distance",
"en": "Remote control"
},
"description": {
"fr": "Contrôler l'afficheur numérique",
"en": "Control the cast player"
}
}

View File

@ -1,200 +0,0 @@
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();

View File

@ -1,30 +0,0 @@
<!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>

View File

@ -1,10 +0,0 @@
{
"title": {
"fr": "Partage d'écran",
"en": "Screen sharing"
},
"description": {
"fr": "Partager son écran",
"en": "Share your screen"
}
}

View File

@ -82,6 +82,7 @@ func main() {
server.WithTLSCertificate(&cert), server.WithTLSCertificate(&cert),
server.WithAddress(conf.HTTP.Address), server.WithAddress(conf.HTTP.Address),
server.WithTLSAddress(conf.HTTPS.Address), server.WithTLSAddress(conf.HTTPS.Address),
server.WithAllowedOrigins(conf.AllowedOrigins...),
) )
if err := server.Start(); err != nil { if err := server.Start(); err != nil {

View File

@ -44,7 +44,7 @@ Voici un exemple commenté du fichier de configuration:
// Activer/désactiver les applications embarquées // Activer/désactiver les applications embarquées
"enabled": true, "enabled": true,
// Application par défaut // Application par défaut
"defaultApp": "home" "defaultApp": "main"
} }
} }
``` ```

1
go.mod
View File

@ -4,6 +4,7 @@ go 1.21.4
require ( require (
gioui.org v0.4.1 gioui.org v0.4.1
github.com/davecgh/go-spew v1.1.1
github.com/gioui-plugins/gio-plugins v0.0.0-20230625001848-8f18aae6c91c github.com/gioui-plugins/gio-plugins v0.0.0-20230625001848-8f18aae6c91c
github.com/go-chi/cors v1.2.1 github.com/go-chi/cors v1.2.1
github.com/gorilla/websocket v1.5.1 github.com/gorilla/websocket v1.5.1

View File

@ -7,6 +7,8 @@ import (
"os" "os"
"forge.cadoles.com/arcad/arcast" "forge.cadoles.com/arcad/arcast"
"forge.cadoles.com/arcad/arcast/pkg/browser"
"forge.cadoles.com/arcad/arcast/pkg/browser/dummy"
"forge.cadoles.com/arcad/arcast/pkg/browser/lorca" "forge.cadoles.com/arcad/arcast/pkg/browser/lorca"
"forge.cadoles.com/arcad/arcast/pkg/config" "forge.cadoles.com/arcad/arcast/pkg/config"
"forge.cadoles.com/arcad/arcast/pkg/server" "forge.cadoles.com/arcad/arcast/pkg/server"
@ -61,35 +63,55 @@ func Run() *cli.Command {
EnvVars: []string{"ARCAST_DESKTOP_WINDOW_WIDTH"}, EnvVars: []string{"ARCAST_DESKTOP_WINDOW_WIDTH"},
Value: defaults.Width, Value: defaults.Width,
}, },
&cli.StringSliceFlag{
Name: "allowed-origins",
EnvVars: []string{"ARCAST_DESKTOP_ALLOWED_ORIGINS"},
Value: cli.NewStringSlice(),
},
&cli.BoolFlag{
Name: "dummy-browser",
EnvVars: []string{"ARCAST_DESKTOP_DUMMY_BROWSER"},
Value: false,
},
}, },
Action: func(ctx *cli.Context) error { Action: func(ctx *cli.Context) error {
configFile := ctx.String("config") configFile := ctx.String("config")
windowHeight := ctx.Int("window-height") windowHeight := ctx.Int("window-height")
windowWidth := ctx.Int("window-width") windowWidth := ctx.Int("window-width")
chromeArgs := addFlagsPrefix(ctx.StringSlice("additional-chrome-arg")...) chromeArgs := addFlagsPrefix(ctx.StringSlice("additional-chrome-arg")...)
dummyBrowser := ctx.Bool("dummy-browser")
browser := lorca.NewBrowser( var browser browser.Browser
if dummyBrowser {
logger.Info(ctx.Context, "using dummy browser")
browser = dummy.NewBrowser()
} else {
lorcaBrowser := lorca.NewBrowser(
lorca.WithAdditionalChromeArgs(chromeArgs...), lorca.WithAdditionalChromeArgs(chromeArgs...),
lorca.WithWindowSize(windowWidth, windowHeight), lorca.WithWindowSize(windowWidth, windowHeight),
) )
if err := browser.Start(); err != nil { if err := lorcaBrowser.Start(); err != nil {
return errors.Wrap(err, "could not start browser") return errors.Wrap(err, "could not start browser")
} }
go func() { go func() {
browser.Wait() lorcaBrowser.Wait()
logger.Warn(ctx.Context, "browser was closed") logger.Warn(ctx.Context, "browser was closed")
os.Exit(1) os.Exit(1)
}() }()
defer func() { defer func() {
logger.Info(ctx.Context, "stopping browser") logger.Info(ctx.Context, "stopping browser")
if err := browser.Stop(); err != nil { if err := lorcaBrowser.Stop(); err != nil {
logger.Error(ctx.Context, "could not stop browser", logger.CapturedE(errors.WithStack(err))) logger.Error(ctx.Context, "could not stop browser", logger.CapturedE(errors.WithStack(err)))
} }
}() }()
browser = lorcaBrowser
}
conf := config.DefaultConfig() conf := config.DefaultConfig()
logger.Info(ctx.Context, "loading or creating configuration file", logger.F("filename", configFile)) logger.Info(ctx.Context, "loading or creating configuration file", logger.F("filename", configFile))
@ -119,6 +141,10 @@ func Run() *cli.Command {
conf.HTTPS.Address = ctx.String("tls-address") conf.HTTPS.Address = ctx.String("tls-address")
} }
if ctx.IsSet("allowed-origins") {
conf.AllowedOrigins = ctx.StringSlice("allowed-origins")
}
server := server.New(browser, server := server.New(browser,
server.WithInstanceID(conf.InstanceID), server.WithInstanceID(conf.InstanceID),
server.WithAppsEnabled(conf.Apps.Enabled), server.WithAppsEnabled(conf.Apps.Enabled),
@ -127,6 +153,7 @@ func Run() *cli.Command {
server.WithAddress(conf.HTTP.Address), server.WithAddress(conf.HTTP.Address),
server.WithTLSAddress(conf.HTTPS.Address), server.WithTLSAddress(conf.HTTPS.Address),
server.WithTLSCertificate(&cert), server.WithTLSCertificate(&cert),
server.WithAllowedOrigins(conf.AllowedOrigins...),
) )
if err := server.Start(); err != nil { if err := server.Start(); err != nil {

View File

@ -1,6 +1,9 @@
{
prep: make build-apps
}
**/*.go **/*.go
pkg/server/templates/**.gotmpl pkg/server/templates/**.gotmpl
apps/**
modd.conf modd.conf
.env { .env {
prep: make build-client prep: make build-client

View File

@ -16,6 +16,7 @@ type Config struct {
HTTP HTTPConfig `json:"http"` HTTP HTTPConfig `json:"http"`
HTTPS HTTPSConfig `json:"https"` HTTPS HTTPSConfig `json:"https"`
Apps AppsConfig `json:"apps"` Apps AppsConfig `json:"apps"`
AllowedOrigins []string `json:"allowedOrigins"`
} }
type HTTPConfig struct { type HTTPConfig struct {
@ -96,6 +97,7 @@ func LoadOrCreate(ctx context.Context, filename string, conf *Config, funcs ...T
func DefaultConfig() *Config { func DefaultConfig() *Config {
return &Config{ return &Config{
InstanceID: server.NewRandomInstanceID(), InstanceID: server.NewRandomInstanceID(),
AllowedOrigins: []string{},
HTTP: HTTPConfig{ HTTP: HTTPConfig{
Address: ":45555", Address: ":45555",
}, },
@ -108,7 +110,7 @@ func DefaultConfig() *Config {
}, },
Apps: AppsConfig{ Apps: AppsConfig{
Enabled: true, Enabled: true,
DefaultApp: "home", DefaultApp: "main",
}, },
} }
} }

View File

@ -13,16 +13,33 @@ import (
) )
var ( var (
upgrader = websocket.Upgrader{}
channels = &channelMap{ channels = &channelMap{
index: make(map[string]map[*websocket.Conn]struct{}), index: make(map[string]map[*websocket.Conn]struct{}),
} }
) )
func (s *Server) checkOrigin(r *http.Request) bool {
allowedOrigins, err := s.getAllowedOrigins()
if err != nil {
logger.Error(r.Context(), "could not retrieve allowed origins", logger.CapturedE(errors.WithStack(err)))
return false
}
requestOrigin := r.Header.Get("Origin")
for _, origin := range allowedOrigins {
if requestOrigin == origin || origin == "*" {
return true
}
}
return true
}
func (s *Server) handleBroadcast(w http.ResponseWriter, r *http.Request) { func (s *Server) handleBroadcast(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
c, err := upgrader.Upgrade(w, r, nil) c, err := s.upgrader.Upgrade(w, r, nil)
if err != nil { if err != nil {
log.Print("upgrade:", err) log.Print("upgrade:", err)
return return
@ -43,7 +60,11 @@ func (s *Server) handleBroadcast(w http.ResponseWriter, r *http.Request) {
messageType, message, err := c.ReadMessage() messageType, message, err := c.ReadMessage()
if err != nil && !websocket.IsCloseError(err, 1001) { if err != nil && !websocket.IsCloseError(err, 1001) {
logger.Error(ctx, "could not read message", logger.E(errors.WithStack(err))) logger.Error(ctx, "could not read message", logger.E(errors.WithStack(err)))
break return
}
if messageType == -1 {
return
} }
logger.Debug(ctx, "broadcasting message", logger.F("message", message), logger.F("messageType", messageType)) logger.Debug(ctx, "broadcasting message", logger.F("message", message), logger.F("messageType", messageType))

View File

@ -36,17 +36,12 @@ func init() {
func (s *Server) startWebServers(ctx context.Context) error { func (s *Server) startWebServers(ctx context.Context) error {
router := chi.NewRouter() router := chi.NewRouter()
if s.appsEnabled { allowedOrigins, err := s.getAllowedOrigins()
ips, err := network.GetLANIPv4Addrs()
if err != nil { if err != nil {
return errors.WithStack(err) return errors.WithStack(err)
} }
allowedOrigins := make([]string, len(ips)) if len(allowedOrigins) > 0 {
for idx, ip := range ips {
allowedOrigins[idx] = fmt.Sprintf("http://%s:%d", ip, s.port)
}
router.Use(cors.Handler(cors.Options{ router.Use(cors.Handler(cors.Options{
AllowedOrigins: allowedOrigins, AllowedOrigins: allowedOrigins,
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
@ -178,6 +173,27 @@ func (s *Server) startHTTPSServer(ctx context.Context, router chi.Router) error
return nil return nil
} }
func (s *Server) getAllowedOrigins() ([]string, error) {
allowedOrigins := make([]string, 0)
if s.appsEnabled {
ips, err := network.GetLANIPv4Addrs()
if err != nil {
return nil, errors.WithStack(err)
}
for _, ip := range ips {
allowedOrigins = append(allowedOrigins, fmt.Sprintf("http://%s:%d", ip, s.port))
}
}
if len(s.allowedOrigins) > 0 {
allowedOrigins = append(allowedOrigins, s.allowedOrigins...)
}
return allowedOrigins, nil
}
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) { func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
type templateData struct { type templateData struct {
IPs []string IPs []string

View File

@ -26,6 +26,7 @@ type Options struct {
EnableServiceDiscovery bool EnableServiceDiscovery bool
EnableApps bool EnableApps bool
DefaultApp string DefaultApp string
AllowedOrigins []string
Apps []App Apps []App
} }
@ -39,6 +40,7 @@ func NewOptions(funcs ...OptionFunc) *Options {
EnableServiceDiscovery: true, EnableServiceDiscovery: true,
EnableApps: false, EnableApps: false,
DefaultApp: "", DefaultApp: "",
AllowedOrigins: make([]string, 0),
Apps: make([]App, 0), Apps: make([]App, 0),
} }
@ -67,6 +69,12 @@ func WithApps(apps ...App) OptionFunc {
} }
} }
func WithAllowedOrigins(origins ...string) OptionFunc {
return func(opts *Options) {
opts.AllowedOrigins = origins
}
}
func WithAddress(addr string) OptionFunc { func WithAddress(addr string) OptionFunc {
return func(opts *Options) { return func(opts *Options) {
opts.Address = addr opts.Address = addr

View File

@ -5,6 +5,7 @@ import (
"crypto/tls" "crypto/tls"
"forge.cadoles.com/arcad/arcast/pkg/browser" "forge.cadoles.com/arcad/arcast/pkg/browser"
"github.com/gorilla/websocket"
"github.com/pkg/errors" "github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger" "gitlab.com/wpetit/goweb/logger"
) )
@ -25,10 +26,12 @@ type Server struct {
appsEnabled bool appsEnabled bool
defaultApp string defaultApp string
allowedOrigins []string
apps []App apps []App
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
upgrader websocket.Upgrader
} }
func (s *Server) Start() error { func (s *Server) Start() error {
@ -78,15 +81,22 @@ func (s *Server) Wait() error {
func New(browser browser.Browser, funcs ...OptionFunc) *Server { func New(browser browser.Browser, funcs ...OptionFunc) *Server {
opts := NewOptions(funcs...) opts := NewOptions(funcs...)
return &Server{ server := &Server{
browser: browser, browser: browser,
instanceID: opts.InstanceID, instanceID: opts.InstanceID,
address: opts.Address, address: opts.Address,
tlsAddress: opts.TLSAddress, tlsAddress: opts.TLSAddress,
tlsCert: opts.TLSCertificate, tlsCert: opts.TLSCertificate,
appsEnabled: opts.EnableApps, appsEnabled: opts.EnableApps,
allowedOrigins: opts.AllowedOrigins,
defaultApp: opts.DefaultApp, defaultApp: opts.DefaultApp,
apps: opts.Apps, apps: opts.Apps,
serviceDiscoveryEnabled: opts.EnableServiceDiscovery, serviceDiscoveryEnabled: opts.EnableServiceDiscovery,
} }
server.upgrader = websocket.Upgrader{
CheckOrigin: server.checkOrigin,
}
return server
} }

View File

@ -13,9 +13,9 @@
} }
body { body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans",
"Segoe UI Symbol"; "Helvetica Neue", sans-serif;
width: 100%; width: 100%;
height: 100%; height: 100%;
background: rgb(76, 96, 188); background: rgb(76, 96, 188);