Compare commits

..

No commits in common. "main" and "feat/pty" have entirely different histories.

56 changed files with 2021 additions and 8217 deletions

2
.env
View file

@ -1,2 +0,0 @@
git remote | grep codeberg || git remote add codeberg git@codeberg.org:xrsh/xrsh-com.git
git remote | grep c-frame || git remote add c-frame git@github.com:c-frame/xrsh-com.git

View file

@ -13,7 +13,7 @@ jobs:
- run: "echo \"${{ secrets.SSHKEY_APPS }}\" > ~/.ssh/id_rsa"
- run: ssh-keyscan github.com >> ~/.ssh/known_hosts # see https://gist.github.com/vikpe/34454d69fe03a9617f2b009cc3ba200b
- run: chmod 600 -R ~/.ssh
- run: git remote add github git@github.com:c-frame/xrsh-com.git
- run: git remote add github git@github.com:coderofsalvation/xrsh-apps
- run: git push github main
# *todo* trigger deploy at website
#- run: git clone git@github.com:coderofsalvation/xrsh xrsh.github # now push empty commit to deploy website

View file

@ -1,41 +0,0 @@
#!/usr/bin/env -S awk -f
# a no-nonsense source-to-markdown generator which scans for:
#
# /**
# * # foo
# *
# * this is markdown $(cat bar.md)
# */
#
# var foo; // comment with 2 leading spaces is markdown too $(date)
#
# easily refactorable to hash-based languages (py/bash/perl/lua e.g.)
# by changing the regexes
#
BEGIN{
# printf README.md until '# Component List'
system("grep -B9999 '# Component List' README.md")
print ""
}
/\$(?![\(|"])/ { cmd=$0;
gsub(/^.*\$\(/,"",cmd);
gsub(/\).*/,"",cmd);
cmd | getline stdout; close(cmd);
sub(/\$\(.*\)/,stdout);
}
/\/\*\*/ { doc=1; sub(/^.*\/\*/,""); }
doc && /\*\// { doc=0;
sub(/[[:space:]]*\*\/.*/,"");
sub(/^[[:space:]]*\*[[:space:]]?/,"");
print
}
doc && /^[[:space:]]*\*/ { sub(/^[[:space:]]*\*[[:space:]]?/,"");
print
}
#!doc && /\/\/ / { sub(".*// ","");
# sub("# ","\n# ");
# sub("> ","\n> ");
# print
# }

245
README.md
View file

@ -1,256 +1,21 @@
# XRshell apps & components
<img src='https://codeberg.org/xrsh/xrsh/media/branch/main/xrsh.svg' width="25%"/>
<img src='https://github.com/coderofsalvation/xrshell/raw/main/src/assets/logo.svg' width="25%"/>
This is a library of useful AFRAME components used in [XRSH](https://xrsh.isvery.ninja) [or in any AFRAME app].<br>
Characteristics:
* selfcontained
* auto-loading of dependencies (via [AFRAME.utils.require()](com/require.js) see this [example](com/example/helloworld.js))
This is a library of useful AFRAME components which can be used in any AFRAME app, which are higher-level than usual (and used in [XRSH](https://coderofsalvation.github.io/xrsh):
# Usage
```html
<script src="https://codeberg.org/xrsh/xrsh-com/com/require.js"/>
<script src="https://codeberg.org/xrsh/xrsh-com/com/example/helloworld.js"/>
<script src="https://coderofsalvation.github.io/xrsh/src/com/example/helloworld.js"/>
<a-entity helloworld="foo:1" class="cubes" name="box">
```
See component list below
## Funding
> this README.md is generated by running `echo "$(./README.awk com/*.js)" > README.md`
## Credits
This project is partially funded through [NGI0 Entrust](https://nlnet.nl/entrust), a fund established by [NLnet](https://nlnet.nl) with financial support from the European Commission's [Next Generation Internet](https://ngi.eu) program. Learn more at the [NLnet project page](https://nlnet.nl/project/xrsh).
This project is funded through [NGI0 Entrust](https://nlnet.nl/entrust), a fund established by [NLnet](https://nlnet.nl) with financial support from the European Commission's [Next Generation Internet](https://ngi.eu) program. Learn more at the [NLnet project page](https://nlnet.nl/project/xrsh).
[<img src="https://nlnet.nl/logo/banner.png" alt="NLnet foundation logo" width="20%" />](https://nlnet.nl)
[<img src="https://nlnet.nl/image/logos/NGI0_tag.svg" alt="NGI Zero Logo" width="20%" />](https://nlnet.nl/entrust)
# Component List
## [data_events](com/data_events.js)
allows components to react to data changes
```html
<script>
AFRAME.registerComponent('mycom',{
init: function(){ this.data.foo = 1 },
event: {
foo: (e) => alert("I was updated!")
}
})
</script>
<a-entity mycom data_events/>
```
## [html-as-texture-in-xr](com/html-as-texture-in-xr.js)
shows domid **only** in immersive mode
(wrapper around [aframe-htmlmesh](https://ada.is/aframe-htmlmesh/)
It also sets class 'XR' to the (HTML) body-element in immersive mode.
This allows CSS (in [dom component](com/dom.js)) to visually update accordingly.
> depends on [AFRAME.utils.require](com/require.js)
```html
<style type="text/css">
.XR #foo { color:red; }
</style>
<a-entity html-as-texture-in-xr="domid: #foo">
<b id="foo">hello</b>
</a-entitiy>
```
| property | type |
|--------------|--------------------|
| `domid` | `string` |
| event | target | info |
|--------------|------------|--------------------------------------|
| `3D` | a-scene | fired when going into immersive mode |
| `2D` | a-scene | fired when leaving immersive mode |
## [isoterminal](com/isoterminal.js)
Renders a windowed terminal in both (non)immersive mode.
It displays an interactive javascript console or boots into
a Linux ISO image (via WASM).
```html
<a-entity isoterminal="iso: xrsh.iso" position="0 1.6 -0.3"></a-entity>
```
> depends on [AFRAME.utils.require](com/require.js)
| property | type | default | info |
|-------------------|-----------|------------------------|------|
| `iso` | `string` | https`//forgejo.isvery.ninja/assets/xrsh-buildroot/main/xrsh.iso" | |
| `overlayfs` | `string` | '' | zip URL/file to autoextract on top of filesystem |
| `width` | `number` | 800 ||
| `height` | `number` | 600 ||
| `depth` | `number` | 0.03 ||
| `lineHeight` | `number` | 18 ||
| `bootMenu` | `string` | "" | character to auto-enter in bootMenu |
| `bootMenuURL` | `string` | "" | character to auto-enter in bootMenu when URL has fragment (#foo.zip e.g.) |
| `padding` | `number` | 18 | |
| `maximized` | `boolean` | false | |
| `minimized` | `boolean` | false | |
| `muteUntilPrompt` | `boolean` | true | mute stdout until a prompt is detected in ISO |
| `HUD` | `boolean` | false | link to camera movement |
| `transparent` | `boolean` | false | heavy, needs good gpu |
| `memory` | `number` | 60 | VM memory (in MB) [NOTE` quest or smartphone webworker might crash > 40mb ] |
| `bufferLatency` | `number` | 1 | in ms` bufferlatency from webworker to term (batch-update every char to texture) |
| `debug` | `boolean` | false | |
| `emulator` | `string` | fbterm | terminal emulator |
> for more info see [xrsh.isvery.ninja](https://xrsh.isvery.ninja)
Component design:
```
css/html template
┌─────────┐ ┌────────────┐ exit-AR
┌───────►│ com/dom ┼──►│ com/window ├───────────────── exit-VR ◄─┐
│ └─────────┘ └───────────┬┘ │
│ │ │
┌──────────┴────────┐ │ ┌───────────┐ ┌─────────────────────────────┐
│ com/isoterminal ├────────────────────────────►│com/term.js│ │com/html-as-texture-in-XR.js │
└────────┬─┬────────┘ │ └──┬─────┬▲─┘ └─────────────────────────────┘
│ │ ┌────────┐ ┌──▼──────▼──────┐ ││ │
│ └───────►│ plane ├─────►text───┼►div#isoterminal│◄────────────────── enter-VR │
│ └────────┘ └────────────────┘ enter-AR ◄─┘
│ │
│ │
│ ISOTerminal.js
│ ┌───────────────────────────┐
│ │ com/isoterminal/worker.js ├
│ └──────────────┌────────────┤
│ │ │ v86.js │
│ │ │ feat/*.js │
│ │ │ libv86.js │
│ │ └────────────┘
│ │
└─────────────────────┘
NOTE: For convenience reasons, events are forwarded between com/isoterminal.js, worker.js and ISOTerminal
Instead of a melting pot of different functionnames, events are flowing through everything (ISOTerminal.emit())
```
## [launcher](com/launcher.js)
displays app (icons) in 2D and 3D handmenu (enduser can launch desktop-like 'apps')
```html
<a-entity launcher>
<a-entity launch="component: helloworld; foo: bar"><a-entity>
</a-entity>
```
| property | type | example |
|--------------|--------------------|----------------------------------------------------------------------------------------|
| `attach` | `selector` | hand or object to attach menu to |
| `registries` | `array` of strings | `<a-entity launcher="registers: https://foo.com/index.json, ./index.json"/>` |
| event | target | info |
|--------------|-------------------------------------------------------------------------------------------------------------|
| `launcher` | an app | when pressing an app icon, `launcher` event will be send to the respective app |
There a multiple ways of letting the launcher know that an app can be launched:
1. any AFRAME component with an `launcher`-event + manifest is automatically added:
```javascript
AFRAME.registerComponent('foo',{
events:{
launcher: function(){ ...launch something... }
},
manifest:{ // HTML5 manifesto JSON object
// https://www.w3.org/TR/appmanifest/
}
}
```
2. dynamically in javascript
```javascript
window.launcher.register({
name:"foo",
icon: "https://.../optional_icon.png"
description: "lorem ipsum",
cb: () => alert("foo")
})
//window.launcher.unregister('foo')
```
Restore the pose.
## [pastedrop](com/pastedrop.js)
detects user copy/paste and file dragdrop action
and clipboard functions
```html
<a-entity pastedrop/>
```
| event | target | info |
|--------------|--------|------------------------------------------|
| `pasteFile` | self | always translates input to a File object |
Restore the pose.
## [require](com/require('').js)
automatically requires dependencies or missing components
```javascript
await AFRAME.utils.require( this.dependencies ) (*) autoload missing components
await AFRAME.utils.require( this.el.getAttributeNames() ) (*) autoload missing components
await AFRAME.utils.require({foo: "https://foo.com/aframe/components/foo.js"},this)
await AFRAME.utils.require(["./app/foo.js","foo.css"],this)
```
> (*) = prefixes baseURL AFRAME.utils.require.baseURL ('./com/' e.g.)
## [window](com/window.js)
wraps a draggable window around a dom id or [dom](com/dom.js) component.
```html
<a-entity window="dom: #mydiv"/>
```
> depends on [AFRAME.utils.require](com/require.js)
| property | type | default | info |
|------------------|-----------|------------------------|------|
| `title` |`string` | "" | |
| `width` |`string` | | |
| `height` |`string` | 260px | |
| `uid` |`string` | | |
| `attach` |`selector` | | |
| `dom` |`selector` | | |
| `max` |`boolean` | false | |
| `min` |`boolean` | false | |
| `x` |`string` | "center" | |
| `y` |`string` | "center" | |
| `class` |`array` | [] | |

View file

@ -4,8 +4,11 @@ AFRAME.registerComponent('cast', {
},
requires: {
dom: "com/dom.js",
window: "com/window.js",
dom: "./com/dom.js", // interpret .dom object
xd: "./com/xd.js", // allow switching between 2D/3D
html: "https://unpkg.com/aframe-htmlmesh@2.1.0/build/aframe-html.js", // html to AFRAME
winboxjs: "https://unpkg.com/winbox@0.2.82/dist/winbox.bundle.min.js", // deadsimple windows: https://nextapps-de.github.io/winbox
winboxcss: "https://unpkg.com/winbox@0.2.82/dist/css/winbox.min.css", //
},
dom: {
@ -20,7 +23,7 @@ AFRAME.registerComponent('cast', {
init: function () { },
etInstallables: function(){
getInstallables: function(){
const installed = document.querySelector('[launcher]').components.launcher.system.getLaunchables()
return this.data.comps.map( (c) => {
return installed[c] ? null : c
@ -34,54 +37,77 @@ AFRAME.registerComponent('cast', {
if( this.el.sceneEl.renderer.xr.isPresenting ){
this.el.sceneEl.exitVR() // *FIXME* we need a gui
}
this.el.object3D.quaternion.copy( AFRAME.scenes[0].camera.quaternion ) // face towards camera
// instance this component
this.el.setAttribute("dom","")
},
DOMready: function(){
this.setupCast();
this.el.setAttribute("html-as-texture-in-xr", `domid: #${this.el.uid}; faceuser: true`)
},
const el = document.querySelector('body');
const cropTarget = await CropTarget.fromElement(el);
const stream = await navigator.mediaDevices.getDisplayMedia();
const [track] = stream.getVideoTracks();
this.track = track
this.stream = stream
this.createWindow()
}
},
setupCast: async function(){
createWindow: async function(){
let s = await AFRAME.utils.require(this.requires)
const video = this.el.dom.querySelector('video')
// instance this component
const instance = this.el.cloneNode(false)
this.el.sceneEl.appendChild( instance )
instance.setAttribute("dom", "")
instance.setAttribute("xd", "") // allows flipping between DOM/WebGL when toggling XD-button
instance.setAttribute("visible", AFRAME.utils.XD() == '3D' ? 'true' : 'false' )
instance.setAttribute("position", AFRAME.utils.XD.getPositionInFrontOfCamera(0.5) )
instance.setAttribute("grabbable","")
instance.object3D.quaternion.copy( AFRAME.scenes[0].camera.quaternion ) // face towards camera
instance.track = this.track
instance.stream = this.stream
video.addEventListener( "loadedmetadata", () => {
const setupWindow = () => {
instance.dom.style.display = 'none'
let width = Math.round(window.innerWidth*0.4)
let factor = width / video.videoWidth
let height = Math.round( video.videoHeight * factor)
const video = instance.dom.querySelector('video')
video.addEventListener( "loadedmetadata", function () {
let width = Math.round(window.innerWidth*0.4)
let factor = width / this.videoWidth
let height = Math.round(this.videoHeight * factor)
new WinBox("Casting Tab",{
width,
height,
x:"center",
y:"center",
id: instance.uid, // important hint for html-mesh
root: document.querySelector("#overlay"),
mount: instance.dom,
onclose: () => { instance.dom.style.display = 'none'; return false; },
oncreate: () => {
const createVideoTexture = () => {
const texture = new THREE.VideoTexture( video );
texture.colorSpace = THREE.SRGBColorSpace;
const geometry = new THREE.PlaneGeometry( 16, 9 );
geometry.scale( 0.2, 0.2, 0.2 );
const material = new THREE.MeshBasicMaterial( { map: texture } );
const mesh = new THREE.Mesh( geometry, material );
mesh.lookAt( AFRAME.scenes[0].camera.position );
this.el.object3D.add(mesh)
}
// instance.setAttribute("html",`html:#${instance.uid}; cursor:#cursor`)
}
});
instance.dom.style.display = '' // show
createVideoTexture.apply(this)
//this.el.setAttribute("window", `title: casting tab; uid: ${this.el.uid}; attach: #overlay; dom: #${this.el.dom.id}; width:${width}; height: ${height}`)
})
// hint grabbable's obb-collider to track the window-object
instance.components['obb-collider'].data.trackedObject3D = 'components.html.el.object3D.children.0'
instance.components['obb-collider'].update()
})
video.srcObject = instance.stream
video.play()
const el = document.querySelector('body');
const cropTarget = await CropTarget.fromElement(el);
const stream = await navigator.mediaDevices.getDisplayMedia();
const [track] = stream.getVideoTracks();
this.track = track
this.stream = stream
video.srcObject = this.stream
video.play()
this.createVideoTexture.apply(instance)
}
setTimeout( () => setupWindow(), 10 ) // give new components time to init
},
createVideoTexture: function(){
console.dir(this)
const texture = new THREE.VideoTexture( video );
texture.colorSpace = THREE.SRGBColorSpace;
const geometry = new THREE.PlaneGeometry( 16, 9 );
geometry.scale( 0.2, 0.2, 0.2 );
const material = new THREE.MeshBasicMaterial( { map: texture } );
const mesh = new THREE.Mesh( geometry, material );
mesh.lookAt( this.sceneEl.camera.position );
this.object3D.add(mesh)
},
manifest: { // HTML5 manifest to identify app to xrsh
@ -90,7 +116,6 @@ AFRAME.registerComponent('cast', {
"icons": [
{
"src": "https://css.gg/cast.svg",
"src": "data:image/svg+xml;base64,PHN2ZwogIHdpZHRoPSIyNCIKICBoZWlnaHQ9IjI0IgogIHZpZXdCb3g9IjAgMCAyNCAyNCIKICBmaWxsPSJub25lIgogIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKPgogIDxwYXRoCiAgICBkPSJNMjAgNkg0VjhIMlY2QzIgNC44OTU0MyAyLjg5NTQzIDQgNCA0SDIwQzIxLjEwNDYgNCAyMiA0Ljg5NTQzIDIyIDZWMThDMjIgMTkuMTA0NiAyMS4xMDQ2IDIwIDIwIDIwSDE1VjE4SDIwVjZaIgogICAgZmlsbD0iY3VycmVudENvbG9yIgogIC8+CiAgPHBhdGgKICAgIGQ9Ik0yIDEzQzUuODY1OTkgMTMgOSAxNi4xMzQgOSAyMEg3QzcgMTcuMjM4NiA0Ljc2MTQyIDE1IDIgMTVWMTNaIgogICAgZmlsbD0iY3VycmVudENvbG9yIgogIC8+CiAgPHBhdGggZD0iTTIgMTdDMy42NTY4NSAxNyA1IDE4LjM0MzEgNSAyMEgyVjE3WiIgZmlsbD0iY3VycmVudENvbG9yIiAvPgogIDxwYXRoCiAgICBkPSJNMiA5QzguMDc1MTMgOSAxMyAxMy45MjQ5IDEzIDIwSDExQzExIDE1LjAyOTQgNi45NzA1NiAxMSAyIDExVjlaIgogICAgZmlsbD0iY3VycmVudENvbG9yIgogIC8+Cjwvc3ZnPg==",
"type": "image/svg+xml",
"sizes": "512x512"
}

159
com/codemirror.js Normal file
View file

@ -0,0 +1,159 @@
if( AFRAME.components.codemirror ) delete AFRAME.components.codemirror
AFRAME.registerComponent('codemirror', {
schema: {
file: { type:"string"},
term: { type:"selector", default: "[isoterminal]" },
},
init: function () {
this.el.object3D.visible = false
if( !this.data.term || !this.data.term.components ) throw 'codemirror cannot get isoterminal'
if( this.data.file && this.data.file[0] != '/'){
this.data.file = "root/"+this.data.file
}
this.isoterminal = this.data.term.components.isoterminal.isoterminal
//this.el.innerHTML = ` `
this.requireAll()
},
requireAll: async function(){
let s = await AFRAME.utils.require(this.requires)
setTimeout( () => this.el.setAttribute("dom",""), 300 )
},
requires:{
window: "com/window.js"
},
dom: {
scale: 0.5,
events: ['click','keydown'],
html: (me) => `<div class="codemirror">
</div>`,
css: (me) => `.codemirror{
width:100%;
}
.codemirror *{
font-size: 14px;
font-family: "Cousine",Liberation Mono,DejaVu Sans Mono,Courier New,monospace;
font-weight:500 !important;
letter-spacing: 0 !important;
text-shadow: 0px 0px 10px #F075;
}
.wb-body + .codemirror{ overflow:hidden; }
.CodeMirror {
margin-top:18px;
}
.cm-s-shadowfox.CodeMirror {
background:transparent !important;
}
`
},
createEditor: function(value){
this.el.setAttribute("window", `title: codemirror; uid: ${this.el.dom.id}; attach: #overlay; dom: #${this.el.dom.id};`)
this.editor = CodeMirror( this.el.dom, {
value,
mode: "htmlmixed",
lineNumbers: true,
styleActiveLine: true,
matchBrackets: true,
Tab: "indentMore",
defaultTab: function(cm) {
if (cm.somethingSelected()) cm.indentSelection("add");
else cm.replaceSelection(" ", "end");
}
})
this.editor.setOption("theme", "shadowfox")
this.editor.updateFile = AFRAME.utils.throttleLeadingAndTrailing( (file,str) => {
this.updateFile(file,str),
2000
})
this.editor.on('change', (instance,changeObj) => {
this.editor.updateFile( this.data.file, instance.getValue() )
})
setTimeout( () => {
this.el.setAttribute("html-as-texture-in-xr", `domid: #${this.el.dom.id}`) // only show aframe-html in xr
},1500)
},
updateFile: async function(file,str){
// we don't do via shellcmd: isoterminal.exec(`echo '${str}' > ${file}`,1)
// as it would require all kindof ugly stringescaping
console.log("updating "+file)
await this.isoterminal.emulator.fs9p.update_file( file, str)
},
events:{
// component events
DOMready: function(e){
this.isoterminal.emulator.read_file( this.data.file )
.then( this.isoterminal.convert.Uint8ArrayToString )
.then( (str) => {
this.createEditor( str )
})
.catch( (e) => {
console.log("error opening "+this.data.file+", creating new one")
this.createEditor("")
})
},
},
manifest: { // HTML5 manifest to identify app to xrsh
"short_name": "Paste",
"name": "Paste",
"icons": [
{
"src": "https://css.gg/clipboard.svg",
"type": "image/svg+xml",
"sizes": "512x512"
}
],
"id": "/?source=pwa",
"start_url": "/?source=pwa",
"background_color": "#3367D6",
"display": "standalone",
"scope": "/",
"theme_color": "#3367D6",
"shortcuts": [
{
"name": "What is the latest news?",
"cli":{
"usage": "helloworld <type> [options]",
"example": "helloworld news",
"args":{
"--latest": {type:"string"}
}
},
"short_name": "Today",
"description": "View weather information for today",
"url": "/today?source=pwa",
"icons": [{ "src": "/images/today.png", "sizes": "192x192" }]
}
],
"description": "Paste the clipboard",
"screenshots": [
{
"src": "/images/screenshot1.png",
"type": "image/png",
"sizes": "540x720",
"form_factor": "narrow"
}
],
"help":`
Helloworld application
This is a help file which describes the application.
It will be rendered thru troika text, and will contain
headers based on non-punctualized lines separated by linebreaks,
in above's case "\nHelloworld application\n" will qualify as header.
`
}
});

View file

@ -17,9 +17,7 @@ window.AFRAME.registerComponent('pinch-to-teleport', {
pos.x += direction.x
pos.z += direction.z
// set the new position
if( !this.data.rig ){
this.data.rig.setAttribute("position", pos);
}
this.data.rig.setAttribute("position", pos);
// !!! NOTE - it would be more efficient to do the
// position change on the players THREE.Object:
// `player.object3D.position.add(direction)`

72
com/control/pressable.js Normal file
View file

@ -0,0 +1,72 @@
// this makes WebXR hand controls able to click things (by touching it)
AFRAME.registerComponent('pressable', {
schema: {
pressDistance: {
default: 0.01
}
},
init: function() {
this.worldPosition = new THREE.Vector3();
this.fingerWorldPosition = new THREE.Vector3();
this.raycaster = new THREE.Raycaster()
this.handEls = document.querySelectorAll('[hand-tracking-controls]');
this.pressed = false;
this.distance = -1
// we throttle by distance, to support scenes with loads of clickable objects (far away)
this.tick = this.throttleByDistance( () => this.detectPress() )
},
throttleByDistance: function(f){
return function(){
if( this.distance < 0 ) return f() // first call
if( !f.tid ){
let x = this.distance
let y = x*(x*0.05)*1000 // parabolic curve
f.tid = setTimeout( function(){
f.tid = null
f()
}, y )
}
}
},
detectPress: function(){
var handEls = this.handEls;
var handEl;
let minDistance = 5
// compensate for xrf-get AFRAME component (which references non-reparented buffergeometries from the 3D model)
let object3D = this.el.object3D.child || this.el.object3D
for (var i = 0; i < handEls.length; i++) {
handEl = handEls[i];
let indexTipPosition = handEl.components['hand-tracking-controls'].indexTipPosition
// Apply the relative position to the parent's world position
handEl.object3D.updateMatrixWorld();
handEl.object3D.getWorldPosition( this.fingerWorldPosition )
this.fingerWorldPosition.add( indexTipPosition )
this.raycaster.far = this.data.pressDistance
// Create a direction vector (doesnt matter because it is supershort for 'touch' purposes)
const direction = new THREE.Vector3(1.0,0,0);
this.raycaster.set(this.fingerWorldPosition, direction)
intersects = this.raycaster.intersectObjects([object3D],true)
object3D.getWorldPosition(this.worldPosition)
distance = this.fingerWorldPosition.distanceTo(this.worldPosition)
minDistance = distance < minDistance ? distance : minDistance
if (intersects.length ){
if( !this.pressed ){
this.el.emit('pressedstarted');
this.el.emit('click');
this.pressed = setTimeout( () => {
this.el.emit('pressedended');
this.pressed = null
},300)
}
}
}
this.distance = minDistance
}
});

26
com/control/wearable.js Normal file
View file

@ -0,0 +1,26 @@
window.AFRAME.registerComponent('xrf-wear', {
schema:{
el: {type:"selector"},
position: {type:"vec3"},
rotation: {type:"vec3"}
},
init: function(){
$('a-scene').addEventListener('enter-vr', (e) => this.wear(e) )
$('a-scene').addEventListener('exit-vr', (e) => this.unwear(e) )
},
wear: function(){
if( !this.wearable ){
let d = this.data
this.wearable = new THREE.Group()
this.el.object3D.children.map( (c) => this.wearable.add(c) )
this.wearable.position.set( d.position.x, d.position.y, d.position.z)
this.wearable.rotation.set( d.rotation.x, d.rotation.y, d.rotation.z)
}
this.data.el.object3D.add(this.wearable)
},
unwear: function(){
this.data.el.remove(this.wearable)
this.wearable.children.map( (c) => this.el.object3D.add(c) )
delete this.wearable
}
})

View file

@ -1,85 +0,0 @@
/*
*Usage:
* <a-entity controlattach="el: #leftHand; class: foo"></a-entity>
* <a-entity id="leftHand">
* <a-entity class="foo" position="0 0 0" rotation="0 0 0" scale="0 0 0"></a-entity>
* <a-entity>
*
* NOTE: you can hint different position/rotation-offsets by adding controller-specific entities to the target:
*
* <a-entity id="leftHand">
* <a-entity class="foo" position="0 0 0" rotation="0 0 0" scale="0 0 0"></a-entity>
* <a-entity class="foo hand-tracking-controls" position="0 0 0" rotation="0 0 0" scale="0 0 0"></a-entity>
* <a-entity>
*
* The controllernames are hinted by the 'controllermodelready' event:
*
* 'hand-tracking-controls',
* 'meta-touch-controls',
* 'valve-index-controls',
* 'logitech-mx-ink-controls',
* 'windows-motion-controls',
* 'hp-mixed-reality-controls',
* 'generic-tracked-controller-controls',
* 'pico-controls',
* (and more in the future)
*/
AFRAME.registerComponent('controlattach', {
schema:{
el: {type:"selector"},
class: {type:"string"}
},
init: function(){
if( !this.data.el ) return console.warn(`controlattach.js: cannot find ${this.data.el}`)
this.controllers = {}
this.remember()
this.data.el.addEventListener('controllermodelready', this.bindPlaceHolders.bind(this,["controllermodelready"]) )
this.data.el.addEventListener('controllerconnected', this.bindPlaceHolders.bind(this,["controllerconnected"] ) )
},
bindPlaceHolders: function(type,e){
let controllerName = e.detail.name
let selector = `.${this.data.class}.${controllerName}`
let placeholder = this.data.el.querySelector(selector)
if( !placeholder ){
placeholder = this.data.el.querySelector(`.${this.data.class}`)
console.warn(`controlattach.js: '${selector}' not found, fallback to default`)
}
if( !placeholder ) return console.warn("controlattach.js: could not find placeholder to attach to")
this.attachPlaceHolder(type,e,placeholder, controllerName)
},
attachPlaceHolder: function(type, el, placeholder, controllerName){
this.el.object3DMap = {} // unsync THREE <-> AFRAME entity
// these are handled by the placeholder entity
this.obj.position.set(0,0,0);
this.obj.rotation.set(0,0,0);
this.obj.scale.set(1,1,1);
if( controllerName != 'hand-tracking-controls' ){
// re-add for controller-models which don't re-add children ('meta-touch-controls' e.g.)
if( this.data.el.getObject3D("mesh") ){
this.data.el.getObject3D("mesh").add(placeholder.object3D)
}
}
placeholder.object3D.add( this.obj )
},
detach: function(){
this.el.setObject3D( this.obj.uuid, this.obj )
this.el.object3D.position.copy( this.position )
this.el.object3D.rotation.copy( this.rotation )
this.el.object3D.scale.copy( this.scale )
},
remember: function(){
this.obj = this.el.object3D
this.position = this.el.object3D.position.clone()
this.rotation = this.el.object3D.rotation.clone()
this.scale = this.el.object3D.scale.clone()
this.parent = this.el.object3D.parent
}
})

View file

@ -1,5 +1,5 @@
/**
* ## [data_events](com/data_events.js)
/*
* ## data_events
*
* allows components to react to data changes
*

View file

@ -1,5 +1,5 @@
/*
* ## [dom](com/dom.js)
* ## dom
*
* instances reactive DOM component from AFRAME component's `dom` metadata
*
@ -36,6 +36,10 @@ if( !AFRAME.components.dom ){
AFRAME.registerComponent('dom',{
requires: {
"requestAnimationFrameXR": "com/requestAnimationFrameXR.js"
},
init: function(){
Object.values(this.el.components)
.map( (c) => {
@ -50,11 +54,12 @@ if( !AFRAME.components.dom ){
this
.ensureOverlay()
.addCSS()
.createReactiveDOMElement()
.assignUniqueID()
.addCSS()
.scaleDOMvsXR()
.triggerKeyboardForInputs()
.stubRequestAnimationFrame()
document.querySelector('#overlay').appendChild(this.el.dom)
this.el.emit('DOMready',{el: this.el.dom})
@ -66,11 +71,8 @@ if( !AFRAME.components.dom ){
if( !overlay ){
overlay = document.createElement('div')
overlay.id = "overlay"
overlay.setAttribute('style','position:fixed;top:0px;left:0px;right:0px;bottom:0px;pointer-events:none')
overlay.setAttribute('style','position:fixed;top:0px;left:0px;right:0px;bottom:0px')
document.body.appendChild(overlay)
document.head.innerHTML += `
<style type="text/css">#overlay * { pointer-events:all }</style>
`
// sceneEl.setAttribute("webxr","overlayElement:#overlay")
}
return this
@ -94,12 +96,8 @@ if( !AFRAME.components.dom ){
this.el.dom.innerHTML = this.dom.html(this)
this.el.dom.className = this.dom.attrName
this.com.data = this.reactify( this.el, this.com.data )
if( this.dom.events ) this.dom.events.map( (e) => this.el.dom.addEventListener(e, (ev) => this.el.emit(e,ev) ) )
this.el.dom = this.el.dom.children[0]
if( this.dom.events ){
this.dom.events.map( (e) => {
this.el.dom.addEventListener(e, (ev) => this.el.emit(e,ev) )
})
}
return this
},
@ -133,5 +131,10 @@ if( !AFRAME.components.dom ){
return this
},
stubRequestAnimationFrame: async function(){
let s = await AFRAME.utils.require(this.requires)
this.el.setAttribute("requestAnimationFrameXR","")
}
})
}

View file

@ -0,0 +1,107 @@
AFRAME.registerComponent('helloworld-html', {
schema: {
foo: { type:"string"}
},
init: function () {
this.el.addEventListener('ready', () => this.el.dom.style.display = 'none' )
},
requires:{
html: "https://unpkg.com/aframe-htmlmesh@2.1.0/build/aframe-html.js", // html to AFRAME
},
dom: {
scale: 1,
events: ['click'],
html: (me) => `<div>
<div class="pad"> helloworld-html: ${me.data.foo} <b>${me.data.myvalue}</b></span>
</div>`,
css: (me) => `.helloworld-html {
color: var(--xrsh-light-gray); /* see index.css */
}`,
},
events:{
// combined AFRAME+DOM reactive events
keydown: function(e){ }, //
click: function(e){
console.dir(e)
}, //
// reactive events for this.data updates
myvalue: function(e){ this.el.dom.querySelector('b').innerText = this.data.myvalue },
ready: function( ){
this.el.dom.style.display = 'none'
console.log("this.el.dom has been added to DOM")
this.el.dom.children[0].id = this.el.uid // important hint for html-mesh
this.data.myvalue = 1
setInterval( () => this.data.myvalue++, 100 )
},
launcher: function(){
this.el.dom.style.display = ''
console.log("launcher")
},
},
manifest: { // HTML5 manifest to identify app to xrsh
"short_name": "Hello world",
"name": "Hello world",
"icons": [
{
"src": "https://css.gg/browser.svg",
"src": "data:image/svg+xml;base64,PHN2ZwogIHdpZHRoPSIyNCIKICBoZWlnaHQ9IjI0IgogIHZpZXdCb3g9IjAgMCAyNCAyNCIKICBmaWxsPSJub25lIgogIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKPgogIDxwYXRoCiAgICBkPSJNNCA4QzQuNTUyMjggOCA1IDcuNTUyMjggNSA3QzUgNi40NDc3MiA0LjU1MjI4IDYgNCA2QzMuNDQ3NzIgNiAzIDYuNDQ3NzIgMyA3QzMgNy41NTIyOCAzLjQ0NzcyIDggNCA4WiIKICAgIGZpbGw9ImN1cnJlbnRDb2xvciIKICAvPgogIDxwYXRoCiAgICBkPSJNOCA3QzggNy41NTIyOCA3LjU1MjI4IDggNyA4QzYuNDQ3NzIgOCA2IDcuNTUyMjggNiA3QzYgNi40NDc3MiA2LjQ0NzcyIDYgNyA2QzcuNTUyMjggNiA4IDYuNDQ3NzIgOCA3WiIKICAgIGZpbGw9ImN1cnJlbnRDb2xvciIKICAvPgogIDxwYXRoCiAgICBkPSJNMTAgOEMxMC41NTIzIDggMTEgNy41NTIyOCAxMSA3QzExIDYuNDQ3NzIgMTAuNTUyMyA2IDEwIDZDOS40NDc3MSA2IDkgNi40NDc3MiA5IDdDOSA3LjU1MjI4IDkuNDQ3NzEgOCAxMCA4WiIKICAgIGZpbGw9ImN1cnJlbnRDb2xvciIKICAvPgogIDxwYXRoCiAgICBmaWxsLXJ1bGU9ImV2ZW5vZGQiCiAgICBjbGlwLXJ1bGU9ImV2ZW5vZGQiCiAgICBkPSJNMyAzQzEuMzQzMTUgMyAwIDQuMzQzMTUgMCA2VjE4QzAgMTkuNjU2OSAxLjM0MzE1IDIxIDMgMjFIMjFDMjIuNjU2OSAyMSAyNCAxOS42NTY5IDI0IDE4VjZDMjQgNC4zNDMxNSAyMi42NTY5IDMgMjEgM0gzWk0yMSA1SDNDMi40NDc3MiA1IDIgNS40NDc3MiAyIDZWOUgyMlY2QzIyIDUuNDQ3NzIgMjEuNTUyMyA1IDIxIDVaTTIgMThWMTFIMjJWMThDMjIgMTguNTUyMyAyMS41NTIzIDE5IDIxIDE5SDNDMi40NDc3MiAxOSAyIDE4LjU1MjMgMiAxOFoiCiAgICBmaWxsPSJjdXJyZW50Q29sb3IiCiAgLz4KPC9zdmc+",
"type": "image/svg+xml",
"sizes": "512x512"
}
],
"id": "/?source=pwa",
"start_url": "/?source=pwa",
"background_color": "#3367D6",
"display": "standalone",
"scope": "/",
"theme_color": "#3367D6",
"shortcuts": [
{
"name": "What is the latest news?",
"cli":{
"usage": "helloworld <type> [options]",
"example": "helloworld news",
"args":{
"--latest": {type:"string"}
}
},
"short_name": "Today",
"description": "View weather information for today",
"url": "/today?source=pwa",
"icons": [{ "src": "/images/today.png", "sizes": "192x192" }]
}
],
"description": "Hello world information",
"screenshots": [
{
"src": "/images/screenshot1.png",
"type": "image/png",
"sizes": "540x720",
"form_factor": "narrow"
}
],
"help":`
Helloworld application
This is a help file which describes the application.
It will be rendered thru troika text, and will contain
headers based on non-punctualized lines separated by linebreaks,
in above's case "\nHelloworld application\n" will qualify as header.
`
}
});

View file

@ -1,113 +1,104 @@
AFRAME.registerComponent('helloworld-window', {
AFRAME.registerComponent('helloworld-htmlform', {
schema: {
foo: { type:"string"}
},
init: function () {
},
init: function () {},
requires:{
window: "com/window.js",
reactive: "com/data2event.js"
html: "https://unpkg.com/aframe-htmlmesh@2.1.0/build/aframe-html.js", // html to AFRAME
winboxjs: "https://unpkg.com/winbox@0.2.82/dist/winbox.bundle.min.js", // deadsimple windows: https://nextapps-de.github.io/winbox
winboxcss: "https://unpkg.com/winbox@0.2.82/dist/css/winbox.min.css", // deadsimple windows: https://nextapps-de.github.io/winbox
},
dom: {
scale: 0.66,
scale: 1,
events: ['click','input'],
html: (me) => `<div class="htmlform">
<fieldset>
<legend>Welcome to XR Shell</legend>
A free offline-first morphable<br>
environment which provides <br>
<span id="myvalue"></span>&nbsp; XR-friendly shells.<br>
<ol>
<li>check the <a href="/" target="_blank">website</a></li>
<li>check the <a href="https://forgejo.isvery.ninja/xrsh/xrsh-buildroot/src/branch/main/buildroot-v86/board/v86/rootfs_overlay/root/manual.md" target="_blank">manual</a></li>
</ol>
<legend>Colour</legend>
<input type="radio" id="color-red" name="color" value="red" checked><label for="color-red"> Red</label><br>
<input type="radio" id="color-blue" name="color" value="blue"><label for="color-blue"> Blue</label><br>
</fieldset>
<br>
<fieldset>
<legend>Icons</legend>
<input type="radio" id="small" name="icons" value="0.8" checked style=""><label for="small" style="margin-right:15px;">Small</label>
<input type="radio" id="big" name="icons" value="1.5"><label for="big">Big</label><br>
<legend>Material:</legend>
<input id="material-wireframe" type="checkbox" name="wireframe"><label for="material-wireframe"> Wireframe</label><br>
</fieldset>
<!--
<fieldset>
<legend>Size</legend>
<input type="range" min="0.1" max="2" value="1" step="0.01" id="myRange" style="background-color: transparent;">
</fieldset>
<button>hello <span id="myvalue"></span></button>
-->
</div>`,
css: (me) => `.htmlform { padding:11px; }
`
css: (me) => `.htmlform { padding:11px; }`
},
events:{
// component events
window: function( ){ console.log("window component mounted") },
html: function( ){ console.log("html-mesh requirement mounted") },
// combined AFRAME+DOM reactive events
click: function(e){ console.dir(e) }, //
click: function(e){ }, //
input: function(e){
if( !e.detail.target ) return
if( e.detail.target.id == 'myRange' ) this.data.myvalue = e.detail.target.value // reactive demonstration
if( e.detail.target.name == 'cmenu' ) document.querySelector(".iconmenu").style.display = e.detail.target.value == 'on' ? '' : 'none';
if( e.detail.target.name == 'icons' ){
document.querySelector('[launcher]').object3D.getObjectByProperty("HTMLMesh").scale.setScalar( e.detail.target.value )
document.querySelector('.iconmenu').style.transform = e.detail.target.value == '0.8' ? 'scale(1)' : 'scale(1.33)'
}
console.dir(e.detail)
},
// reactive events for this.data updates
myvalue: function(e){ this.el.dom.querySelector('#myvalue').innerText = this.data.myvalue },
launcher: async function(){
if( !this.el.getAttribute("dom") ){
let s = await AFRAME.utils.require(this.requires)
let s = await AFRAME.utils.require(this.requires)
// instance this component
this.el.setAttribute("dom", "")
this.el.object3D.quaternion.copy( AFRAME.scenes[0].camera.quaternion ) // face towards camera
}else{
// toggle visibility
this.el.winbox[ this.el.winbox.min ? 'restore' : 'minimize' ]()
// instance this component
const instance = this.el.cloneNode(false)
this.el.sceneEl.appendChild( instance )
instance.setAttribute("dom", "")
instance.setAttribute("show-texture-in-xr", "") // only show aframe-html in xr
instance.setAttribute("grabbable","")
instance.object3D.quaternion.copy( AFRAME.scenes[0].camera.quaternion ) // face towards camera
const setupWindow = () => {
const com = instance.components['helloworld-htmlform']
instance.dom.style.display = 'none'
new WinBox("Hello World",{
width: 250,
height: 340,
x:"center",
y:"center",
id: instance.uid, // important hint for html-mesh
root: document.querySelector("#overlay"),
mount: instance.dom,
onclose: () => { instance.dom.style.display = 'none'; return false; },
oncreate: () => {
instance.setAttribute("position", AFRAME.utils.XD.getPositionInFrontOfCamera(0.5) )
instance.setAttribute("html",`html:#${instance.uid}; cursor:#cursor`)
instance.setAttribute("show-texture-in-xr","") // only show aframe-html texture in xr mode
}
});
instance.dom.style.display = AFRAME.utils.XD() == '3D' ? 'none' : '' // show/hide
// hint grabbable's obb-collider to track the window-object
instance.components['obb-collider'].data.trackedObject3D = 'components.html.el.object3D.children.0'
instance.components['obb-collider'].update()
// data2event demo
instance.setAttribute("data2event","")
com.data.myvalue = 1
com.data.foo = `instance ${instance.uid}: `
setInterval( () => com.data.myvalue++, 500 )
}
setTimeout( () => setupWindow(), 10 ) // give new components time to init
},
DOMready: function(){
this.el.setAttribute("window", `title: XRSH; uid: ${this.el.uid}; attach: #overlay; dom: #${this.el.dom.id}; class: no-min, no-max; width:250; height: 360`)
// data2event demo
this.el.setAttribute("data2event","")
this.data.myvalue = 1001
this.data.foo = `this.el ${this.el.uid}: `
setInterval( () => this.data.myvalue++, 500 )
// if you want your launch-icon to stick around after
// closing the window, then register it manually
window.launcher.register({
component: "helloworld-window",
...this.manifest,
icon: this.manifest.icons[0].src,
cb: () => {
const el = document.createElement("a-entity")
el.setAttribute("helloworld-window","")
el.setAttribute("pressable","")
el.setAttribute("position","0 1.6 0")
AFRAME.scenes[0].appendChild(el)
setTimeout( () => el.emit('launcher',null,false), 100 )
}
})
},
"window.oncreate": function(){
this.el.setAttribute("html-as-texture-in-xr", `domid: .winbox#${this.el.uid}; faceuser: true`)
ready: function( ){
this.el.dom.style.display = 'none'
console.log("this.el.dom has been added to DOM")
this.data.myvalue = 1
}
},

View file

@ -0,0 +1,156 @@
AFRAME.registerComponent('helloworld-iframe', {
schema: {
url: { type:"string"}
},
init: function(){},
requires:{
html: "https://unpkg.com/aframe-htmlmesh@2.1.0/build/aframe-html.js", // html to AFRAME
winboxjs: "https://unpkg.com/winbox@0.2.82/dist/winbox.bundle.min.js", // deadsimple windows: https://nextapps-de.github.io/winbox
winboxcss: "https://unpkg.com/winbox@0.2.82/dist/css/winbox.min.css", //
},
dom: {
scale: 3,
events: ['click','keydown'],
html: (me) => `<div class="relative">
<span id="warning">
<b>Unfortunately,</b><br><br>
The browser does not allow IFRAME rendering<br>
in immersive mode (for security reasons).<br><br>
</span>
<iframe src=""></iframe>
</div>`,
css: (me) => `
.XR #overlay .winbox.iframe{
visibility: visible;
position:relative;
} /* don't hide in XR mode */
.winbox.iframe > .wb-body { background:#FFF !important;
overflow-y: hidden;
overflow-x: hidden;
}
.winbox.iframe iframe { z-index:10; }
.winbox.iframe #warning { position:absolute; z-index:9; top:100px; left:20px; width:100%; height:50px; color:black; display:none; }
`
},
events:{
// combined AFRAME+DOM reactive events
click: function(e){ }, //
keydown: function(e){ }, //
// reactive updates (data2event.js)
url: function(e){
this.el.dom.querySelector('iframe').src = this.data.url
console.dir(this.el.dom.querySelector('iframe'))
},
launcher: async function(){
let URL = this.data.url || prompt('enter URL to display','https://fabien.benetou.fr/Wiki/Wiki')
if( !URL ) return
this.createWindow()
},
createWindow: function(){
let s = await AFRAME.utils.require(this.requires)
// instance this component
const instance = this.el.cloneNode(false)
this.el.sceneEl.appendChild( instance )
instance.setAttribute("dom", "")
instance.setAttribute("data2event","")
instance.setAttribute("visible", AFRAME.utils.XD() == '3D' ? 'true' : 'false' )
instance.setAttribute("position", AFRAME.utils.XD.getPositionInFrontOfCamera(1.39) )
instance.object3D.quaternion.copy( AFRAME.scenes[0].camera.quaternion ) // face towards camera
this.el.sceneEl.addEventListener('3D', () => {
instance.dom.querySelector('#warning').style.display = 'block' // show warning
})
const setupWindow = () => {
const com = instance.components['helloworld-iframe']
instance.dom.style.display = 'none'
new WinBox( this.manifest.short_name+" "+URL,{
width: Math.round(window.innerWidth*0.6),
height: Math.round(window.innerHeight*0.6),
class:["iframe"],
x:"center",
y:"center",
id: instance.uid, // important hint for html-mesh
root: document.querySelector("#overlay"),
mount: instance.dom,
onclose: () => { instance.dom.style.display = 'none'; return false; },
oncreate: () => {
com.data.url = URL
instance.setAttribute("position", AFRAME.utils.XD.getPositionInFrontOfCamera(0.5) )
instance.setAttribute("html",`html:#${instance.uid}; cursor:#cursor`)
instance.setAttribute("show-texture-in-xr","") // only show aframe-html texture in xr mode
}
});
instance.dom.style.display = ''
}
setTimeout( () => setupWindow(), 10 ) // give new components time to init
},
},
manifest: { // HTML5 manifest to identify app to xrsh
"short_name": "Iframe",
"name": "Hello world IFRAME window",
"icons": [
{
"src": "https://css.gg/browse.svg",
"src": "data:image/svg+xml;base64,PHN2ZwogIHdpZHRoPSIyNCIKICBoZWlnaHQ9IjI0IgogIHZpZXdCb3g9IjAgMCAyNCAyNCIKICBmaWxsPSJub25lIgogIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKPgogIDxwYXRoCiAgICBkPSJNNCA4QzQuNTUyMjggOCA1IDcuNTUyMjggNSA3QzUgNi40NDc3MiA0LjU1MjI4IDYgNCA2QzMuNDQ3NzIgNiAzIDYuNDQ3NzIgMyA3QzMgNy41NTIyOCAzLjQ0NzcyIDggNCA4WiIKICAgIGZpbGw9ImN1cnJlbnRDb2xvciIKICAvPgogIDxwYXRoCiAgICBkPSJNOCA3QzggNy41NTIyOCA3LjU1MjI4IDggNyA4QzYuNDQ3NzIgOCA2IDcuNTUyMjggNiA3QzYgNi40NDc3MiA2LjQ0NzcyIDYgNyA2QzcuNTUyMjggNiA4IDYuNDQ3NzIgOCA3WiIKICAgIGZpbGw9ImN1cnJlbnRDb2xvciIKICAvPgogIDxwYXRoCiAgICBkPSJNMTAgOEMxMC41NTIzIDggMTEgNy41NTIyOCAxMSA3QzExIDYuNDQ3NzIgMTAuNTUyMyA2IDEwIDZDOS40NDc3MSA2IDkgNi40NDc3MiA5IDdDOSA3LjU1MjI4IDkuNDQ3NzEgOCAxMCA4WiIKICAgIGZpbGw9ImN1cnJlbnRDb2xvciIKICAvPgogIDxwYXRoCiAgICBmaWxsLXJ1bGU9ImV2ZW5vZGQiCiAgICBjbGlwLXJ1bGU9ImV2ZW5vZGQiCiAgICBkPSJNMyAzQzEuMzQzMTUgMyAwIDQuMzQzMTUgMCA2VjE4QzAgMTkuNjU2OSAxLjM0MzE1IDIxIDMgMjFIMjFDMjIuNjU2OSAyMSAyNCAxOS42NTY5IDI0IDE4VjZDMjQgNC4zNDMxNSAyMi42NTY5IDMgMjEgM0gzWk0yMSA1SDNDMi40NDc3MiA1IDIgNS40NDc3MiAyIDZWOUgyMlY2QzIyIDUuNDQ3NzIgMjEuNTUyMyA1IDIxIDVaTTIgMThWMTFIMjJWMThDMjIgMTguNTUyMyAyMS41NTIzIDE5IDIxIDE5SDNDMi40NDc3MiAxOSAyIDE4LjU1MjMgMiAxOFoiCiAgICBmaWxsPSJjdXJyZW50Q29sb3IiCiAgLz4KPC9zdmc+",
"type": "image/svg+xml",
"sizes": "512x512"
}
],
"id": "/?source=pwa",
"start_url": "/?source=pwa",
"background_color": "#3367D6",
"display": "standalone",
"scope": "/",
"theme_color": "#3367D6",
"shortcuts": [
{
"name": "What is the latest news?",
"cli":{
"usage": "helloworld <type> [options]",
"example": "helloworld news",
"args":{
"--latest": {type:"string"}
}
},
"short_name": "Today",
"description": "View weather information for today",
"url": "/today?source=pwa",
"icons": [{ "src": "/images/today.png", "sizes": "192x192" }]
}
],
"description": "Hello world information",
"screenshots": [
{
"src": "/images/screenshot1.png",
"type": "image/png",
"sizes": "540x720",
"form_factor": "narrow"
}
],
"help":`
Helloworld application
This is a help file which describes the application.
It will be rendered thru troika text, and will contain
headers based on non-punctualized lines separated by linebreaks,
in above's case "\nHelloworld application\n" will qualify as header.
`
}
});

View file

@ -0,0 +1,135 @@
AFRAME.registerComponent('helloworld-window', {
schema: {
foo: { type:"string", "default":"foo"}
},
requires: {
dom: "./com/dom.js", // interpret .dom object
xrtexture: "./com/html-as-texture-in-xr.js", // allow switching between 3D/3D
html: "https://unpkg.com/aframe-htmlmesh@2.1.0/build/aframe-html.js", // html to AFRAME
winboxjs: "https://unpkg.com/winbox@0.2.82/dist/winbox.bundle.min.js", // deadsimple windows: https://nextapps-de.github.io/winbox
winboxcss: "https://unpkg.com/winbox@0.2.82/dist/css/winbox.min.css", //
},
init: function(){ },
dom: {
scale: 0.8,
events: ['click','keydown'],
html: (me) => `<div>
<div class="pad"> <span>${me.data.foo}</span> <b>${me.data.myvalue}</b></span>
</div>`,
css: (me) => `.helloworld-window div.pad { padding:11px; }`
},
events:{
// combined AFRAME+DOM reactive events
click: function(e){ }, //
keydown: function(e){ }, //
// reactive events for this.data updates (data2event.js)
myvalue: function(e){ this.el.dom.querySelector('b').innerText = this.data.myvalue },
foo: function(e){ this.el.dom.querySelector('span').innerText = this.data.foo },
launcher: async function(){
let s = await AFRAME.utils.require(this.requires)
// instance this component
const instance = this.el.cloneNode(false)
this.el.sceneEl.appendChild( instance )
instance.setAttribute("dom", "")
instance.setAttribute("grabbable","")
instance.object3D.quaternion.copy( AFRAME.scenes[0].camera.quaternion ) // face towards camera
const setupWindow = () => {
const com = instance.components['helloworld-window']
instance.dom.style.display = 'none'
new WinBox("Hello World",{
width: 250,
height: 150,
x:"center",
y:"center",
id: instance.uid, // important hint for html-mesh
root: document.querySelector("#overlay"),
mount: instance.dom,
onclose: () => { instance.dom.style.display = 'none'; return false; },
oncreate: () => {
instance.setAttribute("show-texture-in-xr",`domid: #${instance.uid}`) // only show aframe-html texture in xr mode
}
});
instance.dom.style.display = '' // show
// hint grabbable's obb-collider to track the window-object
instance.components['obb-collider'].data.trackedObject3D = 'components.html.el.object3D.children.0'
instance.components['obb-collider'].update()
// data2event demo
instance.setAttribute("data2event","")
com.data.myvalue = 1
com.data.foo = `instance ${instance.uid}: `
setInterval( () => com.data.myvalue++, 500 )
}
setTimeout( () => setupWindow(), 10 ) // give new components time to init
},
},
manifest: { // HTML5 manifest to identify app to xrsh
"short_name": "window",
"name": "Hello world window",
"icons": [
{
"src": "https://css.gg/browser.svg",
"src": "data:image/svg+xml;base64,PHN2ZwogIHdpZHRoPSIyNCIKICBoZWlnaHQ9IjI0IgogIHZpZXdCb3g9IjAgMCAyNCAyNCIKICBmaWxsPSJub25lIgogIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKPgogIDxwYXRoCiAgICBkPSJNNCA4QzQuNTUyMjggOCA1IDcuNTUyMjggNSA3QzUgNi40NDc3MiA0LjU1MjI4IDYgNCA2QzMuNDQ3NzIgNiAzIDYuNDQ3NzIgMyA3QzMgNy41NTIyOCAzLjQ0NzcyIDggNCA4WiIKICAgIGZpbGw9ImN1cnJlbnRDb2xvciIKICAvPgogIDxwYXRoCiAgICBkPSJNOCA3QzggNy41NTIyOCA3LjU1MjI4IDggNyA4QzYuNDQ3NzIgOCA2IDcuNTUyMjggNiA3QzYgNi40NDc3MiA2LjQ0NzcyIDYgNyA2QzcuNTUyMjggNiA4IDYuNDQ3NzIgOCA3WiIKICAgIGZpbGw9ImN1cnJlbnRDb2xvciIKICAvPgogIDxwYXRoCiAgICBkPSJNMTAgOEMxMC41NTIzIDggMTEgNy41NTIyOCAxMSA3QzExIDYuNDQ3NzIgMTAuNTUyMyA2IDEwIDZDOS40NDc3MSA2IDkgNi40NDc3MiA5IDdDOSA3LjU1MjI4IDkuNDQ3NzEgOCAxMCA4WiIKICAgIGZpbGw9ImN1cnJlbnRDb2xvciIKICAvPgogIDxwYXRoCiAgICBmaWxsLXJ1bGU9ImV2ZW5vZGQiCiAgICBjbGlwLXJ1bGU9ImV2ZW5vZGQiCiAgICBkPSJNMyAzQzEuMzQzMTUgMyAwIDQuMzQzMTUgMCA2VjE4QzAgMTkuNjU2OSAxLjM0MzE1IDIxIDMgMjFIMjFDMjIuNjU2OSAyMSAyNCAxOS42NTY5IDI0IDE4VjZDMjQgNC4zNDMxNSAyMi42NTY5IDMgMjEgM0gzWk0yMSA1SDNDMi40NDc3MiA1IDIgNS40NDc3MiAyIDZWOUgyMlY2QzIyIDUuNDQ3NzIgMjEuNTUyMyA1IDIxIDVaTTIgMThWMTFIMjJWMThDMjIgMTguNTUyMyAyMS41NTIzIDE5IDIxIDE5SDNDMi40NDc3MiAxOSAyIDE4LjU1MjMgMiAxOFoiCiAgICBmaWxsPSJjdXJyZW50Q29sb3IiCiAgLz4KPC9zdmc+",
"type": "image/svg+xml",
"sizes": "512x512"
}
],
"id": "/?source=pwa",
"start_url": "/?source=pwa",
"background_color": "#3367D6",
"display": "standalone",
"scope": "/",
"theme_color": "#3367D6",
"shortcuts": [
{
"name": "What is the latest news?",
"cli":{
"usage": "helloworld <type> [options]",
"example": "helloworld news",
"args":{
"--latest": {type:"string"}
}
},
"short_name": "Today",
"description": "View weather information for today",
"url": "/today?source=pwa",
"icons": [{ "src": "/images/today.png", "sizes": "192x192" }]
}
],
"description": "Hello world information",
"screenshots": [
{
"src": "/images/screenshot1.png",
"type": "image/png",
"sizes": "540x720",
"form_factor": "narrow"
}
],
"help":`
Helloworld application
This is a help file which describes the application.
It will be rendered thru troika text, and will contain
headers based on non-punctualized lines separated by linebreaks,
in above's case "\nHelloworld application\n" will qualify as header.
`
}
});

94
com/example/helloworld.js Normal file
View file

@ -0,0 +1,94 @@
AFRAME.registerComponent('helloworld', {
schema: {
wireframe: { type:"boolean", "default":false},
text: {type:"string","default":"hello world"}
},
dependencies: ['data2event'],
init: async function() {
this.el.object3D.visible = false
await AFRAME.utils.require(this.dependencies)
this.el.setAttribute("data2event","")
this.el.setAttribute("grabbable","")
this.el.innerHTML = `
<a-box position="-1 0.5 -3" rotation="0 45 0" color="#4CC3D9"></a-box>
<a-sphere position="0 1.25 -5" radius="1.25" color="#EF2D5E"></a-sphere>
<a-cylinder position="1 0.75 -3" radius="0.5" height="1.5" color="#FFC65D"></a-cylinder>
<a-entity position="0 1.8 -3" scale="10 10 10" text="value: ${this.data.text}; align:center; color:#888"></a-entity>
`
},
events:{
launcher: function(e){
this.el.object3D.visible = !this.el.object3D.visible
clearInterval(this.interval)
this.interval = setInterval( () => {
this.data.wireframe = !this.data.wireframe
}, 500 )
},
// reactive this.data value demo
wireframe:function( ){
this.el.object3D.traverse( (obj) => obj.material && (obj.material.wireframe = this.data.wireframe) )
}
},
manifest: { // HTML5 manifest to identify app to xrsh
"short_name": "Hello world",
"name": "Hello world",
"icons": [
{
"src": "https://css.gg/shape-hexagon.svg",
"src": "data:image/svg+xml;base64,PHN2ZwogIHdpZHRoPSIyNCIKICBoZWlnaHQ9IjI0IgogIHZpZXdCb3g9IjAgMCAyNCAyNCIKICBmaWxsPSJub25lIgogIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKPgogIDxwYXRoCiAgICBmaWxsLXJ1bGU9ImV2ZW5vZGQiCiAgICBjbGlwLXJ1bGU9ImV2ZW5vZGQiCiAgICBkPSJNNiAxNS4yMzQ4TDEyIDE4LjU2ODFMMTggMTUuMjM0OFY4Ljc2NTIxTDEyIDUuNDMxODhMNiA4Ljc2NTIxVjE1LjIzNDhaTTEyIDJMMyA3VjE3TDEyIDIyTDIxIDE3VjdMMTIgMloiCiAgICBmaWxsPSJjdXJyZW50Q29sb3IiCiAgLz4KPC9zdmc+",
"type": "image/svg+xml",
"sizes": "512x512"
}
],
"id": "/?source=pwa",
"start_url": "/?source=pwa",
"background_color": "#3367D6",
"display": "standalone",
"scope": "/",
"theme_color": "#3367D6",
"shortcuts": [
{
"name": "What is the latest news?",
"cli":{
"usage": "helloworld <type> [options]",
"example": "helloworld news",
"args":{
"--latest": {type:"string"}
}
},
"short_name": "Today",
"description": "View weather information for today",
"url": "/today?source=pwa",
"icons": [{ "src": "/images/today.png", "sizes": "192x192" }]
}
],
"description": "Hello world information",
"screenshots": [
{
"src": "/images/screenshot1.png",
"type": "image/png",
"sizes": "540x720",
"form_factor": "narrow"
}
],
"help":`
Helloworld application
This is a help file which describes the application.
It will be rendered thru troika text, and will contain
headers based on non-punctualized lines separated by linebreaks,
in above's case "\nHelloworld application\n" will qualify as header.
`
}
});

View file

@ -1,95 +1,67 @@
/**
* ## [html-as-texture-in-xr](com/html-as-texture-in-xr.js)
*
* shows domid **only** in immersive mode
* (wrapper around [aframe-htmlmesh](https://ada.is/aframe-htmlmesh/)
*
* It also sets class 'XR' to the (HTML) body-element in immersive mode.
* This allows CSS (in [dom component](com/dom.js)) to visually update accordingly.
*
* > depends on [AFRAME.utils.require](com/require.js)
*
* ```html
* <style type="text/css">
* .XR #foo { color:red; }
* </style>
*
* <a-entity html-as-texture-in-xr="domid: #foo">
* <b id="foo">hello</b>
* </a-entitiy>
* ```
*
* | property | type |
* |--------------|--------------------|
* | `domid` | `string` |
*
* | event | target | info |
* |--------------|------------|--------------------------------------|
* | `3D` | a-scene | fired when going into immersive mode |
* | `2D` | a-scene | fired when leaving immersive mode |
*
*/
if( !AFRAME.components['html-as-texture-in-xr'] ){
AFRAME.registerComponent('html-as-texture-in-xr', {
schema: {
domid: { type:"string"},
doublesided: {type: "boolean", default: true},
faceuser: { type: "boolean", default: false}
domid: { type:"string"}
},
dependencies:{
html: "com/lib/aframe-html.js"
html: "https://unpkg.com/aframe-htmlmesh@2.1.0/build/aframe-html.js", // html to AFRAME
//html: "https://coderofsalvation.github.io/aframe-htmlmesh/build/aframe-html.js"
//html: "com/aframe-html.js"
},
init: async function () {
let el = document.querySelector(this.data.domid)
if( ! el ){
return console.error("html-as-texture-in-xr: cannot get dom element "+this.data.domid)
return console.error("html-as-texture-in-xr: cannot get dom element "+this.data.dom.id)
}
let s = await AFRAME.utils.require(this.dependencies)
this.forwardClickToMesh();
this.el.sceneEl.addEventListener('enter-vr', () => this.enableDoubleSided() )
this.el.setAttribute("html",`html: ${this.data.domid}; cursor:#cursor; `)
this.el.setAttribute("html",`html: ${this.data.domid}; cursor:#cursor; xrlayer: true`)
this.el.setAttribute("visible", AFRAME.utils.XD() == '3D' ? 'true' : 'false' )
if( this.data.faceuser ){
this.el.setAttribute("position", AFRAME.utils.XD.getPositionInFrontOfCamera(0.4) )
}
this.el.setAttribute("position", AFRAME.utils.XD.getPositionInFrontOfCamera(0.5) )
},
forwardClickToMesh: function(){
// monkeypatch: forward click to mesh
const handle = AFRAME.components['html'].Component.prototype.handle
AFRAME.components['html'].Component.prototype.handle = function(type,evt){
if( !this.el.sceneEl.renderer.xr.isPresenting ) return // ignore events in desktop mode
if( this.el.sceneEl.renderer.xr.isPresenting && type.match(/^mouse/) ) return // ignore mouse-events in XR
if( type == 'click' && evt.detail.length && evt.detail[0].uv ){
const mesh = this.el.object3D.children[0]
const uv = evt.detail[0].uv;
const _pointer = new THREE.Vector2();
const _event = { type: '', data: _pointer };
_event.type = type;
_event.data.set( uv.x, 1 - uv.y );
mesh.dispatchEvent( _event );
manifest: { // HTML5 manifest to identify app to xrsh
"short_name": "show-texture-in-xr",
"name": "2D/3D switcher",
"icons": [],
"id": "/?source=pwa",
"start_url": "/?source=pwa",
"background_color": "#3367D6",
"display": "standalone",
"scope": "/",
"theme_color": "#3367D6",
"category":"system",
"shortcuts": [
{
"name": "What is the latest news?",
"cli":{
"usage": "helloworld <type> [options]",
"example": "helloworld news",
"args":{
"--latest": {type:"string"}
}
},
"short_name": "Today",
"description": "View weather information for today",
"url": "/today?source=pwa",
"icons": [{ "src": "/images/today.png", "sizes": "192x192" }]
}
return handle.apply(this,[type,evt])
}
},
enableDoubleSided: function(){
// enable doubleside
this.el.object3D.traverse( (o) => {
if( o.constructor && String(o.constructor).match(/HTMLMesh/) ){
o.material.side = THREE.DoubleSide
],
"description": "use ESC-key to toggle between 2D / 3D",
"screenshots": [
{
"src": "/images/screenshot1.png",
"type": "image/png",
"sizes": "540x720",
"form_factor": "narrow"
}
})
],
"help":`
Helloworld application
`
}
});

View file

@ -1,109 +1,51 @@
/**
* ## [isoterminal](com/isoterminal.js)
*
* Renders a windowed terminal in both (non)immersive mode.
* It displays an interactive javascript console or boots into
* a Linux ISO image (via WASM).
*
* ```html
* <a-entity isoterminal="iso: xrsh.iso" position="0 1.6 -0.3"></a-entity>
* ```
*
* > depends on [AFRAME.utils.require](com/require.js)
*
* | property | type | default | info |
* |-------------------|-----------|------------------------|------|
* | `title` | `string` | 'xrsh.iso' | window title |
* | `iso` | `string` | https`//forgejo.isvery.ninja/assets/xrsh-buildroot/main/xrsh.iso" | |
* | `overlayfs` | `string` | '' | zip URL/file to autoextract on top of filesystem |
* | `width` | `number` | 800 ||
* | `height` | `number` | 600 ||
* | `depth` | `number` | 0.03 ||
* | `lineHeight` | `number` | 18 ||
* | `bootMenu` | `string` | "" | character to auto-enter in bootMenu |
* | `bootMenuURL` | `string` | "" | character to auto-enter in bootMenu when URL has fragment (#foo.zip e.g.) |
* | `padding` | `number` | 18 | |
* | `maximized` | `boolean` | false | |
* | `minimized` | `boolean` | false | |
* | `muteUntilPrompt` | `boolean` | true | mute stdout until a prompt is detected in ISO |
* | `HUD` | `boolean` | false | link to camera movement |
* | `transparent` | `boolean` | false | heavy, needs good gpu |
* | `memory` | `number` | 60 | VM memory (in MB) [NOTE` quest or smartphone webworker might crash > 40mb ] |
* | `bufferLatency` | `number` | 1 | in ms` bufferlatency from webworker to term (batch-update every char to texture) |
* | `debug` | `boolean` | false | |
* | `emulator` | `string` | fbterm | terminal emulator |
*
* > for more info see [xrsh.isvery.ninja](https://xrsh.isvery.ninja)
*
* Component design:
* ```
* css/html template
*
* exit-AR
* com/dom com/window exit-VR
*
*
*
* com/isoterminal com/term.js com/html-as-texture-in-XR.js
*
*
* plane textdiv#isoterminal enter-VR
* enter-AR
*
*
* ISOTerminal.js
*
* com/isoterminal/worker.js
*
* v86.js
* feat/*.js
* libv86.js
*
*
*
*
* NOTE: For convenience reasons, events are forwarded between com/isoterminal.js, worker.js and ISOTerminal
* Instead of a melting pot of different functionnames, events are flowing through everything (ISOTerminal.emit())
* ```
*/
function ISOTerminal(instance,opts){
// create a neutral isoterminal object which can be decorated
// with prototype functions and has addListener() and dispatchEvent()
let obj = new EventTarget()
obj.instance = instance
obj.opts = opts
// register default event listeners (enable file based features like isoterminal/jsconsole.js e.g.)
for( let event in ISOTerminal.listener )
for( let cb in ISOTerminal.listener[event] )
obj.addEventListener( event, ISOTerminal.listener[event][cb] )
// compose object with functions
for( let i in ISOTerminal.prototype ) obj[i] = ISOTerminal.prototype[i]
obj.emit('init')
return obj
}
ISOTerminal.prototype.emit = function(event,data){
data = data || false
this.dispatchEvent( new CustomEvent(event, {detail: data} ) )
}
ISOTerminal.addEventListener = (event,cb) => {
ISOTerminal.listener = ISOTerminal.listener || {}
ISOTerminal.listener[event] = ISOTerminal.listener[event] || []
ISOTerminal.listener[event].push(cb)
}
// ISOTerminal has defacto support for AFRAME
// but can be decorated to work without it as well
if( typeof AFRAME != 'undefined '){
AFRAME.registerComponent('isoterminal', {
schema: {
iso: { type:"string", "default":"https://forgejo.isvery.ninja/assets/xrsh-buildroot/main/xrsh.iso" },
overlayfs: { type:"string"},
title: { type:"string", "default":"xrsh.iso"},
width: { type: 'number',"default": 800 },
height: { type: 'number',"default": 600 },
depth: { type: 'number',"default": 0.03 },
lineHeight: { type: 'number',"default": 18 },
bootOnFragment: { type: 'string',"default": "nothing" },
bootMenu: { type: 'string', "default": '' }, // show bootMenu if empty, or autosend key to bootMenu (key '1' e.g.)
bootMenuURL: { type: 'string', "default": '' }, // show bootMenu if empty, or autosend key to bootMenu (key '1' e.g.)
padding: { type: 'number',"default": 18 },
maximized: { type: 'boolean',"default":false},
minimized: { type: 'boolean',"default":false},
muteUntilPrompt:{ type: 'boolean',"default":true}, // mute stdout until a prompt is detected in ISO
HUD: { type: 'boolean',"default":false}, // link to camera movement
transparent: { type:'boolean', "default":false }, // need good gpu
memory: { type: 'number', "default":60 }, // VM memory (in MB) [NOTE: quest or smartphone might crash > 40mb ]
bufferLatency: { type: 'number', "default":1 }, // in ms: bufferlatency from webworker to xterm (batch-update every char to texture)
debug: { type: 'boolean', "default":false },
emulator: { type: 'string', "default": "fbterm" }// terminal emulator
iso: { type:"string", "default":"https://forgejo.isvery.ninja/assets/xrsh-buildroot/main/xrsh.iso" },
overlayfs: { type:"string"},
cols: { type: 'number',"default": 120 },
rows: { type: 'number',"default": 30 },
padding: { type: 'number',"default": 18 },
minimized: { type: 'boolean',"default":false},
maximized: { type: 'boolean',"default":true},
transparent: { type:'boolean', "default":false }, // need good gpu
xterm: { type: 'boolean', "default":true }, // use xterm.js? (=slower)
memory: { type: 'number', "default":48 } // VM memory (in MB)
},
init: function(){
init: async function(){
this.el.object3D.visible = false
if( window.innerWidth < this.data.width ){
this.data.maximized = true
}
this.calculateDimension()
this.initHud()
this.setupPasteDrop()
this.setupEvents()
fetch(this.data.iso,{method: 'HEAD'})
.then( (res) => {
if( res.status != 200 ) throw 'not found'
@ -117,156 +59,74 @@ if( typeof AFRAME != 'undefined '){
},
requires:{
com: "com/dom.js",
window: "com/window.js",
pastedrop: "com/pastedrop.js",
v86: "com/isoterminal/libv86.js",
com: "com/dom.js",
window: "com/window.js",
v86: "com/isoterminal/libv86.js",
// allow xrsh to selfcontain scene + itself
xhook: "com/lib/xhook.min.js",
//selfcontain: "com/selfcontainer.js",
xhook: "https://jpillora.com/xhook/dist/xhook.min.js",
selfcontain: "com/selfcontainer.js",
// html to texture
htmlinxr: "com/html-as-texture-in-xr.js",
// isoterminal global features
PromiseWorker: "com/isoterminal/PromiseWorker.js",
ISOTerminal: "com/isoterminal/ISOTerminal.js",
localforage: "com/isoterminal/localforage.js",
htmlinxr: "com/html-as-texture-in-xr.js",
// isoterminal features
core: "com/isoterminal/core.js",
utils_9p: "com/isoterminal/feat/9pfs_utils.js",
boot: "com/isoterminal/feat/boot.js",
jsconsole: "com/isoterminal/feat/jsconsole.js",
javascript: "com/isoterminal/feat/javascript.js",
indexhtml: "com/isoterminal/feat/index.html.js",
indexjs: "com/isoterminal/feat/index.js.js",
autorestore: "com/isoterminal/feat/autorestore.js",
localforage: "https://cdn.rawgit.com/mozilla/localForage/master/dist/localforage.js"
},
dom: {
scale: 0.66,
scale: 0.5,
events: ['click','keydown'],
html: (me) => `<div class="isoterminal">
<input type="file" id="pastedrop" style="position:absolute; left:-9999px;opacity:0"></input>
<div id="term" tabindex="0"></div>
<div id="screen" style="white-space: pre; font: 14px monospace; "></div>
<canvas style="display: none"></canvas>
<div id="serial"></div>
</div>`,
css: (me) => `
.isoterminal{
css: (me) => `.isoterminal{
padding: ${me.com.data.padding}px;
margin-top:-60px;
padding-bottom:60px;
width:100%;
height:99%;
resize: both;
overflow: hidden;
height:100%;
}
@font-face {
font-family: 'Cousine';
font-style: normal;
font-weight: 400;
src: url(./com/isoterminal/assets/Cousine.ttf) format('truetype');
src: url(./assets/Cousine.ttf) format('truetype');
}
@font-face {
font-family: 'Cousine';
font-style: normal;
font-weight: 700;
src: url(./com/isoterminal/assets/CousineBold.ttf) format('truetype');
}
.isoterminal *{
outline:none;
box-shadow:none;
white-space: pre;
line-height:16px;
display:inline;
overflow: hidden;
}
.term {
font-family: 'Cousine';
line-height: ${me.com.data.lineHeight}px;
font-weight: normal;
font-variant-ligatures: none;
color: #f0f0f0;
overflow: hidden;
white-space: nowrap;
.isoterminal *,
.xterm-dom-renderer-owner-1 .xterm-rows {
font-size: 14px;
font-family: "Cousine",Liberation Mono,DejaVu Sans Mono,Courier New,monospace;
font-weight:500 !important;
letter-spacing: 0 !important;
text-shadow: 0px 0px 10px #F075;
}
.term_content a {
color: inherit;
text-decoration: underline;
color:#2AFF;
}
.term_content a span{
text-shadow: 0px 0px 10px #F07A;
}
.term_content a:hover {
color: inherit;
text-decoration: underline;
animation:fade 1000ms infinite;
-webkit-animation:fade 1000ms infinite;
}
.term_cursor {
color: #000000;
background: #70f;
animation:fade 1000ms infinite;
-webkit-animation:fade 1000ms infinite;
}
.term_char_size {
display: inline-block;
visibility: hidden;
position: absolute;
top: 0px;
left: -1000px;
padding: 0px;
}
.term_textarea {
position: absolute;
top: 0px;
left: 0px;
width: 0px;
height: 0px;
padding: 0px;
border: 0px;
margin: 0px;
opacity: 0;
resize: none;
}
.term_scrollbar { background: transparent url(images/bg-scrollbar-track-y.png) no-repeat 0 0; position: relative; background-position: 0 0; float: right; height: 100%; }
.term_track { background: transparent url(images/bg-scrollbar-trackend-y.png) no-repeat 0 100%; height: 100%; width:13px; position: relative; padding: 0 1px; }
.term_thumb { background: transparent url(images/bg-scrollbar-thumb-y.png) no-repeat 50% 100%; height: 20px; width: 25px; cursor: pointer; overflow: hidden; position: absolute; top: 0; left: -5px; }
.term_thumb .term_end { background: transparent url(images/bg-scrollbar-thumb-y.png) no-repeat 50% 0; overflow: hidden; height: 5px; width: 25px; }
.noSelect { user-select: none; -o-user-select: none; -moz-user-select: none; -khtml-user-select: none; -webkit-user-select: none; }
.isoterminal style{ display:none }
blink{
border:none;
padding:none;
}
#overlay .winbox:has(> .isoterminal){
background:transparent;
box-shadow:none;
}
.XR .cursor {
animation:none;
-webkit-animation:none;
}
.winbox#${me.el.uid} .wb-header{
background: var(--xrsh-black) !important;
}
.wb-body:has(> .isoterminal){
background: var(--xrsh-black);
.wb-body:has(> .isoterminal){
background: #000C;
overflow:hidden;
}
.XR .isoterminal{
background: #000 !important;
.XR .wb-body:has(> .isoterminal){
background: #000;
}
.isoterminal *{
font-size: 14px;
font-family: "Cousine",Liberation Mono,DejaVu Sans Mono,Courier New,monospace;
font-weight:500 !important;
text-shadow: 0px 0px 10px #F075;
}
.isoterminal div{ display:block; }
.isoterminal span{ display: inline }
@keyframes fade {
from { opacity: 1.0; }
@ -280,49 +140,22 @@ if( typeof AFRAME != 'undefined '){
to { opacity: 1.0; }
}
.blink{
.blink {
animation:fade 1000ms infinite;
-webkit-animation:fade 1000ms infinite;
}
`
},
initTerminal: async function(singleton){
// why not latest xterm or v3.12 with builtin-canvas support?
// first versions used 1.5.4, a typescript rewrite which:
// * acts weird with oculus browser keyboard (does not repaint properly after typing)
// * does not use canvas anymore [which would be ideal for THREE.js texture]
// * does not allow switching between dom/canvas
// * only allows a standalone WebGL addon (conflicts with THREE)
// * heavily dependent on requestAnimationFrame (conflicts with THREE)
// * typescript-rewrite results in ~300k lib (instead of 96k)
// * v3.12 had slightly better performance but still very heavy
//
await AFRAME.utils.require(this.requires)
if( this.data.xterm ){
this.requires.xtermjs = "https://unpkg.com/@xterm/xterm@5.5.0/lib/xterm.js"
this.requires.xtermcss = "https://unpkg.com/@xterm/xterm@5.5.0/css/xterm.css"
this.requires.xterm = "com/isoterminal/feat/xterm.js"
}
let features = { // ISOTerminal plugins
boot: "com/isoterminal/feat/boot.js",
javascript: "com/isoterminal/feat/javascript.js",
jsconsole: "com/isoterminal/feat/jsconsole.js",
remotekeyboard: "com/isoterminal/feat/remotekeyboard.js",
indexhtml: "com/isoterminal/feat/index.html.js",
indexjs: "com/isoterminal/feat/index.js.js",
pastedropFeat: "com/isoterminal/feat/pastedrop.js",
httpfs: "com/isoterminal/feat/httpfs.js",
autorestore: "com/isoterminal/feat/autorestore.js",
}
if( document.location.hash.match(/#test/) || this.data.debug ){
features['tests'] = "tests/index.js"
}
if( this.data.emulator == 'fbterm' ){
features['fbtermjs'] = "com/isoterminal/term.js"
features['fbterm'] = "com/isoterminal/feat/term.js"
}
await AFRAME.utils.require(features)
// this one extends autorestore.js
await AFRAME.utils.require({remotestorage: "com/isoterminal/feat/remotestorage.js"})
let s = await AFRAME.utils.require(this.requires)
this.el.setAttribute("selfcontainer","")
@ -341,43 +174,44 @@ if( typeof AFRAME != 'undefined '){
}
// init isoterminal
this.term = new ISOTerminal(instance,this.data)
this.isoterminal = new ISOTerminal(instance,this.data)
instance.addEventListener('DOMready', () => {
this.term.emit('term_init', {instance, aEntity:this})
//instance.winbox.resize(720,380)
let size = `width: ${this.data.width}; height: ${this.data.height}`
instance.setAttribute("window", `title: ${this.data.title}; uid: ${instance.uid}; attach: #overlay; dom: #${instance.dom.id}; ${size}; min: ${this.data.minimized}; max: ${this.data.maximized}; class: no-full, no-min, no-close, no-max, no-resize; grabbable: components.html.el.object3D.children.${this.el.children.length}`)
let size = this.data.xterm ? 'width: 1024px; height:600px'
: 'width: 720px; height:455px'
instance.setAttribute("window", `title: xrsh.iso; uid: ${instance.uid}; attach: #overlay; dom: #${instance.dom.id}; ${size}; min: ${this.data.minimized}; max: ${this.data.maximized}`)
})
instance.addEventListener('window.oncreate', (e) => {
instance.dom.classList.add('blink')
// canvas to texture texture
instance.setAttribute("html-as-texture-in-xr", `domid: .winbox#${instance.uid}; faceuser: true`)
// run iso
let opts = {dom:instance.dom}
for( let i in this.data ) opts[i] = this.data[i]
opts.cols = this.cols
opts.rows = this.rows
this.term.start(opts)
this.isoterminal.runISO(opts)
})
instance.setAttribute("dom", "")
instance.setAttribute("pastedrop", "")
instance.setAttribute("dom", "")
// *REMOVE* make a boot-plugin mechanism in feat/term.js
this.term.addEventListener('enable-console', () => {
this.isoterminal.addEventListener('postReady', (e)=>{
// bugfix: send window dimensions to xterm (xterm.js does that from dom-sizechange to xterm via escape codes)
let wb = instance.winbox
if( this.data.maximized ){
wb.restore()
wb.maximize()
}else wb.resize()
})
this.isoterminal.addEventListener('ready', (e)=>{
instance.dom.classList.remove('blink')
this.isoterminal.emit('status',"running")
setTimeout( () => { // important: after window maximize animation to get true size
instance.setAttribute("html-as-texture-in-xr", `domid: #${instance.uid}`) // only show aframe-html in xr
},1500)
})
this.term.addEventListener('ready', (e) => {
instance.dom.classList.remove('blink')
this.term.emit('status',"running")
})
this.term.addEventListener('status', function(e){
this.isoterminal.addEventListener('status', function(e){
let msg = e.detail
const w = instance.winbox
if(!w) return
@ -394,75 +228,18 @@ if( typeof AFRAME != 'undefined '){
instance.addEventListener('window.onmaximize', resize )
const focus = (e) => {
if( event.target == document.activeElement ){
// if we're already focused, the keyboard is already triggerend
// therefore we blur() the element for correctness
// which allows the WebXR keyboard dissappear if implemented at all
// [Meta Quest 2 does not do this]
event.target.blur()
if( this.isoterminal?.emulator?.serial_adapter?.term ){
this.isoterminal.emulator.serial_adapter.term.focus()
}
// calculate distance between thumb and indexfinger to detect pinch
// which should prevent focus-event (annoying to have keyboard popping up during pinch)
if( e.detail?.withEl?.components['hand-tracking-controls'] ){
const hand = e.detail.withEl.components['hand-tracking-controls']
const thumb = hand.bones.find( (b) => b.name == 'thumb-tip' )
const diff = thumb.position.distanceTo(hand.indexTipPosition)
if( diff < 0.02) return // pinching! don't trigger keyboard (focus)
}
this.el.emit('focus',e.detail)
}
instance.addEventListener('obbcollisionstarted', focus )
this.el.addEventListener('obbcollisionstarted', focus )
this.el.sceneEl.addEventListener('exit-vr', focus )
this.el.sceneEl.addEventListener('exit-ar', focus )
this.el.sceneEl.addEventListener('enter-vr', focus )
this.el.sceneEl.addEventListener('enter-ar', focus )
instance.object3D.quaternion.copy( AFRAME.scenes[0].camera.quaternion ) // face towards camera
},
initHud: function(){
if( AFRAME.utils.device.isMobile() ) this.data.HUD = true
if( this.data.HUD ){
document.querySelector('[camera]').appendChild( this.el )
this.el.setAttribute("position","0 -0.03 -0.4")
}
},
setupPasteDrop: function(){
this.el.addEventListener('pasteFile', (e) => {
e.preventDefault() // prevent bubbling up to window (which is triggering this initially)
if( !this.term.pasteFile ) return // skip if feat/pastedrop.js is not loaded
this.term.pasteFile(e.detail)
})
return this
},
calculateDimension: function(){
if( this.data.width == -1 ) this.data.width = document.body.offsetWidth;
if( this.data.height == -1 ) this.data.height = Math.floor( document.body.offsetHeight - 30 )
if( this.data.height > this.data.width ) this.data.height = this.data.width // mobile smartphone fix
this.data.width -= this.data.padding*2
this.data.height -= this.data.padding*2
this.cols = Math.floor(this.data.width/this.data.lineHeight*2)-1
this.rows = Math.floor( (this.data.height*0.93)/this.data.lineHeight)-1
},
setupEvents: function(){
this.el.addEventListener('exec', (e) => this.term.exec( e.detail ) )
this.el.addEventListener('hook', (e) => this.term.hook( e.detail[0], e.detail[1] ) )
this.el.addEventListener('send', (e) => this.term.send( e.detail[0], e.detail[1] || 0 ) )
this.el.addEventListener('create_file', async (e) => await this.term.worker.create_file( e.detail[0], e.detail[1] ) )
this.el.addEventListener('create_file_from_url', async (e) => await this.term.worker.create_file_from_url( e.detail[0], e.detail[1] ) )
this.el.addEventListener('update_file', async (e) => await this.term.worker.update_file( e.detail[0], e.detail[1] ) )
this.el.addEventListener('append_file', async (e) => await this.term.worker.append_file( e.detail[0], e.detail[1] ) )
this.el.addEventListener('read_file', async (e) => {
const buf = await this.term.worker.read_file( e.detail[0] )
const str = new TextDecoder().decode(buf)
if( typeof e.detail[1] == 'function' ) e.detail[1](str,buf)
else console.log(str)
})
},
events:{
@ -473,13 +250,12 @@ if( typeof AFRAME != 'undefined '){
// reactive events for this.data updates
myvalue: function(e){ this.el.dom.querySelector('b').innerText = this.data.myvalue },
ready: function( ){
this.el.dom.style.display = 'none'
},
launcher: async function(){
if( !this.term.instance ){
this.initTerminal()
}else{
// toggle visibility
this.el.winbox[ this.el.winbox.min ? 'restore' : 'minimize' ]()
}
this.initTerminal()
}
},
@ -503,7 +279,6 @@ if( typeof AFRAME != 'undefined '){
"scope": "/",
"theme_color": "#3367D6",
"shortcuts": [
/*
{
"name": "What is the latest news?",
"cli":{
@ -518,9 +293,8 @@ if( typeof AFRAME != 'undefined '){
"url": "/today?source=pwa",
"icons": [{ "src": "/images/today.png", "sizes": "192x192" }]
}
*/
],
"description": "Runs an .iso file",
"description": "Hello world information",
"screenshots": [
{
"src": "/images/screenshot1.png",
@ -530,7 +304,7 @@ if( typeof AFRAME != 'undefined '){
}
],
"help":`
XRSH application
Helloworld application
This is a help file which describes the application.
It will be rendered thru troika text, and will contain

View file

@ -1,360 +0,0 @@
function ISOTerminal(instance,opts){
// create a neutral isoterminal object which can be decorated
// with prototype functions and has addListener() and dispatchEvent()
let obj = new EventTarget()
obj.instance = instance
obj.opts = opts
// register default event listeners (enable file based features like isoterminal/jsconsole.js e.g.)
for( let event in ISOTerminal.listener )
for( let cb in ISOTerminal.listener[event] )
obj.addEventListener( event, ISOTerminal.listener[event][cb] )
// compose object with functions
for( let i in ISOTerminal.prototype ) obj[i] = ISOTerminal.prototype[i]
obj.emit('init')
instance.sceneEl.emit("isoterminal_init",{})
return obj
}
ISOTerminal.prototype.emit = function(event,data,sender){
data = data || false
const evObj = new CustomEvent(event, {detail: data} )
// forward event to worker/instance/AFRAME element or component-function
// this feels complex, but actually keeps event- and function-names more concise in codebase
let fire = () => {
this.dispatchEvent( evObj )
if( sender != "instance" && this.instance ) this.instance.dispatchEvent(evObj)
if( sender != "worker" && this.worker ) this.worker.postMessage({event,data}, PromiseWorker.prototype.getTransferable(data) )
if( sender !== undefined && typeof this[event] == 'function' ) this[event].apply(this, data && data.push ? data : [data] )
}
if( event.match(/^serial/) ){
this.preventFrameDrop( fire )
}else fire()
}
ISOTerminal.addEventListener = (event,cb) => {
ISOTerminal.listener = ISOTerminal.listener || {}
ISOTerminal.listener[event] = ISOTerminal.listener[event] || []
ISOTerminal.listener[event].push(cb)
}
ISOTerminal.prototype.exec = function(opts){
const shellscript = opts[0];
const cb = opts[1];
let cmd = `printf "\n\r"; { sh<<EOF\n ${shellscript}; \nEOF\n} &> /mnt/exec;\n`
console.log(cmd)
if( cb ){
window.cb = cb
cmd += `js 'document.querySelector("[isoterminal]").emit("read_file", ["exec", window.cb ])';\n`
}
this.send( cmd, 1 )
}
ISOTerminal.prototype.hook = function(hookname,args){
let cmd = `{ type hook || source /etc/profile.sh; }; hook ${hookname} "${args.join('" "')}"`
this.exec([cmd])
}
ISOTerminal.prototype.serial_input = 0; // can be set to 0,1,2,3 to define stdinput tty (xterm plugin)
ISOTerminal.prototype.send = function(str, ttyNr){
if( ttyNr == undefined) ttyNr = this.serial_input
if( (this.emulator || this.worker) && this.ready ){
if( ttyNr == undefined ){
if( this.emulator.serial_adapter ){
this.emulator.serial_adapter.term.paste(str)
}else this.emulator.keyboard_send_text(str) // vga screen
}else{
this.convert.toUint8Array( str ).map( (c) => {
this.preventFrameDrop(
() => {
this.worker.postMessage({event:`serial${ttyNr}-input`,data:c})
}
)
})
}
}else{
this.emit('serial-output-string', str)
}
}
ISOTerminal.prototype.convert = {
arrayBufferToBase64: function(buffer){
let binary = '';
const bytes = new Uint8Array(buffer);
const len = bytes.byteLength;
for (let i = 0; i < len; i++) binary += String.fromCharCode(bytes[i]);
return btoa(binary);
},
base64ToArrayBuffer: function(base64) {
const binaryString = atob(base64);
const len = binaryString.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
},
toUint8Array: function(str) {
str = String(str) || String("")
// Create a new Uint8Array with the same length as the input string
const uint8Array = new Uint8Array(str.length);
// Iterate over the string and populate the Uint8Array
for (let i = 0; i < str.length; i++) {
uint8Array[i] = str.charCodeAt(i);
}
return uint8Array;
},
Uint8ArrayToString: function(arr){
const decoder = new TextDecoder('utf-8'); // Specify encoding
return decoder.decode(arr);
}
}
ISOTerminal.prototype.start = function(opts){
let me = this
this.opts = {...this.opts, ...opts}
let image = {}
if( opts.iso.match(/\.iso$/) ) image.cdrom = { url: opts.iso }
if( opts.iso.match(/\.bin$/) ) image.bzimage = { url: opts.iso }
opts = { ...image,
uart1:true, // /dev/ttyS1
uart2:true, // /dev/ttyS2
uart3:true, // /dev/ttyS3
wasm_path: "v86.wasm",
memory_size: opts.memory * 1024 * 1024,
vga_memory_size: 2 * 1024 * 1024,
//screen_container: opts.dom,
//serial_container: opts.dom,
bios: {
url: "bios/seabios.bin",
},
vga_bios: {
url: "bios/vgabios.bin",
//urg|: "com/isoterminal/bios/VGABIOS-lgpl-latest.bin",
},
cmdline: "rw root=host9p rootfstype=9p rootflags=trans=virtio,cache=loose modules=virtio_pci tsc=reliable init_on_freg|=on vga=ask", //vga=0x122",
net_device:{
//type:"ne2k",
relay_url:"fetch"
//relay_url:"wss://relay.widgetry.org/"
//local_http: true,
//type:"virtio",
//relay_url:"fetch",
//cors_proxy: "https://corsproxy.io/"
},
//bzimage_initrd_from_filesystem: true,
//filesystem: {
// baseurl: "com/isoterminal/v86/images/alpine-rootfs-flat",
// basefs: "com/isoterminal/v86/images/alpine-fs.json",
// },
//screen_dummy: true,
//disable_jit: false,
overlayfs: this.opts.overlayfs,
filesystem: {},
autostart: true,
prompt: this.opts.prompt,
debug: this.opts.debug ? true : false
};
this
.setupWorker(opts)
.startVM(opts)
}
ISOTerminal.prototype.setupWorker = function(opts){
if( typeof window.PromiseWorker == 'undefined' ) return this
/*
* the WebWorker (which runs v86)
*
*/
this.worker = new PromiseWorker( "com/isoterminal/worker.js", (cb,event,data) => {
if( !data.promiseId ) this.emit(event,data,"worker") // forward event to world
this.preventFrameDrop( cb(event,data) )
})
return this
}
ISOTerminal.prototype.getLoaderMsg = function(){
const loading = [
'loading quantum bits and bytes',
'preparing quantum flux capacitors',
'crunching peanuts and chakras',
'preparing parallel universe',
'loading quantum state fluctuations',
'preparing godmode',
'loading cat pawns and cuteness',
'beaming up scotty',
'still faster than Windows update',
'loading a microlinux',
'figuring out meaning of life',
'Aligning your chakras now',
'Breathing in good vibes',
'Finding inner peace soon',
'Centering your Zen energy',
'Awakening third eye powers',
'Tuning into the universe',
'Balancing your cosmic karma',
'Stretching time and space',
'Recharging your soul battery',
'Transcending earthly limits'
]
const empower = [
"FOSS gives users control over their software, offering freedom to modify and share",
"Feeling powerless? FOSS escapes a mindset known as learned helplessness",
"FOSS breaks this cycle by showing that anyone can learn and contribute",
"Proprietary software can make users dependent, but FOSS offers real choices",
"FOSS communities provide support and encourage users to develop new skills",
"FOSS empowers users to customize and improve their tools",
"Engaging with FOSS helps build confidence and self-reliance in tech",
"FOSS shows that anyone can shape the digital world with curiosity and effort",
"Linux can revive old computers, extending their life and reduces e-waste",
"Many lightweight Linux distributions run smoothly on older hardware",
"Installing Linux on aging devices keeps them functional instead of sending them to the landfill",
"Linux uses fewer resources, making it ideal for reusing older machines",
"By using Linux, you can avoid buying new hardware, cutting down on tech waste",
"Instead of discarding slow devices, Linux can bring them back to life",
"Linux supports a wide range of devices, helping to prevent e-waste",
"Open-source drivers in Linux enable compatibility with old peripherals, reducing the need for replacements",
"Free Linux software helps users avoid planned obsolescence in commercial products",
"Switching to Linux promotes sustainability by reducing demand for new gadgets and lowering e-waste"
]
let motd = `
\r. . ____ _____________ ________. ._. ._. . .
\r. . .\\ \\/ /\\______ \\/ _____// | \\. .
\r. . . \\ / | _/\\_____ \\/ ~ \\ .
\r. . . / \\ | | \\/ \\ Y / .
\r. . ./___/\\ \\ |____|_ /_______ /\\___|_ /. .
\r. . . . . .\\_/. . . . \\/ . . . .\\/ . . _ \\/ . .
\r https://xrsh.isvery.ninja ▬▬▬▬▬▬▬▬▬▬▬▬
\rlocal-first, polyglot, unixy WebXR IDE & runtime
\r
\rcredits
\r-------
\rhttps://www.w3.org/TR/webxr
\rhttps://xrfragment.org
\rhttps://threejs.org
\rhttps://aframe.org
\rhttps://busybox.net
\rhttps://buildroot.org
\rfediverse: @lvk@mastodon.online @utopiah@mastodon.pirateparty.be @nlnet@nlnet.nl
\r`
const text_color = "\r"
const text_reset = "\033[0m"
const loadmsg = "\n\r"+loading[ Math.floor(Math.random()*1000) % loading.length ] + "..please wait \n\n\r"
const empowermsg = "\n\r"+text_reset+'"'+empower[ Math.floor(Math.random()*1000) % empower.length ] + '"\n\r'
return { motd, text_color, text_reset, loadmsg, empowermsg}
}
ISOTerminal.prototype.startVM = function(opts){
this.v86opts = opts
this.addEventListener('emulator-started', async (e) => {
if( this.boot.fromImage ) return this.emit('serial-output-string', "\r[!] downloading session...please wait\n\r[!] this could take a while depending on your connection..\n\r")
let line = ''
this.ready = false
this.addEventListener(`serial0-output-string`, async (e) => {
const str = e.detail
// lets scan for a prompt so we can send a 'ready' event to the world
if( !this.ready && str.match(/\n(\/ #|~ #|~%|\[.*\]>)/) ) this.postBoot()
if( this.ready || !this.opts.muteUntilPrompt ) this.emit('serial-output-string', e.detail )
})
});
let msglib = this.getLoaderMsg()
let msg = msglib.motd
this.emit('serial-output-string', msg)
this.emit('bootMenu',{bootMenu: this.opts.bootMenu, bootMenuURL: this.opts.bootMenuURL })
}
ISOTerminal.prototype.bootISO = function(){
const getImage = (str) => decodeURIComponent(
str.match(/\&img=/) ? str.replace(/.*img=/,'').replace(/\&.*/,'') : ''
)
let msglib = this.getLoaderMsg()
this.emit('status',msglib.loadmsg)
let msg = "\n\r" + msglib.empowermsg + msglib.text_color + msglib.loadmsg + msglib.text_reset
this.emit('serial-output-string', msg)
if( getImage(this.boot.hash) ){
this.boot.fromImage = true
}
this.emit('runISO',{...this.v86opts, bufferLatency: this.opts.bufferLatency, img: getImage(this.boot.hash) })
}
ISOTerminal.prototype.postBoot = function(cb){
this.emit('postReady',{})
this.ready = true
setTimeout( () => {
this.emit('ready',{})
if( cb ) cb()
}, 500 )
}
// this is allows (unsophisticated) outputbuffering
ISOTerminal.prototype.bufferOutput = function(byte,cb,latency){
const resetBuffer = () => ({str:""})
this.buffer = this.buffer || resetBuffer()
this.buffer.str += String.fromCharCode(byte)
if( !this.buffer.id ){
cb(this.buffer.str) // send out leading call
this.buffer = resetBuffer()
this.buffer.id = setTimeout( () => { // accumulate succesive calls
if( this.buffer.str ) cb(this.buffer.str)
this.buffer = resetBuffer()
}, this.latency || 250)
}
}
//ISOTerminal.prototype.bufferOutput = function(byte, cb, latency, buffer) {
// const str = String.fromCharCode(byte);
// //if (str === '\r' || str === '\n' || str.charCodeAt(0) < 32 || str.charCodeAt(0) === 127) {
// // cb(str);
// //} else if (str === '\x1b') { // ESC
// // buffer.esc = true;
// //} else if (buffer.esc) {
// // cb('\x1b' + str);
// // buffer.esc = false;
// //} else {
// buffer.str = (buffer.str || '') + str;
// if (Date.now() - (buffer.timestamp || 0) >= latency) {
// console.log(buffer.str)
// cb(buffer.str);
// buffer.str = '';
// buffer.timestamp = Date.now();
// }
// //}
//}
ISOTerminal.prototype.preventFrameDrop = function(cb){
// don't let workers cause framerate dropping
const xr = this.instance.sceneEl.renderer.xr
if( xr.isPresenting ){
xr.getSession().requestAnimationFrame(cb)
}else{
window.requestAnimationFrame(cb)
}
}

View file

@ -1,77 +0,0 @@
/*
* This is basically a Javascript Proxy for 'new Worker()'
* which allows calling worker-functions via promises.
*
* It's basically comlink without the fat.
*
* const w = new PromiseWorker("worker.js", (cb,event_or_fn,data) => {
* cb(event_or_fn,data) // decorate/ratelimit/hooks here
* })
* w.foo().then( console.dir )
*
* in worker.js define a function: this.foo = () => return {foo:"Bar"}
*/
function PromiseWorker(file, onmessage){
let proxy
const worker = new Worker(file)
worker.onmessage = (e) => { // handle messages originating from worker
const {event,data} = e.data // this is worker.onmessage(...)
const cb = (event,data) => () => { //
if( data.promiseId ){ //
proxy.resolver(data) // forward to promise resolver
}
}
onmessage(cb,event,data)
}
return proxy = new Proxy(this,{
get(me,k){
if( k.match(/(postMessage|onmessage)/) ) return worker[k].bind(worker)
if( k.match(/(resolver|promise)/) ) return this[k].bind(this)
// promisify postMessage(...) call
return function(){
return this.promise(me,{event: k, data: [...arguments] })
}
},
promise(me,msg){
if( typeof msg != 'object' || !msg.event || !msg.data ){
throw 'worker.promise({event:..,data:..}) did not receive correct msg : '+JSON.stringify(msg)
}
this.resolvers = this.resolvers || {last:1,pending:{}}
msg.data.promiseId = this.resolvers.last++
// Send id and task to WebWorker
let dataTransferable = PromiseWorker.prototype.getTransferable(msg.data)
worker.postMessage(msg, dataTransferable )
return new Promise( resolve => this.resolvers.pending[ msg.data.promiseId ] = resolve );
},
resolver(data){
if( !data || !data.promiseId ) throw 'promiseId not given'
this.resolvers.pending[ data.promiseId ](data.result);
delete this.resolvers.pending[ data.promiseId ]; // Prevent memory leak
}
})
}
PromiseWorker.prototype.getTransferable = function(data){
let objs = []
function isTransferable(obj) {
return obj instanceof ArrayBuffer ||
obj instanceof MessagePort ||
obj instanceof ImageBitmap ||
(typeof OffscreenCanvas !== 'undefined' && obj instanceof OffscreenCanvas) ||
(typeof ReadableStream !== 'undefined' && obj instanceof ReadableStream) ||
(typeof WritableStream !== 'undefined' && obj instanceof WritableStream) ||
(typeof TransformStream !== 'undefined' && obj instanceof TransformStream);
}
for( var i in data ){
if( isTransferable(data[i]) ) objs.push(data[i])
}
return objs.length ? objs : undefined
}

Binary file not shown.

View file

@ -1,16 +0,0 @@
--2024-09-23 17:49:04-- https://fonts.gstatic.com/s/cousine/v27/d6lNkaiiRdih4SpP9Z8K6T4.ttf
Resolving fonts.gstatic.com (fonts.gstatic.com)... 142.250.180.195, 2a00:1450:400d:806::2003
Connecting to fonts.gstatic.com (fonts.gstatic.com)|142.250.180.195|:443... connected.
HTTP request sent, awaiting response... 200 OK
Length: 287924 (281K) [font/ttf]
Saving to: d6lNkaiiRdih4SpP9Z8K6T4.ttf
0K .......... .......... .......... .......... .......... 17% 2.31M 0s
50K .......... .......... .......... .......... .......... 35% 1.91M 0s
100K .......... .......... .......... .......... .......... 53% 9.09M 0s
150K .......... .......... .......... .......... .......... 71% 3.03M 0s
200K .......... .......... .......... .......... .......... 88% 3.40M 0s
250K .......... .......... .......... . 100% 12.4M=0.09s
2024-09-23 17:49:05 (3.23 MB/s) - d6lNkaiiRdih4SpP9Z8K6T4.ttf saved [287924/287924]

193
com/isoterminal/core.js Normal file
View file

@ -0,0 +1,193 @@
//ISOTerminal.prototype.exec(cmd_array,stdin){
// // exec(['lua'] "print \"hello\") ---> cat /dev/browser/js/stdin | lua > /dev/browser/js/stdout
//}
ISOTerminal.prototype.serial_input = undefined; // can be set to 0,1,2,3 to define stdinput tty (xterm plugin)
ISOTerminal.prototype.exec = function(shellscript){
//let ts = String(Date.now())+".job"
//this.emulator.create_file(ts, this.toUint8Array(shellscript) )
this.send(shellscript+"\n",1)
}
ISOTerminal.prototype.send = function(str, ttyNr){
if( !ttyNr ) ttyNr = this.serial_input
if( !ttyNr ){
if( this.emulator.serial_adapter ){
this.emulator.serial_adapter.term.paste(str)
}else this.emulator.keyboard_send_text(str) // vga screen
}else{
this.convert.toUint8Array( str ).map( (c) => this.emulator.bus.send(`serial${ttyNr}-input`, c ) )
}
}
ISOTerminal.prototype.convert = {
arrayBufferToBase64: function(buffer){
let binary = '';
const bytes = new Uint8Array(buffer);
const len = bytes.byteLength;
for (let i = 0; i < len; i++) binary += String.fromCharCode(bytes[i]);
return window.btoa(binary);
},
base64ToArrayBuffer: function(base64) {
const binaryString = window.atob(base64);
const len = binaryString.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
},
toUint8Array: function(str) {
str = String(str) || String("")
// Create a new Uint8Array with the same length as the input string
const uint8Array = new Uint8Array(str.length);
// Iterate over the string and populate the Uint8Array
for (let i = 0; i < str.length; i++) {
uint8Array[i] = str.charCodeAt(i);
}
return uint8Array;
},
Uint8ArrayToString: function(arr){
const decoder = new TextDecoder('utf-8'); // Specify encoding
return decoder.decode(arr);
}
}
ISOTerminal.prototype.runISO = function(opts){
let me = this
this.opts = {...this.opts, ...opts}
let image = {}
if( opts.iso.match(/\.iso$/) ) image.cdrom = { url: opts.iso }
if( opts.iso.match(/\.bin$/) ) image.bzimage = { url: opts.iso }
opts = { ...image,
uart1:true, // /dev/ttyS1
uart2:true, // /dev/ttyS2
uart3:true, // /dev/ttyS3
wasm_path: "com/isoterminal/v86.wasm",
memory_size: opts.memory * 1024 * 1024,
vga_memory_size: 2 * 1024 * 1024,
screen_container: opts.dom,
//serial_container: opts.dom,
bios: {
url: "com/isoterminal/bios/seabios.bin",
},
vga_bios: {
url: "com/isoterminal/bios/vgabios.bin",
//urg|: "com/isoterminal/bios/VGABIOS-lgpl-latest.bin",
},
network_relay_url: "wss://relay.widgetry.org/",
cmdline: "rw root=host9p rootfstype=9p rootflags=trans=virtio,cache=loose modules=virtio_pci tsc=reliable init_on_freg|=on vga=ask", //vga=0x122",
//bzimage_initrd_from_filesystem: true,
//filesystem: {
// baseurl: "com/isoterminal/v86/images/alpine-rootfs-flat",
// basefs: "com/isoterminal/v86/images/alpine-fs.json",
// },
//screen_dummy: true,
//disable_jit: false,
filesystem: {},
autostart: true,
};
this.emit('runISO',opts)
let emulator = this.emulator = new V86(opts)
const loading = [
'loading quantum bits and bytes',
'preparing quantum flux capacitors',
'crunching peanuts and chakras',
'preparing parallel universe',
'loading quantum state fluctuations',
'preparing godmode',
'loading cat pawns and cuteness',
'beaming up scotty',
'still faster than Windows update',
'loading a microlinux',
'figuring out meaning of life',
'Aligning your chakras now',
'Breathing in good vibes',
'Finding inner peace soon',
'Centering your Zen energy',
'Awakening third eye powers',
'Tuning into the universe',
'Balancing your cosmic karma',
'Stretching time and space',
'Recharging your soul battery',
'Transcending earthly limits'
]
let loadmsg = loading[ Math.floor(Math.random()*1000) % loading.length ]
this.emit('status',loadmsg)
// replace welcome message https://github.com/copy/v86/blob/3c77b98bc4bc7a5d51a2056ea73d7666ca50fc9d/src/browser/serial.js#L231
let welcome = "This is the serial console. Whatever you type or paste here will be sent to COM1"
let motd = "\r"
let msg = `${loadmsg}, please wait..`
while( msg.length < welcome.length ) msg += " "
msg += "\n"
motd += msg+"\033[0m"
emulator.bus.register("emulator-started", async (e) => {
this.emit('emulator-started',e)
if( emulator.serial_adapter ){
emulator.serial_adapter.term.clear()
emulator.serial_adapter.term.write(motd)
}
if( me.opts.overlayfs ){
fetch(me.opts.overlayfs)
.then( (f) => {
f.arrayBuffer().then( (buf) => {
emulator.create_file('overlayfs.zip', new Uint8Array(buf) )
})
})
}
let line = ''
let ready = false
emulator.add_listener(`serial0-output-byte`, async (byte) => {
this.emit('${this.serial}-output-byte',byte)
var chr = String.fromCharCode(byte);
if(chr < " " && chr !== "\n" && chr !== "\t" || chr > "~")
{
return;
}
if(chr === "\n")
{
var new_line = line;
line = "";
}
else if(chr >= " " && chr <= "~")
{
line += chr;
}
if( !ready && line.match(/^(\/ #|~%|\[.*\]>)/) ){
this.emit('postReady',e)
setTimeout( () => this.emit('ready',e), 500 )
ready = true
}
});
});
}
ISOTerminal.prototype.readFromPipe = function(filename,cb){
this.emulator.add_listener("9p-write-end", async (opts) => {
if ( opts[0] == filename.replace(/.*\//,'') ){
const buf = await this.emulator.read_file("console.tty")
cb( this.convert.Uint8ArrayToString(buf) )
}
})
}

View file

@ -1,109 +1,46 @@
let emulator = this.emulator
let me = this
ISOTerminal.addEventListener('emulator-started', function(){
let emulator = this.emulator
let isoterminal = this
emulator.fs9p.update_file = async function(file,data){
const convert = ISOTerminal.prototype.convert
emulator.fs9p.update_file = async function(file,data){
const p = this.SearchPath(file);
const p = this.SearchPath(file);
if(p.id === -1)
{
return emulator.create_file(file,data)
}
const inode = this.GetInode(p.id);
const buf = typeof data == 'string' ? convert.toUint8Array(data) : data || ""
if( buf.length == 0 ) return new Promise( (resolve,reject) => resolve(data) )
try{
if(p.id === -1)
{
return emulator.create_file(file,data)
}
const inode = this.GetInode(p.id);
const buf = typeof data == 'string' ? isoterminal.convert.toUint8Array(data) : data
await this.Write(p.id,0, buf.length, buf )
// update inode
inode.size = buf.length
const now = Math.round(Date.now() / 1000);
inode.atime = inode.mtime = now;
isoterminal.exec(`touch ${file}`) // update inode
return new Promise( (resolve,reject) => resolve(buf) )
}catch(e){
console.error({file,data})
return new Promise( (resolve,reject) => reject(e) )
}
}
emulator.fs9p.create_file_from_url = async function(file,url){
const convert = ISOTerminal.prototype.convert
return fetch(url)
.then( (res) => res.arrayBuffer() )
.then( (buf) => {
let arr = new Uint8Array(buf)
return emulator.create_file(file, arr )
})
.catch( (e) => {
emulator.create_file(file, new Uint8Array() ) // empty file so at least other processes can check for error (v86 has no retcodes for fs9p)
console.error(e)
})
}
}
emulator.fs9p.append_file = async function(file,data){
const convert = ISOTerminal.prototype.convert
emulator.fs9p.append_file = async function(file,data){
const p = this.SearchPath(file);
const p = this.SearchPath(file);
if(p.id === -1)
{
return Promise.resolve(null);
}
const inode = this.GetInode(p.id);
const buf = typeof data == 'string' ? convert.toUint8Array(data) : data
await this.Write(p.id, inode.size, buf.length, buf )
// update inode
inode.size = inode.size + buf.length
const now = Math.round(Date.now() / 1000);
inode.atime = inode.mtime = now;
return new Promise( (resolve,reject) => resolve(buf) )
if(p.id === -1)
{
return Promise.resolve(null);
}
const inode = this.GetInode(p.id);
const buf = typeof data == 'string' ? isoterminal.convert.toUint8Array(data) : data
await this.Write(p.id, inode.size, buf.length, buf )
// update inode
inode.size = inode.size + buf.length
const now = Math.round(Date.now() / 1000);
inode.atime = inode.mtime = now;
return new Promise( (resolve,reject) => resolve(buf) )
}
}
emulator.fs9p.read_file_world = async function(file){
const p = this.SearchPath(file);
if(p.id === -1)
{
return Promise.resolve(null);
}
const inode = this.GetInode(p.id);
const perms = this.parseFilePermissions(inode.mode)
if( !perms.world.read ){
return Promise.resolve(null);
}
return this.Read(p.id, 0, inode.size);
}
emulator.fs9p.parseFilePermissions = function(permissionInt) {
// Convert the permission integer to octal
const octalPermissions = permissionInt.toString(8);
// Extract the permission bits (last 3 digits in octal)
const permissionBits = octalPermissions.slice(-3);
function parsePermission(digit) {
const num = parseInt(digit, 10);
return {
read: Boolean(num & 4), // 4 = read
write: Boolean(num & 2), // 2 = write
execute: Boolean(num & 1) // 1 = execute
};
}
// Decode the permissions
const permissions = {
owner: parsePermission(permissionBits[0]),
group: parsePermission(permissionBits[1]),
world: parsePermission(permissionBits[2]),
};
return permissions;
}
})

View file

@ -1,106 +1,52 @@
// this is restoring state to/from the v86 emulator
// however instead of passing the huge blob between webworker/browser
// we transfer it via localforage as a base64 string
ISOTerminal.addEventListener('emulator-started', function(e){
this.autorestore(e)
})
if( typeof emulator != 'undefined' ){
// inside worker-thread
importScripts("localforage.js") // we don't instance it again here (just use its functions)
this.restore_state = async function(data){
// fastforward instance state
this.opts.muteUntilPrompt = false
this.ready = true
ISOTerminal.prototype.autorestore = async function(e){
return new Promise( (resolve,reject) => {
localforage.getItem("state", async (err,stateBase64) => {
if( stateBase64 && !err ){
state = ISOTerminal.prototype.convert.base64ToArrayBuffer( stateBase64 )
await emulator.restore_state(state)
console.log("restored state")
}else return reject("worker.js: emulator.restore_state (could not get state from localforage)")
resolve()
})
})
}
this.save_state = async function(){
return new Promise( async (resolve,reject ) => {
console.log("saving session")
let state = await emulator.save_state()
localforage.setDriver([
localforage.INDEXEDDB,
localforage.WEBSQL,
localforage.LOCALSTORAGE
])
.then( () => {
localforage.setItem("state", ISOTerminal.prototype.convert.arrayBufferToBase64(state) )
console.log("state saved")
resolve()
})
.catch( reject )
})
}
localforage.setDriver([
localforage.INDEXEDDB,
localforage.WEBSQL,
localforage.LOCALSTORAGE
]).then( () => {
}else{
// inside browser-thread
ISOTerminal.addEventListener('emulator-started', function(e){
this.autorestore(e)
this.emit("autorestore-installed")
})
ISOTerminal.prototype.restore = async function(e){
const onGetItem = (err,stateBase64) => {
const askConfirm = () => {
if( window.localStorage.getItem("restorestate") == "true" ) return true
try{
const scene = document.querySelector('a-scene');
if( scene.is('ar-mode') ) scene.exitAR()
if( scene.is('vr-mode') ) scene.exitVR()
}catch(e){}
return confirm( "Continue old session?" )
}
if( stateBase64 && !err && document.location.hash.length < 2 && askConfirm() ){
localforage.getItem("state", async (err,stateBase64) => {
if( !err && confirm('continue last session?') ){
this.noboot = true // see feat/boot.js
try{
this.worker.restore_state()
.then( () => {
// simulate / fastforward boot events
this.postBoot( () => {
// force redraw terminal issue
this.send("l")
setTimeout( () => this.send("l"), 200 )
//this.send("12")
this.emit("exec",["source /etc/profile.sh; hook wakeup\n"])
this.emit("restored")
})
})
}catch(e){ console.error(e) }
}
state = this.convert.base64ToArrayBuffer( stateBase64 )
this.emulator.restore_state(state)
this.emit('postReady',e)
setTimeout( () => {
this.emit('ready',e)
// press CTRL+a l (=gnu screen redisplay)
setTimeout( () => this.send("l\n"),400 )
// reload index.js
this.emulator.read_file("root/index.js")
.then( this.convert.Uint8ArrayToString )
.then( this.runJavascript )
.catch( console.error )
// reload index.html
this.emulator.read_file("root/index.html")
.then( this.convert.Uint8ArrayToString )
.then( this.runHTML )
.catch( console.error )
}, 500 )
}
})
this.save = async () => {
const state = await this.emulator.save_state()
console.log( String(this.convert.arrayBufferToBase64(state)).substr(0,5) )
localforage.setItem("state", this.convert.arrayBufferToBase64(state) )
}
const doRestore = () => {
localforage.getItem("state", (err,stateBase64) => onGetItem(err,stateBase64) )
window.addEventListener("beforeunload", function (e) {
var confirmationMessage = "Sure you want to leave?\nTIP: enter 'save' to continue this session later";
(e || window.event).returnValue = confirmationMessage; //Gecko + IE
return confirmationMessage; //Webkit, Safari, Chrome
});
}
localforage.setDriver([
localforage.INDEXEDDB,
localforage.WEBSQL,
localforage.LOCALSTORAGE
])
.then( () => doRestore() )
}
ISOTerminal.prototype.autorestore = ISOTerminal.prototype.restore // alias to launch during boot
window.addEventListener("beforeunload", function (e) {
var confirmationMessage = "Sure you want to leave?\nTIP: enter 'save' to continue this session later";
(e || window.event).returnValue = confirmationMessage; //Gecko + IE
return confirmationMessage; //Webkit, Safari, Chrome
});
})
}

View file

@ -1,86 +1,27 @@
ISOTerminal.addEventListener('ready', function(e){
setTimeout( () => this.boot(), 50 ) // allow other features/plugins to settle first (autorestore.js e.g.)
setTimeout( () => this.boot(), 50 ) // because of autorestore.js
})
ISOTerminal.prototype.bootMenu = function(e){
this.boot.menu.selected = false // reset
const autobootURL = e && e.detail.bootMenuURL && document.location.hash.length > 1
const autoboot = e && e.detail.bootMenu || autobootURL
if( !autoboot ){
let msg = '\n\r'
this.boot.menu.map( (m) => {
msg += `\r${m.key}) ${m.title(this.opts)}\n`
})
msg += `\n\renter choice> `
this.send(msg)
}else{ // autoboot
if( this.term ){
this.term.handler( String(e.detail.bootMenu || e.detail.bootMenuURL).charAt(0) )
this.term.handler("\n")
}
}
}
ISOTerminal.addEventListener('bootMenu', function(e){this.bootMenu(e) })
ISOTerminal.prototype.boot = async function(e){
// set environment
let env = [
`export LINES=${this.opts.rows}`,
`export COLUMNS=${this.opts.cols}`,
'export BROWSER=1',
]
let env = ['export BROWSER=1']
for ( let i in document.location ){
if( typeof document.location[i] == 'string' && !String(i).match(/(hash|search)/) ){
env.push( 'export '+String(i).toUpperCase()+'="'+decodeURIComponent( document.location[i]+'"') )
}
if( typeof document.location[i] == 'string' )
env.push( 'export '+String(i).toUpperCase()+'="'+document.location[i]+'"')
}
// we export the cached hash/query (because they might be gone due to remotestorage plugin)
if( this.boot.hash.charAt(2) == '&' ){ // strip bootoption
this.boot.hashExBoot = `#` + this.boot.hash.substr(3)
}
env.push( 'export HASH="'+decodeURIComponent( this.boot.hashExBoot || this.boot.hash ) +'"' )
env.push( 'export QUERY="'+decodeURIComponent( this.boot.query ) +'"' )
await this.worker.create_file("profile.browser", this.convert.toUint8Array( env.join('\n') ) )
await this.emulator.create_file("profile.browser", this.convert.toUint8Array( env.join('\n') ) )
if( this.serial_input == 0 ){
if( !this.noboot ){
this.send("source /etc/profile # \\o/ FOSS powa!\n")
let boot = "source /etc/profile\n"
this.send(boot+"\n")
}
}
}
ISOTerminal.prototype.boot.fromImage = false
ISOTerminal.prototype.boot.menu = []
ISOTerminal.prototype.boot.hash = document.location.hash
ISOTerminal.prototype.boot.query = document.location.search
// here REPL's can be defined
// REPL: iso
if( typeof window.PromiseWorker != 'undefined' ){ // if xrsh v86 is able to run in in worker
ISOTerminal.prototype.boot.menu.push(
{
key: "1",
title: (opts) => `boot ${String(opts.iso || "").replace(/.*\//,'')} Linux ❤️ `,
init: function(){
// hack to notify href clicks
Term.prototype.href = (a) => {
if( a.href ){
this.exec(`source /etc/profile.sh; hook href "${a.href}"`)
}
return false
}
this.bootISO()
},
keyHandler: function(ch){ this.send(ch) } // send to v86 webworker
}
)
if( this.emulator.serial_adapter ) this.emulator.serial_adapter.term.focus()
else{
let els = [...document.querySelectorAll("div#screen")]
els.map( (el) => el.focus() )
}
}

View file

@ -1,56 +0,0 @@
if( typeof emulator != 'undefined' ){
}else{
ISOTerminal.addEventListener('ready', function(e){
function getMimeType(file) {
const mimeTypes = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
mp4: 'video/mp4',
gif: 'image/gif',
};
const extension = file.split('.').pop().toLowerCase();
return mimeTypes[extension] || 'application/octet-stream'; // Fallback
}
// listen for http request to the filesystem ( file://host/path )
xhook.before( (request,callback) => {
console.log(request.url)
if (request.url.match(/^\/mnt\/.*/) ){
let response
let file = request.url.replace(/^\/mnt\//,'')
let mimetype = getMimeType(file)
this.worker.read_file_world(file)
.then( (data) => {
if( data == null ) throw `/mnt/${file} does not exist in ISO filesystem`
let blob = new Blob( [data], {type: getMimeType(file) }) // wrap Uint8Array into array
response = {
headers: new Headers({ 'Content-Type': getMimeType(file) }),
data,
url: file,
status: 200,
blob: () => new Promise( (resolve,reject) => resolve(blob) ),
arrayBuffer: blob.arrayBuffer
}
console.log("serving from iso filesystem: "+file)
console.log("*TODO* large files being served partially")
callback(response)
})
.catch( (e) => {
console.error(e)
response = new Response()
response.status = 404
callback(response)
})
return
}else{
callback()
}
})
})
}

View file

@ -1,42 +1,32 @@
ISOTerminal.addEventListener('init', function(){
if( typeof emulator != 'undefined' ){
// inside worker-thread
this.addEventListener('emulator-started', function(e){
this.listenIndexHTML = () => {
const file = "dev/browser/html"
emulator.readFromPipe( file, async (data) => {
const buf = await emulator.read_file( file )
const emulator = this.emulator
// unix to js device
this.readFromPipe( '/mnt/index.html', async (data) => {
const buf = await emulator.read_file("index.html")
const decoder = new TextDecoder('utf-8');
const html = decoder.decode(buf).replace(/^#!\/bin\/html/,'') // remove leftover shebangs if any
const html = decoder.decode(buf)
try{
this.postMessage({event:'runHTML',data:[html]})
this.runHTML(html)
}catch(e){
console.error(file)
console.error(e)
}
})
}
}else{
// inside browser-thread
ISOTerminal.addEventListener('emulator-started', function(){
this.addEventListener('ready', async () => {
this.worker.listenIndexHTML()
})
})
ISOTerminal.prototype.runHTML = function(html){
let $scene = document.querySelector("a-scene")
let $root = document.querySelector("a-entity#root")
if( !$root ){
$root = document.createElement("a-entity")
$root.id = "root"
$scene.appendChild($root)
}
console.log(html)
$root.innerHTML = html
}
})
})
ISOTerminal.prototype.runHTML = function(html){
let $scene = document.querySelector("a-scene")
let $root = document.querySelector("a-entity#root")
if( !$root ){
$root = document.createElement("a-entity")
$root.id = "root"
$scene.appendChild($root)
}
$root.innerHTML = html
}

View file

@ -1,30 +1,32 @@
if( typeof emulator != 'undefined' ){
// inside worker-thread
ISOTerminal.addEventListener('init', function(){
// unix to js device
this.emulator.readFromPipe( 'dev/browser/js', async (data) => {
const buf = await emulator.read_file("dev/browser/js")
const decoder = new TextDecoder('utf-8');
const js = decoder.decode(buf).replace(/^#!\/bin\/js/,'') // remove leftover shebangs if any
try{
this.postMessage({event:'runJavascript',data:[js]})
}catch(e){
console.error(e)
}
})
this.addEventListener('emulator-started', function(e){
}else{
// inside browser-thread
const emulator = this.emulator
ISOTerminal.prototype.runJavascript = function(js){
let $root = document.querySelector("script#root")
if( !$root ){
$root = document.createElement("script")
$root.id = "root"
document.body.appendChild($root)
}
$root.innerHTML = js
}
// unix to js device
this.readFromPipe( '/mnt/index.js', async (data) => {
const buf = await emulator.read_file("index.js")
const decoder = new TextDecoder('utf-8');
const js = decoder.decode(buf)
try{
this.runJavascript(js)
}catch(e){
console.error(e)
}
})
})
})
ISOTerminal.prototype.runJavascript = function(js){
let $root = document.querySelector("script#root")
if( !$root ){
$root = document.createElement("script")
$root.id = "root"
document.body.appendChild($root)
}
$root.innerHTML = js
}

View file

@ -1,71 +1,29 @@
if( typeof emulator != 'undefined' ){
// inside worker-thread
ISOTerminal.addEventListener('init', function(){
// unix to js device
emulator.readFromPipe( 'dev/browser/js', async (data) => {
const convert = ISOTerminal.prototype.convert
const buf = await this.emulator.read_file("dev/browser/js")
const script = convert.Uint8ArrayToString(buf)
let PID=null
try{
if( script.match(/^PID/) ){
PID = script.match(/^PID=([0-9]+);/)[1]
this.addEventListener('emulator-started', function(e){
const emulator = this.emulator
// unix to js device
this.readFromPipe( '/mnt/js', async (data) => {
const buf = await emulator.read_file("js")
const script = this.convert.Uint8ArrayToString(buf)
let PID="?"
try{
if( script.match(/^PID/) ){
PID = script.match(/^PID=([0-9]+);/)[1]
}
let res = (new Function(`${script}`))()
if( res && typeof res != 'string' ) res = JSON.stringify(res,null,2)
// write output to 9p with PID as filename
// *FIXME* not flexible / robust
emulator.create_file(PID, this.convert.toUint8Array(res) )
}catch(e){
console.error(e)
}
this.postMessage({event:'javascript-eval',data:{script,PID}})
}catch(e){
console.error(e)
}
})
})
})
}else{
// inside browser-thread
})
/*
* here we're going to execute javascript in the browser,
* both a terrible and great idea depending on a corporate vs personal computing angle.
*
* if a function-string gets evaluated, and it returns a promise..then we assume async.
*/
ISOTerminal.addEventListener('javascript-eval', async function(e){
const {script,PID} = e.detail
let res;
let error = false;
const output = (res,PID) => {
if( res && typeof res != 'string' ) res = JSON.stringify(res,null,2)
// update output to 9p with PID as filename (in /mnt/run)
if( PID ){
this.worker.update_file(`run/${PID}.exit`, error ? "1" : "0")
this.worker.update_file(`run/${PID}`, this.convert.toUint8Array(res) )
}
}
try{
let f = new Function(`${script}`);
res = f();
if( res && typeof res.then == 'function' ){ // if we got a promise
res.then( (res) => output(res,PID) )
res.catch( (e) => output(e,PID) )
}else{ // normal sync function
output(res,PID)
}
}catch(err){
error = true
console.error(err)
console.dir(err)
res = "error: "+err.toString()
// try to figure out line *FIXME*
let line = err.stack.split("\n").find(e => e.includes("<anonymous>:") || e.includes("Function:"));
if( line ){
let lineIndex = (line.includes("<anonymous>:") && line.indexOf("<anonymous>:") + "<anonymous>:".length) || (line.includes("Function:") && line.indexOf("Function:") + "Function:".length);
let lnr = +line.substring(lineIndex, lineIndex + 1) - 2
res += script.split("\n")[lnr-1]
}else console.dir(script)
console.error(res)
output(res,PID)
}
})
}

View file

@ -1,109 +1,56 @@
ISOTerminal.prototype.redirectConsole = function(handler){
const log = console._log = console.log;
const dir = console._dir = console.dir;
const err = console._error = console.error;
const warn = console._warn = console.warn;
const addLineFeeds = (str) => typeof str == 'string' ? str.replace(/\n/g,"\r\n") : str
const log = console.log;
const dir = console.dir;
const err = console.error;
const warn = console.warn;
console.log = (...args)=>{
const textArg = args[0];
handler( addLineFeeds(textArg) );
handler(textArg+'\n');
log.apply(log, args);
};
console.error = (...args)=>{
const textArg = args[0]
handler( addLineFeeds(textArg), '\x1b[31merror\x1b[0m');
const textArg = args[0].message?args[0].message:args[0];
handler( textArg+'\n', '\x1b[31merror\x1b[0m');
err.apply(log, args);
};
console.dir = (...args)=>{
const textArg = args[0]
let str = JSON.stringify(textArg,null,2)+'\n'
handler( addLineFeeds(str) )
const textArg = args[0].message?args[0].message:args[0];
handler( JSON.stringify(textArg,null,2)+'\n');
dir.apply(log, args);
};
console.warn = (...args)=>{
const textArg = args[0]
handler( addLineFeeds(textArg),'\x1b[38;5;208mwarn\x1b[0m');
const textArg = args[0].message?args[0].message:args[0];
handler(textArg+'\n','\x1b[38;5;208mwarn\x1b[0m');
err.apply(log, args);
};
}
ISOTerminal.prototype.enableConsole = function(opts){
opts = opts || {stdout:false}
ISOTerminal.addEventListener('emulator-started', function(){
let emulator = this.emulator
this.redirectConsole( (str,prefix) => {
let _str = typeof str == 'string' ? str : JSON.stringify(str)
let finalStr = "";
prefix = prefix ? prefix+' ' : ''
String(_str).trim().split("\n").map( (line) => {
finalStr += `${opts.stdout ? '' : "\x1b[38;5;165m/dev/browser: \x1b[0m"}`+prefix+line+'\n'
let finalStr = ""
prefix = prefix ? prefix+' ' : ' '
str.trim().split("\n").map( (line) => {
finalStr += '\x1b[38;5;165m/dev/browser: \x1b[0m'+prefix+line+'\n'
})
if( opts.stdout ){
this.emit('serial-output-string', finalStr, "worker")
}else this.emit('append_file', ["/dev/browser/console",finalStr])
this.lastStr = finalStr
emulator.fs9p.append_file( "console", finalStr )
})
window.addEventListener('error', function(event) {
if( event.filename ){
console.error(event.filename+":"+event.lineno+":"+event.colno)
console.error(event.message);
console.error(event.error);
}else console.error(event)
console.error(event.filename+":"+event.lineno+":"+event.colno)
console.error(event.message);
console.error(event.error);
});
window.addEventListener('unhandledrejection', function(event) {
console.error(event);
console.error('Unhandled promise rejection:', event.reason);
});
if( opts.stdout ){
window.menu = () => this.bootMenu()
this.emit('serial-output-string', "\n\n\r☑ initialized javascript console\n");
this.emit('serial-output-string', "\r☑ please use these functions to print:\n");
this.emit('serial-output-string', "\r└☑ console.log(\"foo\")\n");
this.emit('serial-output-string', "\r└☑ console.warn(\"foo\")\n");
this.emit('serial-output-string', "\r└☑ console.dir({foo:12})\n");
this.emit('serial-output-string', "\r└☑ console.error(\"foo\")\n");
this.emit('serial-output-string', "\r\n");
this.emit('serial-output-string', "\rtype 'menu()' to return to mainmenu");
this.emit('serial-output-string', "\r\n");
}
}
ISOTerminal.addEventListener('emulator-started', function(){
this.enableConsole()
})
ISOTerminal.addEventListener('init', function(){
this.addEventListener('enable-console', function(opts){
this.enableConsole(opts.detail)
// enable/disable logging file (echo 1 > mnt/console.tty)
this.readFromPipe( '/mnt/console.tty', (data) => {
emulator.log_to_tty = ( String(data).trim() == '1')
})
// REPL: jsconsole
ISOTerminal.prototype.boot.menu.push(
{
key: "j",
title: (opts) => "just give me an javascript-console in WebXR instantly",
init: function(){
this.prompt = "\r> "
this.emit('enable-console',{stdout:true})
this.emit('status',"javascript console")
this.console = ""
setTimeout( () => {
this.send(this.prompt)
}, 100 )
},
keyHandler: function(ch){
this.send(ch)
},
cmdHandler: function(cmd){
this.send("\n\r")
eval(cmd)
setTimeout( () => this.send(this.prompt) ,10) // because worker vs terminal
}
}
)
})

View file

@ -1,48 +0,0 @@
if( typeof emulator != 'undefined' ){
// inside worker-thread
}else{
// inside browser-thread
//
ISOTerminal.prototype.pasteWriteFile = async function(data,type,filename){
this.pasteWriteFile.fileCount = this.pasteWriteFile.fileCount || 0
const file = `clipboard/`+ ( filename || `user-paste-${this.pasteWriteFile.fileCount}`)
await this.worker.create_file(file, data )
// run the xrsh hook
this.hook("clipboard", [ `/mnt/${file}`, type ] )
console.log("clipboard paste: /mnt/"+file)
this.pasteWriteFile.fileCount += 1
}
ISOTerminal.prototype.pasteFile = async function(data){
const {type,item,pastedText} = data
if( pastedText){
// the terminal handles this (pastes text)
// this.pasteWriteFile( this.convert.toUint8Array(pastedText) ,type, null, true)
}else{
const file = item.getAsFile();
const reader = new FileReader();
reader.onload = (e) => {
const arr = new Uint8Array(e.target.result)
this.pasteWriteFile( arr, type, file.name ); // or use readAsDataURL for images
};
reader.readAsArrayBuffer(file);
}
}
ISOTerminal.prototype.pasteInit = function(opts){
// bind upload input
const {instance, aEntity} = opts
const el = aEntity.el.dom.querySelector('#pastedrop') // upload input
el.addEventListener('change', (e) => {
const file = el.files[0];
const item = {...file, getAsFile: () => file } // pasteFile-event works with File objets
const data = { item, type: file.type }
this.emit( 'pasteFile', data, "worker" ) // impersonate as worker (as worker cannot handle File objet)
})
}
ISOTerminal.addEventListener('init', function(){
this.addEventListener('term_init', (opts) => this.pasteInit(opts.detail) )
})
}

View file

@ -1,96 +0,0 @@
ISOTerminal.prototype.enableRemoteKeyboard = function(opts){
let service = {
ready: false,
reset: (service) => {
service.ip = localStorage.getItem("keyboardIP") || ""
service.ip = service.ip.trim()
service.state = "need-ip"
service.attempts = 0
},
init: function init( mainmenu ){
this.emit('status',"")
this.emit('enable-console',{stdout:true})
service.reset(service)
setTimeout( () => {
const clearScreen = "\r"
this.send(clearScreen);
this.send(`\n\rfor instructions\n\rsee ${document.location.origin}/index.html#Remote%20keyboard\n\n\r`)
this.send("enter 'm' for mainmenu\n\n\r")
this.send("keyboard ip-address> ")
// autofill ip
if( service.ip ){
for( let i = 0; i < service.ip.length; i++ ) this.send(service.ip.charAt(i))
}
}, 100 )
},
server: (term) => {
try{
service.addr = `wss://${service.ip}:9090/`;
service.ws = new WebSocket(service.addr)
service.ws.addEventListener("error", console.error )
service.ws.addEventListener("open", (e) => {
if( service.state == 'listening' ){
this.send(`\n\rconnected! \\o/\n\r`)
this.bootMenu()
}
service.state = 'receiving'
})
service.ws.addEventListener("close", () => {
service.attempts += 1
if( service.attempts > 3 && service.state == 'listening'){
service.reset(service)
this.send(`\n\roops..I did not detect any connection :/\n\r`)
localStorage.setItem("keyboardIP","") // reset ip
this.bootMenu()
}else setTimeout( () => service.server(term), 1000 ) // retry connection
}) // retry on EOF
service.ws.onmessage = function(event) {
if( !event.data ) return
event.data.arrayBuffer().then( (buf) => {
const arr = new Uint8Array(buf)
let string = Array.from(arr, byte => String.fromCharCode(byte)).join('')
term.term.handler(string)
service.state = 'receiving'
localStorage.setItem("keyboardIP",service.ip) // save ip for later
})
};
}catch(e){
console.error(e)
service.reset(service)
localStorage.setItem("keyboardIP","") // reset ip
this.bootMenu()
}
}
}
// initialize REPL
ISOTerminal.prototype.boot.menu.push(
{
key: "k",
title: (opts) => "connect a remote keyboard",
init: service.init,
keyHandler: function(ch){
this.send(ch)
if( service.state == 'need-ip'){
if( ch == 'm'){
this.send("\n\r")
this.bootMenu()
}else if( ch == '\n' || ch == '\r'){
service.server(this)
service.state = 'listening'
this.send("\n\rconnecting to "+service.addr)
}else{
service.ip = ch == '\b' ? service.ip.substr(0,this.service.ip.length-1)
: service.ip + ch
}
}
}
}
)
}
ISOTerminal.addEventListener('init', function(){
this.enableRemoteKeyboard()
})

View file

@ -1,365 +0,0 @@
/* Remote storage feature
*
* NOTE: this feature extends the localstorage functions.
* this file can be excluded without crippling the
* core localstorage mechanism.
*/
// see https://remotestorage.io/rs.js/docs/data-modules/
ISOTerminal.prototype.remoteStorageModule = {
name: 'xrsh',
builder: function(privateClient, publicClient) {
return {
exports: {
add: function(data,opts) {
if( !data || !opts.filename || !opts.mimetype) throw 'webxr.add() needs filedata + filename + mimetype'
const client = opts.client = opts.public ? publicClient : privateClient;
return client.storeFile(opts.mimetype, opts.filename,data)
},
getListing: function(a,opts){
opts = opts || {}
const client = opts.public ? publicClient : privateClient;
return client.getListing(a)
},
getFile: function(file,opts){
opts = opts || {}
const client = opts.public ? publicClient : privateClient;
return client.getFile(file)
},
remove: function(file,opts){
opts = opts || {}
const client = opts.public ? publicClient : privateClient;
return client.remove(file)
}
}
}
}
};
// this is the HTML which we append to the remotestorage widget:
// https://remotestorage.io/rs.js/docs/getting-started/connect-widget.html
const _rs_widget_html = `
<div class="form" id="remote">
<select class="remote" id="states"></select>
<button id="save">save</button>
&nbsp;
<a class="btn remote" href="https://inspektor.5apps.com/?path=xrsh%2F" style="background:#888" target="_blank">filemanager</a>
<div id="result"></div>
</div>
<div class="form" id="local" style="position:relative">
<hr>
<div class="header">WebBrowser session:</div>
<div style="text-align:right">
<button id="saveLocal">save</button>
<button id="restoreLocal">restore</button>
</div>
</div>
<!-- disabled for now -->
<div class="form" id="local" style="position:relative; display:none">
<hr>
<div class="header">File session:</div>
<div style="text-align:right">
<button id="saveLocalFile">save file</button>
<button id="restoreLocalFile">import file</button>
</div>
</div>
<style type="text/css">
#remotestorage-widget{
position:fixed;
top:0;
right:0;
}
#remotestorage-widget h1,
#remotestorage-widget h2,
#remotestorage-widget h3,
#remotestorage-widget h4,
#remotestorage-widget h5{
color:#222;
}
#remotestorage-widget .rs-widget{
margin:25px;
}
#remotestorage-widget .rs-closed .form{
display:none !important;
}
#remotestorage-widget .form select#states{
padding: 5px;
margin-top: 10px;
margin-right: 10px;
border-radius: 5px;
}
#remotestorage-widget .form #result{
padding-top:10px;
}
#remotestorage-widget .form select,
#remotestorage-widget .form button,
#remotestorage-widget .form a{
height:35px;
margin-bottom:0;
}
#remotestorage-widget *{
font-size:15px;
}
#remotestorage-widget div.header{
position:absolute;
display:inline;
top:15px;
left:0;
font-weight:bold;
}
#remotestorage-widget .form .btn {
text-decoration: none;
color: white;
margin-top: 10px;
margin-bottom: 10px;
display: inline-block;
line-height: 15px;
}
#remotestorage-widget .form{
margin-top:10px;
}
#remotestorage-widget .btn:hover,
#remotestorage-widget button:hover {
background:#CCC;
}
</style>
`
const widgetForm = function(opts){
let el = document.querySelector("#remotestorage-widget select#states")
const $widget = document.querySelector("#remotestorage-widget .rs-widget")
const $result = document.querySelector("#remotestorage-widget #result")
if( !el ){
let div = document.createElement('div')
div.innerHTML = _rs_widget_html
$widget.appendChild(div)
$widget.querySelector("h3").innerText = "Click here to connect your storage"
el = $widget.querySelector("select#states")
el.addEventListener('change', () => {
if( el.options.selectedIndex == 0 ) return; // ignore default option
this.remoteStorage.widget.filename = el.value
opts.status = "loading..."
$widget.classList.add(["blink"])
this.remoteStorage.xrsh.getFile(el.value)
.then( (data,err) => {
this.restore({remotestorage:true, data:data.data})
})
})
this.setupStorageListeners($widget,opts)
}
this.updateStorageForm(el,$widget,$result,opts)
}
ISOTerminal.prototype.remoteStorageWidget = function(){
if( this.remoteStorageWidget.installed ) return // only do once
let apis = {
//dropbox: "ce876ce",
//googledrive: "c3983c3"
}
const modules = [ this.remoteStorageModule ]
const remoteStorage = this.remoteStorage = new RemoteStorage({logging: true, modules })
remoteStorage.setApiKeys(apis)
remoteStorage.on('not-connected', (e) => {
this.remoteStorage.connected = false
this.remoteStorage.widget.form({show:false})
})
remoteStorage.on('ready', (e) => { })
remoteStorage.on('connected', (e) => {
this.remoteStorage.connected = true
this.remoteStorage.widget.form({update:true,show:true})
})
remoteStorage.access.claim( `xrsh`, 'rw'); // our data dir
remoteStorage.caching.enable( `/xrsh/` ) // local-first, remotestorage-second
remoteStorage.caching.enable( `/public/xrsh/` ) // local-first, remotestorage-second
// create widget
let opts = {}
opts.modalBackdrop = false
opts.leaveOpen = false
const widget = remoteStorage.widget = new window.Widget(remoteStorage, opts)
widget.attach();
widget.form = widgetForm.bind(this)
this.remoteStorageWidget.installed = true
}
ISOTerminal.prototype.setupStorageListeners = function($widget,opts){
this.addEventListener("ready", () => {
opts.loading = false
opts.status = "session loaded"
})
// setup events
$widget.querySelector("button#save").addEventListener("click", () => {
this.save({remotestorage:true})
})
$widget.querySelector("button#saveLocal").addEventListener("click", async () => {
if( confirm("save current session?") ){
$widget.classList.add(['blink'])
await this.worker.save_state()
$widget.classList.remove(['blink'])
alert("succesfully saved session")
}
})
$widget.querySelector("button#restoreLocal").addEventListener("click", async () => {
this.restore({localstorage:true})
})
$widget.querySelector("button#saveLocalFile").addEventListener("click", () => {
alert("saveFile")
})
$widget.querySelector("button#restoreLocalFile").addEventListener("click", () => {
alert("saveFile")
})
$widget.addEventListener("click", () => this.remoteStorage.widget.open() )
this.addEventListener("restored", () => this.remoteStorage.widget.form({loading:false, status:"session restored"}) )
}
ISOTerminal.prototype.updateStorageForm = function(el,$widget,$result,opts){
if( typeof opts.show != 'undefined' ){
$widget.querySelector('.form#remote').style.display = opts.show ? 'block' : 'none'
}
if( typeof opts.status != 'undefined'){
$result.innerText = opts.status
}
if( opts.public ){
const link = this.remoteStorage.remote.href + '/public/xrsh/' + this.remoteStorage.widget.filename
const linkWebView = document.location.href.replace(/(\?|#).*/,'') + `#1&img=${link}`
$result.innerHTML += `<br><a class="btn" href="${linkWebView}" target="_blank">public weblink</a>`
}
if( typeof opts.loading != 'undefined' ){
$widget.classList[ opts.loading ? 'add' : 'remove' ](["blink"])
}
if( opts.update ){
const createOption = (opt) => {
let option = document.createElement("option")
option.value = opt.value
option.innerText = opt.name || opt.value
el.appendChild(option)
}
el.innerHTML = ''
createOption({value:"-- snapshots --"})
this.remoteStorage.xrsh.getListing()
.then( (data,err) => {
for( let file in data ){
createOption({value:file})
}
})
}
}
const requireFiles = () => AFRAME.utils.require({remotestorageWidget:"assets/aframe-remotestorage.min.js"})
const getForage = () => localforage.setDriver([
localforage.INDEXEDDB,
localforage.WEBSQL,
localforage.LOCALSTORAGE
])
// we extend the autorestore feature at the init-event
ISOTerminal.addEventListener('init', function(e){
const decorateSave = () => {
let localSave = this.save
this.save = async (opts) => {
requireFiles()
.then( getForage )
.then( async () => {
if( opts.localstorage ){
this.save.localstorage(opts) // default behaviour
}
this.remoteStorageWidget()
if( opts.remotestorage ){
this.remoteStorage.widget.open() // force open dialog
let filename = String(this.remoteStorage.widget.filename || "snapshot").replace(/\.bin/,'')
filename = prompt("please name your snapshot:", filename )
filename = filename.replace(/\.bin*/,'') + '.bin'
// make it public or not (disabled for now as it does not work flawlessly)
const public = false // confirm('create a public link?')
this.remoteStorage.widget.filename = filename
this.remoteStorage.widget.form({loading:true,status: "saving.."})
await this.worker.save_state()
localforage.getItem("state", async (err,stateBase64) => {
this.remoteStorage.xrsh.add( stateBase64, {filename, mimetype: 'application/x-v86-base64', public})
.then( () => {
console.log("saved to remotestorage")
setTimeout(
() => this.remoteStorage.widget.form({update:true, loading:false, status:"saved succesfully", public})
,500 )
})
.catch( console.error )
})
}
})
}
this.save.localstorage = localSave
}
const decorateRestore = () => {
let localRestore = this.restore
this.restore = async (opts) => {
requireFiles()
.then( getForage )
.then( async () => {
document.querySelector("#remotestorage-widget .rs-widget").classList.add(['blink'])
if( opts.localstorage ){
this.restore.localstorage.apply(this,opts) // default behaviour
}
if( opts.remotestorage ){
localforage.setItem( "state", opts.data )
.then( () => this.restore.localstorage.call(this,opts) ) // now trigger default behaviour
}
})
}
this.restore.localstorage = localRestore
this.autorestore = this.restore // reroute automapping
}
// decorate save() and restore() of autorestore.js after it's declared (hence the setTimeout)
this.addEventListener('autorestore-installed', () => {
decorateSave()
decorateRestore()
})
})
// decorate autorestore
const autorestoreLocalStorage = ISOTerminal.prototype.autorestore;
ISOTerminal.prototype.autorestore = function(data){
requireFiles().then( async () => {
this.remoteStorageWidget()
})
}

View file

@ -1,120 +0,0 @@
ISOTerminal.addEventListener('init', function(){
this.TermInit()
})
ISOTerminal.prototype.TermInit = function(){
const setupTerm = (opts) => {
if( !opts ) return
const {instance, aEntity} = opts
const el = aEntity.el.dom.querySelector('#term')
opts.termOpts = {
cols: aEntity.cols,
rows: aEntity.rows,
el_or_id: el,
scrollback: aEntity.rows*3,
fontSize: null,
//rainbow: [Term.COLOR_MAGENTA, Term.COLOR_CYAN ],
isWebXRKeyboard: () => AFRAME.scenes[0].renderer.xr.isPresenting && AFRAME.scenes[0].renderer.xr.getSession().isSystemKeyboardSupported, // naive way
//map: {
// 'ArrowRight': { ch: false, ctrl: '\x1b\x66' }, // this triggers ash-shell forward-word
// 'ArrowLeft': { ch: false, ctrl: '\x1b\x62' } // backward-word
//}
}
// patch Term-class
Term.prototype.move_textarea = function(){} /* *TODO* *FIXME* does not work in winbox */
Term.prototype.keyDownHandler = function(original){
return function (e){
if ((e.ctrlKey || e.metaKey) && e.key === 'v') {
return true; // bubble up to pasteHandler (see pastedrop.js)
}
original.apply(this,[e])
}
}( Term.prototype.keyDownHandler )
this.term = new Term( opts.termOpts )
Term.prototype.href = (a) => true
this.term.colors = [
/* normal */
"#000000",
"#2FA",
"#7700ff",
"#555555",
"#0000ff",
"#aa00aa",
"#ff00aa",
"#aaaaaa",
/* bright */
"#555555",
"#ff5555",
"#2CF",
"#aa00ff",
"#5555ff",
"#ff55ff",
"#55ffff",
"#ffffff"
];
this.term.open(el)
this.term.el = el
// this is the default REPL
// please do not edit or replace this
// but instead extend/override ISOTerminal.prototype.boot.menu
// as demonstrated in index.html
this.term.setKeyHandler( (ch) => {
let erase = false
const isEnter = ch == '\n' || ch == '\r'
if( ch == '\x7F' ){
ch = "\b \b" // why does write() not just support \x7F ?
erase = true
}
if( this.boot.menu.selected ){
this.boot.menu.selected.keyHandler.call(this,ch)
if( isEnter ){
if( this.boot.menu.selected.cmdHandler ){
this.boot.menu.selected.cmdHandler.call(this,this.lastCmd)
}
this.lastCmd = ""
}
}else if( isEnter ){
let menuitem = this.boot.menu.find( (m) => m.key == this.lastChar )
if( menuitem ){
this.boot.menu.selected = menuitem
this.lastCmd = ""
menuitem.init.call(this, () => {
this.term.write("\n\r")
this.bootMenu()
})
}
}else{
ch.split("").map( (ch) => this.term.write( ch ) )
}
if( !erase ){
this.lastChar = ch
this.lastCmd = this.lastCmd ? this.lastCmd + ch : ch
}else this.lastCmd = this.lastCmd ? this.lastCmd.substr(0, this.lastCmd.length-1) : ""
})
aEntity.el.addEventListener('focus', () => {
let textarea = el.querySelector("textarea")
textarea.focus()
if( document.activeElement != textarea ) textarea.focus()
})
aEntity.el.addEventListener('serial-output-string', (e) => {
let msg = e.detail
this.term.write(msg)
})
//aEntity.term.emit('initTerm',this)
//aEntity.el.addEventListener('focus', () => this.vt100.focus() )
//aEntity.el.addEventListener('serial-output-string', (e) => {
// this.vt100.write(e.detail)
//})
}
this.addEventListener('term_init', (opts) => setupTerm(opts.detail) )
}

View file

@ -0,0 +1,77 @@
ISOTerminal.addEventListener('init', function(){
if( typeof Terminal != 'undefined' ) this.xtermInit()
})
ISOTerminal.addEventListener('runISO', function(e){
let opts = e.detail
opts.serial_container_xtermjs = opts.screen_container
delete opts.screen_container
})
ISOTerminal.prototype.xtermInit = function(){
this.serial_input = 0 // set input to serial line 0
let isoterm = this
// monkeypatch Xterm (which V86 initializes) so we can add our own constructor args
window._Terminal = window.Terminal
window.Terminal = function(opts){
const term = new window._Terminal({ ...opts,
cursorBlink:true,
onSelectionChange: function(e){ console.log("selectchange") },
letterSpacing: 0
})
term.onSelectionChange( () => {
document.execCommand('copy')
term.select(0, 0, 0)
isoterm.emit('status','copied to clipboard')
})
return term
}
this.addEventListener('emulator-started', function(){
this.emulator.serial_adapter.term.element.querySelector('.xterm-viewport').style.background = 'transparent'
// toggle immersive with ESCAPE
//document.body.addEventListener('keydown', (e) => e.key == 'Escape' && this.emulator.serial_adapter.term.blur() )
})
const resize = (w,h) => {
setTimeout( () => {
isoterm.xtermAutoResize(isoterm.emulator.serial_adapter.term, isoterm.instance,-3)
},800) // wait for resize anim
}
isoterm.instance.addEventListener('window.onresize', resize )
isoterm.instance.addEventListener('window.onmaximize', resize )
}
ISOTerminal.prototype.xtermAutoResize = function(term,instance,rowoffset){
if( !term.element ) return
const defaultScrollWidth = 24;
const MINIMUM_COLS = 2;
const MINIMUM_ROWS = 2;
const dims = term._core._renderService.dimensions;
const scrollbarWidth = (term.options.scrollback === 0
? 0
: (term.options.overviewRuler?.width || defaultScrollWidth ));
const parentElementStyle = window.getComputedStyle(instance.dom);
const parentElementHeight = parseInt(parentElementStyle.getPropertyValue('height'));
const parentElementWidth = Math.max(0, parseInt(parentElementStyle.getPropertyValue('width')));
const elementStyle = window.getComputedStyle(term.element);
const elementPadding = {
top: parseInt(elementStyle.getPropertyValue('padding-top')),
bottom: parseInt(elementStyle.getPropertyValue('padding-bottom')),
right: parseInt(elementStyle.getPropertyValue('padding-right')),
left: parseInt(elementStyle.getPropertyValue('padding-left'))
};
const elementPaddingVer = elementPadding.top + elementPadding.bottom;
const elementPaddingHor = elementPadding.right + elementPadding.left;
const availableHeight = parentElementHeight - elementPaddingVer;
const availableWidth = parentElementWidth - elementPaddingHor - scrollbarWidth;
const geometry = {
cols: Math.max(MINIMUM_COLS, Math.floor(availableWidth / dims.css.cell.width)),
rows: Math.max(MINIMUM_ROWS, Math.floor(availableHeight / dims.css.cell.height))
};
term.resize(geometry.cols, geometry.rows + (rowoffset||0) );
}

Binary file not shown.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,134 +0,0 @@
importScripts("libv86.js");
importScripts("ISOTerminal.js") // we don't instance it again here (just use its functions)
this.runISO = async function(opts){
this.opts = opts
if( opts.debug ) console.dir(opts)
if( opts.cdrom && !opts.cdrom.url.match(/^http/) ) opts.cdrom.url = "../../"+opts.cdrom.url
if( opts.bzimage && !opts.cdrom.url.match(/^http/) ) opts.bzimage.url = "../../"+opts.bzimage.url
let emulator = this.emulator = new V86(opts);
console.log("[worker.js] started emulator")
// event forwarding
emulator.buf0 = {}
emulator.buf1 = {}
emulator.buf2 = {}
emulator.add_listener("serial0-output-byte", function(byte){
ISOTerminal.prototype.bufferOutput(byte, (str) => { // we buffer to prevent framerate dropping
if( !str ) return
this.postMessage({event:"serial0-output-string",data:str});
}, opts.bufferLatency, emulator.buf0 )
}.bind(this));
emulator.add_listener("serial1-output-byte", function(byte){
ISOTerminal.prototype.bufferOutput(byte, (str) => { // we buffer to prevent framerate dropping
if( !str ) return
this.postMessage({event:"serial1-output-string",data:str});
}, opts.bufferLatency, emulator.buf1 )
}.bind(this));
emulator.add_listener("serial2-output-byte", function(byte){
ISOTerminal.prototype.bufferOutput(byte, (str) => { // we buffer to prevent framerate dropping
if( !str ) return
this.postMessage({event:"serial2-output-string",data:str});
}, opts.bufferLatency, emulator.buf2 )
}.bind(this));
emulator.add_listener("emulator-started", function(){
importScripts("feat/9pfs_utils.js")
this.postMessage({event:"emulator-started",data:false});
if( opts.img ) this.restoreImage(opts)
}.bind(this));
/*
* forward events/functions so non-worker world can reach them
*/
// stripping '/mnt' is needed (the 9p mounted fs does not know about this)
const stripMountDir = (arr) => {
arr[0] = String(arr[0]).replace(/^\/mnt/,'')
return arr
}
this.create_file = async function(){ return emulator.create_file.apply(emulator, stripMountDir(arguments[0]) ) }
this.create_file_from_url = async function(){ return emulator.fs9p.create_file_from_url.apply(emulator, stripMountDir(arguments[0]) ) }
this.read_file = async function(){ return emulator.read_file.apply(emulator, stripMountDir(arguments[0]) ) }
this.read_file_world = async function(){ return emulator.fs9p.read_file_world.apply(emulator.fs9p, stripMountDir(arguments[0]) ) }
this.append_file = async function(){ emulator.fs9p.append_file.apply(emulator.fs9p, stripMountDir(arguments[0])) }
this.update_file = async function(){ emulator.fs9p.update_file.apply(emulator.fs9p, stripMountDir(arguments[0])) }
// filename will be read from 9pfs: "/mnt/"+filename
emulator.readFromPipe = function(filename,cb){
emulator.add_listener("9p-write-end", async (opts) => {
if ( opts[0] == filename.replace(/.*\//,'') ){
cb()
}
})
}
importScripts("feat/javascript.js")
importScripts("feat/index.html.js")
importScripts("feat/autorestore.js")
if( opts.overlayfs ) await this.addOverlayFS(opts)
}
/*
* forward events/functions so non-worker world can reach them
*/
this['serial0-input'] = function(c){ emulator.bus.send( 'serial0-input', c) } // to /dev/ttyS0
this['serial1-input'] = function(c){ emulator.bus.send( 'serial1-input', c) } // to /dev/ttyS1
this['serial2-input'] = function(c){ emulator.bus.send( 'serial2-input', c) } // to /dev/ttyS2
this.onmessage = async function(e){
let {event,data} = e.data
if( this[event] ){
if( this.opts?.debug ) console.log(`[worker.js] this.${event}(${JSON.stringify(data).substr(0,60)})`)
try{
let result = await this[event](data)
if( data.promiseId ){ // auto-callback to ISOTerminal.worker.promise(...)
this.postMessage({event,data: {...data,result}})
}
}catch(e){
if( data.promiseId ){ // auto-callback to ISOTerminal.worker.promise(...)
this.postMessage({event,data: {...data,error:e}})
}
}
}
}
this.addOverlayFS = async function(opts){
return new Promise( (resolve,reject) => {
// OVERLAY FS *FIXME*
if( opts.overlayfs ){
fetch(opts.overlayfs)
.then( (f) => {
f.arrayBuffer().then( (buf) => {
let filename = opts.overlayfs.match(/\.zip$/) ? 'overlayfs.zip' : '.env'
this.emulator.create_file( filename, new Uint8Array(buf) )
})
})
}
})
}
this.restoreImage = function(opts){
this.postMessage({event:"serial0-output-string",data:`\n\r[!] loading session image:\n\r[!] ${opts.img}\n\r[!] please wait...internet speed matters here..\n`})
fetch( opts.img )
.then( (res) => {
this.postMessage({event:"serial0-output-string",data:`\r[v] image downloaded..restoring..\n\r`})
return res
})
.then( (res) => res.arrayBuffer() )
.then( async (buf) => {
await this.emulator.restore_state(buf)
this.postMessage({event:"serial0-output-string",data:`[v] restored\n\r`})
})
.catch( console.error )
}

View file

@ -1,55 +1,20 @@
/**
* ## [launcher](com/launcher.js)
/*
* ## launcher
*
* displays app (icons) in 2D and 3D handmenu (enduser can launch desktop-like 'apps')
* displays app (icons) for enduser to launch
*
* They can be temporary processes (icon disappears after component is removed) or permanent icons
* when registered via .register({...})
*
* ```html
* <a-entity launcher>
* <a-entity launch="component: helloworld; foo: bar"><a-entity>
* </a-entity>
*
* ```javascript
* <a-entity app="app/launcher.js"/>
* ```
*
* | property | type | example |
* |--------------|--------------------|----------------------------------------------------------------------------------------|
* | `attach` | `selector` | hand or object to attach menu to |
* | `registries` | `array` of strings | `<a-entity launcher="registers: https://foo.com/index.json, ./index.json"/>` |
* | `registries` | `array` of strings | <a-entity app="app/launcher.js; registers: https://foo.com/index.json, ./index.json"/> |
*
* | event | target | info |
* |--------------|-------------------------------------------------------------------------------------------------------------|
* | `launcher` | an app | when pressing an app icon, `launcher` event will be send to the respective app |
*
* There a multiple ways of letting the launcher know that an app can be launched:
*
* 1. any AFRAME component with an `launcher`-event + manifest is automatically added:
*
* ```javascript
* AFRAME.registerComponent('foo',{
* events:{
* launcher: function(){ ...launch something... }
* },
* manifest:{ // HTML5 manifesto JSON object
* // https://www.w3.org/TR/appmanifest/
* }
* }
* ```
*
* 2. dynamically in javascript
*
* ```javascript
* window.launcher.register({
* name:"foo",
* icon: "https://.../optional_icon.png"
* description: "lorem ipsum",
* cb: () => alert("foo")
* })
* //window.launcher.unregister('foo')
* ```
*
*/
*/
AFRAME.registerComponent('launch', { // use this component to auto-launch component
init: function(){
@ -61,6 +26,12 @@ AFRAME.registerComponent('launch', { // use this component to auto-launch compon
AFRAME.registerComponent('launcher', {
schema: {
attach: { type:"selector"},
padding: { type:"number","default":0.15},
fingerTip: {type:"selector"},
fingerDistance: {type:"number", "default":0.25},
rescale: {type:"number","default":0.4},
open: { type:"boolean", "default":true},
colors: { type:"array", "default": [
'#4C73FE',
'#554CFE',
@ -71,40 +42,60 @@ AFRAME.registerComponent('launcher', {
'#333333',
]},
paused: { type:"boolean","default":false},
cols: { type:"number", "default": 5 }
},
requires:{
dom: "com/dom.js",
htmlinxr: "com/html-as-texture-in-xr.js",
data2events: "com/data2event.js"
dependencies:{
dom: "com/dom.js"
},
init: async function () {
this.el.object3D.visible = false;
await AFRAME.utils.require(this.requires)
await AFRAME.utils.require(this.dependencies)
this.worldPosition = new THREE.Vector3()
await AFRAME.utils.require({
html: "https://unpkg.com/aframe-htmlmesh@2.1.0/build/aframe-html.js", // html to AFRAME
dom: "./com/dom.js",
data2events: "./com/data2event.js"
})
this.el.setAttribute("dom","")
this.el.setAttribute("pressable","")
this.el.sceneEl.addEventListener('enter-vr', () => this.centerMenu() )
this.el.sceneEl.addEventListener('enter-ar', () => this.centerMenu() )
this.el.setAttribute("noxd","ignore") // hint to XD.js that we manage ourselve concerning 2D/3D switching
this.render()
if( this.data.attach ){
this.el.object3D.visible = false
if( this.isHand(this.data.attach) ){
this.data.attach.addEventListener('model-loaded', () => {
this.ready = true
this.attachMenu()
})
// add button
this.menubutton = this.createMenuButton()
this.menubutton.object3D.visible = false
this.data.attach.appendChild( this.menubutton )
}else this.data.attach.appendChild(this.el)
}
},
isHand: (el) => {
return el.getAttributeNames().filter( (n) => n.match(/^hand-tracking/) ? n : null ).length ? true : false
},
dom: {
scale: 0.8,
scale: 3,
events: ['click'],
html: (me) => `<div class="iconmenu">loading components..</div>`,
css: (me) => `
.iconmenu {
css: (me) => `.iconmenu {
z-index: 1000;
display: flex;
flex-direction: row;
align-items: flex-start;
height: 50px;
width:764px;
overflow:hidden;
position: fixed;
right: 162px;
bottom: 10px;
left:20px;
background: transparent;
@ -125,8 +116,6 @@ AFRAME.registerComponent('launcher', {
border-top: 2px solid #BBB;
border-bottom: 2px solid #BBB;
font-size:18px;
color: #777;
line-height: 7px;
}
.iconmenu > button:first-child {
@ -143,10 +132,6 @@ AFRAME.registerComponent('launcher', {
padding-top:13px;
}
.iconmenu > button:only-child{
border-radius:5px 5px 5px 5px;
}
.iconmenu > button > img {
transform: translate(0px,-14px);
opacity:0.5;
@ -161,11 +146,15 @@ AFRAME.registerComponent('launcher', {
},
events:{
click: function(e){ },
DOMready: function(){
this.el.setAttribute("html-as-texture-in-xr", `domid: #${this.el.dom.id}; faceuser: false`)
open: function(){
this.preventAccidentalButtonPresses()
if( this.data.open ){
this.el.setAttribute("animation",`dur: 200; property: scale; from: 0 0 1; to: ${this.data.rescale} ${this.data.rescale} ${this.data.rescale}`)
this.menubutton.object3D.visible = false
}else{
this.el.setAttribute("animation",`dur: 200; property: scale; from: ${this.data.rescale} ${this.data.rescale} ${this.data.rescale}; to: 0 0 1`)
this.menubutton.object3D.visible = true
}
}
},
@ -174,70 +163,136 @@ AFRAME.registerComponent('launcher', {
setTimeout( () => this.data.paused = false, 500 ) // prevent menubutton press collide with animated buttons
},
createMenuButton: function(colo){
let aentity = document.createElement('a-entity')
aentity.setAttribute("mixin","menubutton")
aentity.addEventListener('obbcollisionstarted', this.onpress )
aentity.addEventListener('obbcollisionended', this.onreleased )
return aentity
},
render: async function(els){
if( !this.el.dom ) return // too early (dom.js component not ready)
let requires = []
let i = 0
let j = 0
let colors = this.data.colors
const add2D = (launchCom,manifest) => {
const add2D = (launchCom,el,manifest) => {
let btn = document.createElement('button')
let iconDefault = "data:image/svg+xml;base64,PHN2ZwogIHdpZHRoPSIyNCIKICBoZWlnaHQ9IjI0IgogIHZpZXdCb3g9IjAgMCAyNCAyNCIKICBmaWxsPSJub25lIgogIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKPgogIDxwYXRoCiAgICBmaWxsLXJ1bGU9ImV2ZW5vZGQiCiAgICBjbGlwLXJ1bGU9ImV2ZW5vZGQiCiAgICBkPSJNMjAuMTcwMiAzTDIwLjE2NjMgMy4wMDQ1M0MyMS43NDU4IDMuMDkwODQgMjMgNC4zOTg5NiAyMyA2VjE4QzIzIDE5LjY1NjkgMjEuNjU2OSAyMSAyMCAyMUg0QzIuMzQzMTUgMjEgMSAxOS42NTY5IDEgMThWNkMxIDQuMzQzMTUgMi4zNDMxNSAzIDQgM0gyMC4xNzAyWk0xMC40NzY0IDVIMTYuNDc2NEwxMy4wODkgOUg3LjA4ODk5TDEwLjQ3NjQgNVpNNS4wODg5OSA5TDguNDc2NDQgNUg0QzMuNDQ3NzIgNSAzIDUuNDQ3NzIgMyA2VjlINS4wODg5OVpNMyAxMVYxOEMzIDE4LjU1MjMgMy40NDc3MiAxOSA0IDE5SDIwQzIwLjU1MjMgMTkgMjEgMTguNTUyMyAyMSAxOFYxMUgzWk0yMSA5VjZDMjEgNS40NDc3MSAyMC41NTIzIDUgMjAgNUgxOC40NzY0TDE1LjA4OSA5SDIxWiIKICAgIGZpbGw9ImN1cnJlbnRDb2xvciIKICAvPgo8L3N2Zz4="
let html = manifest?.icons?.length > 0 || !manifest.name ? `<img src='${manifest.icons[0].src || iconDefault}' title='${manifest.name}: ${manifest.description}'/>` : ""
if( manifest.name && !html ) html = `${manifest.short_name || manifest.name}`
btn.innerHTML = html
btn.addEventListener('click', (e) => {
launchCom.launcher()
e.stopPropagation()
// visual feedback to user
btn.style.filter = "brightness(0.5)"
this.el.components.html.rerender()
setTimeout( () => {
btn.style.filter = "brightness(1)"
this.el.components.html.rerender()
}, 500 )
return false
})
btn.innerHTML = `${ manifest?.icons?.length > 0
? `<img src='${manifest.icons[0].src}' title='${manifest.name}: ${manifest.description}'/>`
: `${manifest.short_name}`
}`
btn.addEventListener('click', () => el.emit('launcher',{}) )
this.el.dom.appendChild(btn)
}
const add3D = (launchCom,el,manifest) => {
let aentity = document.createElement('a-entity')
let atext = document.createElement('a-entity')
let padding = this.data.padding
if( (i % this.data.cols) == 0 ) j++
aentity.setAttribute("mixin","menuitem")
aentity.setAttribute("position",`${padding+(i++ % this.data.cols) * padding} ${j*padding} 0`)
if( !aentity.getAttribute("material")){
aentity.setAttribute('material',`side: double; color: ${colors[ i % colors.length]}`)
}
aentity.addEventListener('obbcollisionstarted', this.onpress )
aentity.addEventListener('obbcollisionended', this.onreleased )
atext.setAttribute("text",`value: ${manifest.short_name}; align: baseline; anchor: align; align:center; wrapCount:7`)
atext.setAttribute("scale","0.1 0.1 0.1")
aentity.appendChild(atext)
this.el.appendChild(aentity)
aentity.launchCom = launchCom
return aentity
}
// finally render them!
this.el.dom.innerHTML = '' // clear
els = els || this.system.launchables
els = els || this.system.components
els.map( (c) => {
const manifest = c.manifest
const launchComponentKey = c.getAttributeNames().shift()
const launchCom = c.components[ launchComponentKey ]
if( !launchCom ) return console.warn(`could not find component '${launchComponentKey}' (forgot to include script-tag?)`)
const manifest = launchCom.manifest
if( manifest ){
add2D(c,manifest)
add2D(launchCom,c,manifest)
add3D(launchCom,c,manifest)
}
})
this.centerMenu();
},
centerMenu: function(){
// center along x-axis
this.el.object3D.traverse( (o) => {
if( o.constructor && String(o.constructor).match(/HTMLMesh/) ){
this.setOriginToMiddle(o, this.el.object3D, {x:true})
o.position.z = 0.012 // position a bit before the grab-line
o.position.y = -0.01 // position a bit before the grab-line
onpress: function(e){
const launcher = document.querySelector('[launcher]').components.launcher
if( launcher.data.paused ) return // prevent accidental pressed due to animation
if( e.detail.withEl.computedMixinStr == 'menuitem' ) return // dont react to menuitems touching eachother
// if user press menu button toggle menu
if( launcher && !launcher.data.open && e.srcElement.computedMixinStr == 'menubutton' ){
return (launcher.data.open = true)
}
if( launcher && !launcher.data.open ) return // dont process menuitems when menu is closed
let el = e.srcElement
if(!el) return
el.object3D.traverse( (o) => {
if( o.material && o.material.color ){
if( !o.material.colorOriginal ) o.material.colorOriginal = o.material.color.clone()
o.material.color.r *= 0.3
o.material.color.g *= 0.3
o.material.color.b *= 0.3
}
})
this.el.object3D.visible = true // ensure visibility
if( el.launchCom ){
console.log("launcher.js: launching "+el.launchCom.el.getAttributeNames().shift())
launcher.preventAccidentalButtonPresses()
el.launchCom.el.emit('launcher') // launch component!
this.data.open = false // close to prevent infinite loop of clicks when leaving immersive mode
}
},
setOriginToMiddle: function(fromObject, toObject, axis) {
var boxFrom = new THREE.Box3().setFromObject(fromObject);
var boxTo = new THREE.Box3().setFromObject(toObject);
var center = new THREE.Vector3();
if( !toObject.positionOriginal ) toObject.positionOriginal = toObject.position.clone()
center.x = axis.x ? - (boxFrom.max.x/2) : 0
center.y = axis.y ? - (boxFrom.max.y/2) : 0
center.z = axis.z ? - (boxFrom.max.z/2) : 0
toObject.position.copy( toObject.positionOriginal )
toObject.position.sub(center);
onreleased: function(e){
if( e.detail.withEl.computedMixinStr == 'menuitem' ) return // dont react to menuitems touching eachother
let el = e.srcElement
el.object3D.traverse( (o) => {
if( o.material && o.material.color ){
if( o.material.colorOriginal ) o.material.color = o.material.colorOriginal.clone()
}
})
},
attachMenu: function(){
if( this.el.parentNode != this.data.attach ){
this.el.object3D.visible = true
let armature = this.data.attach.object3D.getObjectByName('Armature')
if( !armature ) return console.warn('cannot find armature')
this.data.attach.object3D.children[0].add(this.el.object3D)
this.el.object3D.scale.x = this.data.rescale
this.el.object3D.scale.y = this.data.rescale
this.el.object3D.scale.z = this.data.rescale
// add obb-collider to index finger-tip
let aentity = document.createElement('a-entity')
trackedObject3DVariable = 'parentNode.components.hand-tracking-controls.bones.9';
this.data.fingerTip.appendChild(aentity)
aentity.setAttribute('obb-collider', {trackedObject3D: trackedObject3DVariable, size: 0.015});
if( this.isHand(this.data.attach) ){
// shortly show and hide menu into palm (hint user)
setTimeout( () => { this.data.open = false }, 1500 )
}
}
},
tick: function(){
if( this.ready && this.data.open ){
let indexTipPosition = document.querySelector('#right-hand[hand-tracking-controls]').components['hand-tracking-controls'].indexTipPosition
this.el.object3D.getWorldPosition(this.worldPosition)
const lookingAtPalm = this.data.attach.components['hand-tracking-controls'].wristObject3D.rotation.z > 2.0
if( !lookingAtPalm ){ this.data.open = false }
}
},
manifest: { // HTML5 manifest to identify app to xrsh
"short_name": "launcher",
@ -297,81 +352,35 @@ in above's case "\nHelloworld application\n" will qualify as header.
AFRAME.registerSystem('launcher',{
init: function(){
this.launchables = []
this.dom = []
this.registered = []
this.components = []
// observe HTML changes in <a-scene>
observer = new MutationObserver( (a,b) => this.getLaunchables(a,b) )
observer.observe( this.sceneEl, {characterData: false, childList: true, attributes: false});
window.launcher = this
this.getLaunchables()
},
register: function(launchable){
try{
let {name, description, cb} = launchable
const exist = this.registered.find( (r) => r.manifest.name == name )
if( exist ) return // already registered
this.registered.push({
manifest: {name, description, icons: launchable.icon ? [{src:launchable.icon}] : [] },
launcher: cb
})
}catch(e){
console.error('AFRAME.systems.launcher.register({ name, description, icon, cb }) got invalid obj')
console.error(e)
}
this.getLaunchables()
},
unregister: function(launchableName){
this.registered = this.registered.filter( (l) => l.name != launchableName )
},
getLaunchables: function(mutationsList,observer){
let searchEvent = 'launcher'
let els = [...this.sceneEl.getElementsByTagName("*")]
let seen = {}
this.launchables = [
/*
* {
* manifest: {...}
* launcher: () => ....
* }
*/
];
// collect manually registered launchables
this.registered.map( (launchable) => {
if( launchable.component ) seen[ launchable.component ] = true
this.launchables.push(launchable)
})
// collect launchables in aframe dom elements
this.dom = els.filter( (el) => {
this.components = els.filter( (el) => {
let hasEvent = false
if( el.components ){
for( let i in el.components ){
if( el.components[i].events && el.components[i].events[searchEvent] && !seen[i] ){
let com = hasEvent = seen[i] = el.components[i]
let alreadyAdded = this.launchables.find( (l) => l.manifest.name == com.manifest.name )
if( !alreadyAdded ){
com.launcher = () => com.el.emit('launcher',null,false) // important: no bubble
this.launchables.push({manifest: com.manifest, launcher: com.launcher})
}
hasEvent = seen[i] = true
}
}
}
return hasEvent ? el : null
})
this.updateLauncher()
return this.launchables
return seen
},
updateLauncher: function(){
let launcher = document.querySelector('[launcher]')
if( launcher && launcher.components.launcher) launcher.components.launcher.render()
if( launcher ) launcher.components.launcher.render()
}
})

View file

@ -1,682 +0,0 @@
;
(function (three) {
'use strict';
// This is a copy of https://github.com/mrdoob/three.js/blob/0403020848c26a9605eb91c99a949111ad4a532e/examples/jsm/interactive/HTMLMesh.js
class HTMLMesh extends three.Mesh {
constructor( dom ) {
const texture = new HTMLTexture( dom );
const geometry = new three.PlaneGeometry( texture.image.width * 0.001, texture.image.height * 0.001 );
const material = new three.MeshBasicMaterial( { map: texture, toneMapped: false, transparent: true } );
super( geometry, material );
function onEvent( event ) {
material.map.dispatchDOMEvent( event );
}
this.addEventListener( 'mousedown', onEvent );
this.addEventListener( 'mousemove', onEvent );
this.addEventListener( 'mouseup', onEvent );
this.addEventListener( 'click', onEvent );
this.dispose = function () {
geometry.dispose();
material.dispose();
material.map.dispose();
canvases.delete( dom );
this.removeEventListener( 'mousedown', onEvent );
this.removeEventListener( 'mousemove', onEvent );
this.removeEventListener( 'mouseup', onEvent );
this.removeEventListener( 'click', onEvent );
};
}
}
class HTMLTexture extends three.CanvasTexture {
constructor( dom ) {
super( html2canvas( dom ) );
this.dom = dom;
this.anisotropy = 16;
this.encoding = three.sRGBEncoding;
this.minFilter = three.LinearFilter;
this.magFilter = three.LinearFilter;
// Create an observer on the DOM, and run html2canvas update in the next loop
const observer = new MutationObserver( () => {
if ( ! this.scheduleUpdate ) {
// ideally should use xr.requestAnimationFrame, here setTimeout to avoid passing the renderer
this.scheduleUpdate = setTimeout( () => this.update(), 16 );
}
} );
const config = { attributes: true, childList: true, subtree: true, characterData: true };
observer.observe( dom, config );
this.observer = observer;
}
dispatchDOMEvent( event ) {
if ( event.data ) {
htmlevent( this.dom, event.type, event.data.x, event.data.y );
}
}
update() {
this.image = html2canvas( this.dom );
this.needsUpdate = true;
this.scheduleUpdate = null;
}
dispose() {
if ( this.observer ) {
this.observer.disconnect();
}
this.scheduleUpdate = clearTimeout( this.scheduleUpdate );
super.dispose();
}
}
//
const canvases = new WeakMap();
function html2canvas( element ) {
const range = document.createRange();
const color = new three.Color();
function Clipper( context ) {
const clips = [];
let isClipping = false;
function doClip() {
if ( isClipping ) {
isClipping = false;
context.restore();
}
if ( clips.length === 0 ) return;
let minX = - Infinity, minY = - Infinity;
let maxX = Infinity, maxY = Infinity;
for ( let i = 0; i < clips.length; i ++ ) {
const clip = clips[ i ];
minX = Math.max( minX, clip.x );
minY = Math.max( minY, clip.y );
maxX = Math.min( maxX, clip.x + clip.width );
maxY = Math.min( maxY, clip.y + clip.height );
}
context.save();
context.beginPath();
context.rect( minX, minY, maxX - minX, maxY - minY );
context.clip();
isClipping = true;
}
return {
add: function ( clip ) {
clips.push( clip );
doClip();
},
remove: function () {
clips.pop();
doClip();
}
};
}
function drawText( style, x, y, string ) {
if ( string !== '' ) {
if ( style.textTransform === 'uppercase' ) {
string = string.toUpperCase();
}
context.font = style.fontWeight + ' ' + style.fontSize + ' ' + style.fontFamily;
context.textBaseline = 'top';
context.fillStyle = style.color;
context.fillText( string, x, y + parseFloat( style.fontSize ) * 0.1 );
}
}
function buildRectPath( x, y, w, h, r ) {
if ( w < 2 * r ) r = w / 2;
if ( h < 2 * r ) r = h / 2;
context.beginPath();
context.moveTo( x + r, y );
context.arcTo( x + w, y, x + w, y + h, r );
context.arcTo( x + w, y + h, x, y + h, r );
context.arcTo( x, y + h, x, y, r );
context.arcTo( x, y, x + w, y, r );
context.closePath();
}
function drawBorder( style, which, x, y, width, height ) {
const borderWidth = style[ which + 'Width' ];
const borderStyle = style[ which + 'Style' ];
const borderColor = style[ which + 'Color' ];
if ( borderWidth !== '0px' && borderStyle !== 'none' && borderColor !== 'transparent' && borderColor !== 'rgba(0, 0, 0, 0)' ) {
context.strokeStyle = borderColor;
context.lineWidth = parseFloat( borderWidth );
context.beginPath();
context.moveTo( x, y );
context.lineTo( x + width, y + height );
context.stroke();
}
}
function drawElement( element, style ) {
let x = 0, y = 0, width = 0, height = 0;
if ( element.nodeType === Node.TEXT_NODE ) {
// text
range.selectNode( element );
const rect = range.getBoundingClientRect();
x = rect.left - offset.left - 0.5;
y = rect.top - offset.top - 0.5;
width = rect.width;
height = rect.height;
drawText( style, x, y, element.nodeValue.trim() );
} else if ( element.nodeType === Node.COMMENT_NODE ) {
return;
} else if ( element instanceof HTMLCanvasElement ) {
// Canvas element
if ( element.style.display === 'none' ) return;
const rect = element.getBoundingClientRect();
x = rect.left - offset.left - 0.5;
y = rect.top - offset.top - 0.5;
context.save();
const dpr = window.devicePixelRatio;
context.scale( 1 / dpr, 1 / dpr );
context.drawImage( element, x, y );
context.restore();
} else if ( element instanceof HTMLImageElement ) {
if ( element.style.display === 'none' ) return;
const rect = element.getBoundingClientRect();
x = rect.left - offset.left - 0.5;
y = rect.top - offset.top - 0.5;
width = rect.width;
height = rect.height;
context.drawImage( element, x, y, width, height );
} else {
if ( element.style.display === 'none' ) return;
const rect = element.getBoundingClientRect();
x = rect.left - offset.left - 0.5;
y = rect.top - offset.top - 0.5;
width = rect.width;
height = rect.height;
style = window.getComputedStyle( element );
// Get the border of the element used for fill and border
buildRectPath( x, y, width, height, parseFloat( style.borderRadius ) );
const backgroundColor = style.backgroundColor;
if ( backgroundColor !== 'transparent' && backgroundColor !== 'rgba(0, 0, 0, 0)' ) {
context.fillStyle = backgroundColor;
context.fill();
}
// If all the borders match then stroke the round rectangle
const borders = [ 'borderTop', 'borderLeft', 'borderBottom', 'borderRight' ];
let match = true;
let prevBorder = null;
for ( const border of borders ) {
if ( prevBorder !== null ) {
match = ( style[ border + 'Width' ] === style[ prevBorder + 'Width' ] ) &&
( style[ border + 'Color' ] === style[ prevBorder + 'Color' ] ) &&
( style[ border + 'Style' ] === style[ prevBorder + 'Style' ] );
}
if ( match === false ) break;
prevBorder = border;
}
if ( match === true ) {
// They all match so stroke the rectangle from before allows for border-radius
const width = parseFloat( style.borderTopWidth );
if ( style.borderTopWidth !== '0px' && style.borderTopStyle !== 'none' && style.borderTopColor !== 'transparent' && style.borderTopColor !== 'rgba(0, 0, 0, 0)' ) {
context.strokeStyle = style.borderTopColor;
context.lineWidth = width;
context.stroke();
}
} else {
// Otherwise draw individual borders
drawBorder( style, 'borderTop', x, y, width, 0 );
drawBorder( style, 'borderLeft', x, y, 0, height );
drawBorder( style, 'borderBottom', x, y + height, width, 0 );
drawBorder( style, 'borderRight', x + width, y, 0, height );
}
if ( element instanceof HTMLInputElement ) {
let accentColor = style.accentColor;
if ( accentColor === undefined || accentColor === 'auto' ) accentColor = style.color;
color.set( accentColor );
const luminance = Math.sqrt( 0.299 * ( color.r ** 2 ) + 0.587 * ( color.g ** 2 ) + 0.114 * ( color.b ** 2 ) );
const accentTextColor = luminance < 0.5 ? 'white' : '#111111';
if ( element.type === 'radio' ) {
buildRectPath( x, y, width, height, height );
context.fillStyle = 'white';
context.strokeStyle = accentColor;
context.lineWidth = 1;
context.fill();
context.stroke();
if ( element.checked ) {
buildRectPath( x + 2, y + 2, width - 4, height - 4, height );
context.fillStyle = accentColor;
context.strokeStyle = accentTextColor;
context.lineWidth = 2;
context.fill();
context.stroke();
}
}
if ( element.type === 'checkbox' ) {
buildRectPath( x, y, width, height, 2 );
context.fillStyle = element.checked ? accentColor : 'white';
context.strokeStyle = element.checked ? accentTextColor : accentColor;
context.lineWidth = 1;
context.stroke();
context.fill();
if ( element.checked ) {
const currentTextAlign = context.textAlign;
context.textAlign = 'center';
const properties = {
color: accentTextColor,
fontFamily: style.fontFamily,
fontSize: height + 'px',
fontWeight: 'bold'
};
drawText( properties, x + ( width / 2 ), y, '✔' );
context.textAlign = currentTextAlign;
}
}
if ( element.type === 'range' ) {
const [ min, max, value ] = [ 'min', 'max', 'value' ].map( property => parseFloat( element[ property ] ) );
const position = ( ( value - min ) / ( max - min ) ) * ( width - height );
buildRectPath( x, y + ( height / 4 ), width, height / 2, height / 4 );
context.fillStyle = accentTextColor;
context.strokeStyle = accentColor;
context.lineWidth = 1;
context.fill();
context.stroke();
buildRectPath( x, y + ( height / 4 ), position + ( height / 2 ), height / 2, height / 4 );
context.fillStyle = accentColor;
context.fill();
buildRectPath( x + position, y, height, height, height / 2 );
context.fillStyle = accentColor;
context.fill();
}
if ( element.type === 'color' || element.type === 'text' || element.type === 'number' ) {
clipper.add( { x: x, y: y, width: width, height: height } );
drawText( style, x + parseInt( style.paddingLeft ), y + parseInt( style.paddingTop ), element.value );
clipper.remove();
}
}
}
/*
// debug
context.strokeStyle = '#' + Math.random().toString( 16 ).slice( - 3 );
context.strokeRect( x - 0.5, y - 0.5, width + 1, height + 1 );
*/
const isClipping = style.overflow === 'auto' || style.overflow === 'hidden';
if ( isClipping ) clipper.add( { x: x, y: y, width: width, height: height } );
for ( let i = 0; i < element.childNodes.length; i ++ ) {
drawElement( element.childNodes[ i ], style );
}
if ( isClipping ) clipper.remove();
}
const offset = element.getBoundingClientRect();
let canvas = canvases.get( element );
if ( canvas === undefined ) {
canvas = document.createElement( 'canvas' );
canvas.width = offset.width;
canvas.height = offset.height;
canvases.set( element, canvas );
}
const context = canvas.getContext( '2d'/*, { alpha: false }*/ );
const clipper = new Clipper( context );
// console.time( 'drawElement' );
context.clearRect(0, 0, canvas.width, canvas.height);
drawElement( element );
// console.timeEnd( 'drawElement' );
return canvas;
}
function htmlevent( element, event, x, y ) {
const mouseEventInit = {
clientX: ( x * element.offsetWidth ) + element.offsetLeft,
clientY: ( y * element.offsetHeight ) + element.offsetTop,
view: element.ownerDocument.defaultView
};
// TODO: Find out why this is added. Keep commented out when this file is updated
// window.dispatchEvent( new MouseEvent( event, mouseEventInit ) );
const rect = element.getBoundingClientRect();
x = x * rect.width + rect.left;
y = y * rect.height + rect.top;
function traverse( element ) {
if ( element.nodeType !== Node.TEXT_NODE && element.nodeType !== Node.COMMENT_NODE ) {
const rect = element.getBoundingClientRect();
if ( x > rect.left && x < rect.right && y > rect.top && y < rect.bottom ) {
element.dispatchEvent( new MouseEvent( event, mouseEventInit ) );
if ( element instanceof HTMLInputElement && element.type === 'range' && ( event === 'mousedown' || event === 'click' ) ) {
const [ min, max ] = [ 'min', 'max' ].map( property => parseFloat( element[ property ] ) );
const width = rect.width;
const offsetX = x - rect.x;
const proportion = offsetX / width;
element.value = min + ( max - min ) * proportion;
element.dispatchEvent( new InputEvent( 'input', { bubbles: true } ) );
}
}
for ( let i = 0; i < element.childNodes.length; i ++ ) {
traverse( element.childNodes[ i ] );
}
}
}
traverse( element );
}
/* jshint esversion: 9, -W097 */
const schemaHTML = {
html: {
type: 'selector',
},
cursor: {
type: 'selector',
}
};
{
schemaHTML.html.description = `HTML element to use.`;
schemaHTML.cursor.description = `Visual indicator for where the user is currently pointing`;
}
const _pointer = new THREE.Vector2();
const _event = { type: '', data: _pointer };
AFRAME.registerComponent('html', {
schema: schemaHTML,
init() {
this.rerender = this.rerender.bind(this);
this.handle = this.handle.bind(this);
this.onClick = e => this.handle('click', e);
this.onMouseLeave = e => this.handle('mouseleave', e);
this.onMouseEnter = e => this.handle('mouseenter', e);
this.onMouseUp = e => this.handle('mouseup', e);
this.onMouseDown = e => this.handle('mousedown', e);
this.mouseMoveDetail = {
detail: {
cursorEl: null,
intersection: null
}
};
},
play() {
this.el.addEventListener('click', this.onClick);
this.el.addEventListener('mouseleave', this.onMouseLeave);
this.el.addEventListener('mouseenter', this.onMouseEnter);
this.el.addEventListener('mouseup', this.onMouseUp);
this.el.addEventListener('mousedown', this.onMouseDown);
},
pause() {
this.el.removeEventListener('click', this.onClick);
this.el.removeEventListener('mouseleave', this.onMouseLeave);
this.el.removeEventListener('mouseenter', this.onMouseEnter);
this.el.removeEventListener('mouseup', this.onMouseUp);
this.el.removeEventListener('mousedown', this.onMouseDown);
},
update() {
this.remove();
if (!this.data.html) return;
const mesh = new HTMLMesh(this.data.html);
this.el.setObject3D('html', mesh);
this.data.html.addEventListener('input', this.rerender);
this.data.html.addEventListener('change', this.rerender);
this.cursor = this.data.cursor ? this.data.cursor.object3D : null;
},
tick() {
if (this.activeRaycaster) {
const intersection = this.activeRaycaster.components.raycaster.getIntersection(this.el);
this.mouseMoveDetail.detail.cursorEl = this.activeRaycaster;
this.mouseMoveDetail.detail.intersection = intersection;
this.handle('mousemove', this.mouseMoveDetail);
}
},
handle(type, evt) {
const intersection = evt.detail.intersection;
const raycaster = evt.detail.cursorEl;
if (type === 'mouseenter') {
this.activeRaycaster = raycaster;
}
if (type === 'mouseleave' && this.activeRaycaster === raycaster) {
this.activeRaycaster = null;
}
if (this.cursor) this.cursor.visible = false;
if (intersection) {
const mesh = this.el.getObject3D('html');
const uv = intersection.uv;
_event.type = type;
_event.data.set( uv.x, 1 - uv.y );
mesh.dispatchEvent( _event );
if (this.cursor) {
this.cursor.visible = true;
this.cursor.parent.worldToLocal(this.cursor.position.copy(intersection.point));
}
}
},
rerender() {
const mesh = this.el.getObject3D('html');
if (mesh && !mesh.material.map.scheduleUpdate) {
mesh.material.map.scheduleUpdate = setTimeout( () => mesh.material.map.update(), 16 );
}
},
remove() {
const mesh = this.el.getObject3D('html');
if (mesh) {
this.el.removeObject3D('html');
this.data.html.removeEventListener('input', this.rerender);
this.data.html.removeEventListener('change', this.rerender);
mesh.dispose();
}
this.activeRaycaster = null;
this.mouseMoveDetail.detail.cursorEl = null;
this.mouseMoveDetail.detail.intersection = null;
this.cursor = null;
},
});
})(THREE);

File diff suppressed because one or more lines are too long

179
com/paste.js Normal file
View file

@ -0,0 +1,179 @@
AFRAME.registerComponent('paste', {
schema: {
foo: { type:"string"}
},
init: function () {
this.el.object3D.visible = false
//this.el.innerHTML = ` `
},
requires:{
osbutton: "com/osbutton.js"
},
events:{
// component events
somecomponent: function( ){ console.log("component requirement mounted") },
ready: function(e){ console.log("requires are loaded") },
launcher: function(e){
const paste = () => {
navigator.clipboard.readText()
.then( (base64) => {
let mimetype = base64.replace(/;base64,.*/,'')
let data = base64.replace(/.*;base64,/,'')
let type = this.textHeuristic(data)
console.log("type="+type)
switch( this.textHeuristic(data) ){
case "aframe": this.insertAFRAME(data); break;
default: this.insertText(data); break;
}
this.count += 1
})
}
navigator.permissions.query({ name: 'clipboard-read' })
.then( (permission) => {
if( permission.state != 'granted' ){
this.el.sceneEl.exitVR()
setTimeout( () => paste(), 500 )
return
}else paste()
})
},
},
textHeuristic: function(text){
// Script type identification clues
const bashClues = ["|", "if ", "fi", "cat"];
const htmlClues = ["/>", "href=", "src="];
const aframeClues = ["<a-entity", "/>", "position="];
const jsClues = ["var ", "let ", "function ", "setTimeout","console."];
// Count occurrences of clues for each script type
const bashCount = bashClues.reduce((acc, clue) => acc + (text.includes(clue) ? 1 : 0), 0);
const htmlCount = htmlClues.reduce((acc, clue) => acc + (text.includes(clue) ? 1 : 0), 0);
const aframeCount = aframeClues.reduce((acc, clue) => acc + (text.includes(clue) ? 1 : 0), 0);
const jsCount = jsClues.reduce((acc, clue) => acc + (text.includes(clue) ? 1 : 0), 0);
// Identify the script with the most clues or return unknown if inconclusive
const maxCount = Math.max(bashCount, htmlCount, jsCount, aframeCount);
if (maxCount === 0) {
return "unknown";
} else if (bashCount === maxCount) {
return "bash";
} else if (htmlCount === maxCount) {
return "html";
} else if (jsCount === maxCount) {
return "javascript";
} else {
return "aframe";
}
},
insertAFRAME: function(data){
let scene = document.createElement('a-entity')
scene.id = "embedAframe"
scene.innerHTML = data
let el = document.createElement('a-text')
el.setAttribute("value",data)
el.setAttribute("color","white")
el.setAttribute("align","center")
el.setAttribute("anchor","align")
let osbutton = this.wrapOSButton(el,"aframe",data)
AFRAME.scenes[0].appendChild(osbutton)
console.log(data)
},
insertText: function(data){
let el = document.createElement('a-text')
el.setAttribute("value",data)
el.setAttribute("color","white")
el.setAttribute("align","center")
el.setAttribute("anchor","align")
let osbutton = this.wrapOSButton(el,"text",data)
AFRAME.scenes[0].appendChild(osbutton)
console.log(data)
},
wrapOSButton: function(el,type,data){
let osbutton = document.createElement('a-entity')
let height = type == 'aframe' ? 0.3 : 0.1
let depth = type == 'aframe' ? 0.3 : 0.05
osbutton.setAttribute("osbutton",`width:0.3; height: ${height}; depth: ${depth}; color:blue `)
osbutton.appendChild(el)
osbutton.object3D.position.copy( this.getPositionInFrontOfCamera() )
return osbutton
},
getPositionInFrontOfCamera: function(distance){
const camera = this.el.sceneEl.camera;
let pos = new THREE.Vector3()
let direction = new THREE.Vector3();
// Get camera's forward direction (without rotation)
camera.getWorldDirection(direction);
camera.getWorldPosition(pos)
direction.normalize();
// Scale the direction by 1 meter
if( !distance ) distance = 1.5
direction.multiplyScalar(distance);
// Add the camera's position to the scaled direction to get the target point
pos.add(direction);
return pos
},
manifest: { // HTML5 manifest to identify app to xrsh
"short_name": "Paste",
"name": "Paste",
"icons": [
{
"src": "https://css.gg/clipboard.svg",
"type": "image/svg+xml",
"sizes": "512x512"
}
],
"id": "/?source=pwa",
"start_url": "/?source=pwa",
"background_color": "#3367D6",
"display": "standalone",
"scope": "/",
"theme_color": "#3367D6",
"shortcuts": [
{
"name": "What is the latest news?",
"cli":{
"usage": "helloworld <type> [options]",
"example": "helloworld news",
"args":{
"--latest": {type:"string"}
}
},
"short_name": "Today",
"description": "View weather information for today",
"url": "/today?source=pwa",
"icons": [{ "src": "/images/today.png", "sizes": "192x192" }]
}
],
"description": "Paste the clipboard",
"screenshots": [
{
"src": "/images/screenshot1.png",
"type": "image/png",
"sizes": "540x720",
"form_factor": "narrow"
}
],
"help":`
Helloworld application
This is a help file which describes the application.
It will be rendered thru troika text, and will contain
headers based on non-punctualized lines separated by linebreaks,
in above's case "\nHelloworld application\n" will qualify as header.
`
}
});

View file

@ -1,130 +0,0 @@
/**
* ## [pastedrop](com/pastedrop.js)
*
* detects user copy/paste and file dragdrop action
* and clipboard functions
*
* ```html
* <a-entity pastedrop/>
* ```
*
* | event | target | info |
* |--------------|--------|------------------------------------------|
* | `pasteFile` | self | always translates input to a File object |
*/
AFRAME.registerComponent('pastedrop', {
schema: {
foo: { type:"string"}
},
init: function () {
window.addEventListener('paste', this.onPaste.bind(this) )
document.body.addEventListener('dragover',(e) => e.preventDefault() )
document.body.addEventListener('drop', this.onDrop.bind(this) )
},
initClipboard: function(){
navigator.permissions.query({ name: 'clipboard-read' })
.then( (permission) => {
if( permission.state != 'granted' ){
this.el.sceneEl.exitVR()
setTimeout( () => this.paste(), 500 )
return
}else this.paste()
})
},
getClipboard: function(){
navigator.clipboard.readText()
.then( async (base64) => {
let mimetype = base64.replace(/;base64,.*/,'')
let data = base64.replace(/.*;base64,/,'')
let type = this.textHeuristic(data)
const term = document.querySelector('[isoterminal]').components.isoterminal.term
this.el.emit('pasteFile',{}) /*TODO* data incompatible */
})
},
onDrop: function(e){
e.preventDefault()
this.onPaste({...e, type: "paste", clipboardData: e.dataTransfer})
},
onPaste: function(e){
if( e.type != "paste" ) return
const clipboardData = e.clipboardData || navigator.clipboard;
const items = clipboardData.items;
for (let i = 0; i < items.length; i++) {
const item = items[i];
const type = item.type;
// Check if the item is a file
if (item.kind === "file") {
this.el.emit('pasteFile',{item,type})
} else if (type === "text/plain") {
const pastedText = clipboardData.getData("text/plain");
const newType = "text" // let /root/hook.d/mimetype/text further decide whether this is text/plain (or something else)
this.el.emit('pasteFile',{item,type:newType,pastedText})
}
}
},
manifest: { // HTML5 manifest to identify app to xrsh
"short_name": "Paste",
"name": "Paste",
"icons": [
{
"src": "https://css.gg/clipboard.svg",
"type": "image/svg+xml",
"sizes": "512x512"
}
],
"id": "/?source=pwa",
"start_url": "/?source=pwa",
"background_color": "#3367D6",
"display": "standalone",
"scope": "/",
"theme_color": "#3367D6",
"shortcuts": [
{
"name": "What is the latest news?",
"cli":{
"usage": "helloworld <type> [options]",
"example": "helloworld news",
"args":{
"--latest": {type:"string"}
}
},
"short_name": "Today",
"description": "View weather information for today",
"url": "/today?source=pwa",
"icons": [{ "src": "/images/today.png", "sizes": "192x192" }]
}
],
"description": "Paste the clipboard",
"screenshots": [
{
"src": "/images/screenshot1.png",
"type": "image/png",
"sizes": "540x720",
"form_factor": "narrow"
}
],
"help":`
Helloworld application
This is a help file which describes the application.
It will be rendered thru troika text, and will contain
headers based on non-punctualized lines separated by linebreaks,
in above's case "\nHelloworld application\n" will qualify as header.
`
}
});

View file

@ -1,84 +0,0 @@
// this makes WebXR hand controls able to click things (by touching it)
AFRAME.registerComponent('pressable', {
schema: {
pressDistance: { default: 0.008 },
pressDuration: { default: 300 },
immersiveOnly: { default: true }
},
init: function() {
this.worldPosition = new THREE.Vector3();
this.raycaster = new THREE.Raycaster()
this.handEls = document.querySelectorAll('[hand-tracking-controls]');
this.pressed = false;
this.distance = -1
// we throttle by distance, to support scenes with loads of clickable objects (far away)
this.tick = this.throttleByDistance( () => this.detectPress() )
this.el.addEventListener("raycaster-intersected", (e) => {
if( !this.data || (this.data.immersiveOnly && !this.el.sceneEl.renderer.xr.isPresenting) ) return
this.el.emit('click', e.detail )
})
},
throttleByDistance: function(f){
return function(){
if( this.distance < 0 ) return f() // first call
if( !f.tid ){
let x = this.distance
let y = x*(x*0.05)*1000 // parabolic curve
f.tid = setTimeout( function(){
f.tid = null
f()
}, y )
}
}
},
detectPress: function(){
if( !this.el.sceneEl.renderer.xr.isPresenting ) return // ignore events in desktop mode
if( this.handEls.length == 0 ){
this.handEls = document.querySelectorAll('[hand-tracking-controls]');
}
var handEls = this.handEls;
var handEl;
let minDistance = 5
// compensate for an object inside a group
let object3D = this.el.object3D.type == "Group" ? this.el.object3D.children[0] : this.el.object3D
if( !object3D ) return
for (var i = 0; i < handEls.length; i++) {
handEl = handEls[i];
let indexTip = handEl.components['hand-tracking-controls'] ?
handEl.components['hand-tracking-controls'].indexTipPosition :
false
if( ! indexTip ) return // nothing to do here
this.raycaster.far = this.data.pressDistance
// Create a direction vector to negative Z
const direction = new THREE.Vector3(0,0,-1.0);
direction.normalize()
this.raycaster.set(indexTip, direction)
intersects = this.raycaster.intersectObjects([object3D],true)
object3D.getWorldPosition(this.worldPosition)
distance = indexTip.distanceTo(this.worldPosition)
minDistance = distance < minDistance ? distance : minDistance
if (intersects.length ){
this.i = this.i || 0;
if( !this.pressed ){
this.el.emit('pressedstarted', intersects);
this.el.emit('click', {intersection: intersects[0]});
this.pressed = setTimeout( () => {
this.el.emit('pressedended', intersects);
this.pressed = null
}, this.data.pressDuration )
}
}
}
this.distance = minDistance
},
});

View file

@ -0,0 +1,50 @@
/*
* ## requestAnimationFrameXR
*
* reroutes requestAnimationFrame-calls to xrSession.requestAnimationFrame
* reason: in immersive mode this function behaves differently
* (causing HTML apps like xterm.js not getting updated due to relying
* on window.requestAnimationFrame)
*
* ```html
* <a-entity requestAnimationFrameXR dom/>
* ```
*/
if( !AFRAME.systems.requestAnimationFrameXR ){
AFRAME.registerSystem('requestAnimationFrameXR',{
init: function init(){
if( document.location.hostname.match(/localhost/) ) return // allow webxr polyfill during development (they hang in XR)
AFRAME.systems.requestAnimationFrameXR.q = []
this.sceneEl.addEventListener('enter-vr', this.enable )
this.sceneEl.addEventListener('enter-ar', this.enable )
this.sceneEl.addEventListener('exit-vr', this.disable )
this.sceneEl.addEventListener('exit-ar', this.disable )
},
enable: function enable(){
this.requestAnimationFrame = window.requestAnimationFrame
// NOTE: we don't call xrSession.requestAnimationFrame directly like this:
//
// window.requestAnimationFrame = AFRAME.utils.throttleTick( (cb) => this.sceneEl.xrSession.requestAnimationFrame(cb), 50 )
//
// as that breaks webxr polyfill (for in-browser testing)
// instead we defer calls to tick() (which is called both in XR and non-XR)
//
window.requestAnimationFrame = (cb) => AFRAME.systems.requestAnimationFrameXR.q.push(cb)
const q = AFRAME.systems.requestAnimationFrameXR.q
this.tick = AFRAME.utils.throttleTick( () => {
while( q.length != 0 ) (q.pop())()
},50)
},
disable: function disable(){
delete this.tick
window.requestAnimationFrame = this.requestAnimationFrame
}
})
}

View file

@ -1,21 +1,17 @@
/**
* ## [require](com/require('').js)
*
* automatically requires dependencies or missing components
*
* ```javascript
* await AFRAME.utils.require( this.dependencies ) (*) autoload missing components
* await AFRAME.utils.require( this.el.getAttributeNames() ) (*) autoload missing components
* await AFRAME.utils.require({foo: "https://foo.com/aframe/components/foo.js"},this)
* await AFRAME.utils.require(["./app/foo.js","foo.css"],this)
* ```
*
* > (*) = prefixes baseURL AFRAME.utils.require.baseURL ('./com/' e.g.)
*/
// usage:
//
// await AFRAME.utils.require( this.dependencies ) (*) autoload missing components
// await AFRAME.utils.require( this.el.getAttributeNames() ) (*) autoload missing components
// await AFRAME.utils.require({foo: "https://foo.com/aframe/components/foo.js"},this)
// await AFRAME.utils.require(["./app/foo.js","foo.css"],this)
//
// (*) = prefixes baseURL AFRAME.utils.require.baseURL ('./com/' e.g.)
//
AFRAME.utils.require = function(arr_or_obj,opts){
opts = opts || {}
let i = 0
let deps = []
AFRAME.required = AFRAME.required || {}
let packagesArr = arr_or_obj.map ? arr_or_obj : Object.values(arr_or_obj)
const parseURI = function(uri){
@ -35,8 +31,8 @@ AFRAME.utils.require = function(arr_or_obj,opts){
}
// prevent duplicate requests
if( AFRAME.utils.require.required[id] ) return // already loaded before
AFRAME.utils.require.required[id] = true
if( AFRAME.required[id] ) return // already loaded before
AFRAME.required[id] = true
if( !document.body.querySelector(`script#${id}`) &&
!document.body.querySelector(`link#${id}`) ){
@ -71,41 +67,4 @@ AFRAME.utils.require = function(arr_or_obj,opts){
return Promise.all(deps)
}
AFRAME.utils.require.required = {}
AFRAME.utils.require.baseURL = './com/'
//// this component will scan the DOM for missing components and lazy load them
//AFRAME.registerSystem('require',{
//
// init: function(){
// this.components = []
// // observe HTML changes in <a-scene>
// observer = new MutationObserver( (a,b) => this.getMissingComponents(a,b) )
// observer.observe( this.sceneEl, {characterData: false, childList: true, attributes: false});
// },
//
// getMissingComponents: function(mutationsList,observer){
// let els = [...this.sceneEl.getElementsByTagName("*")]
// let seen = []
//
// els.map( async (el) => {
// let attrs = el.getAttributeNames()
// .filter( (a) => a.match(/(^aframe-injected|^data-aframe|^id$|^class$|^on)/) ? null : a )
// for( let attr in attrs ){
// let component = attrs[attr]
// if( el.components && !el.components[component] ){
// console.info(`require.js: lazy-loading missing <${el.tagName.toLowerCase()} ${component} ... > (TODO: fix selectors in schema)`)
// // require && remount
// try{
// await AFRAME.utils.require([component])
// el.removeAttribute(component)
// el.setAttribute(component, el.getAttribute(component) )
// }catch(e){ } // give up, normal AFRAME behaviour follows
// }
// }
// })
// }
//
//})

View file

@ -34,23 +34,16 @@ AFRAME.registerComponent('selfcontainer', {
installProxyServer: function(){
if( !window.store ) window.store = {}
// selfcontain every webrequest to store (and serve if stored)
let curry = function(me){
return function(request, response, cb){
let data = request ? window.store[ request.url ] || false : false
if( data ){ // return inline version
console.log('selfcontainer.js: serving '+request.url+' from cache')
console.log('selfcontained cache: '+request.url)
let res = new Response()
res[ data.binary ? 'data' : 'text' ] = data.binary ? () => me.convert.base64ToArrayBuffer(data.text) : data.text
cb(res)
}else{
// never cache requests to filesystem or zip/sh packages
if( request.url.match(/(^\/mnt\/|\.zip$|\.sh$)/) ) return cb(response)
console.log("selfcontainer.js: caching "+request.url)
if( response.text ){
data = {text: response.text}
}else{

View file

@ -1,56 +0,0 @@
AFRAME.registerComponent('wearable', {
schema:{
el: {type:"selector"},
controlPos: {type:"vec3"},
controlRot: {type:"vec3"},
handPos: {type:"vec3"},
handRot: {type:"vec3"}
},
init: function(){
this.remember()
if( !this.data.el ) return console.warn(`wear.js: cannot find ${this.data.el}`)
this.data.el.object3D.name = 'wearable'
// hand vs controller multi attach-heuristics (intended to survived AFRAME updates)
this.data.el.addEventListener('controllermodelready', this.attachWhatEverController.bind(this) ) // downside: no model yet
this.data.el.addEventListener('model-loaded', this.attachWhatEverController.bind(this) ) // downside: only called once [model not added yet]
this.el.sceneEl.addEventListener('controllersupdated', this.attachWhatEverController.bind(this) ) // downside: only called when switching [no model yet]
},
attachWhatEverController: function(e){
setTimeout( () => { // needed because the events are called before the model was added via add())
let wrist = false
let hand = this.data.el.components['hand-tracking-controls']
if( hand && hand.controllerPresent ) wrist = hand.wristObject3D
this.attach( wrist || this.data.el.object3D)
this.update( wrist ? 'hand' : 'control')
},100)
},
attach: function(target){
if( this.target && target.uuid == this.target.uuid ) return// already attached
target.add(this.el.object3D )
this.target = target
},
detach: function(){
this.parent.add(this.el.object3D)
this.el.object3D.position.copy( this.position )
this.el.object3D.rotation.copy( this.rotation )
},
remember: function(){
this.position = this.el.object3D.position.clone()
this.rotation = this.el.object3D.rotation.clone()
this.parent = this.el.object3D.parent
},
update: function(type){
let position = type == 'hand' ? this.data.handPos : this.data.controlPos
let rotation = type == 'hand' ? this.data.handRot : this.data.controlRot
this.el.object3D.position.copy( position )
this.el.object3D.rotation.set( rotation.x, rotation.y, rotation.z )
}
})

View file

@ -1,29 +1,3 @@
/**
* ## [window](com/window.js)
*
* wraps a draggable window around a dom id or [dom](com/dom.js) component.
*
* ```html
* <a-entity window="dom: #mydiv"/>
* ```
*
* > depends on [AFRAME.utils.require](com/require.js)
*
* | property | type | default | info |
* |------------------|-----------|------------------------|------|
* | `title` |`string` | "" | |
* | `width` |`string` | | |
* | `height` |`string` | 260px | |
* | `uid` |`string` | | |
* | `attach` |`selector` | | |
* | `dom` |`selector` | | |
* | `max` |`boolean` | false | |
* | `min` |`boolean` | false | |
* | `x` |`string` | "center" | |
* | `y` |`string` | "center" | |
* | `class` |`array` | [] | |
*/
AFRAME.registerComponent('window', {
schema:{
title: {type:'string',"default":"title"},
@ -31,19 +5,17 @@ AFRAME.registerComponent('window', {
height: {type:'string',"default":'260px'},
uid: {type:'string'},
attach: {type:'selector'},
grabbable: {type:'string', "default":"components.html.el.object3D.children.0"},
dom: {type:'selector'},
max: {type:'boolean',"default":false},
min: {type:'boolean',"default":false},
x: {type:'string',"default":"center"},
y: {type:'string',"default":"center"},
"class": {type:'array',"default":[]},
autoresize:{type:'bool', "default": false}
y: {type:'string',"default":"center"}
},
dependencies:{
dom: "com/dom.js",
winboxjs: "assets/winbox.bundle.min.js", // deadsimple windows: https://nextapps-de.github.io/winbox
winboxjs: "https://unpkg.com/winbox@0.2.82/dist/winbox.bundle.min.js", // deadsimple windows: https://nextapps-de.github.io/winbox
//winboxcss: "https://unpkg.com/winbox@0.2.82/dist/css/winbox.min.css", // main theme
},
init: function(){
@ -53,14 +25,9 @@ AFRAME.registerComponent('window', {
setupWindow: async function(){
await AFRAME.utils.require(this.dependencies)
if( !this.el.dom ) return console.error('window element requires dom-component as dependency')
this.el.addEventListener('close', () => {
close()
this.el.winbox.close()
})
this.el.dom.style.display = 'none'
let winbox = this.el.winbox = new WinBox( this.data.title, {
class: this.data.class,
height:this.data.height,
width:this.data.width,
x: this.data.x,
@ -76,84 +43,25 @@ AFRAME.registerComponent('window', {
this.el.emit('window.oncreate',{})
// resize after the dom content has been rendered & updated
setTimeout( () => {
if( !this.data.max && this.data.autoresize ) winbox.resize( this.el.dom.offsetWidth+'px', this.el.dom.offsetHeight+'px' )
if( !this.data.max ) winbox.resize( this.el.dom.offsetWidth+'px', this.el.dom.offsetHeight+'px' )
// hint grabbable's obb-collider to track the window-object
this.el.components['obb-collider'].data.trackedObject3D = this.data.grabbable
this.el.components['obb-collider'].data.trackedObject3D = 'components.html.el.object3D.children.0'
this.el.components['obb-collider'].update()
},1000)
this.patchButtons(e)
},
onminimize: this.onminimize,
onrestore: this.onrestore,
onclose: this.onclose.bind(this),
onclose: () => {
let e = {halt:false}
this.el.emit('window.onclose',e)
if( e.halt ) return true
this.data.dom.style.display = 'none';
if( this.el.parentNode ) this.el.remove() //parentElement.remove( this.el )
this.data.dom.parentElement.remove()
return false
},
});
this.data.dom.style.display = '' // show
this.el.setAttribute("grabbable","")
if( this.el.object3D.position.x == 0 &&
this.el.object3D.position.y == 0 &&
this.el.object3D.position.z == 0 ){ // position next to previous window
var els = [...document.querySelectorAll('[window]')]
if( els.length < 2 ) return
let current = els[ els.length-1 ]
let last = els[ els.length-2 ]
AFRAME.utils.positionObjectNextToNeighbor( current.object3D , last.object3D, 0.02 )
}
},
show: function(state){
this.el.dom.closest('.winbox').style.display = state ? '' : 'none'
},
onminimize: function(e){
if( AFRAME.scenes[0].renderer.xr.isPresenting ){
this.window.style.display = 'none'
}
},
onrestore: function(e){
if( AFRAME.scenes[0].renderer.xr.isPresenting ){
this.window.style.display = ''
}
},
onclose: function(){
let e = {halt:false}
this.el.emit('window.onclose',e)
if( e.halt ) return true
this.data.dom.style.display = 'none';
if( this.el.parentNode ) this.el.remove() //parentElement.remove( this.el )
this.data.dom.parentElement.remove()
return false
},
// the buttons don't work in XR because HTMLMesh does not understand onclick on divs
patchButtons: function(e){
let wEl = e.mount;
let controls = [...wEl.closest(".winbox").querySelectorAll(".wb-control span")]
controls.map( (c) => {
if( c.className.match(/wb-(close|min)/) ){
let btn = document.createElement("button")
btn.className = `xr xr-${c.className}`
btn.innerText = c.className == "wb-close" ? "x" : "_"
btn.addEventListener("click", (e) => { } )// this will bubble up (to click ancestor in XR)
c.appendChild(btn)
}
})
}
})
AFRAME.utils.positionObjectNextToNeighbor = function positionObjectNextToNeighbor(object, lastNeighbor = null, margin ){
if( lastNeighbor == null || object == null) return
// *FIXME* this could be more sophisticated :)
object.position.x = lastNeighbor.position.x + margin
object.position.y = lastNeighbor.position.y - margin
object.position.z = lastNeighbor.position.z + margin
}

81
com/xrfragments.js Normal file
View file

@ -0,0 +1,81 @@
AFRAME.registerComponent('xrfragments', {
schema: {
url: { type:"string"}
},
init: function () {
},
events:{
launcher: async function(){
let url = prompt('enter URL to glb/fbx/json/obj/usdz asset', 'https://xrfragment.org/index.glb')
if( !url ) return
await AFRAME.utils.require({
xrfragments: "https://xrfragment.org/dist/xrfragment.aframe.js",
})
// remove objects which are marked to be removed from scene (with noxrf)
let els = [...document.querySelectorAll('[noxrf]') ]
els.map( (el) => el.remove() )
if( !this.el.getAttribute("xrf") ){
this.el.setAttribute("xrf", url )
let ARbutton = document.querySelector('.a-enter-ar-button')
if( ARbutton ){
ARbutton.addEventListener('click', () => {
AFRAME.XRF.reset()
})
}
}else AFRAME.XRF.navigator.to(url)
}
},
manifest: { // HTML5 manifest to identify app to xrsh
"short_name": "XRF",
"name": "XR Fragment URL",
"icons": [ ],
"id": "/?source=pwa",
"start_url": "/?source=pwa",
"background_color": "#3367D6",
"display": "standalone",
"scope": "/",
"theme_color": "#3367D6",
"shortcuts": [
{
"name": "What is the latest news?",
"cli":{
"usage": "helloworld <type> [options]",
"example": "helloworld news",
"args":{
"--latest": {type:"string"}
}
},
"short_name": "Today",
"description": "View weather information for today",
"url": "/today?source=pwa",
"icons": [{ "src": "/images/today.png", "sizes": "192x192" }]
}
],
"description": "Hello world information",
"screenshots": [
{
"src": "/images/screenshot1.png",
"type": "image/png",
"sizes": "540x720",
"form_factor": "narrow"
}
],
"help":`
Helloworld application
This is a help file which describes the application.
It will be rendered thru troika text, and will contain
headers based on non-punctualized lines separated by linebreaks,
in above's case "\nHelloworld application\n" will qualify as header.
`
}
});