Compare commits

19 Commits

Author SHA1 Message Date
0fcf0b6cc0 fix: typo
Some checks reported warnings
arcad/arcast/pipeline/head This commit is unstable
2024-04-25 16:06:06 +02:00
a7d1f37682 ci: fix mktools installation
Some checks reported warnings
arcad/arcast/pipeline/head This commit is unstable
2024-04-25 15:56:41 +02:00
74268493d6 feat: rewrite app system
Some checks reported warnings
arcad/arcast/pipeline/head This commit is unstable
2024-04-25 15:23:09 +02:00
7b8165a0ec doc: add documentation link in readme + configuration reference
Some checks reported warnings
arcad/arcast/pipeline/head This commit is unstable
2024-04-24 12:13:28 +02:00
654134e2d0 chore: force stop android app before restart 2024-04-24 11:58:10 +02:00
8bd32adc51 ci: sign android apk with keystore
Some checks reported warnings
arcad/arcast/pipeline/head This commit is unstable
2024-04-24 11:41:58 +02:00
f35b279515 chore: uninstall android app before install 2024-04-24 10:50:44 +02:00
2306a08844 chore: add mxq tvstick cleanup script 2024-04-24 10:50:16 +02:00
fa1ed6ea42 feat: use persistent configuration file 2024-04-24 10:49:47 +02:00
071d597f3f doc: fix link name
Some checks reported warnings
arcad/arcast/pipeline/head This commit is unstable
2024-04-23 16:14:37 +02:00
90572efeba feat: more coloured ui 2024-04-23 16:08:31 +02:00
dc8f0e577b chore: add logo 2024-04-23 15:16:09 +02:00
1c06382cec chore: add jenkins pipeline
Some checks reported warnings
arcad/arcast/pipeline/head This commit is unstable
2024-04-23 10:50:42 +02:00
378e0fae3e chore: formatting 2024-04-23 09:59:29 +02:00
a20c6f5ff3 fix(android): ignore safedata store absence 2024-04-23 09:42:02 +02:00
7ba540f56f feat: update android icons 2024-04-23 09:41:14 +02:00
8d46ff7ff8 feat: embed optional apps in player server 2024-04-22 17:06:19 +02:00
acd71c84f6 doc: fix typo 2024-01-21 15:06:34 +01:00
5b08439651 feat(desktop): build and release linux arm/arm64 versions 2024-01-21 15:04:48 +01:00
100 changed files with 21112 additions and 281 deletions

View File

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

1
.gitignore vendored
View File

@ -4,3 +4,4 @@
/tools
.mktools/
/.gitea-release
*.keystore

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

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

65
Jenkinsfile vendored Normal file
View File

@ -0,0 +1,65 @@
@Library('cadoles') _
// Utilisation du pipeline "standard"
// Voir https://forge.cadoles.com/Cadoles/Jenkins/src/branch/master/doc/tutorials/standard-make-pipeline.md
standardMakePipeline([
'baseImage': 'reg.cadoles.com/proxy_cache/library/ubuntu:24.04',
'dockerfileExtension': '''
ARG GOLANG_VERSION=1.22.0
ARG NODEJS_VERSION=20.x
ENV ANDROID_HOME=/opt/android-sdk-linux
ENV ANDROID_SDK_ROOT=${ANDROID_HOME}
ENV ANDROID_NDK_HOME=${ANDROID_HOME}/ndk-bundle
ENV PATH=${PATH}:/usr/local/go/bin/:${ANDROID_HOME}/tools:${ANDROID_HOME}/tools/bin:${ANDROID_HOME}/platform-tools
# Install utilities
RUN apt-get update -y \
&& apt-get install -y wget curl tar build-essential ca-certificates git openjdk-17-jdk unzip locales jq moreutils
# Set locale
RUN locale-gen 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
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
# Install Android SDK/NDK
RUN mkdir -p /opt/android-sdk-linux
WORKDIR /opt/android-sdk-linux
RUN wget -O commandlinetools.zip https://dl.google.com/android/repository/commandlinetools-linux-6858069_latest.zip \
&& mkdir -p ${ANDROID_HOME}/cmdline-tools/latest \
&& unzip -q commandlinetools.zip -d ${ANDROID_HOME}/cmdline-tools/latest \
&& mv ${ANDROID_HOME}/cmdline-tools/latest/cmdline-tools/* ${ANDROID_HOME}/cmdline-tools/latest/ \
&& rm -f commandlinetools.zip
RUN echo y | cmdline-tools/latest/bin/sdkmanager --install \
'platforms;android-31' \
'ndk-bundle' \
'build-tools;34.0.0' \
'tools' \
'platform-tools' \
'extras;android;m2repository' \
'extras;google;m2repository' \
'extras;google;google_play_services'
''',
'hooks': [
'pre-release': {
sh '''
make .mktools
'''
}
],
'credentials': [
file(credentialsId: 'android-keystore', variable: 'ANDROID_KEYSTORE_FILE'),
string(credentialsId: 'android-keystore-pass', variable: 'ANDROID_KEYSTORE_PASS'),
usernamePassword(credentialsId: 'kipp-credentials', usernameVariable: 'MKT_GITEA_RELEASE_USERNAME', passwordVariable: 'MKT_GITEA_RELEASE_PASSWORD')
]
])

View File

@ -4,12 +4,22 @@ GORELEASER_ARGS ?= release --snapshot --rm-dist
GITCHLOG_ARGS ?=
SHELL := /bin/bash
JDK_PATH ?= /usr/lib/jvm/java-11-openjdk
GOARCH ?= amd64
MKT_GITEA_RELEASE_PROJECT := arcast
MKT_GITEA_RELEASE_ORG := arcad
GOTEST_ARGS ?= -short
ANDROID_KEYSTORE_FILE ?= .keystore
ANDROID_KEYSTORE_KEY_ALIAS ?= arcast
export ANDROID_KEYSTORE_PASS ?= NotSoSecret
ANDROID_KEYSTORE_KEY_VALIDITY ?= 365000
ANDROID_BUILD_TOOLS_VERSION ?= 34.0.0
APPS := main
watch: tools/modd/bin/modd deps ## Watching updated files - live reload
( set -o allexport && source .env && set +o allexport && tools/modd/bin/modd )
@ -19,12 +29,17 @@ test: test-go ## Executing tests
test-go: deps
( 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
CGO_ENABLED=0 go build \
CGO_ENABLED=0 GOARCH=$(GOARCH) go build \
-v \
-o ./bin/desktop \
-o ./bin/desktop_$(GOARCH) \
./cmd/desktop
build-client: deps ## Build executable
@ -35,11 +50,39 @@ 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 )
install-android: build-android
$(ANDROID_KEYSTORE_FILE):
keytool -genkey -noprompt \
-alias $(ANDROID_KEYSTORE_KEY_ALIAS) \
-dname "CN=cadoles.com, OU=Dev, O=Cadoles, L=Dijon, S=Bourgogne-FrancheComté, C=FR" \
-keystore "$(ANDROID_KEYSTORE_FILE)" \
-keyalg RSA \
-validity $(ANDROID_KEYSTORE_KEY_VALIDITY) \
-storepass "$(ANDROID_KEYSTORE_PASS)"
release-android: $(ANDROID_KEYSTORE_FILE) 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 )
rm -f android/app/build/outputs/apk/release/app-release-unsigned-aligned.apk
"$(ANDROID_HOME)/build-tools/$(ANDROID_BUILD_TOOLS_VERSION)/zipalign" -p 4 android/app/build/outputs/apk/release/app-release-unsigned.apk android/app/build/outputs/apk/release/app-release-unsigned-aligned.apk
"$(ANDROID_HOME)/build-tools/$(ANDROID_BUILD_TOOLS_VERSION)/apksigner" \
sign \
--ks-key-alias $(ANDROID_KEYSTORE_KEY_ALIAS) \
--ks "$(ANDROID_KEYSTORE_FILE)" \
--ks-pass env:ANDROID_KEYSTORE_PASS \
--in android/app/build/outputs/apk/release/app-release-unsigned-aligned.apk \
--out android/app/build/outputs/apk/release/app-release.apk
install-android: build-apps build-android
adb uninstall com.cadoles.arcast_player
adb install android/app/build/outputs/apk/debug/app-debug.apk
$(MAKE) run-android
run-android:
adb shell am force-stop com.cadoles.arcast_player
adb shell monkey -p com.cadoles.arcast_player -c android.intent.category.LAUNCHER 1
debug-android:
@ -62,13 +105,23 @@ tools/gogio/bin/gogio:
mkdir -p tools/gogio/bin
GOBIN=$(PWD)/tools/gogio/bin go install gioui.org/cmd/gogio@latest
gitea-release: .mktools build
release: gitea-release
gitea-release: .mktools build release-android
rm -rf .gitea-release
mkdir -p .gitea-release
cp ./bin/desktop .gitea-release/arcad_player_linux_amd64
cp ./bin/client .gitea-release/arcad_client_linux_amd64
cp ./android/app/build/outputs/apk/debug/app-debug.apk .gitea-release/arcast_player_debug.apk
cp ./bin/desktop_amd64 .gitea-release/arcast_player_linux_amd64
$(MAKE) GOARCH=arm build-desktop
cp ./bin/desktop_arm .gitea-release/arcast_player_linux_arm
$(MAKE) GOARCH=arm64 build-desktop
cp ./bin/desktop_arm64 .gitea-release/arcast_player_linux_arm64
cp ./bin/client .gitea-release/arcast_client_linux_amd64
cp ./android/app/build/outputs/apk/release/app-release.apk .gitea-release/arcast_player.apk
MKT_GITEA_RELEASE_PROJECT="arcast" \
MKT_GITEA_RELEASE_ORG="arcad" \

View File

@ -1,3 +1,7 @@
<p align="center">
<img src="./misc/logo/icon.png" width="200px" />
</p>
# Arcast
Serveur de diffusion de contenu (Web) pour afficheurs numériques (Linux et Android).
@ -12,7 +16,7 @@ Le client en ligne de commande vous permet de contrôler votre flotte de serveur
#### Dernière version
- Linux: [`amd64`](https://forge.cadoles.com/arcad/arcast/releases/download/latest/arcad_client_linux_amd64)
- Linux: [`amd64`](https://forge.cadoles.com/arcad/arcast/releases/download/latest/arcast_client_linux_amd64)
### Application Android
@ -20,7 +24,7 @@ L'application `arcast_player` pour Android permet de transformer votre appareil
#### Dernière version
- [`debug`](https://forge.cadoles.com/arcad/arcast/releases/download/latest/arcast_player_debug.apk)
- [`APK`](https://forge.cadoles.com/arcad/arcast/releases/download/latest/arcast_player.apk)
### Application de bureau
@ -32,4 +36,8 @@ Avoir [Chromium](https://www.chromium.org/chromium-projects/) (ou `Google Chrome
#### Dernière version
- Linux: [`amd64`](https://forge.cadoles.com/arcad/arcast/releases/download/latest/arcad_player_linux_amd64)
- Linux: [`amd64`](https://forge.cadoles.com/arcad/arcast/releases/download/latest/arcast_player_linux_amd64), [`arm`](https://forge.cadoles.com/arcad/arcast/releases/download/latest/arcast_player_linux_arm), [`arm64`](https://forge.cadoles.com/arcad/arcast/releases/download/latest/arcast_player_linux_arm64)
## Documentation
Voir le répertoire [`doc`](./doc)

View File

@ -3,14 +3,14 @@
xmlns:tools="http://schemas.android.com/tools"
android:targetSandboxVersion="1">
<uses-feature android:glEsVersion="0x00030000"/>
<uses-feature android:glEsVersion="0x00030000" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:allowBackup="true"
@ -18,7 +18,7 @@
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true"
android:theme="@style/Theme.Arcastplayer"
android:usesCleartextTraffic="true"

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
<background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
</adaptive-icon>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

View File

@ -1,3 +1,3 @@
<resources>
<string name="app_name">arcast-player</string>
<string name="app_name">Arcast Player</string>
</resources>

56
apps.go Normal file
View File

@ -0,0 +1,56 @@
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/main/build/**
appsFS embed.FS
)
func init() {
defaultApps, err := loadApps(
"apps/main/build",
)
if err != nil {
panic(errors.WithStack(err))
}
DefaultApps = defaultApps
}
func loadApps(appDirs ...string) ([]server.App, error) {
apps := make([]server.App, 0)
for _, dir := range appDirs {
rawManifest, err := fs.ReadFile(appsFS, filepath.Join(dir, "arcast-app.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)
}
fs, err := fs.Sub(appsFS, dir)
if err != nil {
return nil, errors.WithStack(err)
}
app.FS = fs
apps = append(apps, app)
}
return apps, nil
}

5
apps/main/.eslintrc Normal file
View File

@ -0,0 +1,5 @@
{
"rules": {
"react-hooks/exhaustive-deps": "off"
}
}

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

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

File diff suppressed because it is too large Load Diff

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

@ -0,0 +1,50 @@
{
"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",
"devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "^7.21.11"
}
}

View File

@ -0,0 +1,11 @@
{
"id": "main",
"title": {
"fr": "Accueil",
"en": "Home"
},
"description": {
"fr": "Voir la liste des applications",
"en": "See apps list"
}
}

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,28 +1,26 @@
//go:build android
// +build android
package main
import (
"context"
"crypto/tls"
"os"
"sync"
"path/filepath"
"forge.cadoles.com/arcad/arcast"
"forge.cadoles.com/arcad/arcast/pkg/browser/gioui"
"forge.cadoles.com/arcad/arcast/pkg/config"
"forge.cadoles.com/arcad/arcast/pkg/server"
"gioui.org/app"
"gioui.org/io/system"
"gioui.org/layout"
"gioui.org/op"
"github.com/gioui-plugins/gio-plugins/plugin"
"github.com/gioui-plugins/gio-plugins/safedata"
"github.com/gioui-plugins/gio-plugins/safedata/giosafedata"
"github.com/gioui-plugins/gio-plugins/webviewer/webview"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
const instanceIDSecretIdentifier = "instance_id"
const packageName = "com.cadoles.arcast_player"
func main() {
ctx := context.Background()
@ -34,12 +32,6 @@ func main() {
browser := gioui.NewBrowser(window)
var safeDataConfig safedata.Config
var safeDataConfigWaigGroup sync.WaitGroup
var initSafeDataConfig sync.Once
safeDataConfigWaigGroup.Add(1)
go func() {
ops := new(op.Ops)
for {
@ -54,11 +46,6 @@ func main() {
gtx := layout.NewContext(ops, evt)
browser.Layout(gtx)
evt.Frame(gtx.Ops)
case app.ViewEvent:
initSafeDataConfig.Do(func() {
defer safeDataConfigWaigGroup.Done()
safeDataConfig = giosafedata.NewConfigFromViewEvent(window, evt, "com.cadoles.arcast_player")
})
}
}
}()
@ -69,15 +56,34 @@ func main() {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
safeDataConfigWaigGroup.Wait()
safe := safedata.NewSafeData(safeDataConfig)
instanceID, err := getInstanceIDFromSafeData(safe)
if err != nil {
logger.Fatal(ctx, "could not retrieve instance id", logger.CapturedE(errors.WithStack(err)))
conf := config.DefaultConfig()
configFiles := getConfigFiles(ctx)
for _, f := range configFiles {
logger.Info(ctx, "loading or creating configuration file", logger.F("filename", f))
if err := config.LoadOrCreate(ctx, f, conf, config.DefaultTransforms...); err != nil {
logger.Error(ctx, "could not load configuration file", logger.CapturedE(errors.WithStack(err)))
continue
}
server := server.New(browser, server.WithInstanceID(instanceID))
break
}
cert, err := tls.X509KeyPair(conf.HTTPS.Cert, conf.HTTPS.Key)
if err != nil {
logger.Fatal(ctx, "could not parse x509 certificate", logger.CapturedE(errors.WithStack(err)))
}
server := server.New(
browser,
server.WithInstanceID(conf.InstanceID),
server.WithAppsEnabled(conf.Apps.Enabled),
server.WithDefaultApp(conf.Apps.DefaultApp),
server.WithApps(arcast.DefaultApps...),
server.WithTLSCertificate(&cert),
server.WithAddress(conf.HTTP.Address),
server.WithTLSAddress(conf.HTTPS.Address),
server.WithAllowedOrigins(conf.AllowedOrigins...),
)
if err := server.Start(); err != nil {
logger.Fatal(ctx, "could not start server", logger.CapturedE(errors.WithStack(err)))
@ -98,26 +104,19 @@ func main() {
app.Main()
}
func getInstanceIDFromSafeData(safe *safedata.SafeData) (string, error) {
instanceIDSecret, err := safe.Get(instanceIDSecretIdentifier)
if err != nil && err.Error() != "not found" {
return "", errors.Wrapf(err, "could not retrieve instance id secret")
}
func getConfigFiles(ctx context.Context) []string {
configFiles := make([]string, 0)
var instanceID string
if len(instanceIDSecret.Data) > 0 {
instanceID = string(instanceIDSecret.Data)
sharedStorageConfigFile := filepath.Join("/storage/emulated/0/Android/data", packageName, "files/config.json")
configFiles = append(configFiles, sharedStorageConfigFile)
dataDir, err := app.DataDir()
if err != nil {
logger.Error(ctx, "could not retrieve app data dir", logger.CapturedE(errors.WithStack(err)))
} else {
instanceID = server.NewRandomInstanceID()
instanceIDSecret.Identifier = instanceIDSecretIdentifier
instanceIDSecret.Data = []byte(instanceID)
instanceIDSecret.Description = "Arcast player instance identifier"
if err := safe.Set(instanceIDSecret); err != nil {
return "", errors.Wrapf(err, "could not save instance id secret")
}
appDataConfigFile := filepath.Join(dataDir, "config.json")
configFiles = append(configFiles, appDataConfigFile)
}
return instanceID, nil
return configFiles
}

5
doc/README.md Normal file
View File

@ -0,0 +1,5 @@
# Documentation
- [Configuration](./configuration.md)
- [API HTTP](./http-api.md)
- [mDNS](./mdns.md)

50
doc/configuration.md Normal file
View File

@ -0,0 +1,50 @@
# Configuration
Le server Arcast utilise un fichier de configuration permettant de personnaliser son comportement.
Par défaut, ce fichier de configuration est créé aux emplacements suivants au lancement de l'application:
- **Linux Desktop**:
- `${HOME}/.config/arcast-player/config.json`
- **Android**:
- `/storage/emulated/0/Android/data/com.cadoles.arcast_player`
- `/data/user/0/com.cadoles.arcast_player/files/config.json` (si le premier chemin n'est pas accessible en lecture/écriture)
Voici un exemple commenté du fichier de configuration:
```json
{
// Identifiant de l'instance
"instanceId": "<instance_id>",
// Configuration du serveur HTTP
"http": {
// Couple <address>:<port> d'écoute
// Par défaut ":" i.e. toutes les adresses avec port aléatoire
"address": ":"
},
// Configuration du serveur HTTPS
"https": {
// Couple <address>:<port> d'écoute
// Par défaut ":" i.e. toutes les adresses avec port aléatoire
"address": ":",
// Certificat x509 (format PEM, encodé en base64)
"cert": "<base64_encoded_pem_cert>",
// Clé privée du certificat (format PEM, encodé en base64)
"key": "<base64_encoded_pem_key>",
// Configuration de la génération du certificat autosigné
"selfSigned": {
// Activer/désactiver la génération du certificat auto-signé
"enabled": true,
// Empreinte réseau associée au certificat autosigné
// Permet de détecter si il y a eu un changement sur les adresses IPs du player
"networkFingerprint": "40f4cd914c6163657fe4e37128f90b501b768221c77b5dbfa35ca74a84b8732a"
}
},
// Configuration des applications embarquées
"apps": {
// Activer/désactiver les applications embarquées
"enabled": true,
// Application par défaut
"defaultApp": "main"
}
}
```

3
go.mod
View File

@ -4,7 +4,10 @@ go 1.21.4
require (
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/go-chi/cors v1.2.1
github.com/gorilla/websocket v1.5.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

4
go.sum
View File

@ -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=

View File

@ -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)
}

View File

@ -1,10 +1,16 @@
package player
import (
"context"
"crypto/tls"
"fmt"
"os"
"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/config"
"forge.cadoles.com/arcad/arcast/pkg/server"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
@ -13,9 +19,15 @@ import (
func Run() *cli.Command {
defaults := lorca.NewOptions()
return &cli.Command{
Name: "run",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "config",
EnvVars: []string{"ARCAST_DESKTOP_CONFIG"},
Value: config.DefaultConfigFile(context.Background()),
},
&cli.StringSliceFlag{
Name: "additional-chrome-arg",
EnvVars: []string{"ARCAST_DESKTOP_ADDITIONAL_CHROME_ARGS"},
@ -26,50 +38,123 @@ func Run() *cli.Command {
EnvVars: []string{"ARCAST_DESKTOP_INSTANCE_ID"},
Value: "",
},
&cli.StringFlag{
Name: "address",
EnvVars: []string{"ARCAST_DESKTOP_ADDRESS"},
Value: ":",
},
&cli.StringFlag{
Name: "https-address",
EnvVars: []string{"ARCAST_DESKTOP_HTTPS_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"},
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 {
configFile := ctx.String("config")
windowHeight := ctx.Int("window-height")
windowWidth := ctx.Int("window-width")
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.WithWindowSize(windowWidth, windowHeight),
)
if err := browser.Start(); err != nil {
if err := lorcaBrowser.Start(); err != nil {
return errors.Wrap(err, "could not start browser")
}
go func() {
browser.Wait()
lorcaBrowser.Wait()
logger.Warn(ctx.Context, "browser was closed")
os.Exit(1)
}()
defer func() {
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)))
}
}()
instanceID := ctx.String("instance-id")
if instanceID == "" {
instanceID = server.NewRandomInstanceID()
browser = lorcaBrowser
}
server := server.New(browser, server.WithInstanceID(instanceID))
conf := config.DefaultConfig()
logger.Info(ctx.Context, "loading or creating configuration file", logger.F("filename", configFile))
if err := config.LoadOrCreate(ctx.Context, configFile, conf, config.DefaultTransforms...); err != nil {
logger.Error(ctx.Context, "could not load configuration file", logger.CapturedE(errors.WithStack(err)))
}
instanceID := ctx.String("instance-id")
if instanceID != "" {
conf.InstanceID = instanceID
}
cert, err := tls.X509KeyPair(conf.HTTPS.Cert, conf.HTTPS.Key)
if err != nil {
return errors.Wrap(err, "could not parse tls cert/key pair")
}
if ctx.IsSet("apps") {
conf.Apps.Enabled = ctx.Bool("apps")
}
if ctx.IsSet("address") {
conf.HTTP.Address = ctx.String("address")
}
if ctx.IsSet("https-address") {
conf.HTTPS.Address = ctx.String("tls-address")
}
if ctx.IsSet("allowed-origins") {
conf.AllowedOrigins = ctx.StringSlice("allowed-origins")
}
server := server.New(browser,
server.WithInstanceID(conf.InstanceID),
server.WithAppsEnabled(conf.Apps.Enabled),
server.WithDefaultApp(conf.Apps.DefaultApp),
server.WithApps(arcast.DefaultApps...),
server.WithAddress(conf.HTTP.Address),
server.WithTLSAddress(conf.HTTPS.Address),
server.WithTLSCertificate(&cert),
server.WithAllowedOrigins(conf.AllowedOrigins...),
)
if err := server.Start(); err != nil {
return errors.Wrap(err, "could not start server")

BIN
misc/logo/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@ -0,0 +1,82 @@
### Source https://github.com/DesktopECHO/T95-H616-Malware
###
### Clobber Malware/Junk - Run in PowerShell or Bash
### Requires ADB Connection (ie: adb connect 10.13.12.11:5555)
###
### Install ADB (If not already installed)
###
### - Assuming you're on Windows, to install ADB simply
### install Chocolatey and install ADB using Choco:
### choco install adb
###
### - macOS users have Homebrew to accomplish the same thing:
### brew install android-platform-tools
###
wget https://github.com/bromite/bromite/releases/latest/download/arm_ChromePublic.apk -O chromium.apk
wget https://github.com/TeamAmaze/AmazeFileManager/releases/latest/download/app-fdroid-release.apk -O filemanager.apk
wget https://raw.githubusercontent.com/make-github-pseudonymous-again/pixels/main/1x1%23000000.jpg -O BlackBackground.jpg
wget https://f-droid.org/repo/com.termux_118.apk -O termux.apk
wget https://github.com/DesktopECHO/Pi-hole-for-Android/releases/latest/download/pideploy.apk -O pideploy.apk
adb push BlackBackground.jpg /sdcard/Download/BlackBackground.jpg
adb root
sleep 10
adb shell "cmd uimode night yes"
adb install filemanager.apk
adb install termux.apk
adb install pideploy.apk
adb install chromium.apk
adb shell pm uninstall --user 0 com.adups.fota
adb shell pm uninstall --user 0 com.ftest
adb shell pm uninstall --user 0 com.ionitech.airscreen
adb shell pm uninstall --user 0 com.charon.rocketfly
adb shell pm uninstall --user 0 com.netflix.mediaclient
adb shell pm uninstall --user 0 com.android.music
adb shell pm uninstall --user 0 com.android.dynsystem
adb shell pm uninstall --user 0 com.android.egg
adb shell pm uninstall --user 0 com.www.intallapp
adb shell pm uninstall --user 0 com.www.productdeclare
adb shell pm uninstall --user 0 org.xbmc.kodi
adb shell pm uninstall --user 0 com.softwinner.TvdFileManager
adb shell pm uninstall --user 0 com.apkpure.aegon
adb shell pm uninstall --user 0 com.amazon.avod.thirdpartyclient
adb shell pm uninstall --user 0 com.adups.market
adb shell pm uninstall --user 0 com.android.camera2
adb shell pm uninstall --user 0 com.android.deskclock
adb shell pm uninstall --user 0 com.android.chrome
adb shell pm uninstall --user 0 com.google.android.feedback
adb shell pm uninstall --user 0 com.google.android.katniss
adb shell pm uninstall --user 0 net.lightflash.wallpapersn
adb shell pm uninstall --user 0 com.google.android.youtube.tv
adb shell pm uninstall --user 0 com.android.gallery3d
adb shell pm uninstall --user 0 com.android.wallpaper.livepicker
adb shell pm uninstall --user 0 com.android.wallpaperbackup
adb shell pm uninstall --user 0 com.android.dreams.basic
adb shell pm uninstall --user 0 com.android.dreams.web
adb shell pm uninstall --user 0 com.android.dreams.phototable
adb shell pm uninstall --user 0 cm.aptoidetv.pt
adb shell rm -rf /data/data/org.xbmc.kodi
adb shell rm -rf /data/data/com.www.intallapp
adb shell rm -rf /data/data/com.www.productdeclare
adb shell rm -rf /data/media/0/Android/data/com.adups.fota
adb shell rm -rf /data/misc/profiles/ref/com.adups.fota
adb shell rm -rf /data/misc/profiles/cur/0/com.adups.fota
adb shell rm -rf /data/system/shared_prefs
adb shell mkdir -p /data/system/shared_prefs/open_preference.xml
adb shell chmod 0000 /data/system/shared_prefs/open_preference.xml
adb shell /vendor/bin/busybox chattr +i /data/system/shared_prefs/open_preference.xml
adb shell rm -rf /data/system/Corejava
adb shell touch /data/system/Corejava
adb shell chmod 0000 /data/system/Corejava
adb shell /vendor/bin/busybox chattr +i /data/system/Corejava
adb shell rm -rf /data/data/com.adups.fota
adb shell touch /data/data/com.adups.fota
adb shell chmod 0000 /data/data/com.adups.fota
adb shell /vendor/bin/busybox chattr +i /data/data/com.adups.fota
echo Cleanup finished, reboot your device.

View File

@ -1,8 +1,12 @@
{
prep: make build-apps
}
**/*.go
pkg/server/templates/**.gotmpl
modd.conf
.env {
prep: make build-client
prep: make build-desktop
daemon: make run RUN_CMD="bin/desktop --debug --log-level debug run"
daemon: make run RUN_CMD="bin/desktop_amd64 --debug --log-level debug run"
}

View File

@ -44,8 +44,19 @@ func (b *Browser) Layout(gtx layout.Context) {
webviewer.WebViewOp{Tag: &b.tag}.Push(gtx.Ops)
webviewer.OffsetOp{Point: f32.Point{Y: float32(gtx.Constraints.Max.Y - gtx.Constraints.Max.Y)}}.Add(gtx.Ops)
webviewer.RectOp{Size: f32.Point{X: float32(gtx.Constraints.Max.X), Y: float32(gtx.Constraints.Max.Y)}}.Add(gtx.Ops)
webviewer.OffsetOp{
Point: f32.Point{
X: 0,
Y: 0,
},
}.Add(gtx.Ops)
webviewer.RectOp{
Size: f32.Point{
X: float32(gtx.Constraints.Max.X),
Y: float32(gtx.Constraints.Max.Y),
},
}.Add(gtx.Ops)
if b.changed {
logger.Debug(ctx, "url changed", logger.F("url", b.url))

116
pkg/config/config.go Normal file
View File

@ -0,0 +1,116 @@
package config
import (
"context"
"encoding/json"
"os"
"path/filepath"
"forge.cadoles.com/arcad/arcast/pkg/server"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
type Config struct {
InstanceID string `json:"instanceId"`
HTTP HTTPConfig `json:"http"`
HTTPS HTTPSConfig `json:"https"`
Apps AppsConfig `json:"apps"`
AllowedOrigins []string `json:"allowedOrigins"`
}
type HTTPConfig struct {
Address string `json:"address"`
}
type HTTPSConfig struct {
Address string `json:"address"`
Cert []byte `json:"cert"`
Key []byte `json:"key"`
SelfSigned SelfSignedCertConfig `json:"selfSigned"`
}
type SelfSignedCertConfig struct {
Enabled bool `json:"enabled"`
NetworkFingerprint string `json:"networkFingerprint"`
}
type AppsConfig struct {
Enabled bool `json:"enabled"`
DefaultApp string `json:"defaultApp"`
}
type TransformFunc func(ctx context.Context, conf *Config) error
func DefaultConfigFile(ctx context.Context) string {
configDir, err := os.UserConfigDir()
if err != nil {
logger.Error(ctx, "could not get user config dir", logger.CapturedE(errors.WithStack(err)))
configDir = ""
}
if configDir != "" {
configDir = filepath.Join(configDir, "arcast-player")
}
return filepath.Join(configDir, "config.json")
}
func LoadOrCreate(ctx context.Context, filename string, conf *Config, funcs ...TransformFunc) error {
data, err := os.ReadFile(filename)
if err != nil && !os.IsNotExist(err) {
return errors.WithStack(err)
}
if data != nil {
if err := json.Unmarshal(data, conf); err != nil {
return errors.WithStack(err)
}
}
for _, fn := range funcs {
if err := fn(ctx, conf); err != nil {
return errors.WithStack(err)
}
}
data, err = json.MarshalIndent(conf, "", " ")
if err != nil {
return errors.WithStack(err)
}
dirname := filepath.Dir(filename)
if _, err := os.Stat(dirname); os.IsNotExist(err) {
if err := os.MkdirAll(dirname, 0777); err != nil {
return errors.WithStack(err)
}
}
if err := os.WriteFile(filename, data, 0640); err != nil {
return errors.WithStack(err)
}
return nil
}
func DefaultConfig() *Config {
return &Config{
InstanceID: server.NewRandomInstanceID(),
AllowedOrigins: []string{},
HTTP: HTTPConfig{
Address: ":45555",
},
HTTPS: HTTPSConfig{
Address: ":45556",
SelfSigned: SelfSignedCertConfig{
Enabled: true,
NetworkFingerprint: "",
},
},
Apps: AppsConfig{
Enabled: true,
DefaultApp: "main",
},
}
}

View File

@ -0,0 +1,6 @@
package config
var DefaultTransforms = []TransformFunc{
GenerateSelfSignedCert,
RenewExpiredSelfSignedCert,
}

100
pkg/config/selfsigned.go Normal file
View File

@ -0,0 +1,100 @@
package config
import (
"context"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"fmt"
"slices"
"time"
"forge.cadoles.com/arcad/arcast/pkg/network"
"forge.cadoles.com/arcad/arcast/pkg/selfsigned"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
func GenerateSelfSignedCert(ctx context.Context, conf *Config) error {
if !conf.HTTPS.SelfSigned.Enabled {
return nil
}
if conf.HTTPS.Cert != nil && conf.HTTPS.Key != nil {
return nil
}
rawCert, rawKey, err := selfsigned.NewLANKeyPair()
if err != nil {
return errors.Wrap(err, "could not generate self signed x509 key pair")
}
conf.HTTPS.Cert = rawCert
conf.HTTPS.Key = rawKey
networkFingerprint, err := getNetworkFingerprint()
if err != nil {
return errors.Wrap(err, "could not retrieve network fingerprint")
}
conf.HTTPS.SelfSigned.NetworkFingerprint = networkFingerprint
return nil
}
func RenewExpiredSelfSignedCert(ctx context.Context, conf *Config) error {
if !conf.HTTPS.SelfSigned.Enabled {
return nil
}
if conf.HTTPS.Cert == nil || conf.HTTPS.Key == nil {
if err := GenerateSelfSignedCert(ctx, conf); err != nil {
return errors.WithStack(err)
}
}
cert, err := tls.X509KeyPair(conf.HTTPS.Cert, conf.HTTPS.Key)
if err != nil {
return errors.Wrap(err, "could not parse x509 cert/key pair")
}
leaf, err := x509.ParseCertificate(cert.Certificate[0])
if err != nil {
logger.Error(ctx, "could not parse x509 certificate, regenerating one", logger.CapturedE(errors.WithStack(err)))
if err := GenerateSelfSignedCert(ctx, conf); err != nil {
return errors.WithStack(err)
}
}
// Check that self-signed certificate is still valid
if time.Now().Before(leaf.NotAfter) {
return nil
}
logger.Warn(ctx, "self-signed certificate has expired, regenerating one", logger.CapturedE(errors.WithStack(err)))
if err := GenerateSelfSignedCert(ctx, conf); err != nil {
return errors.WithStack(err)
}
return nil
}
func getNetworkFingerprint() (string, error) {
ips, err := network.GetLANIPv4Addrs()
if err != nil {
return "", errors.WithStack(err)
}
slices.Sort(ips)
hash := sha256.New()
for _, ip := range ips {
if _, err := hash.Write([]byte(ip)); err != nil {
return "", errors.WithStack(err)
}
}
return fmt.Sprintf("%x", hash.Sum(nil)), nil
}

View File

@ -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()

129
pkg/selfsigned/cert.go Normal file
View File

@ -0,0 +1,129 @@
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 NewLANKeyPair() ([]byte, []byte, error) {
hosts, err := network.GetLANIPv4Addrs()
if err != nil {
return nil, nil, errors.WithStack(err)
}
hosts = append(hosts, "127.0.0.1")
rawCert, rawKey, err := NewCertKeyPair(hosts...)
if err != nil {
return nil, nil, errors.WithStack(err)
}
return rawCert, rawKey, nil
}
func NewLANCert() (*tls.Certificate, error) {
rawCert, rawKey, err := NewLANKeyPair()
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
View 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
View 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)
}

127
pkg/server/broadcast.go Normal file
View File

@ -0,0 +1,127 @@
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 (
channels = &channelMap{
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) {
ctx := r.Context()
c, err := s.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 && !websocket.IsCloseError(err, 1001) {
logger.Error(ctx, "could not read message", logger.E(errors.WithStack(err)))
return
}
if messageType == -1 {
return
}
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)))
}
}
}

View File

@ -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,56 @@ func init() {
idleTemplate = tmpl
}
func (s *Server) startHTTPServer(ctx context.Context) error {
func (s *Server) startWebServers(ctx context.Context) error {
router := chi.NewRouter()
allowedOrigins, err := s.getAllowedOrigins()
if err != nil {
return errors.WithStack(err)
}
if len(allowedOrigins) > 0 {
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 +122,88 @@ func (s *Server) startHTTPServer(ctx context.Context) error {
}
}()
if err := s.resetBrowser(); err != nil {
return nil
}
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
},
},
}
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 CastRequest struct {
URL string `json:"url" validate:"required"`
}
func (s *Server) getAllowedOrigins() ([]string, error) {
allowedOrigins := make([]string, 0)
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 s.appsEnabled {
ips, err := network.GetLANIPv4Addrs()
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
return nil, errors.WithStack(err)
}
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
for _, ip := range ips {
allowedOrigins = append(allowedOrigins, fmt.Sprintf("http://%s:%d", ip, s.port))
}
}
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
if len(s.allowedOrigins) > 0 {
allowedOrigins = append(allowedOrigins, s.allowedOrigins...)
}
api.DataResponse(w, http.StatusOK, &StatusResponse{
ID: s.instanceID,
URL: url,
Status: status.String(),
Title: title,
})
return allowedOrigins, nil
}
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
type templateData struct {
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)
@ -183,6 +214,8 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
ID: s.instanceID,
IPs: ips,
Port: s.port,
TLSPort: s.tlsPort,
Apps: s.appsEnabled,
}
if err := idleTemplate.Execute(w, d); err != nil {

View File

@ -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)
}

View File

@ -1,6 +1,8 @@
package server
import (
"crypto/tls"
"github.com/jaevor/go-nanoid"
"github.com/pkg/errors"
)
@ -19,7 +21,13 @@ func init() {
type Options struct {
InstanceID string
Address string
DisableServiceDiscovery bool
TLSAddress string
TLSCertificate *tls.Certificate
EnableServiceDiscovery bool
EnableApps bool
DefaultApp string
AllowedOrigins []string
Apps []App
}
type OptionFunc func(opts *Options)
@ -28,7 +36,12 @@ func NewOptions(funcs ...OptionFunc) *Options {
opts := &Options{
InstanceID: NewRandomInstanceID(),
Address: ":",
DisableServiceDiscovery: false,
TLSAddress: ":",
EnableServiceDiscovery: true,
EnableApps: false,
DefaultApp: "",
AllowedOrigins: make([]string, 0),
Apps: make([]App, 0),
}
for _, fn := range funcs {
@ -38,21 +51,57 @@ 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 WithAllowedOrigins(origins ...string) OptionFunc {
return func(opts *Options) {
opts.AllowedOrigins = origins
}
}
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
}
}

View File

@ -2,8 +2,10 @@ package server
import (
"context"
"crypto/tls"
"forge.cadoles.com/arcad/arcast/pkg/browser"
"github.com/gorilla/websocket"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
@ -12,12 +14,24 @@ type Server struct {
browser browser.Browser
instanceID string
address string
port int
disableServiceDiscovery bool
tlsAddress string
tlsPort int
tlsCert *tls.Certificate
serviceDiscoveryEnabled bool
appsEnabled bool
defaultApp string
allowedOrigins []string
apps []App
ctx context.Context
cancel context.CancelFunc
upgrader websocket.Upgrader
}
func (s *Server) Start() error {
@ -26,16 +40,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)
}
@ -67,10 +81,22 @@ func (s *Server) Wait() error {
func New(browser browser.Browser, funcs ...OptionFunc) *Server {
opts := NewOptions(funcs...)
return &Server{
server := &Server{
browser: browser,
instanceID: opts.InstanceID,
address: opts.Address,
disableServiceDiscovery: opts.DisableServiceDiscovery,
tlsAddress: opts.TLSAddress,
tlsCert: opts.TLSCertificate,
appsEnabled: opts.EnableApps,
allowedOrigins: opts.AllowedOrigins,
defaultApp: opts.DefaultApp,
apps: opts.Apps,
serviceDiscoveryEnabled: opts.EnableServiceDiscovery,
}
server.upgrader = websocket.Upgrader{
CheckOrigin: server.checkOrigin,
}
return server
}

File diff suppressed because one or more lines are too long