janusweb 1.5.56 → 1.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/deploy.yml +71 -0
- package/Dockerfile +14 -0
- package/README.md +258 -19
- package/janusxr.com/.args +2 -0
- package/janusxr.com/.init.lua +30 -0
- package/media/assets/webui/apps/comms/voip.js +27 -25
- package/media/assets/webui/apps/editor/editor.js +16 -0
- package/media/assets/webui/apps/locomotion/locomotion-assets.json +3 -0
- package/media/assets/webui/apps/locomotion/teleport.mp3 +0 -0
- package/media/assets/webui/apps/locomotion/teleporter.js +75 -43
- package/media/assets/webui/apps/navigation/navigation.js +6 -1
- package/media/assets/webui/apps/xrfragment/level0-sidecarfile.js +308 -0
- package/media/assets/webui/apps/xrfragment/level2-hyperlink.js +265 -0
- package/media/assets/webui/apps/xrfragment/level7-engine-prefixes.export.js +93 -0
- package/media/assets/webui/apps/xrfragment/level7-engine-prefixes.import.js +229 -0
- package/media/assets/webui/apps/xrfragment/patch/metaquest-fix-flickering.js +15 -0
- package/media/assets/webui/apps/xrfragment/xrfragment.json +14 -0
- package/media/assets/webui/apps/xrmenu/images/teleport.png +0 -0
- package/media/assets/webui/apps/xrmenu/xrmenu-assets.json +2 -1
- package/media/assets/webui/apps/xrmenu/xrmenu.js +39 -15
- package/media/assets/webui/default.json +1 -0
- package/media/images/portal.svg +130 -0
- package/media/index.html +48 -1
- package/package.json +1 -1
- package/scripts/client.js +24 -0
- package/scripts/config.js +12 -1
- package/scripts/janusbase.js +14 -22
- package/scripts/janusghost.js +2 -2
- package/scripts/janusparagraph.js +195 -12
- package/scripts/janusplayer.js +9 -6
- package/scripts/room.js +91 -44
- package/scripts/sound.js +1 -1
- package/scripts/text.js +2 -1
- package/scripts/translators/paragraph/html-xml-rss.js +47 -0
- package/scripts/translators/peertube.js +89 -0
- package/scripts/translators/xrfragments.js +93 -0
- package/tests/room/paragraph.xml +47 -0
- package/utils/build.sh +1 -0
- package/utils/clean-build.sh +5 -0
- package/utils/dev-link.sh +75 -0
- package/utils/release.binary.sh +36 -0
- package/utils/release.docker.sh +11 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
elation.require([], function() {
|
|
2
|
+
elation.component.add('janusweb.translators.peertube', function() {
|
|
3
|
+
this.init = function() {
|
|
4
|
+
this.description = "peertube website to 3D translator"
|
|
5
|
+
}
|
|
6
|
+
this.exec = function(args) {
|
|
7
|
+
return new Promise(elation.bind(this, function(resolve, reject) {
|
|
8
|
+
|
|
9
|
+
var room = this.room = args.room;
|
|
10
|
+
|
|
11
|
+
reject() // for now
|
|
12
|
+
|
|
13
|
+
this.setupEvents()
|
|
14
|
+
var datapath = elation.config.get('janusweb.datapath', '/media/janusweb');
|
|
15
|
+
|
|
16
|
+
var roomdata = {
|
|
17
|
+
assets: {
|
|
18
|
+
assetlist: [
|
|
19
|
+
// {assettype: 'model', name: 'scene', src: args.url},
|
|
20
|
+
]
|
|
21
|
+
},
|
|
22
|
+
room: {
|
|
23
|
+
pos: [0,0,0],
|
|
24
|
+
xdir: "1 0 0",
|
|
25
|
+
zdir: "0 0 1",
|
|
26
|
+
},
|
|
27
|
+
object: [
|
|
28
|
+
// {id: 'scene', js_id: 0, pos: "0 0 0", xdir: "-1 0 0", zdir: "0 0 -1", lighting: "false"}
|
|
29
|
+
],
|
|
30
|
+
link: []
|
|
31
|
+
};
|
|
32
|
+
resolve(roomdata);
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// translate XR Fragments microformat into JML
|
|
37
|
+
this.parseSource = async function(sourcecode, room){
|
|
38
|
+
this.room = room
|
|
39
|
+
const isJML = /<fireboxroom>[\s\S]*?<\/fireboxroom>/si;
|
|
40
|
+
const isPeertube = /<meta\s+[^>]*property=['"]og:platform['"]\s+[^>]*content=['"]peertube['"][^>]*\/?>/si;
|
|
41
|
+
const isPeertubeVideo = /<meta\s+[^>]*property=['"]og:type['"]\s+[^>]*content=['"]video['"][^>]*\/?>/si;
|
|
42
|
+
const isPeertubeInstance = /<meta\s+[^>]*property=['"]og:type['"]\s+[^>]*content=['"]website['"][^>]*\/?>/si;
|
|
43
|
+
const is = {
|
|
44
|
+
JML: sourcecode.match(isJML),
|
|
45
|
+
peertube: sourcecode.match(isPeertube),
|
|
46
|
+
peertubeVideo: sourcecode.match(isPeertubeVideo),
|
|
47
|
+
peertubeInstance: sourcecode.match(isPeertubeInstance),
|
|
48
|
+
}
|
|
49
|
+
if( is.JML || !is.peertube ) return // JML takes precedence over microformats
|
|
50
|
+
if( !is.peertubeVideo && !is.peertubeInstance ) return
|
|
51
|
+
|
|
52
|
+
// ok..peertube time: get domtree
|
|
53
|
+
let el = document.createElement("div")
|
|
54
|
+
el.innerHTML = sourcecode
|
|
55
|
+
|
|
56
|
+
// try to fetch title + cover
|
|
57
|
+
const title = el.querySelector("title")
|
|
58
|
+
let logo = el.querySelector("meta[property='og:image']")?.getAttribute("content")
|
|
59
|
+
logo = logo ? logo : el.querySelector("meta[property='og:image:url']")?.getAttribute("content")
|
|
60
|
+
|
|
61
|
+
let url = new URL( String(room.url) )
|
|
62
|
+
let instance = url?.origin
|
|
63
|
+
let search = url?.search ? new URLSearchParams(url.search).get("search") : ""
|
|
64
|
+
|
|
65
|
+
// return JML
|
|
66
|
+
return room.parseSource(`
|
|
67
|
+
<title>${ title ? title.innerText.replace(/\n.*/g,'') : room.baseurl.split("/").pop() }</title>
|
|
68
|
+
<FireBoxRoom>
|
|
69
|
+
<Assets>
|
|
70
|
+
${ logo ? `<assetimage src="${logo}" id="logo"/>` : ''}
|
|
71
|
+
<assetscript src="https://codeberg.org/coderofsalvation/janus-script-peertube/raw/branch/master/build/janus-script-peertube.js"/>
|
|
72
|
+
<assetobject src="https://codeberg.org/coderofsalvation/janus-script-peertube/raw/branch/master/build/asset/button.glb" id="button"/>
|
|
73
|
+
<assetobject src="https://codeberg.org/coderofsalvation/janus-script-peertube/raw/branch/master/build/asset/button.icon.glb" id="icon"/>
|
|
74
|
+
<assetsound src="https://codeberg.org/coderofsalvation/janus-script-peertube/raw/branch/master/build/asset/button.click.mp3" id="button-click"/>
|
|
75
|
+
<assetsound src="https://codeberg.org/coderofsalvation/janus-script-peertube/raw/branch/master/build/asset/button.hover.mp3" id="button-hover"/>
|
|
76
|
+
</Assets>
|
|
77
|
+
<Room use_local_asset="room_2pedestal" pos="1.15 0 -5" rotation="0 1.5708 0" fwd="1 0 0">
|
|
78
|
+
${ is.peertubeInstance ? `<peertube-app pos="4.55 2.71 -4.7" scale="2 2 1" rotation="0 90 0" js_id="mypeertube-app" instance="${instance}" ${search?`search="${search}"`:``} autoload="true"/>`
|
|
79
|
+
: `<peertube pos="4.55 2.71 -4.7" scale="2 2 1" rotation="0 90 0" js_id="peertube" src="${room.url}" auto_play="true"/>`
|
|
80
|
+
}
|
|
81
|
+
${ logo ? `<image id="logo"/>` : ''}
|
|
82
|
+
</Room>
|
|
83
|
+
</FireBoxRoom>
|
|
84
|
+
`)
|
|
85
|
+
}
|
|
86
|
+
// peertube microformat heuristic: <meta property="og:platform" content="PeerTube">
|
|
87
|
+
this.parseSource.regex = /<meta\s+[^>]*property=['"]og:platform['"]\s+[^>]*content=['"]peertube['"][^>]*\/?>/si;
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
elation.require([], function() {
|
|
2
|
+
elation.component.add('janusweb.translators.xrfragments', function() {
|
|
3
|
+
this.init = function() {
|
|
4
|
+
this.description = "this implements the https://xrfragment.org standard for immersive 3d file browsing"
|
|
5
|
+
}
|
|
6
|
+
this.exec = function(args) {
|
|
7
|
+
return new Promise(elation.bind(this, function(resolve, reject) {
|
|
8
|
+
|
|
9
|
+
var room = this.room = args.room;
|
|
10
|
+
this.setupEvents()
|
|
11
|
+
|
|
12
|
+
var datapath = elation.config.get('janusweb.datapath', '/media/janusweb');
|
|
13
|
+
|
|
14
|
+
var roomdata = {
|
|
15
|
+
assets: {
|
|
16
|
+
assetlist: [
|
|
17
|
+
{assettype: 'model', name: 'scene', src: args.url},
|
|
18
|
+
]
|
|
19
|
+
},
|
|
20
|
+
room: {
|
|
21
|
+
gravity: 0,
|
|
22
|
+
pos: [0,0,0],
|
|
23
|
+
xdir: "1 0 0",
|
|
24
|
+
zdir: "0 0 1",
|
|
25
|
+
},
|
|
26
|
+
object: [
|
|
27
|
+
{id: 'scene', js_id: "scene", pos: "0 0 0", xdir: "-1 0 0", zdir: "0 0 -1"}
|
|
28
|
+
],
|
|
29
|
+
link: []
|
|
30
|
+
};
|
|
31
|
+
resolve(roomdata);
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
this.spawnUserAtFragment = function(source) {
|
|
36
|
+
// XR Fragments deeplink spec: explicit or default spawn
|
|
37
|
+
// https://xrfragment.org/#teleport%20camera
|
|
38
|
+
if( ! room.urlhash ) room.urlhash = 'spawn'
|
|
39
|
+
room.setPlayerPosition.apply(room)
|
|
40
|
+
console.log("[xrfragment] camera teleport")
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
this.setupEvents = function(){
|
|
44
|
+
elation.events.add(room._room, 'room_load_complete', this.spawnUserAtFragment.bind(this) )
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// translate XR Fragments microformat into JML
|
|
48
|
+
this.parseSource = async function(sourcecode, room){
|
|
49
|
+
this.room = room
|
|
50
|
+
const isJML = /<fireboxroom>[\s\S]*?<\/fireboxroom>/si;
|
|
51
|
+
if( sourcecode.match(isJML) ) return // JML takes precedence over microformats
|
|
52
|
+
|
|
53
|
+
// extract href/title value
|
|
54
|
+
let el = document.createElement("div")
|
|
55
|
+
el.innerHTML = sourcecode
|
|
56
|
+
const link = el.querySelector("link[as=spatial-entrypoint]")
|
|
57
|
+
if( !link ) return
|
|
58
|
+
const title = el.querySelector("title")
|
|
59
|
+
const href = link.getAttribute("href")
|
|
60
|
+
if( !href ) return
|
|
61
|
+
console.log("[xrfragment] detected XRF microformat")
|
|
62
|
+
const hrefNoHash = href.replace(/#.*/,'')
|
|
63
|
+
|
|
64
|
+
// if microformat has xr fragment in URI, use it if room-url has no xr fragment
|
|
65
|
+
if( href.match(/#/) && !room.urlhash ){
|
|
66
|
+
room.urlhash = href.replace(/.*#/,'')
|
|
67
|
+
}
|
|
68
|
+
// check if link exists
|
|
69
|
+
const hrefFull = hrefNoHash.match('://') ? hrefNoHash : String(room.baseurl+hrefNoHash)
|
|
70
|
+
const exist = await fetch( hrefFull ,{method:'HEAD'})
|
|
71
|
+
if( !exist.ok ) return console.warn(`[xrfragment] ${link.outerHTML} resolves to invalid url ${hrefFull}`)
|
|
72
|
+
|
|
73
|
+
// setup events
|
|
74
|
+
this.setupEvents()
|
|
75
|
+
// return JML
|
|
76
|
+
return room.parseSource(`
|
|
77
|
+
<title>${ title ? title.innerText.replace(/\n.*/g,'') : room.baseurl.split("/").pop() }</title>
|
|
78
|
+
<FireBoxRoom>
|
|
79
|
+
<Assets>
|
|
80
|
+
<assetobject id="scene" src="${hrefNoHash}"/>
|
|
81
|
+
</Assets>
|
|
82
|
+
<Room gravity="0">
|
|
83
|
+
<object pos="0 0 0" collision_id="scene" id="scene" />
|
|
84
|
+
</Room>
|
|
85
|
+
</FireBoxRoom>
|
|
86
|
+
`)
|
|
87
|
+
}
|
|
88
|
+
// microformat heuristic (https://xrfragment.org/#XRF%20microformat)
|
|
89
|
+
// example: <link rel="alternate" as="spatial-entrypoint" src="https://foo.org/bar.glb">
|
|
90
|
+
this.parseSource.regex = /<link\s+[^>]*rel=['"]spatial-entrypoint['"][^>]*\/?>/si;
|
|
91
|
+
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<html>
|
|
2
|
+
<body>
|
|
3
|
+
<style type="text/css">
|
|
4
|
+
body > div,
|
|
5
|
+
.paragraphcontainer {
|
|
6
|
+
background: linear-gradient(45deg,#73F7,#F377);
|
|
7
|
+
color: #FFF;
|
|
8
|
+
border:3px solid #73F;
|
|
9
|
+
border-radius:12px;
|
|
10
|
+
padding:7px;
|
|
11
|
+
}
|
|
12
|
+
</style>
|
|
13
|
+
<div class="section">
|
|
14
|
+
<b>hello from JML container document</b>
|
|
15
|
+
<br/>
|
|
16
|
+
This content is from the document<br/>
|
|
17
|
+
which embeds this Janus room JML.<br/>
|
|
18
|
+
<img src="https://janusxr.org/images/janusxr-logo.png" width="70"/>
|
|
19
|
+
</div>
|
|
20
|
+
<div class="section">
|
|
21
|
+
<b>container: Foo bar</b>
|
|
22
|
+
<br/>
|
|
23
|
+
So you're looking at an multidimensional<br/>
|
|
24
|
+
hypertext document 💪<br/>
|
|
25
|
+
<img src="https://janusxr.org/images/team/team_bai.jpg" width="70"/>
|
|
26
|
+
</div>
|
|
27
|
+
</body>
|
|
28
|
+
<fireboxroom>
|
|
29
|
+
<assets>
|
|
30
|
+
</assets>
|
|
31
|
+
<room pos="0 -1 -2">
|
|
32
|
+
<paragraph js_id="p1" lighting="false" scale="0.4 0.2 1" width="400" height="200" url="https://janusxr.org" selector=".infoText" rotation="0 180 0" position="-1 0 0"/>
|
|
33
|
+
<paragraph js_id="p2" lighting="false" scale="0.4 0.2 1" width="400" height="200" url="https://mastodon.online/@lvk.rss" selector="item description" rotation="0 180 0" pos="0 0 0"/>
|
|
34
|
+
<paragraph js_id="p3" lighting="false" scale="0.4 0.2 1" width="400" height="200" selector=".section" rotation="0 180 0" pos="1 0 0"/>
|
|
35
|
+
<paragraph js_id="p4" lighting="false" scale="0.4 0.2 1" width="400" height="200" selector=".info" rotation="0 180 0" pos="2 0 0" css=".paragraphcontainer{background:black;color:#A5F; border-radius:10px}" indexrotate="1700">
|
|
36
|
+
<![CDATA[
|
|
37
|
+
<div class="info">Clicking this <div> will cycle</div>
|
|
38
|
+
<div class="info">through all divs</div>
|
|
39
|
+
<div class="info">with class="info"</div>
|
|
40
|
+
<div class="info">this also works with RSS urls</div>
|
|
41
|
+
<div class="info">when you set the selector</div>
|
|
42
|
+
<div class="info">to selector="item description"</div>
|
|
43
|
+
]]>
|
|
44
|
+
</paragraph>
|
|
45
|
+
</room>
|
|
46
|
+
</fireboxroom>
|
|
47
|
+
</html>
|
package/utils/build.sh
CHANGED
package/utils/clean-build.sh
CHANGED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Manage local npm links for janusweb's elation-family dependencies.
|
|
3
|
+
#
|
|
4
|
+
# Default workflow: `npm install` pulls published versions of these
|
|
5
|
+
# packages into node_modules. Use this script to swap any of them for
|
|
6
|
+
# a local checkout while developing, and swap back when done.
|
|
7
|
+
#
|
|
8
|
+
# Usage:
|
|
9
|
+
# utils/dev-link.sh link <pkg> <path> point node_modules/<pkg> at <path>
|
|
10
|
+
# utils/dev-link.sh unlink <pkg> remove the link and reinstall <pkg>
|
|
11
|
+
# utils/dev-link.sh status show link state for tracked packages
|
|
12
|
+
|
|
13
|
+
set -e
|
|
14
|
+
|
|
15
|
+
# Packages this script knows about, for the `status` listing. Linking
|
|
16
|
+
# isn't restricted to these — `link <name> <path>` works for any name.
|
|
17
|
+
TRACKED=(elation elation-engine cyclone-physics elation-share)
|
|
18
|
+
|
|
19
|
+
JANUSWEB_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
|
20
|
+
|
|
21
|
+
usage() {
|
|
22
|
+
sed -n '2,12p' "$0" | sed 's/^# \?//'
|
|
23
|
+
exit 1
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
cmd_link() {
|
|
27
|
+
local name="$1" path="$2"
|
|
28
|
+
if [ -z "$name" ] || [ -z "$path" ]; then
|
|
29
|
+
echo "link requires <pkg> and <path>" >&2; usage
|
|
30
|
+
fi
|
|
31
|
+
if [ ! -d "$path" ]; then
|
|
32
|
+
echo "Source path not found: $path" >&2; exit 1
|
|
33
|
+
fi
|
|
34
|
+
path="$(cd "$path" && pwd)"
|
|
35
|
+
echo "==> Linking $name from $path"
|
|
36
|
+
(cd "$path" && npm link >/dev/null)
|
|
37
|
+
(cd "$JANUSWEB_DIR" && npm link "$name" >/dev/null)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
cmd_unlink() {
|
|
41
|
+
local name="$1"
|
|
42
|
+
if [ -z "$name" ]; then
|
|
43
|
+
echo "unlink requires <pkg>" >&2; usage
|
|
44
|
+
fi
|
|
45
|
+
local target="$JANUSWEB_DIR/node_modules/$name"
|
|
46
|
+
if [ ! -L "$target" ]; then
|
|
47
|
+
echo "$name is not linked"; exit 0
|
|
48
|
+
fi
|
|
49
|
+
echo "==> Unlinking $name and reinstalling from registry"
|
|
50
|
+
(cd "$JANUSWEB_DIR" && npm unlink "$name" --no-save >/dev/null) || true
|
|
51
|
+
(cd "$JANUSWEB_DIR" && npm install "$name" --no-save >/dev/null)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
cmd_status() {
|
|
55
|
+
for name in "${TRACKED[@]}"; do
|
|
56
|
+
local target="$JANUSWEB_DIR/node_modules/$name"
|
|
57
|
+
if [ -L "$target" ]; then
|
|
58
|
+
printf " %-22s linked -> %s\n" "$name" "$(readlink "$target")"
|
|
59
|
+
elif [ -d "$target" ]; then
|
|
60
|
+
local v
|
|
61
|
+
v=$(node -p "require('$target/package.json').version" 2>/dev/null || echo "?")
|
|
62
|
+
printf " %-22s installed (%s)\n" "$name" "$v"
|
|
63
|
+
else
|
|
64
|
+
printf " %-22s not installed\n" "$name"
|
|
65
|
+
fi
|
|
66
|
+
done
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
case "${1:-}" in
|
|
70
|
+
link) shift; cmd_link "$@" ;;
|
|
71
|
+
unlink) shift; cmd_unlink "$@" ;;
|
|
72
|
+
status) cmd_status ;;
|
|
73
|
+
-h|--help|help|'') usage ;;
|
|
74
|
+
*) echo "Unknown command: $1" >&2; echo; usage ;;
|
|
75
|
+
esac
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
set -e
|
|
3
|
+
|
|
4
|
+
VERSION=$( $(which node) -pe "require('./package.json').version")
|
|
5
|
+
BUILD=build/$VERSION
|
|
6
|
+
|
|
7
|
+
test -f "$BUILD/index.html" || { echo "[x] could not find $BUILD/index.html (run 'npm run build' first)"; exit 1;}
|
|
8
|
+
|
|
9
|
+
download(){
|
|
10
|
+
rm $BUILD/*.com || true # delete old
|
|
11
|
+
url="https://redbean.dev/redbean-3.0.0.com"
|
|
12
|
+
checksum="382f1288bb96ace4bab5145e7df236846c33cc4f1be69233710682a9e71e7467 $BUILD/janusxr.com"
|
|
13
|
+
verify(){
|
|
14
|
+
echo "$checksum" > /tmp/checksum
|
|
15
|
+
sha256sum -c /tmp/checksum || { echo "psuedosecurity checksum failed"; exit 1; }
|
|
16
|
+
chmod +x $BUILD/janusxr.com
|
|
17
|
+
}
|
|
18
|
+
test -f $BUILD/janusxr.com || {
|
|
19
|
+
wget "$url" -O $BUILD/janusxr.com
|
|
20
|
+
verify
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
configure(){
|
|
25
|
+
cd $BUILD
|
|
26
|
+
ln -fs media/images/icons/janusweb-256x256.ico favicon.ico
|
|
27
|
+
sed -i 's|<janus-viewer src.*|<janus-viewer src="./index.html"></janus-viewer>|g' index.html
|
|
28
|
+
zip -r janusxr.com * -x janusweb.min.js
|
|
29
|
+
set -e
|
|
30
|
+
set -x
|
|
31
|
+
cd ../../janusxr.com
|
|
32
|
+
zip -r ../$BUILD/janusxr.com .args .init.lua *
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
download
|
|
36
|
+
configure
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
VERSION=$( $(which node) -pe "require('./package.json').version")
|
|
3
|
+
|
|
4
|
+
set -xe
|
|
5
|
+
docker build -t janusxr --build-arg JANUSXR_VERSION=$VERSION .
|
|
6
|
+
|
|
7
|
+
# push to registry
|
|
8
|
+
docker login codeberg.org
|
|
9
|
+
docker tag janusxr:latest codeberg.org/coderofsalvation/janusxr:$VERSION
|
|
10
|
+
docker push codeberg.org/coderofsalvation/janusxr:$VERSION
|
|
11
|
+
|