Commits vergleichen

16 Commits

Autor SHA1 Nachricht Datum
977864073f feat: use goreleaser to generate linux packages 2024-04-26 15:09:11 +02:00
768393adc8 feat: allow homepage customization
Einige Prüfungen meldeten Warnungen
arcad/arcast/pipeline/head This commit is unstable
2024-04-26 12:12:15 +02:00
35585959f5 feat(android): do not use sync.Mutex
Einige Prüfungen meldeten Warnungen
arcad/arcast/pipeline/head This commit is unstable
2024-04-26 09:35:16 +02:00
0fcf0b6cc0 fix: typo
Einige Prüfungen meldeten Warnungen
arcad/arcast/pipeline/head This commit is unstable
2024-04-25 16:06:06 +02:00
a7d1f37682 ci: fix mktools installation
Einige Prüfungen meldeten Warnungen
arcad/arcast/pipeline/head This commit is unstable
2024-04-25 15:56:41 +02:00
74268493d6 feat: rewrite app system
Einige Prüfungen meldeten Warnungen
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
Einige Prüfungen meldeten Warnungen
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
Einige Prüfungen meldeten Warnungen
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
Einige Prüfungen meldeten Warnungen
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
Einige Prüfungen meldeten Warnungen
arcad/arcast/pipeline/head This commit is unstable
2024-04-23 10:50:42 +02:00
85 geänderte Dateien mit 20774 neuen und 914 gelöschten Zeilen

Datei anzeigen

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

3
.gitignore vendored
Datei anzeigen

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

98
.goreleaser.yml Normale Datei
Datei anzeigen

@ -0,0 +1,98 @@
project_name: arcast
before:
hooks:
- go mod tidy
builds:
- id: arcast-player
binary: arcast-player
env:
- CGO_ENABLED=0
ldflags:
- -s
- -w
- -X 'main.CommitRef={{ .Commit }}'
- -X 'main.Version={{ .Version }}'
gcflags:
- -trimpath="${PWD}"
asmflags:
- -trimpath="${PWD}"
goos:
- linux
goarch:
- amd64
- arm64
main: ./cmd/desktop
- id: arcast-client
binary: arcast-client
env:
- CGO_ENABLED=0
ldflags:
- -s
- -w
- -X 'main.CommitRef={{ .Commit }}'
- -X 'main.Version={{ .Version }}'
gcflags:
- -trimpath="${PWD}"
asmflags:
- -trimpath="${PWD}"
goos:
- linux
goarch:
- amd64
- arm64
main: ./cmd/client
archives:
- id: arcast-client
builds: ["arcast-client"]
name_template: '{{ .ProjectName }}-client_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ with .Mips }}_{{ . }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}'
files:
- README.md
- id: arcast-player
builds: ["arcast-player"]
name_template: '{{ .ProjectName }}-player_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ with .Mips }}_{{ . }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}'
files:
- README.md
checksum:
name_template: "checksums.txt"
snapshot:
name_template: "{{ .Version }}"
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
nfpms:
- id: arcast-player
builds:
- "arcast-player"
package_name: arcast-player
homepage: https://forge.cadoles.com/arcad/arcast
maintainer: Cadoles <contact@cadoles.com>
description: |-
Web diffusion player
license: AGPL-3.0
formats:
- apk
- deb
dependencies:
- chromium | chromium-browser | google-chrome-stable
contents:
- src: misc/packaging/player.desktop
dst: /usr/share/applications/arcast-player.desktop
type: config
- src: misc/logo/icon.png
dst: /usr/share/pixmaps/arcast-player.png
type: config
- id: arcast-client
builds:
- "arcast-client"
package_name: arcast-client
homepage: https://forge.cadoles.com/arcad/arcast
maintainer: Cadoles <contact@cadoles.com>
description: |-
Arcast player command-line client
license: AGPL-3.0
formats:
- apk
- deb

5
.vscode/settings.json vendored Normale Datei
Datei anzeigen

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

58
Jenkinsfile vendored
Datei anzeigen

@ -3,17 +3,63 @@
// 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': '''
RUN apt-get update \
&& apt-get install -y zip jq
ARG GOLANG_VERSION=1.22.0
ARG NODEJS_VERSION=20.x
RUN wget https://go.dev/dl/go1.22.0.linux-amd64.tar.gz \
&& rm -rf /usr/local/go \
&& tar -C /usr/local -xzf go1.22.0.linux-amd64.tar.gz
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
ENV PATH="${PATH}:/usr/local/go/bin"
# 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')
]
])

Datei anzeigen

@ -1,6 +1,6 @@
LINT_ARGS ?= --timeout 5m
GORELEASER_VERSION ?= v1.13.1
GORELEASER_ARGS ?= release --snapshot --rm-dist
GORELEASER_VERSION ?= v1.25.1
GORELEASER_ARGS ?= release --snapshot --clean
GITCHLOG_ARGS ?=
SHELL := /bin/bash
JDK_PATH ?= /usr/lib/jvm/java-11-openjdk
@ -11,6 +11,15 @@ 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 )
@ -20,7 +29,12 @@ 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 GOARCH=$(GOARCH) go build \
@ -37,15 +51,38 @@ build-client: deps ## Build executable
build-android: tools/gogio/bin/gogio deps ## Build executable
mkdir -p dist
CGO_ENABLED=1 GOOS=android CGO_CFLAGS="-I${JDK_PATH}/include -I${JDK_PATH}/include/linux -w" tools/gogio/bin/gogio -target android -buildmode archive -o android/app/libs/mobile.aar -x ./cmd/mobile
( cd android && ./gradlew assembleDebug )
( cd android && VERSION_NAME=$(MKT_PROJECT_VERSION)-debug ./gradlew assembleDebug )
release-android: tools/gogio/bin/gogio deps ## Build executable
$(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 )
( cd android && VERSION_NAME=$(MKT_PROJECT_VERSION) ./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-android
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:
@ -68,20 +105,16 @@ 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 goreleaser release-android
rm -rf .gitea-release
mkdir -p .gitea-release
cp ./bin/desktop_amd64 .gitea-release/arcad_player_linux_amd64
$(MAKE) GOARCH=arm build-desktop
cp ./bin/desktop_arm .gitea-release/arcad_player_linux_arm
$(MAKE) GOARCH=arm64 build-desktop
cp ./bin/desktop_arm64 .gitea-release/arcad_player_linux_arm64
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 ./dist/*.apk .gitea-release/
cp ./dist/*.deb .gitea-release/
cp ./dist/*.tar.gz .gitea-release/
cp ./android/app/build/outputs/apk/release/app-release.apk .gitea-release/arcast_player_$(MKT_PROJECT_VERSION).apk
MKT_GITEA_RELEASE_PROJECT="arcast" \
MKT_GITEA_RELEASE_ORG="arcad" \
@ -95,6 +128,9 @@ gitea-release: .mktools build
MKT_GITEA_RELEASE_ATTACHMENTS="$$(find .gitea-release/* -type f)" \
$(MAKE) mkt-gitea-release
.PHONY: goreleaser
goreleaser: .env .mktools
( set -o allexport && source .env && set +o allexport && curl -sfL https://goreleaser.com/static/run | VERSION=$(GORELEASER_VERSION) GORELEASER_CURRENT_TAG="$(MKT_PROJECT_VERSION)" bash /dev/stdin $(GORELEASER_ARGS) )
.PHONY: mktools
mktools:

Datei anzeigen

@ -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), [`arm`](https://forge.cadoles.com/arcad/arcast/releases/download/latest/arcad_player_linux_arm), [`arm64`](https://forge.cadoles.com/arcad/arcast/releases/download/latest/arcad_player_linux_arm64)
- 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)

Datei anzeigen

@ -3,6 +3,16 @@ plugins {
id 'org.jetbrains.kotlin.android'
}
def gitVersion() {
def counter = 0
def process = "git rev-list master --first-parent --count".execute()
return process.text.toInteger()
}
def versionName() {
return System.env.VERSION_NAME ? System.env.VERSION_NAME : "0.0.0"
}
android {
namespace 'com.cadoles.arcast_player'
compileSdk 34
@ -11,8 +21,8 @@ android {
applicationId "com.cadoles.arcast_player"
minSdk 24
targetSdk 34
versionCode 1
versionName "1.0"
versionCode gitVersion()
versionName versionName()
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@ -47,7 +57,6 @@ android {
}
dependencies {
implementation 'androidx.core:core-ktx:1.10.1'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.1'
implementation 'androidx.activity:activity-compose:1.7.0'

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

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

5
apps/main/.eslintrc Normale Datei
Datei anzeigen

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

23
apps/main/.gitignore vendored Normale Datei
Datei anzeigen

@ -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 Normale Datei
Datei anzeigen

@ -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 generiert Normale Datei

Datei-Diff unterdrückt, da er zu groß ist Diff laden

50
apps/main/package.json Normale Datei
Datei anzeigen

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

Datei anzeigen

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

BIN
apps/main/public/favicon.ico Normale Datei

Binäre Datei nicht angezeigt.

Nachher

Breite:  |  Höhe:  |  Größe: 3.8 KiB

43
apps/main/public/index.html Normale Datei
Datei anzeigen

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

BIN
apps/main/public/logo192.png Normale Datei

Binäre Datei nicht angezeigt.

Nachher

Breite:  |  Höhe:  |  Größe: 5.2 KiB

BIN
apps/main/public/logo512.png Normale Datei

Binäre Datei nicht angezeigt.

Nachher

Breite:  |  Höhe:  |  Größe: 9.4 KiB

Datei anzeigen

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

3
apps/main/public/robots.txt Normale Datei
Datei anzeigen

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

0
apps/main/src/App.css Normale Datei
Datei anzeigen

36
apps/main/src/App.tsx Normale Datei
Datei anzeigen

@ -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 Normale Datei
Datei anzeigen

@ -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 Normale Datei
Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

@ -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 Normale Datei
Datei anzeigen

@ -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 Normale Datei
Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

@ -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 Normale Datei
Datei anzeigen

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

5
apps/main/src/setupTests.ts Normale Datei
Datei anzeigen

@ -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 Normale Datei
Datei anzeigen

@ -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"
]
}

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

@ -5,10 +5,13 @@ import (
"forge.cadoles.com/arcad/arcast/internal/command/client"
)
var Version = "dev"
func main() {
command.Main(
"arcast",
"Arcast cli client",
"cli client",
Version,
client.Root().Subcommands...,
)
}

Datei anzeigen

@ -5,10 +5,13 @@ import (
"forge.cadoles.com/arcad/arcast/internal/command/player"
)
var Version = "dev"
func main() {
command.Main(
"arcast",
"Arcast desktop player",
"desktop player",
Version,
player.Root().Subcommands...,
)
}

Datei anzeigen

@ -1,53 +1,41 @@
//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/selfsigned"
"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()
webview.SetDebug(true)
window := app.NewWindow(
app.Fullscreen.Option(),
app.AnyOrientation.Option(),
)
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 {
evt := window.NextEvent()
plugin.Install(window, evt)
switch evt := evt.(type) {
case system.DestroyEvent:
os.Exit(0)
@ -55,12 +43,7 @@ func main() {
case system.FrameEvent:
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")
})
evt.Frame(ops)
}
}
}()
@ -71,26 +54,33 @@ func main() {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
safeDataConfigWaigGroup.Wait()
safe := safedata.NewSafeData(safeDataConfig)
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
}
instanceID, err := getInstanceIDFromSafeData(ctx, safe)
if err != nil {
logger.Fatal(ctx, "could not retrieve instance id", logger.CapturedE(errors.WithStack(err)))
break
}
cert, err := selfsigned.NewLANCert()
cert, err := tls.X509KeyPair(conf.HTTPS.Cert, conf.HTTPS.Key)
if err != nil {
logger.Fatal(ctx, "could not generate self signed certificate", logger.CapturedE(errors.WithStack(err)))
logger.Fatal(ctx, "could not parse x509 certificate", logger.CapturedE(errors.WithStack(err)))
}
server := server.New(
browser,
server.WithInstanceID(instanceID),
server.WithAppsEnabled(true),
server.WithDefaultApp("home"),
server.WithInstanceID(conf.InstanceID),
server.WithAppsEnabled(conf.Apps.Enabled),
server.WithDefaultApp(conf.Apps.DefaultApp),
server.WithApps(arcast.DefaultApps...),
server.WithTLSCertificate(cert),
server.WithTLSCertificate(&cert),
server.WithAddress(conf.HTTP.Address),
server.WithTLSAddress(conf.HTTPS.Address),
server.WithAllowedOrigins(conf.AllowedOrigins...),
)
if err := server.Start(); err != nil {
@ -112,26 +102,19 @@ func main() {
app.Main()
}
func getInstanceIDFromSafeData(ctx context.Context, safe *safedata.SafeData) (string, error) {
instanceIDSecret, err := safe.Get(instanceIDSecretIdentifier)
if err != nil && err.Error() != "not found" {
logger.Error(ctx, "could not retrieve instance id secret", logger.CapturedE(errors.WithStack(err)))
}
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 Normale Datei
Datei anzeigen

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

59
doc/configuration.md Normale Datei
Datei anzeigen

@ -0,0 +1,59 @@
# 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": ":",
// Répertoire de personnalisation de la page d'accueil
// Voir section "Personnalisation" ci-dessous
"customDir": "${CONFIG_DIR}/custom"
},
// 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"
}
}
```
## Personnalisation
Il est possible de personnaliser la page d'accueil du player Arcast en créant des fichiers dans le répertoire définit par l'attribut de configuration `http.customDir`.
Le contenu de ce répertoire doit répliquer l'arborescence embarquée par défaut (voir https://forge.cadoles.com/arcad/arcast/src/branch/develop/pkg/server/embed). Chaque fichier présent remplacera celui embarqué par défaut.

4
go.mod
Datei anzeigen

@ -4,7 +4,8 @@ go 1.21.4
require (
gioui.org v0.4.1
github.com/gioui-plugins/gio-plugins v0.0.0-20230625001848-8f18aae6c91c
github.com/dschmidt/go-layerfs v0.1.0
github.com/gioui-plugins/gio-plugins v0.0.0-20240323070753-3331d8c2df5d
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
@ -27,7 +28,6 @@ require (
github.com/go-playground/universal-translator v0.18.0 // indirect
github.com/go-text/typesetting v0.0.0-20230803102845-24e03d8b5372 // indirect
github.com/inkeliz/go_inkwasm v0.0.0-20220912074516-049d3472c98a // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/jedib0t/go-pretty/v6 v6.4.9 // indirect
github.com/leodido/go-urn v1.2.1 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect

11
go.sum
Datei anzeigen

@ -32,8 +32,10 @@ github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46t
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gioui-plugins/gio-plugins v0.0.0-20230625001848-8f18aae6c91c h1:naFDaf0CvDEYZ3Zpxx20DY/cCvBQqKwsV7ZzBt3M/bU=
github.com/gioui-plugins/gio-plugins v0.0.0-20230625001848-8f18aae6c91c/go.mod h1:nBuRsi6udr2x6eorarLHtRkoRaWBICt+WzaE7zQXgYY=
github.com/dschmidt/go-layerfs v0.1.0 h1:jE6aHDfjNzS/31DS48th6EkmELwTa1Uf+aO4jRkBs3U=
github.com/dschmidt/go-layerfs v0.1.0/go.mod h1:m62aff0hn23Q/tQBRiNSeLD7EUuimDvsuCvCpzBr3Gw=
github.com/gioui-plugins/gio-plugins v0.0.0-20240323070753-3331d8c2df5d h1:8b7owUJ8sNmgqEk+1d7ylr3TCH3vliCvY/6ycfize8o=
github.com/gioui-plugins/gio-plugins v0.0.0-20240323070753-3331d8c2df5d/go.mod h1:3XVleuCdPpdajFL+ASh2wmXZNskitXQQ4jhVss0VHZg=
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=
@ -62,8 +64,6 @@ github.com/inkeliz/go_inkwasm v0.0.0-20220912074516-049d3472c98a h1:uZklbtdSPrDL
github.com/inkeliz/go_inkwasm v0.0.0-20220912074516-049d3472c98a/go.mod h1:LPI3Qojj7OgTyc2R4RPB6BuMSgjoOXCObwnDzz1SOVk=
github.com/jaevor/go-nanoid v1.3.0 h1:nD+iepesZS6pr3uOVf20vR9GdGgJW1HPaR46gtrxzkg=
github.com/jaevor/go-nanoid v1.3.0/go.mod h1:SI+jFaPuddYkqkVQoNGHs81navCtH388TcrH0RqFKgY=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/jedib0t/go-pretty/v6 v6.4.9 h1:vZ6bjGg2eBSrJn365qlxGcaWu09Id+LHtrfDWlB2Usc=
github.com/jedib0t/go-pretty/v6 v6.4.9/go.mod h1:Ndk3ase2CkQbXLLNf5QDHoYb6J9WtVfmHZu9n8rk2xs=
github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w=
@ -88,6 +88,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pkg/profile v1.6.0/go.mod h1:qBsxPvzyUincmltOk6iyRVxHYg4adc0OFOv72ZdLa18=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/psanford/memfs v0.0.0-20210214183328-a001468d78ef h1:NKxTG6GVGbfMXc2mIk+KphcH6hagbVXhcFkbTgYleTI=
github.com/psanford/memfs v0.0.0-20210214183328-a001468d78ef/go.mod h1:tcaRap0jS3eifrEEllL6ZMd9dg8IlDpi2S1oARrQ+NI=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
@ -144,7 +146,6 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=

Datei anzeigen

@ -10,11 +10,12 @@ import (
"gitlab.com/wpetit/goweb/logger"
)
func Main(name string, usage string, commands ...*cli.Command) {
func Main(name string, usage string, version string, commands ...*cli.Command) {
app := &cli.App{
Name: name,
Usage: usage,
Commands: commands,
Version: version,
Before: func(ctx *cli.Context) error {
workdir := ctx.String("workdir")
// Switch to new working directory if defined

Datei anzeigen

@ -1,12 +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/selfsigned"
"forge.cadoles.com/arcad/arcast/pkg/config"
"forge.cadoles.com/arcad/arcast/pkg/server"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
@ -15,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"},
@ -34,8 +44,8 @@ func Run() *cli.Command {
Value: ":",
},
&cli.StringFlag{
Name: "tls-address",
EnvVars: []string{"ARCAST_DESKTOP_TLS_ADDRESS"},
Name: "https-address",
EnvVars: []string{"ARCAST_DESKTOP_HTTPS_ADDRESS"},
Value: ":",
},
&cli.IntFlag{
@ -53,55 +63,107 @@ func Run() *cli.Command {
EnvVars: []string{"ARCAST_DESKTOP_WINDOW_WIDTH"},
Value: defaults.Width,
},
&cli.StringSliceFlag{
Name: "allowed-origins",
EnvVars: []string{"ARCAST_DESKTOP_ALLOWED_ORIGINS"},
Value: cli.NewStringSlice(),
},
&cli.StringFlag{
Name: "custom-files-dir",
EnvVars: []string{"ARCAST_DESKTOP_CUSTOM_FILES_DIR"},
Value: "",
},
&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")...)
enableApps := ctx.Bool("apps")
serverAddress := ctx.String("address")
serverTLSAddress := ctx.String("tls-address")
dummyBrowser := ctx.Bool("dummy-browser")
browser := lorca.NewBrowser(
lorca.WithAdditionalChromeArgs(chromeArgs...),
lorca.WithWindowSize(windowWidth, windowHeight),
)
var browser browser.Browser
if err := browser.Start(); err != nil {
return errors.Wrap(err, "could not start 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 := lorcaBrowser.Start(); err != nil {
return errors.Wrap(err, "could not start browser")
}
go func() {
lorcaBrowser.Wait()
logger.Warn(ctx.Context, "browser was closed")
os.Exit(1)
}()
defer func() {
logger.Info(ctx.Context, "stopping browser")
if err := lorcaBrowser.Stop(); err != nil {
logger.Error(ctx.Context, "could not stop browser", logger.CapturedE(errors.WithStack(err)))
}
}()
browser = lorcaBrowser
}
go func() {
browser.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 {
logger.Error(ctx.Context, "could not stop browser", logger.CapturedE(errors.WithStack(err)))
}
}()
conf := config.DefaultConfig()
instanceID := ctx.String("instance-id")
if instanceID == "" {
instanceID = server.NewRandomInstanceID()
if instanceID != "" {
conf.InstanceID = instanceID
}
cert, err := selfsigned.NewLANCert()
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")
}
if ctx.IsSet("custom-dir") {
conf.HTTP.CustomDir = ctx.String("custom-dir")
}
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)))
}
cert, err := tls.X509KeyPair(conf.HTTPS.Cert, conf.HTTPS.Key)
if err != nil {
return errors.Wrap(err, "could not generate self signed certificate")
return errors.Wrap(err, "could not parse tls cert/key pair")
}
server := server.New(browser,
server.WithInstanceID(instanceID),
server.WithAppsEnabled(enableApps),
server.WithDefaultApp("home"),
server.WithInstanceID(conf.InstanceID),
server.WithAppsEnabled(conf.Apps.Enabled),
server.WithDefaultApp(conf.Apps.DefaultApp),
server.WithApps(arcast.DefaultApps...),
server.WithAddress(serverAddress),
server.WithTLSAddress(serverTLSAddress),
server.WithTLSCertificate(cert),
server.WithAddress(conf.HTTP.Address),
server.WithTLSAddress(conf.HTTPS.Address),
server.WithTLSCertificate(&cert),
server.WithAllowedOrigins(conf.AllowedOrigins...),
server.WithUpperLayerDir(conf.HTTP.CustomDir),
)
if err := server.Start(); err != nil {

BIN
misc/logo/icon.png Normale Datei

Binäre Datei nicht angezeigt.

Nachher

Breite:  |  Höhe:  |  Größe: 21 KiB

Datei anzeigen

@ -0,0 +1,7 @@
[Desktop Entry]
Type=Application
Version=1.0
Name=Arcast Player
Comment=Web diffusion server
Path=/usr/bin/arcast-player
Icon=arcast-player

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

@ -2,7 +2,7 @@ package gioui
import (
"context"
"sync"
"sync/atomic"
"forge.cadoles.com/arcad/arcast/pkg/browser"
"gioui.org/app"
@ -16,26 +16,20 @@ type Browser struct {
window *app.Window
tag int
url string
changed bool
status browser.Status
title string
mutex sync.Mutex
url *atomic.Value
changed *atomic.Bool
status *atomic.Value
title *atomic.Value
}
func (b *Browser) Layout(gtx layout.Context) {
b.mutex.Lock()
defer b.mutex.Unlock()
func (b *Browser) Layout(gtx layout.Context) layout.Dimensions {
events := gtx.Events(&b.tag)
for _, evt := range events {
switch ev := evt.(type) {
case webviewer.TitleEvent:
b.title = ev.Title
b.title.Store(ev.Title)
case webviewer.NavigationEvent:
b.url = ev.URL
b.url.Store(ev.URL)
}
}
@ -58,21 +52,20 @@ func (b *Browser) Layout(gtx layout.Context) {
},
}.Add(gtx.Ops)
if b.changed {
logger.Debug(ctx, "url changed", logger.F("url", b.url))
webviewer.NavigateOp{URL: b.url}.Add(gtx.Ops)
b.changed = false
if b.changed.CompareAndSwap(true, false) {
url := b.url.Load().(string)
logger.Debug(ctx, "url changed", logger.F("url", url))
webviewer.NavigateOp{URL: url}.Add(gtx.Ops)
}
return layout.Dimensions{Size: gtx.Constraints.Max}
}
// Load implements browser.Browser.
func (b *Browser) Load(url string) error {
b.mutex.Lock()
defer b.mutex.Unlock()
b.url = url
b.changed = true
b.status = browser.StatusCasting
b.url.Store(url)
b.changed.Store(true)
b.status.Store(browser.StatusCasting)
b.window.Invalidate()
@ -81,36 +74,24 @@ func (b *Browser) Load(url string) error {
// Status implements browser.Browser.
func (b *Browser) Status() (browser.Status, error) {
b.mutex.Lock()
defer b.mutex.Unlock()
return b.status, nil
return b.status.Load().(browser.Status), nil
}
// Title implements browser.Browser.
func (b *Browser) Title() (string, error) {
b.mutex.Lock()
defer b.mutex.Unlock()
return b.title, nil
return b.title.Load().(string), nil
}
// URL implements browser.Browser.
func (b *Browser) URL() (string, error) {
b.mutex.Lock()
defer b.mutex.Unlock()
return b.url, nil
return b.url.Load().(string), nil
}
// Reset implements browser.Browser.
func (b *Browser) Reset(url string) error {
b.mutex.Lock()
defer b.mutex.Unlock()
b.url = url
b.changed = true
b.status = browser.StatusIdle
b.url.Store(url)
b.changed.Store(true)
b.status.Store(browser.StatusIdle)
b.window.Invalidate()
@ -118,12 +99,20 @@ func (b *Browser) Reset(url string) error {
}
func NewBrowser(window *app.Window) *Browser {
return &Browser{
b := &Browser{
window: window,
url: "",
changed: true,
status: browser.StatusIdle,
url: &atomic.Value{},
changed: &atomic.Bool{},
status: &atomic.Value{},
title: &atomic.Value{},
}
b.url.Store("")
b.title.Store("")
b.changed.Store(false)
b.status.Store(browser.StatusIdle)
return b
}
var _ browser.Browser = &Browser{}

118
pkg/config/config.go Normale Datei
Datei anzeigen

@ -0,0 +1,118 @@
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"`
CustomDir string `json:"customDir"`
}
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, filename string, 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, filename, 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",
CustomDir: "",
},
HTTPS: HTTPSConfig{
Address: ":45556",
SelfSigned: SelfSignedCertConfig{
Enabled: true,
NetworkFingerprint: "",
},
},
Apps: AppsConfig{
Enabled: true,
DefaultApp: "main",
},
}
}

28
pkg/config/custom_files.go Normale Datei
Datei anzeigen

@ -0,0 +1,28 @@
package config
import (
"context"
"os"
"path/filepath"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
func CreateCustomDir(ctx context.Context, filename string, conf *Config) error {
if conf.HTTP.CustomDir != "" {
return nil
}
configDir := filepath.Dir(filename)
customFilesDir := filepath.Join(configDir, "custom")
if err := os.MkdirAll(customFilesDir, 0755); err != nil {
logger.Error(ctx, "could not create custom files directory", logger.CapturedE(errors.WithStack(err)))
return nil
}
conf.HTTP.CustomDir = customFilesDir
return nil
}

Datei anzeigen

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

100
pkg/config/selfsigned.go Normale Datei
Datei anzeigen

@ -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, filename string, 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, filename string, conf *Config) error {
if !conf.HTTPS.SelfSigned.Enabled {
return nil
}
if conf.HTTPS.Cert == nil || conf.HTTPS.Key == nil {
if err := GenerateSelfSignedCert(ctx, filename, 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, filename, 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, filename, 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
}

Datei anzeigen

@ -19,15 +19,24 @@ import (
"github.com/pkg/errors"
)
func NewLANCert() (*tls.Certificate, error) {
func NewLANKeyPair() ([]byte, []byte, error) {
hosts, err := network.GetLANIPv4Addrs()
if err != nil {
return nil, errors.WithStack(err)
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)
}

Datei anzeigen

@ -13,16 +13,33 @@ import (
)
var (
upgrader = websocket.Upgrader{}
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 := upgrader.Upgrade(w, r, nil)
c, err := s.upgrader.Upgrade(w, r, nil)
if err != nil {
log.Print("upgrade:", err)
return
@ -41,9 +58,13 @@ func (s *Server) handleBroadcast(w http.ResponseWriter, r *http.Request) {
for {
messageType, message, err := c.ReadMessage()
if err != nil {
if err != nil && !websocket.IsCloseError(err, 1001) {
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))

Datei anzeigen

@ -0,0 +1,64 @@
{{ define "base" }}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="/logo.png" />
<title>Ready to cast !</title>
<link rel="stylesheet" href="style.css" />
{{ block "head" .
}}{{
end
}}
</head>
<body>
<div class="container">
<div class="panel">
<div id="icon"></div>
{{ block "message" . }}
<h2 id="title" class="text-centered">Ready to cast !</h2>
{{ end }}
{{ block "info" . }}
<p><b>Instance ID</b></p>
<p class="text-small text-centered">
<code>{{ .ID }}</code>
</p>
<p><b>Addresses</b></p>
<ul class="text-italic text-small text-centered">
{{ $port := .Port }}
{{
range.IPs
}}
<li>
<code>{{ . }}:{{ $port }}</code>
</li>
{{
end
}}
</ul>
{{ end }}
{{if .Apps }}
{{ block "apps" . }}
<p><b>Apps</b></p>
<ul class="text-italic text-small text-centered">
{{ $tlsPort := .TLSPort }}
{{
range.IPs
}}
<li>
<a href="https://{{ . }}:{{ $tlsPort }}/apps">
https://{{ . }}:{{ $tlsPort }}/apps
</a>
</li>
{{
end
}}
</ul>
{{ end }}
{{ end }}
</div>
</div>
</body>
</html>
{{ end }}

Datei anzeigen

@ -0,0 +1,3 @@
{{ define "index" }}
{{ template "base" . }}
{{ end }}

BIN
pkg/server/embed/logo.png Normale Datei

Binäre Datei nicht angezeigt.

Nachher

Breite:  |  Höhe:  |  Größe: 25 KiB

121
pkg/server/embed/style.css Normale Datei
Datei anzeigen

@ -0,0 +1,121 @@
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%;
}
}
*,
*: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;
}
.panel {
display: block;
background-color: #fff;
border-radius: 15px;
box-shadow: 10px 10px 10px #33333361;
position: relative;
padding: 50px 30px 30px 30px;
min-width: 50%;
color: #333;
}
#title {
margin: 10px 0px 20px 0px;
}
#icon {
width: 100px;
aspect-ratio: 1/1;
background-size: contain;
background-position: center center;
background-image: url("logo.png");
position: absolute;
left: 50%;
margin-left: -50px;
margin-top: -100px;
background-repeat: no-repeat;
}
.panel p,
.panel ul {
margin-top: 10px;
}
.text-centered {
text-align: center;
}
.text-italic {
font-style: italic;
}
.text-small {
font-size: 0.8em;
}
.mt {
margin-top: 1em;
display: block;
}

90
pkg/server/fs.go Normale Datei
Datei anzeigen

@ -0,0 +1,90 @@
package server
import (
"embed"
"html/template"
"io/fs"
"net/http"
"os"
"strings"
"forge.cadoles.com/arcad/arcast/pkg/network"
"github.com/dschmidt/go-layerfs"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
_ "embed"
)
var (
//go:embed embed/**
embedFS embed.FS
)
func (s *Server) initLayeredFS() error {
layers := make([]fs.FS, 0)
if s.upperLayerDir != "" {
upperLayer := os.DirFS(s.upperLayerDir)
layers = append(layers, upperLayer)
}
baseLayer, err := fs.Sub(embedFS, "embed")
if err != nil {
return errors.WithStack(err)
}
layers = append(layers, baseLayer)
s.layeredFS = layerfs.New(layers...)
return nil
}
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if strings.HasPrefix(path, "/_templates") {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
http.ServeFileFS(w, r, s.layeredFS, path)
}
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
type templateData struct {
IPs []string
Port int
TLSPort int
ID string
Apps bool
}
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
}
d := templateData{
ID: s.instanceID,
IPs: ips,
Port: s.port,
TLSPort: s.tlsPort,
Apps: s.appsEnabled,
}
templates, err := template.New("").ParseFS(s.layeredFS, "_partials/*.gohtml", "_templates/*.gohtml")
if err != nil {
logger.Error(r.Context(), "could not parse template", logger.CapturedE(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if err := templates.ExecuteTemplate(w, "index", d); err != nil {
logger.Error(r.Context(), "could not render index page", logger.CapturedE(errors.WithStack(err)))
}
}

Datei anzeigen

@ -4,7 +4,6 @@ import (
"context"
"crypto/tls"
"fmt"
"html/template"
"net"
"net/http"
"strconv"
@ -14,39 +13,17 @@ import (
"github.com/go-chi/cors"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
_ "embed"
)
var (
//go:embed templates/idle.html.gotmpl
rawIdleTemplate []byte
idleTemplate *template.Template
)
func init() {
tmpl, err := template.New("").Parse(string(rawIdleTemplate))
if err != nil {
panic(errors.Wrap(err, "could not parse idle template"))
}
idleTemplate = tmpl
}
func (s *Server) startWebServers(ctx context.Context) error {
router := chi.NewRouter()
if s.appsEnabled {
ips, err := network.GetLANIPv4Addrs()
if err != nil {
return errors.WithStack(err)
}
allowedOrigins := make([]string, len(ips))
for idx, ip := range ips {
allowedOrigins[idx] = fmt.Sprintf("http://%s:%d", ip, s.port)
}
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"},
@ -55,7 +32,6 @@ func (s *Server) startWebServers(ctx context.Context) error {
}))
}
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)
@ -68,6 +44,9 @@ func (s *Server) startWebServers(ctx context.Context) error {
router.Handle("/api/v1/broadcast/{channelID}", http.HandlerFunc(s.handleBroadcast))
}
router.Get("/", s.handleIndex)
router.Get("/*", s.handleStatic)
if err := s.startHTTPServer(ctx, router); err != nil {
return errors.WithStack(err)
}
@ -178,31 +157,23 @@ func (s *Server) startHTTPSServer(ctx context.Context, router chi.Router) error
return nil
}
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
type templateData struct {
IPs []string
Port int
TLSPort int
ID string
Apps bool
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))
}
}
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
if len(s.allowedOrigins) > 0 {
allowedOrigins = append(allowedOrigins, s.allowedOrigins...)
}
d := templateData{
ID: s.instanceID,
IPs: ips,
Port: s.port,
TLSPort: s.tlsPort,
Apps: s.appsEnabled,
}
if err := idleTemplate.Execute(w, d); err != nil {
logger.Error(r.Context(), "could not render idle page", logger.CapturedE(errors.WithStack(err)))
}
return allowedOrigins, nil
}

Datei anzeigen

@ -26,7 +26,9 @@ type Options struct {
EnableServiceDiscovery bool
EnableApps bool
DefaultApp string
AllowedOrigins []string
Apps []App
UpperLayerDir string
}
type OptionFunc func(opts *Options)
@ -39,7 +41,9 @@ func NewOptions(funcs ...OptionFunc) *Options {
EnableServiceDiscovery: true,
EnableApps: false,
DefaultApp: "",
AllowedOrigins: make([]string, 0),
Apps: make([]App, 0),
UpperLayerDir: "",
}
for _, fn := range funcs {
@ -67,6 +71,12 @@ func WithApps(apps ...App) OptionFunc {
}
}
func WithAllowedOrigins(origins ...string) OptionFunc {
return func(opts *Options) {
opts.AllowedOrigins = origins
}
}
func WithAddress(addr string) OptionFunc {
return func(opts *Options) {
opts.Address = addr
@ -97,6 +107,12 @@ func WithServiceDiscoveryEnabled(enabled bool) OptionFunc {
}
}
func WithUpperLayerDir(dir string) OptionFunc {
return func(opts *Options) {
opts.UpperLayerDir = dir
}
}
func NewRandomInstanceID() string {
return newRandomInstanceID()
}

Datei anzeigen

@ -3,8 +3,10 @@ package server
import (
"context"
"crypto/tls"
"io/fs"
"forge.cadoles.com/arcad/arcast/pkg/browser"
"github.com/gorilla/websocket"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
@ -23,16 +25,27 @@ type Server struct {
serviceDiscoveryEnabled bool
appsEnabled bool
defaultApp string
apps []App
appsEnabled bool
defaultApp string
allowedOrigins []string
apps []App
ctx context.Context
cancel context.CancelFunc
ctx context.Context
cancel context.CancelFunc
upgrader websocket.Upgrader
layeredFS fs.FS
upperLayerDir string
}
func (s *Server) Start() error {
serverCtx, cancelServer := context.WithCancel(context.Background())
ctx := context.Background()
if err := s.initLayeredFS(); err != nil {
return errors.WithStack(err)
}
serverCtx, cancelServer := context.WithCancel(ctx)
s.cancel = cancelServer
s.ctx = serverCtx
@ -78,15 +91,23 @@ 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,
tlsAddress: opts.TLSAddress,
tlsCert: opts.TLSCertificate,
appsEnabled: opts.EnableApps,
allowedOrigins: opts.AllowedOrigins,
defaultApp: opts.DefaultApp,
apps: opts.Apps,
serviceDiscoveryEnabled: opts.EnableServiceDiscovery,
upperLayerDir: opts.UpperLayerDir,
}
server.upgrader = websocket.Upgrader{
CheckOrigin: server.checkOrigin,
}
return server
}

Datei anzeigen

@ -1,115 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Arcast - Idle</title>
<style>
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;
}
.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;
}
.mt {
margin-top: 1em;
display: block;
}
</style>
</head>
<body>
<div class="container">
<div class="panel">
<h1>Arcast - Idle</h1>
<p>Instance ID:</p>
<p class="text-centered text-small">
<code>{{ .ID }}</code>
</p>
<p>Addresses:</p>
<ul class="text-italic text-small">
{{ $port := .Port }}
{{range .IPs}}
<li><code>{{ . }}:{{ $port }}</code></li>
{{end}}
</ul>
{{if .Apps }}
<p>Apps:</p>
<ul class="text-italic text-small">
{{ $tlsPort := .TLSPort }}
{{range .IPs}}
<li><a href="https://{{ . }}:{{ $tlsPort }}/apps">https://{{ . }}:{{ $tlsPort }}/apps</a></li>
{{end}}
</ul>
{{end}}
</div>
</div>
</body>
</html>