feat: initial commit

This commit is contained in:
wpetit 2023-12-13 20:07:22 +01:00
commit 5d0311b731
79 changed files with 3143 additions and 0 deletions

1
.env.dist Normal file
View File

@ -0,0 +1 @@
ARCAST_DESKTOP_ADDITIONAL_CHROME_ARGS=

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
/bin
/dist
/.env
/tools

60
Makefile Normal file
View File

@ -0,0 +1,60 @@
LINT_ARGS ?= --timeout 5m
GORELEASER_VERSION ?= v1.13.1
GORELEASER_ARGS ?= release --snapshot --rm-dist
GITCHLOG_ARGS ?=
SHELL := /bin/bash
JDK_PATH ?= /usr/lib/jvm/java-11-openjdk
GOTEST_ARGS ?= -short
watch: tools/modd/bin/modd deps ## Watching updated files - live reload
( set -o allexport && source .env && set +o allexport && tools/modd/bin/modd )
.PHONY: test
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-mobile build-client ## Build artefacts
build-desktop: deps ## Build executable
CGO_ENABLED=0 go build \
-v \
-o ./bin/desktop \
./cmd/desktop
build-client: deps ## Build executable
CGO_ENABLED=0 go build \
-v \
-o ./bin/client \
./cmd/client
build-mobile: 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
( cd android && ./gradlew assembleDebug )
install-android: build-mobile
adb install android/app/build/outputs/apk/debug/app-debug.apk
adb shell monkey -p com.cadoles.arcast_player -c android.intent.category.LAUNCHER 1
debug-android:
adb logcat -s 'com.cadoles.arcast_player:* GoLog:* s.arcast_player:* com.cadoles.forge.mobile:*'
.env:
cp -f .env.dist .env
run: .env
( set -o allexport && source .env && set +o allexport && $(RUN_CMD))
.PHONY: deps
deps: .env
tools/modd/bin/modd:
mkdir -p tools/modd/bin
GOBIN=$(PWD)/tools/modd/bin go install github.com/cortesi/modd/cmd/modd@latest
tools/gogio/bin/gogio:
mkdir -p tools/gogio/bin
GOBIN=$(PWD)/tools/gogio/bin go install gioui.org/cmd/gogio@latest

1
NOTES.md Normal file
View File

@ -0,0 +1 @@
- https://github.com/raspi-alpine/builder

10
android/.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
*.iml
.gradle
/local.properties
/.idea
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties

1
android/app/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

69
android/app/build.gradle Normal file
View File

@ -0,0 +1,69 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
}
android {
namespace 'com.cadoles.arcast_player'
compileSdk 34
defaultConfig {
applicationId "com.cadoles.arcast_player"
minSdk 24
targetSdk 34
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary true
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion '1.5.1'
}
packaging {
resources {
excludes += '/META-INF/{AL2.0,LGPL2.1}'
}
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.10.1'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.1'
implementation 'androidx.activity:activity-compose:1.7.0'
implementation platform('androidx.compose:compose-bom:2023.08.00')
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.ui:ui-graphics'
implementation 'androidx.compose.ui:ui-tooling-preview'
implementation 'androidx.compose.material3:material3'
implementation files('libs/sys_android.jar')
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation platform('androidx.compose:compose-bom:2023.08.00')
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
debugImplementation 'androidx.compose.ui:ui-tooling'
debugImplementation 'androidx.compose.ui:ui-test-manifest'
implementation fileTree(include: ['*.aar'], dir: 'libs')
implementation fileTree(include: ['*.jar'], dir: 'libs')
}

BIN
android/app/libs/mobile.aar Normal file

Binary file not shown.

Binary file not shown.

21
android/app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:targetSandboxVersion="1">
<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"/>
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Arcastplayer"
android:usesCleartextTraffic="true"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.Arcastplayer"
android:keepScreenOn="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name="org.gioui.GioActivity"
android:theme="@style/Theme.GioApp"
android:configChanges="orientation|keyboardHidden"
android:windowSoftInputMode="adjustResize"
android:keepScreenOn="true">
</activity>
</application>
</manifest>

View File

@ -0,0 +1,38 @@
package com.cadoles.arcast_player
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.cadoles.arcast_player.ui.theme.ArcastplayerTheme
import org.gioui.GioActivity
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
startActivity(Intent(this, GioActivity::class.java))
}
}
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Text(
text = "Hello $name!",
modifier = modifier
)
}
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
ArcastplayerTheme {
Greeting("Android")
}
}

View File

@ -0,0 +1,11 @@
package com.cadoles.arcast_player.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)

View File

@ -0,0 +1,70 @@
package com.cadoles.arcast_player.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
)
@Composable
fun ArcastplayerTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.primary.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
}
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}

View File

@ -0,0 +1,34 @@
package com.cadoles.arcast_player.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)

View File

@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View File

@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View File

@ -0,0 +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" />
</adaptive-icon>

View File

@ -0,0 +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" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View File

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

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Arcastplayer" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older that API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>

5
android/build.gradle Normal file
View File

@ -0,0 +1,5 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id 'com.android.application' version '8.2.0' apply false
id 'org.jetbrains.kotlin.android' version '1.9.0' apply false
}

23
android/gradle.properties Normal file
View File

@ -0,0 +1,23 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true

Binary file not shown.

View File

@ -0,0 +1,6 @@
#Tue Dec 19 13:23:21 CET 2023
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

185
android/gradlew vendored Executable file
View File

@ -0,0 +1,185 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"

89
android/gradlew.bat vendored Normal file
View File

@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

17
android/settings.gradle Normal file
View File

@ -0,0 +1,17 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "arcast-player"
include ':app'

14
cmd/client/main.go Normal file
View File

@ -0,0 +1,14 @@
package main
import (
"forge.cadoles.com/arcad/arcast/internal/command"
"forge.cadoles.com/arcad/arcast/internal/command/client"
)
func main() {
command.Main(
"arcast",
"Arcast cli client",
client.Root().Subcommands...,
)
}

14
cmd/desktop/main.go Normal file
View File

@ -0,0 +1,14 @@
package main
import (
"forge.cadoles.com/arcad/arcast/internal/command"
"forge.cadoles.com/arcad/arcast/internal/command/player"
)
func main() {
command.Main(
"arcast",
"Arcast desktop player",
player.Root(),
)
}

81
cmd/mobile/main.go Normal file
View File

@ -0,0 +1,81 @@
//go:build android
// +build android
package main
import (
"context"
"os"
"forge.cadoles.com/arcad/arcast/pkg/browser/gioui"
"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/webviewer/webview"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
// Android permissions
_ "net"
_ "gioui.org/app/permission/networkstate"
_ "gioui.org/app/permission/storage"
_ "gioui.org/app/permission/wakelock"
)
func main() {
ctx := context.Background()
webview.SetDebug(true)
window := app.NewWindow(
app.Fullscreen.Option(),
)
browser := gioui.NewBrowser(window)
go func() {
ops := new(op.Ops)
for {
evt := window.NextEvent()
plugin.Install(window, evt)
switch evt := evt.(type) {
case system.DestroyEvent:
os.Exit(0)
return
case system.FrameEvent:
gtx := layout.NewContext(ops, evt)
browser.Layout(gtx)
evt.Frame(gtx.Ops)
}
}
}()
go func() {
for {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
server := server.New(browser)
if err := server.Start(); err != nil {
logger.Fatal(ctx, "could not start server", logger.CapturedE(errors.WithStack(err)))
}
defer func() {
if err := server.Stop(); err != nil {
logger.Error(ctx, "could not stop server", logger.CapturedE(errors.WithStack(err)))
}
}()
if err := server.Wait(); err != nil {
logger.Error(ctx, "could not wait for server", logger.CapturedE(errors.WithStack(err)))
}
}
}()
app.Main()
}

17
doc/http-api.md Normal file
View File

@ -0,0 +1,17 @@
# HTTP API
## Authentication
## Endpoints
### `POST /api/v1/cast`
### `DELETE /api/v1/cast`
### `GET /api/v1/status`
## Payloads
### `CastRequest`
### `StatusResponse`

7
doc/mdns.md Normal file
View File

@ -0,0 +1,7 @@
## mDNS
Un serveur Arcast s'annonce sur le réseau local via le protocole [`mDNS`](https://en.wikipedia.org/wiki/Multicast_DNS).
Le service annoncé est `_arcast._http._tcp`.
À titre de test la commande `mdns-scan` peut être utilisée pour identifier si des serveurs sont présents sur le réseau local.

62
go.mod Normal file
View File

@ -0,0 +1,62 @@
module forge.cadoles.com/arcad/arcast
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/grandcat/zeroconf v1.0.1-0.20230119201135-e4f60f8407b1
github.com/pkg/errors v0.9.1
github.com/zserge/lorca v0.1.10
)
require (
cdr.dev/slog v1.6.1 // indirect
gioui.org/cpu v0.0.0-20220412190645-f1e9e8c3b1f7 // indirect
gioui.org/shader v1.0.8 // 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
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
github.com/go-playground/locales v0.14.0 // indirect
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
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/miekg/dns v1.1.57 // indirect
github.com/muesli/reflow v0.3.0 // indirect
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
golang.org/x/crypto v0.16.0 // indirect
golang.org/x/exp v0.0.0-20221012211006-4de253d81b95 // indirect
golang.org/x/exp/shiny v0.0.0-20220921164117-439092de6870 // indirect
golang.org/x/image v0.5.0 // indirect
golang.org/x/mod v0.14.0 // indirect
golang.org/x/term v0.15.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/tools v0.16.1 // indirect
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
gopkg.in/go-playground/validator.v9 v9.29.1 // indirect
)
require (
github.com/go-chi/chi/v5 v5.0.10
github.com/urfave/cli/v2 v2.26.0
gitlab.com/wpetit/goweb v0.0.0-20231215190137-4a8add1d3d07
golang.org/x/net v0.19.0 // indirect
golang.org/x/sys v0.15.0 // indirect
)

193
go.sum Normal file
View File

@ -0,0 +1,193 @@
cdr.dev/slog v1.6.1 h1:IQjWZD0x6//sfv5n+qEhbu3wBkmtBQY5DILXNvMaIv4=
cdr.dev/slog v1.6.1/go.mod h1:eHEYQLaZvxnIAXC+XdTSNLb/kgA/X2RVSF72v5wsxEI=
cloud.google.com/go/compute v1.23.0 h1:tP41Zoavr8ptEqaW6j+LQOnyBBhO7OkOMAGrgLopTwY=
cloud.google.com/go/compute v1.23.0/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM=
cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
cloud.google.com/go/logging v1.7.0 h1:CJYxlNNNNAMkHp9em/YEXcfJg+rPDg7YfwoRpMU+t5I=
cloud.google.com/go/logging v1.7.0/go.mod h1:3xjP2CjkM3ZkO73aj4ASA5wRPGGCRrPIAeNqVNkzY8M=
cloud.google.com/go/longrunning v0.5.1 h1:Fr7TXftcqTudoyRJa113hyaqlGdiBQkp0Gq7tErFDWI=
cloud.google.com/go/longrunning v0.5.1/go.mod h1:spvimkwdz6SPWKEt/XBij79E9fiTkHSQl/fRUUQJYJc=
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY=
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA=
gioui.org v0.4.1 h1:sCTw5Fexg0xg9CxYmbrkKtHXcobf0JMbl5XpF2TC/zc=
gioui.org v0.4.1/go.mod h1:2atiYR4upH71/6ehnh6XsUELa7JZOrOHHNMDxGBZF0Q=
gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
gioui.org/cpu v0.0.0-20220412190645-f1e9e8c3b1f7 h1:tNJdnP5CgM39PRc+KWmBRRYX/zJ+rd5XaYxY5d5veqA=
gioui.org/cpu v0.0.0-20220412190645-f1e9e8c3b1f7/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
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/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=
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E=
github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c=
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
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/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-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=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU=
github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho=
github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
github.com/go-text/typesetting v0.0.0-20230803102845-24e03d8b5372 h1:FQivqchis6bE2/9uF70M2gmmLpe82esEm2QadL0TEJo=
github.com/go-text/typesetting v0.0.0-20230803102845-24e03d8b5372/go.mod h1:evDBbvNR/KaVFZ2ZlDSOWWXIUKq0wCOEtzLxRM8SG3k=
github.com/go-text/typesetting-utils v0.0.0-20230616150549-2a7df14b6a22 h1:LBQTFxP2MfsyEDqSKmUBZaDuDHN1vpqDyOZjcqS7MYI=
github.com/go-text/typesetting-utils v0.0.0-20230616150549-2a7df14b6a22/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o=
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/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=
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI=
github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM=
github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/profile v1.6.0/go.mod h1:qBsxPvzyUincmltOk6iyRVxHYg4adc0OFOv72ZdLa18=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.4/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/urfave/cli/v2 v2.26.0 h1:3f3AMg3HpThFNT4I++TKOejZO8yU55t3JnnSr4S4QEI=
github.com/urfave/cli/v2 v2.26.0/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
github.com/wlynxg/anet v0.0.1 h1:VbkEEgHxPSrRQSiyRd0pmrbcEQAEU2TTb8fb4DmSYoQ=
github.com/wlynxg/anet v0.0.1/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
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=
go.opentelemetry.io/otel v1.21.0/go.mod h1:QZzNPQPm1zLX4gZK4cMi+71eaorMSGT3A4znnUvNNEo=
go.opentelemetry.io/otel/metric v1.21.0 h1:tlYWfeo+Bocx5kLEloTjbcDwBuELRrIFxwdQ36PlJu4=
go.opentelemetry.io/otel/metric v1.21.0/go.mod h1:o1p3CA8nNHW8j5yuQLdc1eeqEaPfzug24uvsyIEJRWM=
go.opentelemetry.io/otel/sdk v1.16.0 h1:Z1Ok1YsijYL0CSJpHt4cS3wDDh7p572grzNrBMiMWgE=
go.opentelemetry.io/otel/sdk v1.16.0/go.mod h1:tMsIuKXuuIWPBAOrH+eHtvhTL+SntFtXF9QD68aP6p4=
go.opentelemetry.io/otel/trace v1.21.0 h1:WD9i5gzvoUPuXIXH24ZNBudiarZDKuekPqi/E8fpfLc=
go.opentelemetry.io/otel/trace v1.21.0/go.mod h1:LGbsEB0f9LGjN+OZaQQ26sohbOmiMR+BaslueVtS/qQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY=
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/exp v0.0.0-20221012211006-4de253d81b95 h1:sBdrWpxhGDdTAYNqbgBLAR+ULAPPhfgncLr1X0lyWtg=
golang.org/x/exp v0.0.0-20221012211006-4de253d81b95/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/exp/shiny v0.0.0-20220921164117-439092de6870 h1:GjCs9zNN8fojJskeK7QiiVecCaMk0dfGTyL6IUcmp0o=
golang.org/x/exp/shiny v0.0.0-20220921164117-439092de6870/go.mod h1:VjAR7z0ngyATZTELrBSkxOOHhhlnVUxDye4mcjx5h/8=
golang.org/x/image v0.5.0 h1:5JMiNunQeQw++mMOz48/ISeNu3Iweh/JaZU8ZLqHRrI=
golang.org/x/image v0.5.0/go.mod h1:FVC7BI/5Ym8R25iw5OLsgshdUBbT1h5jZTpA+mvAdZ4=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210423184538-5f58ad60dda6/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
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=
golang.org/x/sys v0.0.0-20210426080607-c94f62235c83/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4=
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.16.1 h1:TLyB3WofjdOEepBHAU20JdNC1Zbg87elYofWYAY5oZA=
golang.org/x/tools v0.16.1/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
google.golang.org/genproto v0.0.0-20230726155614-23370e0ffb3e h1:xIXmWJ303kJCuogpj0bHq+dcjcZHU+XFyc1I0Yl9cRg=
google.golang.org/genproto v0.0.0-20230726155614-23370e0ffb3e/go.mod h1:0ggbjUrZYpy1q+ANUS30SEoGZ53cdfwtbuG7Ptgy108=
google.golang.org/genproto/googleapis/api v0.0.0-20230706204954-ccb25ca9f130 h1:XVeBY8d/FaK4848myy41HBqnDwvxeV3zMZhwN1TvAMU=
google.golang.org/genproto/googleapis/api v0.0.0-20230706204954-ccb25ca9f130/go.mod h1:mPBs5jNgx2GuQGvFwUvVKqtn6HsUw9nP64BedgvqEsQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20230706204954-ccb25ca9f130 h1:2FZP5XuJY9zQyGM5N0rtovnoXjiMUEIUMvw0m9wlpLc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20230706204954-ccb25ca9f130/go.mod h1:8mL13HKkDa+IuJ8yruA3ci0q+0vsUz4m//+ottjwS5o=
google.golang.org/grpc v1.57.0 h1:kfzNeI/klCGD2YPMUlaGNT3pxvYfga7smW3Vth8Zsiw=
google.golang.org/grpc v1.57.0/go.mod h1:Sd+9RMTACXwmub0zcNY2c4arhtrbBYD1AUHI/dt16Mo=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM=
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
gopkg.in/go-playground/validator.v9 v9.29.1 h1:SvGtYmN60a5CVKTOzMSyfzWDeZRxRuGvRQyEAKbw1xc=
gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -0,0 +1,59 @@
package client
import (
"os"
"forge.cadoles.com/arcad/arcast/internal/command/client/flag"
"forge.cadoles.com/arcad/arcast/pkg/client"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
"gitlab.com/wpetit/goweb/cli/format"
)
func Cast() *cli.Command {
return &cli.Command{
Name: "cast",
ArgsUsage: "<url>",
Flags: flag.ComposeFlags(
playerFlags...,
),
Action: func(ctx *cli.Context) error {
baseFlags := flag.GetBaseFlags(ctx)
url := ctx.Args().First()
if url == "" {
return errors.New("you must specify an url to cast")
}
statuses := make([]wrappedStatus, 0)
err := forEachPlayer(ctx, func(cl *client.Client, playerAddr string) error {
status, err := cl.Cast(ctx.Context, playerAddr, url)
if err != nil {
return errors.Wrap(err, "could not cast")
}
statuses = append(statuses, wrappedStatus{
Status: status.Status,
URL: status.URL,
Title: status.Title,
ID: status.ID,
Address: playerAddr,
})
return nil
})
if err != nil {
return errors.WithStack(err)
}
hints := wrappedStatusHints(baseFlags.OutputMode)
if err := format.Write(baseFlags.Format, os.Stdout, hints, AsAnySlice(statuses...)...); err != nil {
return errors.WithStack(err)
}
return nil
},
}
}

View File

@ -0,0 +1,92 @@
package flag
import (
"fmt"
"os"
"strings"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
"gitlab.com/wpetit/goweb/cli/format"
_ "gitlab.com/wpetit/goweb/cli/format/json"
"gitlab.com/wpetit/goweb/cli/format/table"
)
func ComposeFlags(flags ...cli.Flag) []cli.Flag {
baseFlags := []cli.Flag{
&cli.StringFlag{
Name: "format",
Aliases: []string{"f"},
Usage: fmt.Sprintf("use `FORMAT` as output format (available: %s)", format.Available()),
Value: string(table.Format),
},
&cli.StringFlag{
Name: "mode",
Aliases: []string{"m"},
Usage: fmt.Sprintf("use `MODE` as output mode (available: %s)", []format.OutputMode{format.OutputModeCompact, format.OutputModeWide}),
Value: string(format.OutputModeCompact),
},
&cli.StringFlag{
Name: "token",
Aliases: []string{"t"},
EnvVars: []string{`ARCAST_TOKEN`},
Usage: "use `TOKEN` as authentication token",
},
&cli.StringFlag{
Name: "token-file",
EnvVars: []string{`ARCAST_TOKEN_FILE`},
Usage: "use `TOKEN_FILE` as file containing the authentication token",
Value: ".bouncer-token",
TakesFile: true,
},
}
flags = append(flags, baseFlags...)
return flags
}
type BaseFlags struct {
ServerURL string
Format format.Format
OutputMode format.OutputMode
Token string
TokenFile string
}
func GetBaseFlags(ctx *cli.Context) *BaseFlags {
serverURL := ctx.String("server")
rawFormat := ctx.String("format")
rawOutputMode := ctx.String("mode")
tokenFile := ctx.String("token-file")
token := ctx.String("token")
return &BaseFlags{
ServerURL: serverURL,
Format: format.Format(rawFormat),
OutputMode: format.OutputMode(rawOutputMode),
Token: token,
TokenFile: tokenFile,
}
}
func GetToken(flags *BaseFlags) (string, error) {
if flags.Token != "" {
return flags.Token, nil
}
if flags.TokenFile == "" {
return "", nil
}
rawToken, err := os.ReadFile(flags.TokenFile)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return "", errors.WithStack(err)
}
if rawToken == nil {
return "", nil
}
return strings.TrimSpace(string(rawToken)), nil
}

View File

@ -0,0 +1,37 @@
package client
import (
"gitlab.com/wpetit/goweb/cli/format"
)
func playerHints(outputMode format.OutputMode) format.Hints {
return format.Hints{
OutputMode: outputMode,
Props: []format.Prop{
format.NewProp("ID", "ID"),
format.NewProp("IPs", "IPs"),
format.NewProp("Port", "Port"),
},
}
}
type wrappedStatus struct {
ID string `json:"id"`
Status string `json:"status"`
URL string `json:"url"`
Title string `json:"title"`
Address string `json:"address"`
}
func wrappedStatusHints(outputMode format.OutputMode) format.Hints {
return format.Hints{
OutputMode: outputMode,
Props: []format.Prop{
format.NewProp("ID", "ID"),
format.NewProp("Address", "Address"),
format.NewProp("Status", "Status"),
format.NewProp("Title", "Title"),
format.NewProp("URL", "URL"),
},
}
}

View File

@ -0,0 +1,53 @@
package client
import (
"os"
"forge.cadoles.com/arcad/arcast/internal/command/client/flag"
"forge.cadoles.com/arcad/arcast/pkg/client"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
"gitlab.com/wpetit/goweb/cli/format"
)
func Reset() *cli.Command {
return &cli.Command{
Name: "reset",
Flags: flag.ComposeFlags(
playerFlags...,
),
Action: func(ctx *cli.Context) error {
baseFlags := flag.GetBaseFlags(ctx)
statuses := make([]wrappedStatus, 0)
err := forEachPlayer(ctx, func(cl *client.Client, playerAddr string) error {
status, err := cl.Reset(ctx.Context, playerAddr)
if err != nil {
return errors.Wrap(err, "could not cast")
}
statuses = append(statuses, wrappedStatus{
Status: status.Status,
URL: status.URL,
Title: status.Title,
ID: status.ID,
Address: playerAddr,
})
return nil
})
if err != nil {
return errors.WithStack(err)
}
hints := wrappedStatusHints(baseFlags.OutputMode)
if err := format.Write(baseFlags.Format, os.Stdout, hints, AsAnySlice(statuses...)...); err != nil {
return errors.WithStack(err)
}
return nil
},
}
}

View File

@ -0,0 +1,15 @@
package client
import "github.com/urfave/cli/v2"
func Root() *cli.Command {
return &cli.Command{
Name: "client",
Subcommands: []*cli.Command{
Scan(),
Cast(),
Reset(),
Status(),
},
}
}

View File

@ -0,0 +1,49 @@
package client
import (
"context"
"os"
"time"
"forge.cadoles.com/arcad/arcast/internal/command/client/flag"
"forge.cadoles.com/arcad/arcast/pkg/client"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
"gitlab.com/wpetit/goweb/cli/format"
)
func Scan() *cli.Command {
return &cli.Command{
Name: "scan",
Flags: flag.ComposeFlags(
&cli.DurationFlag{
Name: "timeout",
EnvVars: []string{"ARCAST_CLIENT_SCAN_TIMEOUT"},
Usage: "Use `TIMEOUT` as maximum scan duration",
Value: 5 * time.Second,
},
),
Action: func(ctx *cli.Context) error {
baseFlags := flag.GetBaseFlags(ctx)
timeout := ctx.Duration("timeout")
client := client.New()
scanCtx, cancel := context.WithTimeout(ctx.Context, timeout)
defer cancel()
players, err := client.Scan(scanCtx)
if err != nil && (!errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded)) {
return errors.Wrap(err, "could not scan for players")
}
hints := playerHints(baseFlags.OutputMode)
if err := format.Write(baseFlags.Format, os.Stdout, hints, AsAnySlice(players...)...); err != nil {
return errors.WithStack(err)
}
return nil
},
}
}

View File

@ -0,0 +1,53 @@
package client
import (
"os"
"forge.cadoles.com/arcad/arcast/internal/command/client/flag"
"forge.cadoles.com/arcad/arcast/pkg/client"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
"gitlab.com/wpetit/goweb/cli/format"
)
func Status() *cli.Command {
return &cli.Command{
Name: "status",
Flags: flag.ComposeFlags(
playerFlags...,
),
Action: func(ctx *cli.Context) error {
baseFlags := flag.GetBaseFlags(ctx)
statuses := make([]wrappedStatus, 0)
err := forEachPlayer(ctx, func(cl *client.Client, playerAddr string) error {
status, err := cl.Status(ctx.Context, playerAddr)
if err != nil {
return errors.Wrap(err, "could not cast")
}
statuses = append(statuses, wrappedStatus{
Status: status.Status,
URL: status.URL,
Title: status.Title,
ID: status.ID,
Address: playerAddr,
})
return nil
})
if err != nil {
return errors.WithStack(err)
}
hints := wrappedStatusHints(baseFlags.OutputMode)
if err := format.Write(baseFlags.Format, os.Stdout, hints, AsAnySlice(statuses...)...); err != nil {
return errors.WithStack(err)
}
return nil
},
}
}

View File

@ -0,0 +1,91 @@
package client
import (
"context"
"fmt"
"time"
"forge.cadoles.com/arcad/arcast/pkg/client"
"forge.cadoles.com/arcad/arcast/pkg/server"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
)
var (
playerScanTimeoutFlag = "scan-timeout"
flagPlayerAddr = "player-address"
flagPlayerID = "player-id"
playerFlags = []cli.Flag{
&cli.DurationFlag{
Name: playerScanTimeoutFlag,
EnvVars: []string{"ARCAST_CLIENT_CAST_TIMEOUT"},
Usage: "Use `TIMEOUT` as maximum cast duration",
Value: 5 * time.Second,
},
&cli.StringSliceFlag{
Name: flagPlayerID,
Aliases: []string{"i"},
EnvVars: []string{"ARCAST_CLIENT_PLAYER_ID"},
Usage: "Use `ID` as player id",
Value: cli.NewStringSlice(),
},
&cli.StringSliceFlag{
Name: flagPlayerAddr,
Aliases: []string{"a"},
EnvVars: []string{"ARCAST_CLIENT_PLAYER_ADDR"},
Usage: "Use `ADDR` as player address",
Value: cli.NewStringSlice(),
},
}
)
func forEachPlayer(ctx *cli.Context, fn func(cl *client.Client, playerAddr string) error) error {
playerAddrs := ctx.StringSlice(flagPlayerAddr)
cl := client.New()
if ctx.IsSet(flagPlayerID) || !ctx.IsSet(flagPlayerAddr) {
scanTimeout := ctx.Duration(playerScanTimeoutFlag)
scanCtx, cancel := context.WithTimeout(ctx.Context, scanTimeout)
defer cancel()
playerIDs := ctx.StringSlice(flagPlayerID)
players, err := cl.Scan(scanCtx, client.WithPlayerIDs(playerIDs...))
if err != nil && (!errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded)) {
return errors.Wrap(err, "could not scan for players")
}
for _, p := range players {
preferredIP, err := server.FindPreferredLocalAddress(p.IPs...)
if err != nil {
return errors.Errorf("could not retrieve player '%s' preferred address", p.ID)
}
playerAddr := fmt.Sprintf("%s:%d", preferredIP, p.Port)
playerAddrs = append(playerAddrs, playerAddr)
}
}
if len(playerAddrs) == 0 {
return errors.New("no players found")
}
for _, addr := range playerAddrs {
if err := fn(cl, addr); err != nil {
return errors.WithStack(err)
}
}
return nil
}
func AsAnySlice[T any](src ...T) []any {
dst := make([]any, len(src))
for i, s := range src {
dst[i] = s
}
return dst
}

84
internal/command/main.go Normal file
View File

@ -0,0 +1,84 @@
package command
import (
"fmt"
"os"
"sort"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
"gitlab.com/wpetit/goweb/logger"
)
func Main(name string, usage string, commands ...*cli.Command) {
app := &cli.App{
Name: name,
Usage: usage,
Commands: commands,
Before: func(ctx *cli.Context) error {
workdir := ctx.String("workdir")
// Switch to new working directory if defined
if workdir != "" {
if err := os.Chdir(workdir); err != nil {
return errors.Wrap(err, "could not change working directory")
}
}
logLevel := ctx.String("log-level")
switch logLevel {
case "debug":
logger.SetLevel(logger.LevelDebug)
case "info":
logger.SetLevel(logger.LevelInfo)
case "warn":
logger.SetLevel(logger.LevelWarn)
case "error":
logger.SetLevel(logger.LevelError)
case "critical":
logger.SetLevel(logger.LevelCritical)
}
return nil
},
Flags: []cli.Flag{
&cli.StringFlag{
Name: "workdir",
Value: "",
EnvVars: []string{"ARCAST_WORKDIR"},
Usage: "The working directory",
},
&cli.BoolFlag{
Name: "debug",
EnvVars: []string{"ARCAST_DEBUG"},
Usage: "Enable debug mode",
},
&cli.StringFlag{
Name: "log-level",
EnvVars: []string{"ARCAST_LOG_LEVEL"},
Usage: "Set logging level",
Value: "info",
},
},
}
app.ExitErrHandler = func(ctx *cli.Context, err error) {
if err == nil {
return
}
debug := ctx.Bool("debug")
if !debug {
fmt.Printf("[ERROR] %+v\n", err)
} else {
fmt.Printf("%+v", err)
}
}
sort.Sort(cli.FlagsByName(app.Flags))
sort.Sort(cli.CommandsByName(app.Commands))
if err := app.Run(os.Args); err != nil {
os.Exit(1)
}
}

View File

@ -0,0 +1,12 @@
package player
import "github.com/urfave/cli/v2"
func Root() *cli.Command {
return &cli.Command{
Name: "player",
Subcommands: []*cli.Command{
Run(),
},
}
}

View File

@ -0,0 +1,87 @@
package player
import (
"fmt"
"os"
"forge.cadoles.com/arcad/arcast/pkg/browser/lorca"
"forge.cadoles.com/arcad/arcast/pkg/server"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
"gitlab.com/wpetit/goweb/logger"
)
func Run() *cli.Command {
defaults := lorca.NewOptions()
return &cli.Command{
Name: "run",
Flags: []cli.Flag{
&cli.StringSliceFlag{
Name: "additional-chrome-arg",
EnvVars: []string{"ARCAST_DESKTOP_ADDITIONAL_CHROME_ARGS"},
Value: cli.NewStringSlice("incognito"),
},
&cli.IntFlag{
Name: "window-height",
Value: defaults.Height,
},
&cli.IntFlag{
Name: "window-width",
Value: defaults.Width,
},
},
Action: func(ctx *cli.Context) error {
windowHeight := ctx.Int("window-height")
windowWidth := ctx.Int("window-width")
chromeArgs := addFlagsPrefix(ctx.StringSlice("additional-chrome-arg")...)
browser := lorca.NewBrowser(
lorca.WithAdditionalChromeArgs(chromeArgs...),
lorca.WithWindowSize(windowWidth, windowHeight),
)
if err := browser.Start(); err != nil {
return errors.Wrap(err, "could not start browser")
}
go func() {
browser.Wait()
logger.Warn(ctx.Context, "browser was closed")
os.Exit(1)
}()
defer func() {
logger.Info(ctx.Context, "stopping browser")
if err := browser.Stop(); err != nil {
logger.Error(ctx.Context, "could not stop browser", logger.CapturedE(errors.WithStack(err)))
}
}()
server := server.New(browser)
if err := server.Start(); err != nil {
return errors.Wrap(err, "could not start server")
}
defer func() {
if err := server.Stop(); err != nil {
logger.Error(ctx.Context, "could not stop server", logger.CapturedE(errors.WithStack(err)))
}
}()
if err := server.Wait(); err != nil {
return errors.Wrap(err, "could not wait for server")
}
return nil
},
}
}
func addFlagsPrefix(stripped ...string) []string {
flags := make([]string, len(stripped))
for i, s := range stripped {
flags[i] = fmt.Sprintf("--%s", s)
}
return flags
}

8
modd.conf Normal file
View File

@ -0,0 +1,8 @@
**/*.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 player run"
}

35
pkg/browser/browser.go Normal file
View File

@ -0,0 +1,35 @@
package browser
import "fmt"
type Status int
const (
StatusUnknown Status = iota
StatusIdle
StatusCasting
)
func (s Status) String() string {
switch s {
case StatusIdle:
return "idle"
case StatusCasting:
return "casting"
default:
return fmt.Sprintf("unknown (%d)", s)
}
}
type Browser interface {
// Cast loads an URL
Load(url string) error
// Reset resets the browser to the given idle URL
Reset(url string) error
// Status returns the browser's current status
Status() (Status, error)
// Title returns the browser's currently loaded page title
Title() (string, error)
// URL returns the browser's currently loaded page URL
URL() (string, error)
}

View File

@ -0,0 +1,52 @@
package dummy
import (
"context"
"forge.cadoles.com/arcad/arcast/pkg/browser"
"gitlab.com/wpetit/goweb/logger"
)
type Browser struct {
status browser.Status
url string
}
// Load implements browser.Browser.
func (b *Browser) Load(url string) error {
logger.Debug(context.Background(), "loading url", logger.F("url", url))
b.status = browser.StatusCasting
b.url = url
return nil
}
// Status implements browser.Browser.
func (b *Browser) Status() (browser.Status, error) {
return b.status, nil
}
// Title implements browser.Browser.
func (b *Browser) Title() (string, error) {
return "", nil
}
// URL implements browser.Browser.
func (b *Browser) URL() (string, error) {
return b.url, nil
}
// Reset implements browser.Browser.
func (b *Browser) Reset(url string) error {
b.status = browser.StatusIdle
b.url = url
return nil
}
func NewBrowser() *Browser {
return &Browser{
status: browser.StatusIdle,
url: "",
}
}
var _ browser.Browser = &Browser{}

View File

@ -0,0 +1,118 @@
package gioui
import (
"context"
"sync"
"forge.cadoles.com/arcad/arcast/pkg/browser"
"gioui.org/app"
"gioui.org/f32"
"gioui.org/layout"
"github.com/gioui-plugins/gio-plugins/webviewer"
"gitlab.com/wpetit/goweb/logger"
)
type Browser struct {
window *app.Window
tag int
url string
changed bool
status browser.Status
title string
mutex sync.Mutex
}
func (b *Browser) Layout(gtx layout.Context) {
b.mutex.Lock()
defer b.mutex.Unlock()
events := gtx.Events(&b.tag)
for _, evt := range events {
switch ev := evt.(type) {
case webviewer.TitleEvent:
b.title = ev.Title
case webviewer.NavigationEvent:
b.url = ev.URL
}
}
ctx := context.Background()
logger.Debug(ctx, "drawing")
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)
if b.changed {
logger.Debug(ctx, "url changed", logger.F("url", b.url))
webviewer.NavigateOp{URL: b.url}.Add(gtx.Ops)
b.changed = false
}
}
// 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.window.Invalidate()
return nil
}
// Status implements browser.Browser.
func (b *Browser) Status() (browser.Status, error) {
b.mutex.Lock()
defer b.mutex.Unlock()
return b.status, nil
}
// Title implements browser.Browser.
func (b *Browser) Title() (string, error) {
b.mutex.Lock()
defer b.mutex.Unlock()
return b.title, nil
}
// URL implements browser.Browser.
func (b *Browser) URL() (string, error) {
b.mutex.Lock()
defer b.mutex.Unlock()
return b.url, 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.window.Invalidate()
return nil
}
func NewBrowser(window *app.Window) *Browser {
return &Browser{
window: window,
url: "",
changed: true,
status: browser.StatusIdle,
}
}
var _ browser.Browser = &Browser{}

View File

@ -0,0 +1,100 @@
package lorca
import (
"context"
"forge.cadoles.com/arcad/arcast/pkg/browser"
"github.com/pkg/errors"
"github.com/zserge/lorca"
"gitlab.com/wpetit/goweb/logger"
)
type Browser struct {
ui lorca.UI
status browser.Status
opts *Options
}
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...)
if err != nil {
return errors.WithStack(err)
}
b.ui = ui
return nil
}
func (b *Browser) Stop() error {
if err := b.ui.Close(); err != nil {
return errors.WithStack(err)
}
b.ui = nil
return nil
}
func (b *Browser) Wait() {
<-b.ui.Done()
}
// Load implements browser.Browser.
func (b *Browser) Load(url string) error {
if err := b.ui.Load(url); err != nil {
return errors.WithStack(err)
}
b.status = browser.StatusCasting
return nil
}
// Unload implements browser.Browser.
func (b *Browser) Reset(url string) error {
if err := b.ui.Load(url); err != nil {
return errors.WithStack(err)
}
b.status = browser.StatusIdle
return nil
}
// Status implements browser.Browser.
func (b *Browser) Status() (browser.Status, error) {
return b.status, nil
}
// Title implements browser.Browser.
func (b *Browser) Title() (string, error) {
result := b.ui.Eval("document.title.toString()")
if err := result.Err(); err != nil {
return "", errors.WithStack(err)
}
return result.String(), nil
}
// URL implements browser.Browser.
func (b *Browser) URL() (string, error) {
result := b.ui.Eval("window.location.toString()")
if err := result.Err(); err != nil {
return "", errors.WithStack(err)
}
return result.String(), nil
}
func NewBrowser(funcs ...OptionsFunc) *Browser {
opts := NewOptions(funcs...)
return &Browser{
status: browser.StatusIdle,
opts: opts,
}
}
var _ browser.Browser = &Browser{}

View File

@ -0,0 +1,40 @@
package lorca
type Options struct {
Width int
Height int
ChromeArgs []string
}
type OptionsFunc func(opts *Options)
var DefaultChromeArgs = []string{
"--remote-allow-origins=*",
}
func NewOptions(funcs ...OptionsFunc) *Options {
opts := &Options{
Width: 800,
Height: 600,
ChromeArgs: DefaultChromeArgs,
}
for _, fn := range funcs {
fn(opts)
}
return opts
}
func WithWindowSize(width, height int) OptionsFunc {
return func(opts *Options) {
opts.Width = width
opts.Height = height
}
}
func WithAdditionalChromeArgs(args ...string) OptionsFunc {
return func(opts *Options) {
opts.ChromeArgs = append(args, DefaultChromeArgs...)
}
}

81
pkg/client/api.go Normal file
View File

@ -0,0 +1,81 @@
package client
import (
"bytes"
"context"
"encoding/json"
"net/http"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
func (c *Client) apiGet(ctx context.Context, url string, result any, funcs ...HTTPOptionFunc) error {
if err := c.apiDo(ctx, http.MethodGet, url, nil, result, funcs...); err != nil {
return errors.WithStack(err)
}
return nil
}
func (c *Client) apiPost(ctx context.Context, url string, payload any, result any, funcs ...HTTPOptionFunc) error {
if err := c.apiDo(ctx, http.MethodPost, url, payload, result, funcs...); err != nil {
return errors.WithStack(err)
}
return nil
}
func (c *Client) apiDelete(ctx context.Context, url string, payload any, result any, funcs ...HTTPOptionFunc) error {
if err := c.apiDo(ctx, http.MethodDelete, url, payload, result, funcs...); err != nil {
return errors.WithStack(err)
}
return nil
}
func (c *Client) apiDo(ctx context.Context, method string, url string, payload any, response any, funcs ...HTTPOptionFunc) error {
opts := NewHTTPOptions(funcs...)
logger.Debug(
ctx, "new http request",
logger.F("method", method),
logger.F("url", url),
logger.F("payload", payload),
)
var buf bytes.Buffer
encoder := json.NewEncoder(&buf)
if err := encoder.Encode(payload); err != nil {
return errors.WithStack(err)
}
req, err := http.NewRequest(method, url, &buf)
if err != nil {
return errors.WithStack(err)
}
for key, values := range opts.Headers {
for _, v := range values {
req.Header.Add(key, v)
}
}
res, err := c.http.Do(req)
if err != nil {
return errors.WithStack(err)
}
defer res.Body.Close()
decoder := json.NewDecoder(res.Body)
if err := decoder.Decode(&api.Response{Data: &response}); err != nil {
return errors.WithStack(err)
}
return nil
}

27
pkg/client/cast.go Normal file
View File

@ -0,0 +1,27 @@
package client
import (
"context"
"fmt"
"forge.cadoles.com/arcad/arcast/pkg/server"
"github.com/pkg/errors"
)
type Status = server.StatusResponse
func (c *Client) Cast(ctx context.Context, addr string, url string) (*Status, error) {
endpoint := fmt.Sprintf("http://%s/api/v1/cast", addr)
req := &server.CastRequest{
URL: url,
}
res := &server.StatusResponse{}
if err := c.apiPost(ctx, endpoint, &req, &res); err != nil {
return nil, errors.WithStack(err)
}
return res, nil
}

15
pkg/client/client.go Normal file
View File

@ -0,0 +1,15 @@
package client
import (
"net/http"
)
type Client struct {
http *http.Client
}
func New() *Client {
return &Client{
http: &http.Client{},
}
}

43
pkg/client/options.go Normal file
View File

@ -0,0 +1,43 @@
package client
import "net/http"
type HTTPOptions struct {
Headers http.Header
}
type HTTPOptionFunc func(opts *HTTPOptions)
func NewHTTPOptions(funcs ...HTTPOptionFunc) *HTTPOptions {
opts := &HTTPOptions{}
for _, fn := range funcs {
fn(opts)
}
return opts
}
type ScanOptions struct {
PlayerIDs []string
}
func NewScanOptions(funcs ...ScanOptionFunc) *ScanOptions {
opts := &ScanOptions{
PlayerIDs: make([]string, 0),
}
for _, fn := range funcs {
fn(opts)
}
return opts
}
type ScanOptionFunc func(opts *ScanOptions)
func WithPlayerIDs(ids ...string) ScanOptionFunc {
return func(opts *ScanOptions) {
opts.PlayerIDs = ids
}
}

11
pkg/client/player.go Normal file
View File

@ -0,0 +1,11 @@
package client
import (
"net"
)
type Player struct {
ID string
IPs []net.IP
Port int
}

21
pkg/client/reset.go Normal file
View File

@ -0,0 +1,21 @@
package client
import (
"context"
"fmt"
"forge.cadoles.com/arcad/arcast/pkg/server"
"github.com/pkg/errors"
)
func (c *Client) Reset(ctx context.Context, addr string) (*Status, error) {
endpoint := fmt.Sprintf("http://%s/api/v1/cast", addr)
res := &server.StatusResponse{}
if err := c.apiDelete(ctx, endpoint, nil, &res); err != nil {
return nil, errors.WithStack(err)
}
return res, nil
}

69
pkg/client/scan.go Normal file
View File

@ -0,0 +1,69 @@
package client
import (
"context"
"slices"
"forge.cadoles.com/arcad/arcast/pkg/server"
"github.com/grandcat/zeroconf"
"github.com/pkg/errors"
)
func (c *Client) Scan(ctx context.Context, funcs ...ScanOptionFunc) ([]*Player, error) {
opts := NewScanOptions(funcs...)
resolver, err := zeroconf.NewResolver(nil)
if err != nil {
return nil, errors.WithStack(err)
}
done := make(chan struct{})
players := make([]*Player, 0)
entries := make(chan *zeroconf.ServiceEntry)
go func(results <-chan *zeroconf.ServiceEntry) {
defer close(done)
for entry := range results {
addPlayer := func() {
players = append(players, &Player{
ID: entry.Instance,
IPs: entry.AddrIPv4,
Port: entry.Port,
})
}
searchedLen := len(opts.PlayerIDs)
if searchedLen == 0 {
addPlayer()
continue
}
if slices.Contains(opts.PlayerIDs, entry.Instance) {
addPlayer()
}
if searchedLen == len(players) {
break
}
}
done <- struct{}{}
}(entries)
err = resolver.Browse(ctx, server.MDNSService, server.MDNSDomain, entries)
if err != nil {
return nil, errors.WithStack(err)
}
select {
case <-done:
return players, nil
case <-ctx.Done():
if err := ctx.Err(); err != nil {
return players, errors.WithStack(err)
}
return players, nil
}
}

21
pkg/client/status.go Normal file
View File

@ -0,0 +1,21 @@
package client
import (
"context"
"fmt"
"forge.cadoles.com/arcad/arcast/pkg/server"
"github.com/pkg/errors"
)
func (c *Client) Status(ctx context.Context, addr string) (*Status, error) {
endpoint := fmt.Sprintf("http://%s/api/v1/status", addr)
res := &server.StatusResponse{}
if err := c.apiGet(ctx, endpoint, res); err != nil {
return nil, errors.WithStack(err)
}
return res, nil
}

191
pkg/server/http.go Normal file
View File

@ -0,0 +1,191 @@
package server
import (
"context"
"fmt"
"html/template"
"net"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
_ "embed"
)
var (
//go:embed templates/idle.html.gotmpl
rawIdleTemplate []byte
idleTemplate *template.Template
)
func init() {
tmpl, err := template.New("").Parse(string(rawIdleTemplate))
if err != nil {
panic(errors.Wrap(err, "could not parse idle template"))
}
idleTemplate = tmpl
}
func (s *Server) startHTTPServer(ctx context.Context) error {
router := chi.NewRouter()
router.Get("/", s.handleHome)
router.Post("/api/v1/cast", s.handleCast)
router.Delete("/api/v1/cast", s.handleReset)
router.Get("/api/v1/status", s.handleStatus)
server := http.Server{
Addr: s.address,
Handler: router,
}
listener, err := net.Listen("tcp", s.address)
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 tcp connections", logger.F("port", port), logger.F("host", host))
s.port = int(port)
go func() {
logger.Debug(ctx, "starting http server")
if err := server.Serve(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 http server")
if err := server.Close(); err != nil {
logger.Error(ctx, "could not close http server", logger.CapturedE(errors.WithStack(err)))
}
}()
if err := s.resetBrowser(); err != nil {
return errors.WithStack(err)
}
return nil
}
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,
})
}
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
type templateData struct {
IPs []string
Port int
ID string
}
ips, err := getLANIPv4Addrs()
if err != nil {
logger.Error(r.Context(), "could not retrieve lan ip addresses", logger.CapturedE(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
d := templateData{
ID: s.instanceID,
IPs: ips,
Port: s.port,
}
if err := idleTemplate.Execute(w, d); err != nil {
logger.Error(r.Context(), "could not render idle page", logger.CapturedE(errors.WithStack(err)))
}
}

43
pkg/server/mdns.go Normal file
View File

@ -0,0 +1,43 @@
package server
import (
"context"
"github.com/grandcat/zeroconf"
"github.com/pkg/errors"
"github.com/wlynxg/anet"
"gitlab.com/wpetit/goweb/logger"
)
const (
MDNSService = "_arcast._http._tcp"
MDNSDomain = "local."
)
func (s *Server) startMDNServer(ctx context.Context) error {
logger.Debug(ctx, "starting mdns server")
ifaces, err := anet.Interfaces()
if err != nil {
return errors.WithStack(err)
}
ips, err := getLANIPv4Addrs()
if err != nil {
return errors.WithStack(err)
}
logger.Debug(ctx, "advertising ips", logger.F("ips", ips))
server, err := zeroconf.RegisterProxy(s.instanceID, MDNSService, MDNSDomain, s.port, s.instanceID, ips, []string{}, ifaces)
if err != nil {
return errors.WithStack(err)
}
go func() {
<-ctx.Done()
logger.Debug(ctx, "closing mdns server")
server.Shutdown()
}()
return nil
}

60
pkg/server/network.go Normal file
View File

@ -0,0 +1,60 @@
package server
import (
"net"
"github.com/pkg/errors"
"github.com/wlynxg/anet"
)
var (
_, lanA, _ = net.ParseCIDR("10.0.0.0/8")
_, lanB, _ = net.ParseCIDR("172.16.0.0/12")
_, lanC, _ = net.ParseCIDR("192.168.0.0/16")
)
func getLANIPv4Addrs() ([]string, error) {
ips := make([]string, 0)
addrs, err := anet.InterfaceAddrs()
if err != nil {
return nil, errors.WithStack(err)
}
for _, address := range addrs {
if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
ipv4 := ipnet.IP.To4()
if ipv4 == nil {
continue
}
isLAN := lanA.Contains(ipv4) || lanB.Contains(ipv4) || lanC.Contains(ipv4)
if !isLAN {
continue
}
ips = append(ips, ipv4.String())
}
}
return ips, nil
}
func FindPreferredLocalAddress(ips ...net.IP) (string, error) {
localAddrs, err := anet.InterfaceAddrs()
if err != nil {
return "", errors.WithStack(err)
}
for _, addr := range localAddrs {
if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
for _, ip := range ips {
if ipnet.Contains(ip) {
return ip.String(), nil
}
}
}
}
return ips[0].String(), nil
}

61
pkg/server/options.go Normal file
View File

@ -0,0 +1,61 @@
package server
import (
"github.com/jaevor/go-nanoid"
"github.com/pkg/errors"
)
var newRandomInstanceID func() string
func init() {
generator, err := nanoid.Standard(21)
if err != nil {
panic(errors.Wrap(err, "could not generate random instance id"))
}
newRandomInstanceID = generator
}
type Options struct {
InstanceID string
Address string
DisableServiceDiscovery bool
}
type OptionFunc func(opts *Options)
func NewOptions(funcs ...OptionFunc) *Options {
opts := &Options{
InstanceID: NewRandomInstanceID(),
Address: ":",
DisableServiceDiscovery: false,
}
for _, fn := range funcs {
fn(opts)
}
return opts
}
func WithAddress(addr string) OptionFunc {
return func(opts *Options) {
opts.Address = addr
}
}
func WithInstanceID(id string) OptionFunc {
return func(opts *Options) {
opts.InstanceID = id
}
}
func WithServiceDiscoveryDisabled(disabled bool) OptionFunc {
return func(opts *Options) {
opts.DisableServiceDiscovery = disabled
}
}
func NewRandomInstanceID() string {
return newRandomInstanceID()
}

76
pkg/server/server.go Normal file
View File

@ -0,0 +1,76 @@
package server
import (
"context"
"forge.cadoles.com/arcad/arcast/pkg/browser"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
type Server struct {
browser browser.Browser
instanceID string
address string
port int
disableServiceDiscovery bool
ctx context.Context
cancel context.CancelFunc
}
func (s *Server) Start() error {
serverCtx, cancelServer := context.WithCancel(context.Background())
s.cancel = cancelServer
s.ctx = serverCtx
httpServerCtx, cancelHTTPServer := context.WithCancel(serverCtx)
if err := s.startHTTPServer(httpServerCtx); err != nil {
cancelHTTPServer()
return errors.WithStack(err)
}
if !s.disableServiceDiscovery {
mdnsServerCtx, cancelMDNSServer := context.WithCancel(serverCtx)
if err := s.startMDNServer(mdnsServerCtx); err != nil {
cancelHTTPServer()
cancelMDNSServer()
return errors.WithStack(err)
}
} else {
logger.Info(serverCtx, "service discovery disabled")
}
return nil
}
func (s *Server) Stop() error {
if s.cancel != nil {
s.cancel()
}
return nil
}
func (s *Server) Wait() error {
<-s.ctx.Done()
if err := s.ctx.Err(); err != nil {
return errors.WithStack(err)
}
return nil
}
func New(browser browser.Browser, funcs ...OptionFunc) *Server {
opts := NewOptions(funcs...)
return &Server{
browser: browser,
instanceID: opts.InstanceID,
address: opts.Address,
disableServiceDiscovery: opts.DisableServiceDiscovery,
}
}

View File

@ -0,0 +1,101 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Arcast - Idle</title>
<style>
html {
box-sizing: border-box;
font-size: 16px;
width: 100%;
height: 100%;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
width: 100%;
height: 100%;
background-color: #e1e1e1;
}
*,
*:before,
*:after {
box-sizing: inherit;
}
ol,
ul {
list-style: none;
}
body,
h1,
h2,
h3,
h4,
h5,
h6,
p,
ol,
ul {
margin: 0;
padding: 0;
font-weight: normal;
}
.container {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.panel {
display: block;
background-color: #fff;
border-radius: 5px;
padding: 10px 20px;
box-shadow: 2px 2px #3333331d;
}
.panel p, .panel ul {
margin-top: 10px;
}
.text-centered {
text-align: center;
}
.text-italic {
font-style: italic;
}
.text-small {
font-size: 0.8em;
}
</style>
</head>
<body>
<div class="container">
<div class="panel">
<h1>Arcast</h1>
<p>Instance ID:</p>
<p class="text-centered text-small">
<code>{{ .ID }}</code>
</p>
<p>Addresses:</p>
<ul class="text-italic text-small">
{{ $port := .Port }}
{{range .IPs}}
<li><code>{{ . }}:{{ $port }}</code></li>
{{end}}
</ul>
</div>
</div>
</body>
</html>