Compare commits

..

21 次程式碼提交

作者 SHA1 備註 提交日期
35585959f5 feat(android): do not use sync.Mutex
Some checks reported warnings
arcad/arcast/pipeline/head This commit is unstable
2024-04-26 09:35:16 +02:00
0fcf0b6cc0 fix: typo
Some checks reported warnings
arcad/arcast/pipeline/head This commit is unstable
2024-04-25 16:06:06 +02:00
a7d1f37682 ci: fix mktools installation
Some checks reported warnings
arcad/arcast/pipeline/head This commit is unstable
2024-04-25 15:56:41 +02:00
74268493d6 feat: rewrite app system
Some checks reported warnings
arcad/arcast/pipeline/head This commit is unstable
2024-04-25 15:23:09 +02:00
7b8165a0ec doc: add documentation link in readme + configuration reference
Some checks reported warnings
arcad/arcast/pipeline/head This commit is unstable
2024-04-24 12:13:28 +02:00
654134e2d0 chore: force stop android app before restart 2024-04-24 11:58:10 +02:00
8bd32adc51 ci: sign android apk with keystore
Some checks reported warnings
arcad/arcast/pipeline/head This commit is unstable
2024-04-24 11:41:58 +02:00
f35b279515 chore: uninstall android app before install 2024-04-24 10:50:44 +02:00
2306a08844 chore: add mxq tvstick cleanup script 2024-04-24 10:50:16 +02:00
fa1ed6ea42 feat: use persistent configuration file 2024-04-24 10:49:47 +02:00
071d597f3f doc: fix link name
Some checks reported warnings
arcad/arcast/pipeline/head This commit is unstable
2024-04-23 16:14:37 +02:00
90572efeba feat: more coloured ui 2024-04-23 16:08:31 +02:00
dc8f0e577b chore: add logo 2024-04-23 15:16:09 +02:00
1c06382cec chore: add jenkins pipeline
Some checks reported warnings
arcad/arcast/pipeline/head This commit is unstable
2024-04-23 10:50:42 +02:00
378e0fae3e chore: formatting 2024-04-23 09:59:29 +02:00
a20c6f5ff3 fix(android): ignore safedata store absence 2024-04-23 09:42:02 +02:00
7ba540f56f feat: update android icons 2024-04-23 09:41:14 +02:00
8d46ff7ff8 feat: embed optional apps in player server 2024-04-22 17:06:19 +02:00
acd71c84f6 doc: fix typo 2024-01-21 15:06:34 +01:00
5b08439651 feat(desktop): build and release linux arm/arm64 versions 2024-01-21 15:04:48 +01:00
a1804d3a2e feat: use github/bornholm/lorca fork 2024-01-21 14:59:19 +01:00
共有 102 個文件被更改,包括 21164 次插入350 次删除

查看文件

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

3
.gitignore vendored
查看文件

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

5
.vscode/settings.json vendored Normal file
查看文件

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

65
Jenkinsfile vendored Normal file
查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

Binary file not shown.

After

Width:  |  Height:  |  大小: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  大小: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  大小: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  大小: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  大小: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  大小: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  大小: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  大小: 982 B

Binary file not shown.

After

Width:  |  Height:  |  大小: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  大小: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  大小: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  大小: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  大小: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  大小: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  大小: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  大小: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  大小: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  大小: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  大小: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  大小: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  大小: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  大小: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  大小: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  大小: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  大小: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  大小: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  大小: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  大小: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  大小: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  大小: 7.6 KiB

查看文件

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

56
apps.go Normal file
查看文件

@ -0,0 +1,56 @@
package arcast
import (
"embed"
"encoding/json"
"io/fs"
"path/filepath"
"forge.cadoles.com/arcad/arcast/pkg/server"
"github.com/pkg/errors"
)
var (
DefaultApps []server.App
//go:embed apps/main/build/**
appsFS embed.FS
)
func init() {
defaultApps, err := loadApps(
"apps/main/build",
)
if err != nil {
panic(errors.WithStack(err))
}
DefaultApps = defaultApps
}
func loadApps(appDirs ...string) ([]server.App, error) {
apps := make([]server.App, 0)
for _, dir := range appDirs {
rawManifest, err := fs.ReadFile(appsFS, filepath.Join(dir, "arcast-app.json"))
if err != nil {
return nil, errors.WithStack(err)
}
var app server.App
if err := json.Unmarshal(rawManifest, &app); err != nil {
return nil, errors.WithStack(err)
}
fs, err := fs.Sub(appsFS, dir)
if err != nil {
return nil, errors.WithStack(err)
}
app.FS = fs
apps = append(apps, app)
}
return apps, nil
}

5
apps/main/.eslintrc Normal file
查看文件

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

23
apps/main/.gitignore vendored Normal file
查看文件

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

12
apps/main/Makefile Normal file
查看文件

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

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

文件差異過大導致無法顯示 Load Diff

50
apps/main/package.json Normal file
查看文件

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

查看文件

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

二進制
apps/main/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  大小: 3.8 KiB

查看文件

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

二進制
apps/main/public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  大小: 5.2 KiB

二進制
apps/main/public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  大小: 9.4 KiB

查看文件

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

查看文件

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

0
apps/main/src/App.css Normal file
查看文件

36
apps/main/src/App.tsx Normal file
查看文件

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

164
apps/main/src/api/arcast.ts Normal file
查看文件

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

275
apps/main/src/api/webrtc.ts Normal file
查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

105
apps/main/src/index.css Normal file
查看文件

@ -0,0 +1,105 @@
html {
box-sizing: border-box;
font-size: 16px;
width: 100%;
height: 100%;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans",
"Helvetica Neue", sans-serif;
width: 100%;
height: 100%;
background: rgb(76, 96, 188);
background: linear-gradient(
415deg,
rgba(4, 168, 243, 1),
rgb(76, 136, 188, 1),
rgba(76, 96, 188, 1),
rgb(115, 76, 188, 1),
rgb(87, 76, 188, 1)
);
background-size: 400% 400%;
animation: gradient 15s ease infinite;
}
@keyframes gradient {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
#root {
height: 100%;
}
*,
*:before,
*:after {
box-sizing: inherit;
}
ol,
ul {
list-style: none;
}
body,
h1,
h2,
h3,
h4,
h5,
h6,
p,
ol,
ul {
margin: 0;
padding: 0;
font-weight: normal;
}
.container {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.panel {
display: block;
background-color: #fff;
border-radius: 5px;
padding: 10px 20px;
box-shadow: 2px 2px #3333331d;
}
.panel p, .panel ul {
margin-top: 10px;
}
.text-centered {
text-align: center;
}
.text-italic {
font-style: italic;
}
.text-small {
font-size: 0.8em;
}
.fullwidth {
width: 100%;
}

18
apps/main/src/index.tsx Normal file
查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

1
apps/main/src/react-app-env.d.ts vendored Normal file
查看文件

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

查看文件

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

26
apps/main/tsconfig.json Normal file
查看文件

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

查看文件

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

5
doc/README.md Normal file
查看文件

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

50
doc/configuration.md Normal file
查看文件

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

15
go.mod
查看文件

@ -4,12 +4,13 @@ go 1.21.4
require (
gioui.org v0.4.1
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0
github.com/davecgh/go-spew v1.1.1
github.com/gioui-plugins/gio-plugins v0.0.0-20230625001848-8f18aae6c91c
github.com/google/uuid v1.3.0
github.com/gioui-plugins/gio-plugins v0.0.0-20240323070753-3331d8c2df5d
github.com/go-chi/cors v1.2.1
github.com/gorilla/websocket v1.5.1
github.com/grandcat/zeroconf v1.0.1-0.20230119201135-e4f60f8407b1
github.com/jaevor/go-nanoid v1.3.0
github.com/pkg/errors v0.9.1
github.com/wlynxg/anet v0.0.1
github.com/zserge/lorca v0.1.10
)
@ -17,6 +18,7 @@ require (
cdr.dev/slog v1.6.1 // indirect
gioui.org/cpu v0.0.0-20220412190645-f1e9e8c3b1f7 // indirect
gioui.org/shader v1.0.8 // indirect
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
github.com/charmbracelet/lipgloss v0.7.1 // indirect
@ -25,8 +27,6 @@ require (
github.com/go-playground/universal-translator v0.18.0 // indirect
github.com/go-text/typesetting v0.0.0-20230803102845-24e03d8b5372 // indirect
github.com/inkeliz/go_inkwasm v0.0.0-20220912074516-049d3472c98a // indirect
github.com/jaevor/go-nanoid v1.3.0 // 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
@ -37,7 +37,6 @@ require (
github.com/muesli/termenv v0.15.2 // indirect
github.com/rivo/uniseg v0.4.4 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/wlynxg/anet v0.0.1 // indirect
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
go.opentelemetry.io/otel v1.21.0 // indirect
go.opentelemetry.io/otel/trace v1.21.0 // indirect
@ -60,3 +59,5 @@ require (
golang.org/x/net v0.19.0 // indirect
golang.org/x/sys v0.15.0 // indirect
)
replace github.com/zserge/lorca => github.com/Bornholm/lorca v0.0.0-20240121134933-d5e83569cb4c

17
go.sum
查看文件

@ -19,6 +19,8 @@ gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 h1:bGG/g4ypjrCJoSvFrP5hafr9PPB5aw8SjcOWWila7ZI=
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0/go.mod h1:+axXBRUTIDlCeE73IKeD/os7LoEnTKdkp8/gQOFjqyo=
github.com/Bornholm/lorca v0.0.0-20240121134933-d5e83569cb4c h1:xG9xSpEqZYOvd1Bvx/36i3RBTA17d7XnuvoMIG+/ry8=
github.com/Bornholm/lorca v0.0.0-20240121134933-d5e83569cb4c/go.mod h1:TUOtEogaMwy0b+p9e4NxZC1jbPRUaNaEw5DnjQF//F8=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
@ -30,10 +32,12 @@ github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46t
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gioui-plugins/gio-plugins v0.0.0-20230625001848-8f18aae6c91c h1:naFDaf0CvDEYZ3Zpxx20DY/cCvBQqKwsV7ZzBt3M/bU=
github.com/gioui-plugins/gio-plugins v0.0.0-20230625001848-8f18aae6c91c/go.mod h1:nBuRsi6udr2x6eorarLHtRkoRaWBICt+WzaE7zQXgYY=
github.com/gioui-plugins/gio-plugins v0.0.0-20240323070753-3331d8c2df5d h1:8b7owUJ8sNmgqEk+1d7ylr3TCH3vliCvY/6ycfize8o=
github.com/gioui-plugins/gio-plugins v0.0.0-20240323070753-3331d8c2df5d/go.mod h1:3XVleuCdPpdajFL+ASh2wmXZNskitXQQ4jhVss0VHZg=
github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk=
github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY=
github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
@ -50,16 +54,14 @@ github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/grandcat/zeroconf v1.0.1-0.20230119201135-e4f60f8407b1 h1:cNb52t5fkWv8ZiicKWnc2eZnhsCCoH7WmRBMIbMp04Q=
github.com/grandcat/zeroconf v1.0.1-0.20230119201135-e4f60f8407b1/go.mod h1:I6CSXU4zCGL08JOk9NbcT0ofAgnIkS/fVXbYzfSoDic=
github.com/inkeliz/go_inkwasm v0.0.0-20220912074516-049d3472c98a h1:uZklbtdSPrDL/d1EUKd9s8a0Byla2TT01Wg/GZ4xj0w=
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=
@ -104,8 +106,6 @@ github.com/wlynxg/anet v0.0.1/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguH
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zserge/lorca v0.1.10 h1:f/xBJ3D3ipcVRCcvN8XqZnpoKcOXV8I4vwqlFyw7ruc=
github.com/zserge/lorca v0.1.10/go.mod h1:bVmnIbIRlOcoV285KIRSe4bUABKi7R7384Ycuum6e4A=
gitlab.com/wpetit/goweb v0.0.0-20231215190137-4a8add1d3d07 h1:0V95X1cBpdj5zyOe6oGtn/BQHlRpV8WlL3eTs3jaxiA=
gitlab.com/wpetit/goweb v0.0.0-20231215190137-4a8add1d3d07/go.mod h1:Nfr7aZPiSN6biFumhiHbh9k8A3rKQRzR+o0bVtv78UY=
go.opentelemetry.io/otel v1.21.0 h1:hzLeKBZEL7Okw2mGzZ0cc4k/A7Fta0uoPgaJCr8fsFc=
@ -142,7 +142,6 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=

查看文件

@ -6,7 +6,7 @@ import (
"time"
"forge.cadoles.com/arcad/arcast/pkg/client"
"forge.cadoles.com/arcad/arcast/pkg/server"
"forge.cadoles.com/arcad/arcast/pkg/network"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
)
@ -57,7 +57,7 @@ func forEachPlayer(ctx *cli.Context, fn func(cl *client.Client, playerAddr strin
}
for _, p := range players {
preferredIP, err := server.FindPreferredLocalAddress(p.IPs...)
preferredIP, err := network.FindPreferredLocalAddress(p.IPs...)
if err != nil {
return errors.Errorf("could not retrieve player '%s' preferred address", p.ID)
}

查看文件

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

二進制
misc/logo/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  大小: 21 KiB

查看文件

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

查看文件

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

查看文件

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

查看文件

@ -18,7 +18,10 @@ type Browser struct {
func (b *Browser) Start() error {
logger.Debug(context.Background(), "starting browser", logger.F("opts", b.opts))
ui, err := lorca.New("", "", b.opts.Width, b.opts.Height, b.opts.ChromeArgs...)
ui, err := lorca.New(
lorca.WithWindowSize(b.opts.Width, b.opts.Height),
lorca.WithAdditionalCustomArgs(b.opts.ChromeArgs...),
)
if err != nil {
return errors.WithStack(err)
}

查看文件

@ -14,8 +14,8 @@ var DefaultChromeArgs = []string{
func NewOptions(funcs ...OptionsFunc) *Options {
opts := &Options{
Width: 800,
Height: 600,
Width: 0,
Height: 0,
ChromeArgs: DefaultChromeArgs,
}

116
pkg/config/config.go Normal file
查看文件

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

查看文件

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

100
pkg/config/selfsigned.go Normal file
查看文件

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

查看文件

@ -1,4 +1,4 @@
package server
package network
import (
"net"
@ -13,7 +13,7 @@ var (
_, lanC, _ = net.ParseCIDR("192.168.0.0/16")
)
func getLANIPv4Addrs() ([]string, error) {
func GetLANIPv4Addrs() ([]string, error) {
ips := make([]string, 0)
addrs, err := anet.InterfaceAddrs()

129
pkg/selfsigned/cert.go Normal file
查看文件

@ -0,0 +1,129 @@
package selfsigned
import (
"bytes"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net"
"time"
"forge.cadoles.com/arcad/arcast/pkg/network"
"github.com/pkg/errors"
)
func NewLANKeyPair() ([]byte, []byte, error) {
hosts, err := network.GetLANIPv4Addrs()
if err != nil {
return nil, nil, errors.WithStack(err)
}
hosts = append(hosts, "127.0.0.1")
rawCert, rawKey, err := NewCertKeyPair(hosts...)
if err != nil {
return nil, nil, errors.WithStack(err)
}
return rawCert, rawKey, nil
}
func NewLANCert() (*tls.Certificate, error) {
rawCert, rawKey, err := NewLANKeyPair()
if err != nil {
return nil, errors.WithStack(err)
}
cert, err := tls.X509KeyPair(rawCert, rawKey)
if err != nil {
return nil, errors.WithStack(err)
}
return &cert, nil
}
func NewCertKeyPair(hosts ...string) ([]byte, []byte, error) {
priv, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
if err != nil {
return nil, nil, errors.WithStack(err)
}
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil {
return nil, nil, errors.WithStack(err)
}
keyUsage := x509.KeyUsageDigitalSignature
notBefore := time.Now()
notAfter := notBefore.Add(365 * 24 * time.Hour)
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
Organization: []string{"Arcast Org"},
},
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: keyUsage,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
for _, h := range hosts {
if ip := net.ParseIP(h); ip != nil {
template.IPAddresses = append(template.IPAddresses, ip)
} else {
template.DNSNames = append(template.DNSNames, h)
}
}
template.IsCA = true
template.KeyUsage |= x509.KeyUsageCertSign
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, publicKey(priv), priv)
if err != nil {
return nil, nil, errors.WithStack(err)
}
var cert bytes.Buffer
if err := pem.Encode(&cert, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil {
return nil, nil, errors.WithStack(err)
}
var key bytes.Buffer
privBytes, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
return nil, nil, errors.WithStack(err)
}
if err := pem.Encode(&key, &pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}); err != nil {
return nil, nil, errors.WithStack(err)
}
return cert.Bytes(), key.Bytes(), nil
}
func publicKey(priv any) any {
switch k := priv.(type) {
case *rsa.PrivateKey:
return &k.PublicKey
case *ecdsa.PrivateKey:
return &k.PublicKey
case ed25519.PrivateKey:
return k.Public().(ed25519.PublicKey)
default:
return nil
}
}

118
pkg/server/api.go Normal file
查看文件

@ -0,0 +1,118 @@
package server
import (
"fmt"
"net/http"
"forge.cadoles.com/arcad/arcast/pkg/network"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
type InfoResponse struct {
IPs []string `json:"ips"`
Port int `json:"port"`
TLSPort int `json:"tlsPort"`
InstanceID string `json:"instanceId"`
AppsEnabled bool `json:"appsEnabled"`
ServiceDiscoveryEnabled bool `json:"serviceDiscoveryEnabled"`
}
func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
ips, err := network.GetLANIPv4Addrs()
if err != nil {
logger.Error(r.Context(), "could not retrieve lan ip addresses", logger.CapturedE(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
api.DataResponse(w, http.StatusOK, &InfoResponse{
IPs: ips,
TLSPort: s.tlsPort,
Port: s.port,
InstanceID: s.instanceID,
AppsEnabled: s.appsEnabled,
ServiceDiscoveryEnabled: s.serviceDiscoveryEnabled,
})
}
type CastRequest struct {
URL string `json:"url" validate:"required"`
}
func (s *Server) handleCast(w http.ResponseWriter, r *http.Request) {
req := &CastRequest{}
if ok := api.Bind(w, r, req); !ok {
return
}
if err := s.browser.Load(req.URL); err != nil {
logger.Error(r.Context(), "could not load url", logger.CapturedE(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
}
http.Redirect(w, r, "/api/v1/status", http.StatusSeeOther)
}
func (s *Server) handleReset(w http.ResponseWriter, r *http.Request) {
if err := s.resetBrowser(); err != nil {
logger.Error(r.Context(), "could not unload url", logger.CapturedE(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
}
http.Redirect(w, r, "/api/v1/status", http.StatusSeeOther)
}
func (s *Server) resetBrowser() error {
idleURL := fmt.Sprintf("http://localhost:%d", s.port)
if err := s.browser.Reset(idleURL); err != nil {
return errors.WithStack(err)
}
return nil
}
type StatusResponse struct {
ID string `json:"id"`
URL string `json:"url"`
Status string `json:"status"`
Title string `json:"title"`
}
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
url, err := s.browser.URL()
if err != nil {
logger.Error(r.Context(), "could not retrieve browser url", logger.CapturedE(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
}
status, err := s.browser.Status()
if err != nil {
logger.Error(r.Context(), "could not retrieve browser status", logger.CapturedE(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
}
title, err := s.browser.Title()
if err != nil {
logger.Error(r.Context(), "could not retrieve browser page title", logger.CapturedE(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, &StatusResponse{
ID: s.instanceID,
URL: url,
Status: status.String(),
Title: title,
})
}

63
pkg/server/apps.go Normal file
查看文件

@ -0,0 +1,63 @@
package server
import (
"io/fs"
"net/http"
"strings"
"sync"
"github.com/go-chi/chi/v5"
"gitlab.com/wpetit/goweb/api"
_ "embed"
)
type App struct {
ID string `json:"id"`
Title map[string]string `json:"title"`
Description map[string]string `json:"description"`
Icon string `json:"icon"`
FS fs.FS `json:"-"`
Hidden bool `json:"hidden"`
}
func (s *Server) handleDefaultApp(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/apps/"+s.defaultApp+"/", http.StatusTemporaryRedirect)
}
func (s *Server) handleApps(w http.ResponseWriter, r *http.Request) {
api.DataResponse(w, http.StatusOK, struct {
DefaultApp string `json:"defaultApp"`
Apps []App `json:"apps"`
}{
DefaultApp: s.defaultApp,
Apps: s.apps,
})
}
var (
indexedAppFilesystems map[string]fs.FS
indexAppsOnce sync.Once
)
func (s *Server) handleAppFilesystem(w http.ResponseWriter, r *http.Request) {
indexAppsOnce.Do(func() {
indexedAppFilesystems = make(map[string]fs.FS, len(s.apps))
for _, app := range s.apps {
indexedAppFilesystems[app.ID] = app.FS
}
})
appID := chi.URLParam(r, "appID")
fs, exists := indexedAppFilesystems[appID]
if !exists {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
name := strings.TrimPrefix(r.URL.Path, "/apps/"+appID)
http.ServeFileFS(w, r, fs, name)
}

127
pkg/server/broadcast.go Normal file
查看文件

@ -0,0 +1,127 @@
package server
import (
"context"
"log"
"net/http"
"sync"
"github.com/go-chi/chi/v5"
"github.com/gorilla/websocket"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
var (
channels = &channelMap{
index: make(map[string]map[*websocket.Conn]struct{}),
}
)
func (s *Server) checkOrigin(r *http.Request) bool {
allowedOrigins, err := s.getAllowedOrigins()
if err != nil {
logger.Error(r.Context(), "could not retrieve allowed origins", logger.CapturedE(errors.WithStack(err)))
return false
}
requestOrigin := r.Header.Get("Origin")
for _, origin := range allowedOrigins {
if requestOrigin == origin || origin == "*" {
return true
}
}
return true
}
func (s *Server) handleBroadcast(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
c, err := s.upgrader.Upgrade(w, r, nil)
if err != nil {
log.Print("upgrade:", err)
return
}
channelID := chi.URLParam(r, "channelID")
channels.Add(channelID, c)
defer func() {
channels.Remove(channelID, c)
if err := c.Close(); err != nil && !websocket.IsCloseError(err, 1001) {
logger.Error(ctx, "could not close connection", logger.E(errors.WithStack(err)))
}
}()
for {
messageType, message, err := c.ReadMessage()
if err != nil && !websocket.IsCloseError(err, 1001) {
logger.Error(ctx, "could not read message", logger.E(errors.WithStack(err)))
return
}
if messageType == -1 {
return
}
logger.Debug(ctx, "broadcasting message", logger.F("message", message), logger.F("messageType", messageType))
channels.Send(ctx, channelID, messageType, message, c)
}
}
type channelMap struct {
mutex sync.RWMutex
index map[string]map[*websocket.Conn]struct{}
}
func (m *channelMap) Remove(channelID string, conn *websocket.Conn) {
m.mutex.Lock()
defer m.mutex.Unlock()
conns, exists := m.index[channelID]
if !exists {
return
}
delete(conns, conn)
if len(conns) == 0 {
delete(m.index, channelID)
}
}
func (m *channelMap) Add(channelID string, conn *websocket.Conn) {
m.mutex.Lock()
defer m.mutex.Unlock()
conns, exists := m.index[channelID]
if !exists {
conns = make(map[*websocket.Conn]struct{})
}
conns[conn] = struct{}{}
m.index[channelID] = conns
}
func (m *channelMap) Send(ctx context.Context, channelID string, messageType int, message []byte, except *websocket.Conn) {
m.mutex.RLock()
defer m.mutex.RUnlock()
conns, exists := m.index[channelID]
if !exists {
return
}
for c := range conns {
if except == c {
continue
}
if err := c.WriteMessage(messageType, message); err != nil {
logger.Error(ctx, "could not write message", logger.E(errors.WithStack(err)))
}
}
}

查看文件

@ -2,15 +2,17 @@ package server
import (
"context"
"crypto/tls"
"fmt"
"html/template"
"net"
"net/http"
"strconv"
"forge.cadoles.com/arcad/arcast/pkg/network"
"github.com/go-chi/chi/v5"
"github.com/go-chi/cors"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
_ "embed"
@ -31,14 +33,56 @@ func init() {
idleTemplate = tmpl
}
func (s *Server) startHTTPServer(ctx context.Context) error {
func (s *Server) startWebServers(ctx context.Context) error {
router := chi.NewRouter()
allowedOrigins, err := s.getAllowedOrigins()
if err != nil {
return errors.WithStack(err)
}
if len(allowedOrigins) > 0 {
router.Use(cors.Handler(cors.Options{
AllowedOrigins: allowedOrigins,
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
AllowCredentials: false,
}))
}
router.Get("/", s.handleHome)
router.Get("/api/v1/info", s.handleInfo)
router.Post("/api/v1/cast", s.handleCast)
router.Delete("/api/v1/cast", s.handleReset)
router.Get("/api/v1/status", s.handleStatus)
if s.appsEnabled {
router.Get("/apps", s.handleDefaultApp)
router.Get("/api/v1/apps", s.handleApps)
router.Handle("/apps/{appID}/*", http.HandlerFunc(s.handleAppFilesystem))
router.Handle("/api/v1/broadcast/{channelID}", http.HandlerFunc(s.handleBroadcast))
}
if err := s.startHTTPServer(ctx, router); err != nil {
return errors.WithStack(err)
}
if s.tlsCert != nil {
if err := s.startHTTPSServer(ctx, router); err != nil {
return errors.WithStack(err)
}
} else {
logger.Info(ctx, "no tls certificate configured, not starting https server")
}
if err := s.resetBrowser(); err != nil {
return errors.WithStack(err)
}
return nil
}
func (s *Server) startHTTPServer(ctx context.Context, router chi.Router) error {
server := http.Server{
Addr: s.address,
Handler: router,
@ -78,101 +122,88 @@ func (s *Server) startHTTPServer(ctx context.Context) error {
}
}()
if err := s.resetBrowser(); err != nil {
return nil
}
func (s *Server) startHTTPSServer(ctx context.Context, router chi.Router) error {
server := http.Server{
Addr: s.address,
Handler: router,
TLSConfig: &tls.Config{
GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
return s.tlsCert, nil
},
},
}
listener, err := net.Listen("tcp", s.tlsAddress)
if err != nil {
return errors.WithStack(err)
}
host, rawPort, err := net.SplitHostPort(listener.Addr().String())
if err != nil {
return errors.WithStack(err)
}
port, err := strconv.ParseInt(rawPort, 10, 32)
if err != nil {
return errors.Wrapf(err, "could not parse listening port '%v'", rawPort)
}
logger.Debug(ctx, "listening for tls tcp connections", logger.F("port", port), logger.F("host", host))
s.tlsPort = int(port)
go func() {
logger.Debug(ctx, "starting https server")
if err := server.ServeTLS(listener, "", ""); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error(ctx, "could not listen", logger.CapturedE(errors.WithStack(err)))
}
}()
go func() {
<-ctx.Done()
logger.Debug(ctx, "closing https server")
if err := server.Close(); err != nil {
logger.Error(ctx, "could not close https server", logger.CapturedE(errors.WithStack(err)))
}
}()
return nil
}
type CastRequest struct {
URL string `json:"url" validate:"required"`
}
func (s *Server) getAllowedOrigins() ([]string, error) {
allowedOrigins := make([]string, 0)
func (s *Server) handleCast(w http.ResponseWriter, r *http.Request) {
req := &CastRequest{}
if ok := api.Bind(w, r, req); !ok {
return
if s.appsEnabled {
ips, err := network.GetLANIPv4Addrs()
if err != nil {
return nil, errors.WithStack(err)
}
for _, ip := range ips {
allowedOrigins = append(allowedOrigins, fmt.Sprintf("http://%s:%d", ip, s.port))
}
}
if err := s.browser.Load(req.URL); err != nil {
logger.Error(r.Context(), "could not load url", logger.CapturedE(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
if len(s.allowedOrigins) > 0 {
allowedOrigins = append(allowedOrigins, s.allowedOrigins...)
}
http.Redirect(w, r, "/api/v1/status", http.StatusSeeOther)
}
func (s *Server) handleReset(w http.ResponseWriter, r *http.Request) {
if err := s.resetBrowser(); err != nil {
logger.Error(r.Context(), "could not unload url", logger.CapturedE(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
}
http.Redirect(w, r, "/api/v1/status", http.StatusSeeOther)
}
func (s *Server) resetBrowser() error {
idleURL := fmt.Sprintf("http://localhost:%d", s.port)
if err := s.browser.Reset(idleURL); err != nil {
return errors.WithStack(err)
}
return nil
}
type StatusResponse struct {
ID string `json:"id"`
URL string `json:"url"`
Status string `json:"status"`
Title string `json:"title"`
}
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
url, err := s.browser.URL()
if err != nil {
logger.Error(r.Context(), "could not retrieve browser url", logger.CapturedE(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
}
status, err := s.browser.Status()
if err != nil {
logger.Error(r.Context(), "could not retrieve browser status", logger.CapturedE(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
}
title, err := s.browser.Title()
if err != nil {
logger.Error(r.Context(), "could not retrieve browser page title", logger.CapturedE(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, &StatusResponse{
ID: s.instanceID,
URL: url,
Status: status.String(),
Title: title,
})
return allowedOrigins, nil
}
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
type templateData struct {
IPs []string
Port int
ID string
IPs []string
Port int
TLSPort int
ID string
Apps bool
}
ips, err := getLANIPv4Addrs()
ips, err := network.GetLANIPv4Addrs()
if err != nil {
logger.Error(r.Context(), "could not retrieve lan ip addresses", logger.CapturedE(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -180,9 +211,11 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
}
d := templateData{
ID: s.instanceID,
IPs: ips,
Port: s.port,
ID: s.instanceID,
IPs: ips,
Port: s.port,
TLSPort: s.tlsPort,
Apps: s.appsEnabled,
}
if err := idleTemplate.Execute(w, d); err != nil {

查看文件

@ -3,6 +3,7 @@ package server
import (
"context"
"forge.cadoles.com/arcad/arcast/pkg/network"
"github.com/grandcat/zeroconf"
"github.com/pkg/errors"
"github.com/wlynxg/anet"
@ -21,7 +22,7 @@ func (s *Server) startMDNServer(ctx context.Context) error {
return errors.WithStack(err)
}
ips, err := getLANIPv4Addrs()
ips, err := network.GetLANIPv4Addrs()
if err != nil {
return errors.WithStack(err)
}

查看文件

@ -1,6 +1,8 @@
package server
import (
"crypto/tls"
"github.com/jaevor/go-nanoid"
"github.com/pkg/errors"
)
@ -17,18 +19,29 @@ func init() {
}
type Options struct {
InstanceID string
Address string
DisableServiceDiscovery bool
InstanceID string
Address string
TLSAddress string
TLSCertificate *tls.Certificate
EnableServiceDiscovery bool
EnableApps bool
DefaultApp string
AllowedOrigins []string
Apps []App
}
type OptionFunc func(opts *Options)
func NewOptions(funcs ...OptionFunc) *Options {
opts := &Options{
InstanceID: NewRandomInstanceID(),
Address: ":",
DisableServiceDiscovery: false,
InstanceID: NewRandomInstanceID(),
Address: ":",
TLSAddress: ":",
EnableServiceDiscovery: true,
EnableApps: false,
DefaultApp: "",
AllowedOrigins: make([]string, 0),
Apps: make([]App, 0),
}
for _, fn := range funcs {
@ -38,21 +51,57 @@ func NewOptions(funcs ...OptionFunc) *Options {
return opts
}
func WithAppsEnabled(enabled bool) OptionFunc {
return func(opts *Options) {
opts.EnableApps = enabled
}
}
func WithDefaultApp(defaultApp string) OptionFunc {
return func(opts *Options) {
opts.DefaultApp = defaultApp
}
}
func WithApps(apps ...App) OptionFunc {
return func(opts *Options) {
opts.Apps = apps
}
}
func WithAllowedOrigins(origins ...string) OptionFunc {
return func(opts *Options) {
opts.AllowedOrigins = origins
}
}
func WithAddress(addr string) OptionFunc {
return func(opts *Options) {
opts.Address = addr
}
}
func WithTLSAddress(addr string) OptionFunc {
return func(opts *Options) {
opts.TLSAddress = addr
}
}
func WithTLSCertificate(cert *tls.Certificate) OptionFunc {
return func(opts *Options) {
opts.TLSCertificate = cert
}
}
func WithInstanceID(id string) OptionFunc {
return func(opts *Options) {
opts.InstanceID = id
}
}
func WithServiceDiscoveryDisabled(disabled bool) OptionFunc {
func WithServiceDiscoveryEnabled(enabled bool) OptionFunc {
return func(opts *Options) {
opts.DisableServiceDiscovery = disabled
opts.EnableServiceDiscovery = enabled
}
}

Some files were not shown because too many files have changed in this diff Show More