Compare commits

1 Commits

Author SHA1 Message Date
e21964ee46 chore: add jenkins pipeline
Some checks failed
arcad/arcast/pipeline/head There was a failure building this commit
2024-04-23 10:02:26 +02:00
85 changed files with 916 additions and 20776 deletions

View File

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

3
.gitignore vendored
View File

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

View File

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

View File

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

58
Jenkinsfile vendored
View File

@ -3,63 +3,17 @@
// Utilisation du pipeline "standard"
// Voir https://forge.cadoles.com/Cadoles/Jenkins/src/branch/master/doc/tutorials/standard-make-pipeline.md
standardMakePipeline([
'baseImage': 'reg.cadoles.com/proxy_cache/library/ubuntu:24.04',
'dockerfileExtension': '''
ARG GOLANG_VERSION=1.22.0
ARG NODEJS_VERSION=20.x
RUN apt-get update \
&& apt-get install -y zip jq
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
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
# 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'
ENV PATH="${PATH}:/usr/local/go/bin"
''',
'hooks': [
'pre-release': {
sh '''
make .mktools
'''
}
],
'credentials': [
file(credentialsId: 'android-keystore', variable: 'ANDROID_KEYSTORE_FILE'),
string(credentialsId: 'android-keystore-pass', variable: 'ANDROID_KEYSTORE_PASS'),
usernamePassword(credentialsId: 'kipp-credentials', usernameVariable: 'MKT_GITEA_RELEASE_USERNAME', passwordVariable: 'MKT_GITEA_RELEASE_PASSWORD')
]
])

View File

@ -1,6 +1,6 @@
LINT_ARGS ?= --timeout 5m
GORELEASER_VERSION ?= v1.25.1
GORELEASER_ARGS ?= release --snapshot --clean
GORELEASER_VERSION ?= v1.13.1
GORELEASER_ARGS ?= release --snapshot --rm-dist
GITCHLOG_ARGS ?=
SHELL := /bin/bash
JDK_PATH ?= /usr/lib/jvm/java-11-openjdk
@ -11,15 +11,6 @@ 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 )
@ -29,12 +20,7 @@ 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-apps build-desktop build-android build-client ## Build artefacts
build-apps: $(foreach name,$(APPS),build-app-$(name))
build-app-%:
$(MAKE) -C apps/$* build
build: build-desktop build-android build-client ## Build artefacts
build-desktop: deps ## Build executable
CGO_ENABLED=0 GOARCH=$(GOARCH) go build \
@ -51,38 +37,15 @@ 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 && VERSION_NAME=$(MKT_PROJECT_VERSION)-debug ./gradlew assembleDebug )
( cd android && ./gradlew assembleDebug )
$(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
release-android: tools/gogio/bin/gogio deps ## Build executable
mkdir -p dist
CGO_ENABLED=1 GOOS=android CGO_CFLAGS="-I${JDK_PATH}/include -I${JDK_PATH}/include/linux -w" tools/gogio/bin/gogio -target android -buildmode archive -o android/app/libs/mobile.aar -x ./cmd/mobile
( cd android && 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
( cd android && ./gradlew assemble )
install-android: build-apps build-android
adb uninstall com.cadoles.arcast_player
install-android: build-android
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:
@ -105,16 +68,20 @@ tools/gogio/bin/gogio:
mkdir -p tools/gogio/bin
GOBIN=$(PWD)/tools/gogio/bin go install gioui.org/cmd/gogio@latest
release: gitea-release
gitea-release: .mktools goreleaser release-android
gitea-release: .mktools build
rm -rf .gitea-release
mkdir -p .gitea-release
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
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
MKT_GITEA_RELEASE_PROJECT="arcast" \
MKT_GITEA_RELEASE_ORG="arcad" \
@ -128,9 +95,6 @@ gitea-release: .mktools goreleaser release-android
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:

View File

@ -1,7 +1,3 @@
<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).
@ -16,7 +12,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/arcast_client_linux_amd64)
- Linux: [`amd64`](https://forge.cadoles.com/arcad/arcast/releases/download/latest/arcad_client_linux_amd64)
### Application Android
@ -24,7 +20,7 @@ L'application `arcast_player` pour Android permet de transformer votre appareil
#### Dernière version
- [`APK`](https://forge.cadoles.com/arcad/arcast/releases/download/latest/arcast_player.apk)
- [`debug`](https://forge.cadoles.com/arcad/arcast/releases/download/latest/arcast_player_debug.apk)
### Application de bureau
@ -36,8 +32,4 @@ 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/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)
- 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)

View File

@ -3,16 +3,6 @@ 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
@ -21,8 +11,8 @@ android {
applicationId "com.cadoles.arcast_player"
minSdk 24
targetSdk 34
versionCode gitVersion()
versionName versionName()
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@ -57,6 +47,7 @@ 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'

19
apps/home/app.js Normal file
View File

@ -0,0 +1,19 @@
fetch("/api/v1/apps")
.then((res) => res.json())
.then((res) => {
const defaultApp = res.data.defaultApp;
const apps = res.data.apps;
const container = document.createElement("div");
container.className = "container";
apps.forEach((app) => {
if (app.id === defaultApp || app.hidden) return;
const appLink = document.createElement("a");
appLink.className = "app-link";
appLink.href = "/apps/" + app.id + "/";
appLink.innerText = app.title["fr"];
container.appendChild(appLink);
});
document.getElementById("main").replaceWith(container);
});

16
apps/home/index.html Normal file
View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Arcast - Home</title>
<link rel="stylesheet" href="/apps/lib/style.css">
<link rel="stylesheet" href="style.css">
<script type="text/javascript" src="app.js" defer></script>
</head>
<body>
<div id="main" class="container">
<p class="text-center">Loading...</p>
</div>
</body>
</html>

View File

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

23
apps/home/style.css Normal file
View File

@ -0,0 +1,23 @@
.mt {
margin-top: 1em;
display: block;
}
.app-link {
display: block;
background: white;
border-radius: 5px;
width: 100px;
height: 100px;
box-shadow: 1px 1px 3px #ccc;
color: #333;
text-decoration: none;
text-align: center;
padding-top: 30px;
}
.app-link:hover {
background-color: #abdbdb;
box-shadow: 1px 1px 3px #aaa;
text-shadow: 1px 1px white;
}

46
apps/lib/arcast.js Normal file
View File

@ -0,0 +1,46 @@
(function (Arcast) {
Arcast.getInfo = function () {
return fetch("/api/v1/info")
.then((res) => res.json())
.then((res) => res.data);
};
Arcast.getBroadcastingChannel = function (id, onmessage) {
var scheme = "wss";
if (window.location.protocol === "http:") {
scheme = "ws";
}
var ws = new WebSocket(
`${scheme}://${window.location.host}/api/v1/broadcast/${id}`
);
var channel = {
opened: false,
send: (message) => {
ws.send(message);
},
close: () => {
ws.close();
},
};
ws.onmessage = (evt) => {
if (typeof onmessage !== "function") {
return;
}
onmessage(evt.data);
};
ws.onclose = () => {
ws.onmessage = null;
ws.onclose = null;
ws.onopen = null;
channel.opened = false;
};
ws.onopen = () => {
channel.opened = true;
};
return channel;
};
})((window.Arcast = window.Arcast || {}));

3
apps/lib/manifest.json Normal file
View File

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

77
apps/lib/style.css Normal file
View File

@ -0,0 +1,77 @@
html {
box-sizing: border-box;
font-size: 16px;
width: 100%;
height: 100%;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
width: 100%;
height: 100%;
background-color: #e1e1e1;
}
*,
*:before,
*:after {
box-sizing: inherit;
}
ol,
ul {
list-style: none;
}
body,
h1,
h2,
h3,
h4,
h5,
h6,
p,
ol,
ul {
margin: 0;
padding: 0;
font-weight: normal;
}
.container {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.panel {
display: block;
background-color: #fff;
border-radius: 5px;
padding: 10px 20px;
box-shadow: 2px 2px #3333331d;
}
.panel p, .panel ul {
margin-top: 10px;
}
.text-centered {
text-align: center;
}
.text-italic {
font-style: italic;
}
.text-small {
font-size: 0.8em;
}
.fullwidth {
width: 100%;
}

View File

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

23
apps/main/.gitignore vendored
View File

@ -1,23 +0,0 @@
# 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*

View File

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

18284
apps/main/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,50 +0,0 @@
{
"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"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

View File

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

View File

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

View File

View File

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

View File

@ -1,164 +0,0 @@
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'] })
}
})
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +0,0 @@
// 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';

View File

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

View File

@ -0,0 +1,66 @@
function main() {
refreshStatus();
setInterval(refreshStatus, 10000)
}
function refreshStatus() {
return fetch("/api/v1/status")
.then(res => res.json())
.then(res => {
const newStatus = document.createElement("tr")
newStatus.id = "status"
let td = document.createElement("td")
td.innerText = res.data.status
newStatus.appendChild(td)
td = document.createElement("td")
td.innerText = res.data.title
newStatus.appendChild(td)
td = document.createElement("td")
td.innerText = res.data.url
document.getElementById("url-input").placeholder = res.data.url
newStatus.appendChild(td)
document.getElementById("status").replaceWith(newStatus)
})
.catch(err => {
console.error(err);
window.location.reload()
})
}
function castUrl() {
const urlInput = document.getElementById("url-input")
const url = urlInput.value
if (url === "") return Promise.resolve()
return fetch("/api/v1/cast", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
"url": url,
})
})
.then(res => res.json())
.then(() => refreshStatus())
.then(() => {
urlInput.value = ""
})
}
function reset() {
const urlInput = document.getElementById("url-input")
return fetch("/api/v1/cast", {
method: "DELETE",
})
.then(res => res.json())
.then(() => refreshStatus())
.then(() => {
urlInput.value = ""
})
}
main()

View File

@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Arcast - Remote Control</title>
<link rel="stylesheet" href="/apps/lib/style.css" />
<script type="text/javascript" src="../lib/arcast.js" defer></script>
<script type="text/javascript" src="app.js" defer></script>
</head>
<body>
<div id="main" class="container">
<div class="panel">
<h1>Remote control</h1>
<table class="fullwidth">
<thead>
<tr>
<th>Status</th>
<th>Title</th>
<th>URL</th>
</tr>
</thead>
<tbody>
<tr id="status" class="text-centered">
<td colspan="3">Refreshing...</td>
</tr>
</tbody>
</table>
<input class="text-input" id="url-input" placeholder />
<button onclick="castUrl()">Cast</button>
<button onclick="reset()">Reset</button>
</div>
</div>
</body>
</html>

View File

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

200
apps/screen-sharing/app.js Normal file
View File

@ -0,0 +1,200 @@
const displayMediaOptions = {
video: {
displaySurface: "browser",
},
audio: {
suppressLocalAudioPlayback: false,
},
preferCurrentTab: false,
selfBrowserSurface: "exclude",
systemAudio: "include",
surfaceSwitching: "include",
monitorTypeSurfaces: "include",
};
const peerConnection = new RTCPeerConnection();
const signaling = Arcast.getBroadcastingChannel(
"screen-sharing",
onSignalReceived
);
var secret = window.crypto.randomUUID();
var receivedAnswer = false;
var receivedOffer = false;
var isOffering = false;
peerConnection.onconnectionstatechange = (evt) => {
console.log("Connection state changed", evt);
};
peerConnection.oniceconnectionstatechange = () => {
console.log("ICE state: ", peerConnection.iceConnectionState);
};
peerConnection.onicecandidate = (evt) => {
signaling.send(
JSON.stringify({
type: "icecandidate",
data: {
candidate: evt.candidate,
},
})
);
};
peerConnection.ontrack = function (e) {
var screenplay = document.getElementById("screenplay");
if (!screenplay) {
screenplay = document.createElement("video");
screenplay.id = "screenplay";
document.body.appendChild(screenplay);
}
screenplay.autoplay = true;
screenplay.playsInline = true;
screenplay.srcObject = e.streams[0];
screenplay.muted = true;
e.track.onended = (e) => (screenplay.srcObject = screenplay.srcObject);
};
function main() {
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has("secret")) {
secret = urlParams.get("secret");
}
}
function shareScreen() {
return Arcast.getInfo().then((info) => {
return navigator.mediaDevices
.getDisplayMedia(displayMediaOptions)
.then((captureStream) => {
isOffering = true;
const videoTrack = captureStream.getVideoTracks()[0];
peerConnection.addTrack(videoTrack, captureStream);
peerConnection.createOffer().then((offer) => {
peerConnection.setLocalDescription(offer).then(() => {
const intervalId = setInterval(() => {
if (receivedAnswer) {
clearInterval(intervalId);
return;
}
signaling.send(
JSON.stringify({
type: "offer",
data: {
secret: secret,
offer: offer,
},
})
);
}, 1000);
const url =
"http://127.0.0.1:" +
info.port +
"/apps/screen-sharing/?secret=" +
encodeURIComponent(secret);
fetch("/api/v1/cast", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
url: url,
}),
});
});
});
});
});
}
function onSignalReceived(data) {
const message = JSON.parse(data);
switch (message.type) {
case "offer":
if (message.data.secret !== secret) {
return;
}
if (receivedOffer || isOffering) {
return;
}
peerConnection.setRemoteDescription(
new RTCSessionDescription(message.data.offer),
() => {
peerConnection.createAnswer(function (answer) {
peerConnection.setLocalDescription(
answer,
function () {
signaling.send(
JSON.stringify({
type: "answer",
data: {
secret: secret,
answer: answer,
},
})
);
},
error
);
}, error);
},
error
);
receivedOffer = true;
break;
case "answer":
if (receivedAnswer || !isOffering) {
return;
}
if (message.data.secret !== secret) {
return;
}
peerConnection.setRemoteDescription(
new RTCSessionDescription(message.data.answer),
() => {},
error
);
receivedAnswer = true;
break;
case "icecandidate":
if (message.data.candidate) {
peerConnection.addIceCandidate(message.data.candidate);
}
break;
default:
console.log("Received unhandled message", message);
}
}
function endCall() {
var videos = document.getElementsByTagName("video");
for (var i = 0; i < videos.length; i++) {
videos[i].pause();
}
peerConnection.close();
}
function error(err) {
console.error(err);
endCall();
}
main();

View File

@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Arcast - Screen sharing</title>
<link rel="stylesheet" href="/apps/lib/style.css" />
<script type="text/javascript" src="/apps/lib/arcast.js" defer></script>
<script type="text/javascript" src="app.js" defer></script>
<style>
#screenplay {
width: 100% !important;
height: auto !important;
object-fit: contain;
position: absolute;
top: 50%;
transform: translateY(-50%);
object-fit: fill;
}
</style>
</head>
<body>
<div id="main" class="container">
<div class="panel">
<h1>Screen sharing</h1>
<button onclick="shareScreen()">Start</button>
</div>
</div>
</body>
</html>

View File

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

View File

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

View File

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

View File

@ -1,41 +1,53 @@
//go:build android
// +build android
package main
import (
"context"
"crypto/tls"
"os"
"path/filepath"
"sync"
"forge.cadoles.com/arcad/arcast"
"forge.cadoles.com/arcad/arcast/pkg/browser/gioui"
"forge.cadoles.com/arcad/arcast/pkg/config"
"forge.cadoles.com/arcad/arcast/pkg/selfsigned"
"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 packageName = "com.cadoles.arcast_player"
const instanceIDSecretIdentifier = "instance_id"
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)
@ -43,7 +55,12 @@ func main() {
case system.FrameEvent:
gtx := layout.NewContext(ops, evt)
browser.Layout(gtx)
evt.Frame(ops)
evt.Frame(gtx.Ops)
case app.ViewEvent:
initSafeDataConfig.Do(func() {
defer safeDataConfigWaigGroup.Done()
safeDataConfig = giosafedata.NewConfigFromViewEvent(window, evt, "com.cadoles.arcast_player")
})
}
}
}()
@ -54,33 +71,26 @@ func main() {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
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
}
safeDataConfigWaigGroup.Wait()
safe := safedata.NewSafeData(safeDataConfig)
break
instanceID, err := getInstanceIDFromSafeData(ctx, safe)
if err != nil {
logger.Fatal(ctx, "could not retrieve instance id", logger.CapturedE(errors.WithStack(err)))
}
cert, err := tls.X509KeyPair(conf.HTTPS.Cert, conf.HTTPS.Key)
cert, err := selfsigned.NewLANCert()
if err != nil {
logger.Fatal(ctx, "could not parse x509 certificate", logger.CapturedE(errors.WithStack(err)))
logger.Fatal(ctx, "could not generate self signed certificate", logger.CapturedE(errors.WithStack(err)))
}
server := server.New(
browser,
server.WithInstanceID(conf.InstanceID),
server.WithAppsEnabled(conf.Apps.Enabled),
server.WithDefaultApp(conf.Apps.DefaultApp),
server.WithInstanceID(instanceID),
server.WithAppsEnabled(true),
server.WithDefaultApp("home"),
server.WithApps(arcast.DefaultApps...),
server.WithTLSCertificate(&cert),
server.WithAddress(conf.HTTP.Address),
server.WithTLSAddress(conf.HTTPS.Address),
server.WithAllowedOrigins(conf.AllowedOrigins...),
server.WithTLSCertificate(cert),
)
if err := server.Start(); err != nil {
@ -102,19 +112,26 @@ func main() {
app.Main()
}
func getConfigFiles(ctx context.Context) []string {
configFiles := make([]string, 0)
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 {
appDataConfigFile := filepath.Join(dataDir, "config.json")
configFiles = append(configFiles, appDataConfigFile)
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)))
}
return configFiles
var instanceID string
if len(instanceIDSecret.Data) > 0 {
instanceID = string(instanceIDSecret.Data)
} 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")
}
}
return instanceID, nil
}

View File

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

View File

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

View File

@ -1,59 +0,0 @@
# 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
View File

@ -4,8 +4,7 @@ go 1.21.4
require (
gioui.org v0.4.1
github.com/dschmidt/go-layerfs v0.1.0
github.com/gioui-plugins/gio-plugins v0.0.0-20240323070753-3331d8c2df5d
github.com/gioui-plugins/gio-plugins v0.0.0-20230625001848-8f18aae6c91c
github.com/go-chi/cors v1.2.1
github.com/gorilla/websocket v1.5.1
github.com/grandcat/zeroconf v1.0.1-0.20230119201135-e4f60f8407b1
@ -28,6 +27,7 @@ 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
View File

@ -32,10 +32,8 @@ 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/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/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/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=
@ -64,6 +62,8 @@ 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,8 +88,6 @@ 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=
@ -146,6 +144,7 @@ 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=

View File

@ -10,12 +10,11 @@ import (
"gitlab.com/wpetit/goweb/logger"
)
func Main(name string, usage string, version string, commands ...*cli.Command) {
func Main(name string, usage 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

View File

@ -1,16 +1,12 @@
package player
import (
"context"
"crypto/tls"
"fmt"
"os"
"forge.cadoles.com/arcad/arcast"
"forge.cadoles.com/arcad/arcast/pkg/browser"
"forge.cadoles.com/arcad/arcast/pkg/browser/dummy"
"forge.cadoles.com/arcad/arcast/pkg/browser/lorca"
"forge.cadoles.com/arcad/arcast/pkg/config"
"forge.cadoles.com/arcad/arcast/pkg/selfsigned"
"forge.cadoles.com/arcad/arcast/pkg/server"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
@ -19,15 +15,9 @@ 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"},
@ -44,8 +34,8 @@ func Run() *cli.Command {
Value: ":",
},
&cli.StringFlag{
Name: "https-address",
EnvVars: []string{"ARCAST_DESKTOP_HTTPS_ADDRESS"},
Name: "tls-address",
EnvVars: []string{"ARCAST_DESKTOP_TLS_ADDRESS"},
Value: ":",
},
&cli.IntFlag{
@ -63,107 +53,55 @@ 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")...)
dummyBrowser := ctx.Bool("dummy-browser")
enableApps := ctx.Bool("apps")
serverAddress := ctx.String("address")
serverTLSAddress := ctx.String("tls-address")
var browser browser.Browser
browser := lorca.NewBrowser(
lorca.WithAdditionalChromeArgs(chromeArgs...),
lorca.WithWindowSize(windowWidth, windowHeight),
)
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
if err := browser.Start(); err != nil {
return errors.Wrap(err, "could not start browser")
}
conf := config.DefaultConfig()
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)))
}
}()
instanceID := ctx.String("instance-id")
if instanceID != "" {
conf.InstanceID = instanceID
if instanceID == "" {
instanceID = server.NewRandomInstanceID()
}
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)
cert, err := selfsigned.NewLANCert()
if err != nil {
return errors.Wrap(err, "could not parse tls cert/key pair")
return errors.Wrap(err, "could not generate self signed certificate")
}
server := server.New(browser,
server.WithInstanceID(conf.InstanceID),
server.WithAppsEnabled(conf.Apps.Enabled),
server.WithDefaultApp(conf.Apps.DefaultApp),
server.WithInstanceID(instanceID),
server.WithAppsEnabled(enableApps),
server.WithDefaultApp("home"),
server.WithApps(arcast.DefaultApps...),
server.WithAddress(conf.HTTP.Address),
server.WithTLSAddress(conf.HTTPS.Address),
server.WithTLSCertificate(&cert),
server.WithAllowedOrigins(conf.AllowedOrigins...),
server.WithUpperLayerDir(conf.HTTP.CustomDir),
server.WithAddress(serverAddress),
server.WithTLSAddress(serverTLSAddress),
server.WithTLSCertificate(cert),
)
if err := server.Start(); err != nil {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

View File

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

View File

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

View File

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

View File

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

View File

@ -1,118 +0,0 @@
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",
},
}
}

View File

@ -1,28 +0,0 @@
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
}

View File

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

View File

@ -1,100 +0,0 @@
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
}

View File

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

View File

@ -13,33 +13,16 @@ 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 := s.upgrader.Upgrade(w, r, nil)
c, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Print("upgrade:", err)
return
@ -58,13 +41,9 @@ func (s *Server) handleBroadcast(w http.ResponseWriter, r *http.Request) {
for {
messageType, message, err := c.ReadMessage()
if err != nil && !websocket.IsCloseError(err, 1001) {
if err != nil {
logger.Error(ctx, "could not read message", logger.E(errors.WithStack(err)))
return
}
if messageType == -1 {
return
break
}
logger.Debug(ctx, "broadcasting message", logger.F("message", message), logger.F("messageType", messageType))

View File

@ -1,64 +0,0 @@
{{ 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 }}

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

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

View File

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

View File

@ -4,6 +4,7 @@ import (
"context"
"crypto/tls"
"fmt"
"html/template"
"net"
"net/http"
"strconv"
@ -13,17 +14,39 @@ 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()
allowedOrigins, err := s.getAllowedOrigins()
if err != nil {
return errors.WithStack(err)
}
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)
}
if len(allowedOrigins) > 0 {
router.Use(cors.Handler(cors.Options{
AllowedOrigins: allowedOrigins,
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
@ -32,6 +55,7 @@ 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)
@ -44,9 +68,6 @@ 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)
}
@ -157,23 +178,31 @@ func (s *Server) startHTTPSServer(ctx context.Context, router chi.Router) error
return nil
}
func (s *Server) getAllowedOrigins() ([]string, error) {
allowedOrigins := make([]string, 0)
if s.appsEnabled {
ips, err := network.GetLANIPv4Addrs()
if err != nil {
return nil, errors.WithStack(err)
}
for _, ip := range ips {
allowedOrigins = append(allowedOrigins, fmt.Sprintf("http://%s:%d", ip, s.port))
}
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
type templateData struct {
IPs []string
Port int
TLSPort int
ID string
Apps bool
}
if len(s.allowedOrigins) > 0 {
allowedOrigins = append(allowedOrigins, s.allowedOrigins...)
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
}
return allowedOrigins, nil
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)))
}
}

View File

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

View File

@ -3,10 +3,8 @@ 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"
)
@ -25,27 +23,16 @@ type Server struct {
serviceDiscoveryEnabled bool
appsEnabled bool
defaultApp string
allowedOrigins []string
apps []App
appsEnabled bool
defaultApp string
apps []App
ctx context.Context
cancel context.CancelFunc
upgrader websocket.Upgrader
layeredFS fs.FS
upperLayerDir string
ctx context.Context
cancel context.CancelFunc
}
func (s *Server) Start() error {
ctx := context.Background()
if err := s.initLayeredFS(); err != nil {
return errors.WithStack(err)
}
serverCtx, cancelServer := context.WithCancel(ctx)
serverCtx, cancelServer := context.WithCancel(context.Background())
s.cancel = cancelServer
s.ctx = serverCtx
@ -91,23 +78,15 @@ func (s *Server) Wait() error {
func New(browser browser.Browser, funcs ...OptionFunc) *Server {
opts := NewOptions(funcs...)
server := &Server{
return &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
}

View File

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