Merge pull request #154 from Flow-Works/dev
[🪢] Merge `dev` into `master`
This commit is contained in:
commit
518e821014
19 changed files with 1006 additions and 591 deletions
|
|
@ -7,6 +7,6 @@
|
||||||
<link rel="shortcut icon" href="./src/assets/flow.png" type="image/png">
|
<link rel="shortcut icon" href="./src/assets/flow.png" type="image/png">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script src="./src/kernel.ts" type="module"></script>
|
<script src="./src/bootloader.ts" type="module"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
163
package-lock.json
generated
163
package-lock.json
generated
|
|
@ -9,6 +9,8 @@
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"ansi-to-html": "^0.7.2",
|
||||||
|
"chalk": "^5.3.0",
|
||||||
"eruda": "^3.0.1",
|
"eruda": "^3.0.1",
|
||||||
"js-ini": "^1.6.0",
|
"js-ini": "^1.6.0",
|
||||||
"material-symbols": "^0.14.3",
|
"material-symbols": "^0.14.3",
|
||||||
|
|
@ -303,6 +305,23 @@
|
||||||
"node": ">=v18"
|
"node": ">=v18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@commitlint/load/node_modules/chalk": {
|
||||||
|
"version": "4.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
|
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.1.0",
|
||||||
|
"supports-color": "^7.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@commitlint/load/node_modules/cosmiconfig": {
|
"node_modules/@commitlint/load/node_modules/cosmiconfig": {
|
||||||
"version": "8.3.6",
|
"version": "8.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz",
|
||||||
|
|
@ -400,6 +419,23 @@
|
||||||
"node": ">=v18"
|
"node": ">=v18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@commitlint/types/node_modules/chalk": {
|
||||||
|
"version": "4.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
|
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.1.0",
|
||||||
|
"supports-color": "^7.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@esbuild/android-arm": {
|
"node_modules/@esbuild/android-arm": {
|
||||||
"version": "0.18.20",
|
"version": "0.18.20",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz",
|
||||||
|
|
@ -1877,6 +1913,20 @@
|
||||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ansi-to-html": {
|
||||||
|
"version": "0.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-to-html/-/ansi-to-html-0.7.2.tgz",
|
||||||
|
"integrity": "sha512-v6MqmEpNlxF+POuyhKkidusCHWWkaLcGRURzivcU3I9tv7k4JVhFcnukrM5Rlk2rUywdZuzYAZ+kbZqWCnfN3g==",
|
||||||
|
"dependencies": {
|
||||||
|
"entities": "^2.2.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"ansi-to-html": "bin/ansi-to-html"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ansicolors": {
|
"node_modules/ansicolors": {
|
||||||
"version": "0.3.2",
|
"version": "0.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ansicolors/-/ansicolors-0.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ansicolors/-/ansicolors-0.3.2.tgz",
|
||||||
|
|
@ -2376,16 +2426,11 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/chalk": {
|
"node_modules/chalk": {
|
||||||
"version": "4.1.2",
|
"version": "5.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz",
|
||||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
"integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
|
||||||
"ansi-styles": "^4.1.0",
|
|
||||||
"supports-color": "^7.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": "^12.17.0 || ^14.13 || >=16.0.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
|
|
@ -3239,6 +3284,14 @@
|
||||||
"integrity": "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==",
|
"integrity": "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/entities": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/env-ci": {
|
"node_modules/env-ci": {
|
||||||
"version": "11.0.0",
|
"version": "11.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/env-ci/-/env-ci-11.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/env-ci/-/env-ci-11.0.0.tgz",
|
||||||
|
|
@ -3906,6 +3959,22 @@
|
||||||
"url": "https://opencollective.com/eslint"
|
"url": "https://opencollective.com/eslint"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eslint/node_modules/chalk": {
|
||||||
|
"version": "4.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
|
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.1.0",
|
||||||
|
"supports-color": "^7.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/eslint/node_modules/eslint-scope": {
|
"node_modules/eslint/node_modules/eslint-scope": {
|
||||||
"version": "7.2.2",
|
"version": "7.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
|
||||||
|
|
@ -5066,6 +5135,22 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/inquirer/node_modules/chalk": {
|
||||||
|
"version": "4.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
|
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.1.0",
|
||||||
|
"supports-color": "^7.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/inquirer/node_modules/escape-string-regexp": {
|
"node_modules/inquirer/node_modules/escape-string-regexp": {
|
||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
|
||||||
|
|
@ -5937,6 +6022,22 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/log-symbols/node_modules/chalk": {
|
||||||
|
"version": "4.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
|
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.1.0",
|
||||||
|
"supports-color": "^7.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/log-symbols/node_modules/is-unicode-supported": {
|
"node_modules/log-symbols/node_modules/is-unicode-supported": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz",
|
||||||
|
|
@ -6055,18 +6156,6 @@
|
||||||
"marked": ">=1 <12"
|
"marked": ">=1 <12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/marked-terminal/node_modules/chalk": {
|
|
||||||
"version": "5.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz",
|
|
||||||
"integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==",
|
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
|
||||||
"node": "^12.17.0 || ^14.13 || >=16.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/material-symbols": {
|
"node_modules/material-symbols": {
|
||||||
"version": "0.14.5",
|
"version": "0.14.5",
|
||||||
"resolved": "https://registry.npmjs.org/material-symbols/-/material-symbols-0.14.5.tgz",
|
"resolved": "https://registry.npmjs.org/material-symbols/-/material-symbols-0.14.5.tgz",
|
||||||
|
|
@ -9397,6 +9486,22 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ora/node_modules/chalk": {
|
||||||
|
"version": "4.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
|
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.1.0",
|
||||||
|
"supports-color": "^7.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ora/node_modules/is-unicode-supported": {
|
"node_modules/ora/node_modules/is-unicode-supported": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz",
|
||||||
|
|
@ -11936,6 +12041,22 @@
|
||||||
"vite": ">=2.0.0"
|
"vite": ">=2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vite-plugin-compression/node_modules/chalk": {
|
||||||
|
"version": "4.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
|
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.1.0",
|
||||||
|
"supports-color": "^7.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite-plugin-dynamic-import": {
|
"node_modules/vite-plugin-dynamic-import": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/vite-plugin-dynamic-import/-/vite-plugin-dynamic-import-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/vite-plugin-dynamic-import/-/vite-plugin-dynamic-import-1.5.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
"name": "flowos",
|
"name": "flowos",
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"description": "The most aesthetic webOS.",
|
"description": "The most aesthetic webOS.",
|
||||||
"main": "src/kernel.ts",
|
"main": "src/bootloader.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"docs": "typedoc src/**",
|
"docs": "typedoc src/**",
|
||||||
"test": "ts-standard",
|
"test": "ts-standard",
|
||||||
|
|
@ -34,6 +34,8 @@
|
||||||
"vite-plugin-node-polyfills": "^0.15.0"
|
"vite-plugin-node-polyfills": "^0.15.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"ansi-to-html": "^0.7.2",
|
||||||
|
"chalk": "^5.3.0",
|
||||||
"eruda": "^3.0.1",
|
"eruda": "^3.0.1",
|
||||||
"js-ini": "^1.6.0",
|
"js-ini": "^1.6.0",
|
||||||
"material-symbols": "^0.14.3",
|
"material-symbols": "^0.14.3",
|
||||||
|
|
|
||||||
126
src/bootloader.ts
Normal file
126
src/bootloader.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
/**
|
||||||
|
* FlowOS Bootloader
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Kernel, { spaces } from './kernel'
|
||||||
|
import HTML from './HTML'
|
||||||
|
|
||||||
|
import logo from './assets/flow.png'
|
||||||
|
|
||||||
|
const body = new HTML(document.body)
|
||||||
|
|
||||||
|
body.html('<style>* { box-sizing: border-box }</style>')
|
||||||
|
body.style({
|
||||||
|
margin: '0',
|
||||||
|
width: '100vw',
|
||||||
|
height: '100vh',
|
||||||
|
overflow: 'hidden'
|
||||||
|
})
|
||||||
|
|
||||||
|
const boot = new HTML('div').styleJs({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
background: '#11111b',
|
||||||
|
padding: '100px',
|
||||||
|
'font-family': 'monospace',
|
||||||
|
userSelect: 'none',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}).appendTo(body)
|
||||||
|
|
||||||
|
boot.appendMany(
|
||||||
|
new HTML('div')
|
||||||
|
.styleJs({
|
||||||
|
display: 'flex',
|
||||||
|
height: '40px',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px'
|
||||||
|
})
|
||||||
|
.appendMany(
|
||||||
|
new HTML('img').attr({
|
||||||
|
src: logo,
|
||||||
|
height: '40px'
|
||||||
|
}),
|
||||||
|
new HTML('h1').text('FlowOS').styleJs({
|
||||||
|
color: 'white'
|
||||||
|
})
|
||||||
|
),
|
||||||
|
new HTML('img').attr({
|
||||||
|
src: logo
|
||||||
|
}).styleJs({
|
||||||
|
position: 'absolute',
|
||||||
|
right: '-8vw',
|
||||||
|
top: '-7vw',
|
||||||
|
opacity: '0.03',
|
||||||
|
height: '50vw',
|
||||||
|
'pointer-events': 'none',
|
||||||
|
zIndex: '0'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const terminal = new HTML('div').style({
|
||||||
|
color: '#89b4fa',
|
||||||
|
padding: '10px 3px',
|
||||||
|
'word-break': 'break-all',
|
||||||
|
'white-space': 'pre-wrap',
|
||||||
|
flex: '1',
|
||||||
|
'user-select': 'text',
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: '2'
|
||||||
|
}).appendTo(boot)
|
||||||
|
|
||||||
|
const progress = new HTML('div').style({
|
||||||
|
width: '0',
|
||||||
|
background: '#89b4fa',
|
||||||
|
transition: 'width 0.5s cubic-bezier(1,0,0,1)',
|
||||||
|
height: '5px'
|
||||||
|
})
|
||||||
|
new HTML('div').style({
|
||||||
|
height: '5px',
|
||||||
|
width: '100%',
|
||||||
|
background: '#181825'
|
||||||
|
}).appendTo(boot)
|
||||||
|
.append(progress)
|
||||||
|
|
||||||
|
const write = (content: string): void => {
|
||||||
|
terminal.text(terminal.getText() + content)
|
||||||
|
}
|
||||||
|
|
||||||
|
const writeln = (content = ''): void => {
|
||||||
|
write(`${content}\n`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalConsoleLog = console.log
|
||||||
|
const originalConsoleError = console.error
|
||||||
|
const originalConsoleWarn = console.warn
|
||||||
|
const originalConsoleGroup = console.group
|
||||||
|
window.console.log = (...args: any) => {
|
||||||
|
originalConsoleLog(...args)
|
||||||
|
writeln(args)
|
||||||
|
}
|
||||||
|
window.console.warn = (...args: any) => {
|
||||||
|
originalConsoleWarn(...args)
|
||||||
|
writeln(args)
|
||||||
|
}
|
||||||
|
window.console.error = (...args: any) => {
|
||||||
|
originalConsoleError(...args)
|
||||||
|
writeln(args)
|
||||||
|
}
|
||||||
|
window.console.group = (...args: any) => {
|
||||||
|
originalConsoleGroup(...args)
|
||||||
|
writeln(spaces + String(args))
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const args = new URLSearchParams(window.location.search)
|
||||||
|
const kernel = new Kernel()
|
||||||
|
await kernel.boot(boot, progress, args)
|
||||||
|
} catch (e) {
|
||||||
|
writeln()
|
||||||
|
writeln('An error occured while booting FlowOS.')
|
||||||
|
writeln('Please report this error to Flow Works.')
|
||||||
|
writeln()
|
||||||
|
console.error(e.stack)
|
||||||
|
}
|
||||||
158
src/kernel.ts
158
src/kernel.ts
|
|
@ -1,29 +1,44 @@
|
||||||
import './assets/style.less'
|
import pkg from '../package.json'
|
||||||
import { version } from '../package.json'
|
import VirtualFS from './system/VirtualFS'
|
||||||
import { v4 as uuid } from 'uuid'
|
import HTML from './HTML'
|
||||||
|
import { Executable, KernelConfig, Package, Permission, Process, ProcessInfo, FileSystem } from './types'
|
||||||
import ProcessLib from './structures/ProcessLib'
|
import ProcessLib from './structures/ProcessLib'
|
||||||
import ProcLib from './structures/ProcLib'
|
|
||||||
import { Executable, Process, Package, ProcessInfo, KernelConfig, Permission } from './types'
|
|
||||||
import semver from 'semver'
|
import semver from 'semver'
|
||||||
|
import ProcLib from './structures/ProcLib'
|
||||||
|
import { v4 as uuid } from 'uuid'
|
||||||
|
import eruda from 'eruda'
|
||||||
|
import { parse } from 'js-ini'
|
||||||
|
|
||||||
declare global {
|
export const spaces = ' '
|
||||||
interface Window {
|
|
||||||
kernel: Kernel
|
const print = {
|
||||||
|
ok: (action: string, text: string) => console.log(`[ OK ] ${action} ${text}`),
|
||||||
|
failed: (action: string, text: string, error: any) => console.error(`[FAILED] ${action} ${text}`),
|
||||||
|
none: (action: string, text: string) => console.group(`${action} ${text}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handle = async (type: 'target' | 'service' | 'mount', name: string, Instance: any): Promise<boolean | any> => {
|
||||||
|
try {
|
||||||
|
if (type !== 'target') print.none(type === 'mount' ? 'Mounting' : 'Starting', name)
|
||||||
|
const instance = typeof Instance === 'object' ? Instance : new Instance()
|
||||||
|
const data = await instance.init()
|
||||||
|
print.ok(
|
||||||
|
type === 'service'
|
||||||
|
? 'Started'
|
||||||
|
: type === 'mount'
|
||||||
|
? 'Mounted'
|
||||||
|
: 'Reached target',
|
||||||
|
name
|
||||||
|
)
|
||||||
|
console.groupEnd()
|
||||||
|
return typeof Instance === 'object' ? data : instance
|
||||||
|
} catch (e) {
|
||||||
|
print.failed('Failed', `to start ${name}`, e)
|
||||||
|
console.error(`${spaces}${e.stack.split('\n').join(`\n${spaces}`) as string}`)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams(window.location.search)
|
|
||||||
|
|
||||||
async function enableDebug (): Promise<void> {
|
|
||||||
const { default: eruda } = await import('eruda')
|
|
||||||
eruda.init()
|
|
||||||
return await Promise.resolve()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.get('debug') != null) {
|
|
||||||
enableDebug().catch(e => console.error(e))
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class Kernel {
|
export default class Kernel {
|
||||||
readonly version: string
|
readonly version: string
|
||||||
readonly codename: string
|
readonly codename: string
|
||||||
|
|
@ -32,31 +47,86 @@ export default class Kernel {
|
||||||
[key: string]: Package
|
[key: string]: Package
|
||||||
} = {}
|
} = {}
|
||||||
|
|
||||||
fs: any
|
fs: FileSystem | false
|
||||||
|
|
||||||
config: KernelConfig
|
config: KernelConfig | false
|
||||||
lastPid: number = 0
|
lastPid: number = 0
|
||||||
|
|
||||||
constructor (version: string) {
|
constructor () {
|
||||||
this.codename = 'Mochi'
|
this.codename = 'Pocky'
|
||||||
this.version = version
|
this.version = pkg.version
|
||||||
}
|
}
|
||||||
|
|
||||||
setFS (fs: any, process: ProcessLib): void {
|
async boot (boot: HTML, progress: HTML, args: URLSearchParams): Promise<void> {
|
||||||
if (process.permission === Permission.SYSTEM) {
|
progress.style({ width: '0%' })
|
||||||
this.fs = fs
|
const bootArgs = args.toString().replace(/=($|&)/g, '=true ')
|
||||||
}
|
console.log(`FlowOS - v${pkg.version}, Flow Works (c) ${new Date().getFullYear()}`)
|
||||||
}
|
console.log()
|
||||||
|
console.log(`User Agent : ${navigator.userAgent}`)
|
||||||
setConfig (data: any, process: ProcessLib): void {
|
console.log(`Boot Args : ${bootArgs === '' ? 'None' : bootArgs}`)
|
||||||
if (process.permission === Permission.SYSTEM) {
|
console.log()
|
||||||
this.config = data
|
console.log('...')
|
||||||
document.dispatchEvent(new CustomEvent('config_update', {
|
console.log()
|
||||||
detail: {
|
if (args.has('debug')) eruda.init()
|
||||||
config: this.config
|
this.fs = await handle('target', 'Virtual File Systems', VirtualFS)
|
||||||
|
if (this.fs === false) return
|
||||||
|
else progress.style({ width: '20%' })
|
||||||
|
this.config = await handle('target', 'FlowOS Configuration', {
|
||||||
|
init: async () => {
|
||||||
|
if (this.fs === false) return
|
||||||
|
return parse(Buffer.from(await this.fs.readFile('/etc/flow')).toString()) as any
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (this.config === false) return
|
||||||
|
else progress.style({ width: '40%' })
|
||||||
|
const tmp = await handle('mount', 'Temporary Directory (/tmp)', {
|
||||||
|
init: async () => {
|
||||||
|
if (this.fs === false) return false
|
||||||
|
if (await this.fs.exists('/tmp')) {
|
||||||
|
await this.fs.rmdir('/tmp')
|
||||||
}
|
}
|
||||||
}))
|
return await this.fs.mkdir('/tmp')
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
if (tmp === false) return
|
||||||
|
else progress.style({ width: '60%' })
|
||||||
|
const sw = await handle('service', 'Service Worker', {
|
||||||
|
init: async () => {
|
||||||
|
if (this.config === false) return false
|
||||||
|
const registrations = await navigator.serviceWorker.getRegistrations()
|
||||||
|
for (const registration of registrations) {
|
||||||
|
await registration.unregister()
|
||||||
|
}
|
||||||
|
await navigator.serviceWorker.register(`/uv-sw.js?url=${encodeURIComponent(btoa(this.config.SERVER))}&e=${uuid()}`, {
|
||||||
|
scope: '/service/'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (sw === false) return
|
||||||
|
else progress.style({ width: '80%' })
|
||||||
|
await handle('service', 'Desktop Environment', {
|
||||||
|
init: () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
import('./assets/style.less')
|
||||||
|
.then(() => {
|
||||||
|
boot.style({ display: 'none' })
|
||||||
|
import('material-symbols')
|
||||||
|
.then(async () => {
|
||||||
|
if (this.fs === false) return
|
||||||
|
console.log()
|
||||||
|
console.log('Welcome to FlowOS!')
|
||||||
|
console.log()
|
||||||
|
progress.style({ width: '100%' })
|
||||||
|
setTimeout(() => {
|
||||||
|
this.startExecutable('Desktop', Permission.SYSTEM).catch(e => console.error(e))
|
||||||
|
}, 750)
|
||||||
|
})
|
||||||
|
.catch(e => { throw e })
|
||||||
|
})
|
||||||
|
.catch(e => { throw e })
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async startExecutable (url: string, permission = Permission.USER, data = {}): Promise<{ procLib: ProcessLib, executable: Process } | any> {
|
async startExecutable (url: string, permission = Permission.USER, data = {}): Promise<{ procLib: ProcessLib, executable: Process } | any> {
|
||||||
|
|
@ -67,7 +137,7 @@ export default class Kernel {
|
||||||
const importedExecutable = (await module()) as any
|
const importedExecutable = (await module()) as any
|
||||||
executable = importedExecutable.default
|
executable = importedExecutable.default
|
||||||
} catch {
|
} catch {
|
||||||
if (this.fs === undefined) throw new Error('Filesystem hasn\'t been initiated.')
|
if (this.fs === false) throw new Error('Filesystem hasn\'t been initiated.')
|
||||||
const dataURL = `data:text/javascript;base64,${Buffer.from(await this.fs.readFile(`/opt/${url}.js`)).toString('base64')}`
|
const dataURL = `data:text/javascript;base64,${Buffer.from(await this.fs.readFile(`/opt/${url}.js`)).toString('base64')}`
|
||||||
const importedExecutable = await import(dataURL)
|
const importedExecutable = await import(dataURL)
|
||||||
executable = importedExecutable.default
|
executable = importedExecutable.default
|
||||||
|
|
@ -111,7 +181,7 @@ export default class Kernel {
|
||||||
const importedExecutable = (await module()) as any
|
const importedExecutable = (await module()) as any
|
||||||
executable = importedExecutable.default
|
executable = importedExecutable.default
|
||||||
} catch {
|
} catch {
|
||||||
if (this.fs === undefined) throw new Error('Filesystem hasn\'t been initiated.')
|
if (this.fs === false) throw new Error('Filesystem hasn\'t been initiated.')
|
||||||
const dataURL = `data:text/javascript;base64,${Buffer.from(await this.fs.readFile(`/opt/${url}.js`)).toString('base64')}`
|
const dataURL = `data:text/javascript;base64,${Buffer.from(await this.fs.readFile(`/opt/${url}.js`)).toString('base64')}`
|
||||||
const importedExecutable = await import(dataURL)
|
const importedExecutable = await import(dataURL)
|
||||||
executable = importedExecutable.default
|
executable = importedExecutable.default
|
||||||
|
|
@ -126,9 +196,3 @@ export default class Kernel {
|
||||||
return executable
|
return executable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
import('material-symbols')
|
|
||||||
const kernel = new Kernel(version)
|
|
||||||
kernel.startExecutable('BootLoader', Permission.SYSTEM).catch(e => console.error(e))
|
|
||||||
})
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import semver from 'semver'
|
import semver from 'semver'
|
||||||
import Kernel from '../kernel'
|
import Kernel from '../kernel'
|
||||||
import { Process, Executable, Package, Library, Permission, LoadedLibrary, LibraryPath } from '../types'
|
import { Process, Executable, Package, Library, Permission, LoadedLibrary, LibraryPath, FileSystem } from '../types'
|
||||||
import FlowWindow from './FlowWindow'
|
import FlowWindow from './FlowWindow'
|
||||||
import LibraryLib from './LibraryLib'
|
import LibraryLib from './LibraryLib'
|
||||||
import ProcLib from './ProcLib'
|
import ProcLib from './ProcLib'
|
||||||
|
|
@ -9,6 +9,7 @@ export default class ProcessLib {
|
||||||
readonly pid: number
|
readonly pid: number
|
||||||
readonly token: string
|
readonly token: string
|
||||||
process: Process
|
process: Process
|
||||||
|
fs: FileSystem
|
||||||
private readonly _kernel: Kernel
|
private readonly _kernel: Kernel
|
||||||
readonly kernel: {
|
readonly kernel: {
|
||||||
getExecutable: (url: string) => Promise<Executable>
|
getExecutable: (url: string) => Promise<Executable>
|
||||||
|
|
@ -17,8 +18,7 @@ export default class ProcessLib {
|
||||||
[key: string]: Package
|
[key: string]: Package
|
||||||
}
|
}
|
||||||
config: any
|
config: any
|
||||||
setConfig: (data: any) => void
|
setConfig: (config: any) => any
|
||||||
setFS: (fs: any) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
readonly permission: Permission
|
readonly permission: Permission
|
||||||
|
|
@ -32,6 +32,8 @@ export default class ProcessLib {
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor (url: string, pid: number, token: string, permission = Permission.USER, data = {}, process: Process, kernel: Kernel) {
|
constructor (url: string, pid: number, token: string, permission = Permission.USER, data = {}, process: Process, kernel: Kernel) {
|
||||||
|
if (kernel.fs === false) return
|
||||||
|
this.fs = kernel.fs
|
||||||
this.permission = permission
|
this.permission = permission
|
||||||
this.pid = pid
|
this.pid = pid
|
||||||
this.token = token
|
this.token = token
|
||||||
|
|
@ -41,8 +43,9 @@ export default class ProcessLib {
|
||||||
processList: kernel.processList,
|
processList: kernel.processList,
|
||||||
packageList: kernel.packageList,
|
packageList: kernel.packageList,
|
||||||
config: kernel.config,
|
config: kernel.config,
|
||||||
setConfig: (data: any) => kernel.setConfig(data, this),
|
setConfig: (config) => {
|
||||||
setFS: (fs: any) => kernel.setFS(fs, this)
|
if (this.permission >= Permission.ELEVATED) kernel.config = config
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.process = process
|
this.process = process
|
||||||
this.data = data
|
this.data = data
|
||||||
|
|
@ -65,8 +68,7 @@ export default class ProcessLib {
|
||||||
const importedExecutable = (await module()) as any
|
const importedExecutable = (await module()) as any
|
||||||
executable = importedExecutable.default
|
executable = importedExecutable.default
|
||||||
} catch {
|
} catch {
|
||||||
if (this._kernel.fs === undefined) throw new Error('Filesystem hasn\'t been initiated.')
|
const dataURL = `data:text/javascript;base64,${Buffer.from(await this.fs.readFile(`/opt/${url}.js`)).toString('base64')}`
|
||||||
const dataURL = `data:text/javascript;base64,${Buffer.from(await this._kernel.fs.readFile(`/opt/${url}.js`)).toString('base64')}`
|
|
||||||
const importedExecutable = await import(dataURL)
|
const importedExecutable = await import(dataURL)
|
||||||
executable = importedExecutable.default
|
executable = importedExecutable.default
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import HTML from '../HTML'
|
import HTML from '../HTML'
|
||||||
import { AppClosedEvent, AppOpenedEvent, Directory, FileSystemObject, Process } from '../types'
|
import { AppClosedEvent, AppOpenedEvent, Process } from '../types'
|
||||||
import { getTime } from '../utils'
|
import { getTime } from '../utils'
|
||||||
import { db, defaultFS, initializeDatabase, read, setFileSystem, write } from './lib/VirtualFS'
|
|
||||||
import nullIcon from '../assets/icons/application-default-icon.svg'
|
import nullIcon from '../assets/icons/application-default-icon.svg'
|
||||||
import { parse } from 'js-ini'
|
import { parse } from 'js-ini'
|
||||||
import { v4 as uuid } from 'uuid'
|
import { v4 as uuid } from 'uuid'
|
||||||
|
|
@ -17,39 +16,11 @@ const BootLoader: Process = {
|
||||||
const splashElement = splashScreen.getElement()
|
const splashElement = splashScreen.getElement()
|
||||||
splashElement.appendTo(document.body)
|
splashElement.appendTo(document.body)
|
||||||
|
|
||||||
const fs = await process.loadLibrary('lib/VirtualFS')
|
const { fs } = process
|
||||||
const wm = await process.loadLibrary('lib/WindowManager')
|
const wm = await process.loadLibrary('lib/WindowManager')
|
||||||
const launcher = await process.loadLibrary('lib/Launcher')
|
const launcher = await process.loadLibrary('lib/Launcher')
|
||||||
|
|
||||||
await initializeDatabase('virtualfs')
|
|
||||||
db.onerror = (event: Event) => {
|
|
||||||
const target = event.target as IDBRequest
|
|
||||||
const errorMessage = target.error !== null ? target.error.message : 'Unknown error'
|
|
||||||
throw new Error(`[VirtualFS] ${target.error?.name ?? 'Unknown Error'}: ${errorMessage}`)
|
|
||||||
}
|
|
||||||
if ('storage' in navigator) {
|
|
||||||
await navigator.storage?.persist()?.catch(e => console.error(e))
|
|
||||||
} else {
|
|
||||||
console.warn('Persistent storage is not supported.')
|
|
||||||
}
|
|
||||||
const fileSystem = await read() as FileSystemObject
|
|
||||||
if (fileSystem === undefined) {
|
|
||||||
await write(defaultFS)
|
|
||||||
} else {
|
|
||||||
const appsDirectory = ((fileSystem.root.children.home as Directory).children.Applications as Directory).children
|
|
||||||
const defaultAppsDirectory = ((defaultFS.root.children.home as Directory).children.Applications as Directory).children
|
|
||||||
for (const file in defaultAppsDirectory) {
|
|
||||||
if (appsDirectory[file] === undefined && defaultAppsDirectory[file] !== undefined) {
|
|
||||||
console.log(file)
|
|
||||||
appsDirectory[file] = defaultAppsDirectory[file]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await write(fileSystem)
|
|
||||||
await setFileSystem(fileSystem)
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = Buffer.from(await fs.readFile('/etc/flow')).toString()
|
const config = Buffer.from(await fs.readFile('/etc/flow')).toString()
|
||||||
process.kernel.setFS(fs)
|
|
||||||
process.kernel.setConfig(parse(config))
|
process.kernel.setConfig(parse(config))
|
||||||
|
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
|
|
|
||||||
149
src/system/Desktop.ts
Normal file
149
src/system/Desktop.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
import HTML from '../HTML'
|
||||||
|
import { AppClosedEvent, AppOpenedEvent, Process } from '../types'
|
||||||
|
import { getTime } from '../utils'
|
||||||
|
import nullIcon from '../assets/icons/application-default-icon.svg'
|
||||||
|
|
||||||
|
const BootLoader: Process = {
|
||||||
|
config: {
|
||||||
|
name: 'Desktop',
|
||||||
|
type: 'process',
|
||||||
|
targetVer: '1.0.0-indev.0'
|
||||||
|
},
|
||||||
|
run: async (process) => {
|
||||||
|
const splashScreen = await process.loadLibrary('lib/SplashScreen')
|
||||||
|
const splashElement = splashScreen.getElement()
|
||||||
|
splashElement.appendTo(document.body)
|
||||||
|
|
||||||
|
const { fs } = process
|
||||||
|
const wm = await process.loadLibrary('lib/WindowManager')
|
||||||
|
const launcher = await process.loadLibrary('lib/Launcher')
|
||||||
|
|
||||||
|
const input = new HTML('input').attr({
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'Search'
|
||||||
|
}).on('keyup', () => {
|
||||||
|
apps.elm.innerHTML = ''
|
||||||
|
renderApps().catch(e => console.error(e))
|
||||||
|
}).appendTo(launcher.element)
|
||||||
|
const apps = new HTML('apps').appendTo(launcher.element)
|
||||||
|
|
||||||
|
const renderApps = async (): Promise<void> => {
|
||||||
|
apps.html('')
|
||||||
|
const files = await fs.readdir('/home/Applications/')
|
||||||
|
files
|
||||||
|
.filter((x: string) => x.endsWith('.app') && ((input.elm as HTMLInputElement) !== null ? x.toLowerCase().includes((input.elm as HTMLInputElement).value.toLowerCase()) : true))
|
||||||
|
.forEach((file: string) => {
|
||||||
|
fs.readFile(`/home/Applications/${file}`).then(async (data: Uint8Array) => {
|
||||||
|
const path = Buffer.from(data).toString()
|
||||||
|
const executable = await process.kernel.getExecutable(path) as Process
|
||||||
|
|
||||||
|
const appElement = new HTML('app').on('click', () => {
|
||||||
|
process.launch(path).catch((e: any) => console.error(e))
|
||||||
|
launcher.toggle()
|
||||||
|
}).appendTo(apps)
|
||||||
|
new HTML('img').attr({
|
||||||
|
src: executable.config.icon ?? nullIcon,
|
||||||
|
alt: `${executable.config.name} icon`
|
||||||
|
}).appendTo(appElement)
|
||||||
|
new HTML('div').text(executable.config.name).appendTo(appElement)
|
||||||
|
}).catch((e: any) => console.error(e))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await renderApps()
|
||||||
|
document.addEventListener('fs_update', () => {
|
||||||
|
renderApps().catch(e => console.error(e))
|
||||||
|
})
|
||||||
|
|
||||||
|
launcher.element.on('click', (e: Event) => {
|
||||||
|
if (e.target !== e.currentTarget) return
|
||||||
|
launcher.toggle()
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusBar = await process.loadLibrary('lib/StatusBar')
|
||||||
|
|
||||||
|
statusBar.element.html(`
|
||||||
|
<div class="outlined" data-toolbar-id="start"><span class="material-symbols-rounded">space_dashboard</span></div>
|
||||||
|
|
||||||
|
<div data-toolbar-id="apps"></div>
|
||||||
|
<flex></flex>
|
||||||
|
<div class="outlined" data-toolbar-id="plugins"><span class="material-symbols-rounded">expand_less</span></div>
|
||||||
|
<div class="outlined" data-toolbar-id="controls">
|
||||||
|
<span class="material-symbols-rounded battery">battery_2_bar</span>
|
||||||
|
<span class="material-symbols-rounded signal">signal_cellular_4_bar</span>
|
||||||
|
</div>
|
||||||
|
<div class="outlined" data-toolbar-id="calendar"></div>
|
||||||
|
|
||||||
|
`)
|
||||||
|
|
||||||
|
setInterval((): any => {
|
||||||
|
getTime().then((time) => {
|
||||||
|
statusBar.element.qs('div[data-toolbar-id="calendar"]')?.text(time)
|
||||||
|
}).catch(e => console.error)
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
statusBar.element.qs('div[data-toolbar-id="start"]')?.on('click', () => {
|
||||||
|
launcher.toggle()
|
||||||
|
})
|
||||||
|
|
||||||
|
if ('getBattery' in navigator) {
|
||||||
|
(navigator as any).getBattery().then((battery: any) => {
|
||||||
|
statusBar.updateBatteryIcon(battery)
|
||||||
|
|
||||||
|
battery.addEventListener('levelchange', () => {
|
||||||
|
statusBar.updateBatteryIcon(battery)
|
||||||
|
})
|
||||||
|
|
||||||
|
battery.addEventListener('chargingchange', () => {
|
||||||
|
statusBar.updateBatteryIcon(battery)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const batteryDiv = document.querySelector('div[data-toolbar-id="controls"] > .battery')
|
||||||
|
if (batteryDiv != null) {
|
||||||
|
batteryDiv.innerHTML = 'battery_unknown'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ping (startTime: number): Promise<void> {
|
||||||
|
fetch(`${process.kernel.config.SERVER as string}/bare/`)
|
||||||
|
.then(() => {
|
||||||
|
const endTime = performance.now()
|
||||||
|
const pingTime = endTime - startTime
|
||||||
|
statusBar.updateIcon(pingTime)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
(document.querySelector('div[data-toolbar-id="controls"] > .signal') as HTMLElement).innerHTML = 'signal_cellular_connected_no_internet_4_bar'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval((): any => ping(performance.now()), 10_000)
|
||||||
|
|
||||||
|
document.addEventListener('app_opened', (e: AppOpenedEvent): void => {
|
||||||
|
new HTML('app').appendMany(
|
||||||
|
new HTML('img').attr({
|
||||||
|
alt: `${e.detail.proc.config.name} icon`,
|
||||||
|
'data-id': e.detail.token,
|
||||||
|
src: e.detail.proc.config.icon ?? nullIcon
|
||||||
|
}).on('click', () => {
|
||||||
|
e.detail.win.focus()
|
||||||
|
e.detail.win.toggleMin()
|
||||||
|
})
|
||||||
|
).appendTo(statusBar.element.qs('div[data-toolbar-id="apps"]')?.elm as HTMLElement)
|
||||||
|
})
|
||||||
|
|
||||||
|
document.addEventListener('app_closed', (e: AppClosedEvent): void => {
|
||||||
|
statusBar.element.qs('div[data-toolbar-id="apps"]')?.qs(`img[data-id="${e.detail.token}"]`)?.elm.parentElement?.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
document.body.style.flexDirection = 'column-reverse'
|
||||||
|
|
||||||
|
await statusBar.element.appendTo(document.body)
|
||||||
|
await launcher.element.appendTo(document.body)
|
||||||
|
await wm.windowArea.appendTo(document.body)
|
||||||
|
|
||||||
|
splashElement.cleanup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BootLoader
|
||||||
433
src/system/VirtualFS.ts
Normal file
433
src/system/VirtualFS.ts
Normal file
|
|
@ -0,0 +1,433 @@
|
||||||
|
import { Directory, Errors, File, Permission, Stats } from '../types'
|
||||||
|
|
||||||
|
export const defaultFS: { root: Directory } = {
|
||||||
|
root: {
|
||||||
|
type: 'directory',
|
||||||
|
deleteable: false,
|
||||||
|
permission: Permission.SYSTEM,
|
||||||
|
children: {
|
||||||
|
home: {
|
||||||
|
type: 'directory',
|
||||||
|
deleteable: false,
|
||||||
|
permission: Permission.SYSTEM,
|
||||||
|
children: {
|
||||||
|
Downloads: {
|
||||||
|
type: 'directory',
|
||||||
|
deleteable: false,
|
||||||
|
permission: Permission.USER,
|
||||||
|
children: {}
|
||||||
|
},
|
||||||
|
Applications: {
|
||||||
|
type: 'directory',
|
||||||
|
deleteable: false,
|
||||||
|
permission: Permission.USER,
|
||||||
|
children: {
|
||||||
|
'Info.app': {
|
||||||
|
type: 'file',
|
||||||
|
deleteable: true,
|
||||||
|
permission: Permission.USER,
|
||||||
|
content: Buffer.from('apps/Info')
|
||||||
|
},
|
||||||
|
'Manager.app': {
|
||||||
|
type: 'file',
|
||||||
|
deleteable: true,
|
||||||
|
permission: Permission.USER,
|
||||||
|
content: Buffer.from('apps/Manager')
|
||||||
|
},
|
||||||
|
'Store.app': {
|
||||||
|
type: 'file',
|
||||||
|
deleteable: true,
|
||||||
|
permission: Permission.USER,
|
||||||
|
content: Buffer.from('apps/Store')
|
||||||
|
},
|
||||||
|
'TaskManager.app': {
|
||||||
|
type: 'file',
|
||||||
|
deleteable: true,
|
||||||
|
permission: Permission.USER,
|
||||||
|
content: Buffer.from('apps/TaskManager')
|
||||||
|
},
|
||||||
|
'Browser.app': {
|
||||||
|
type: 'file',
|
||||||
|
deleteable: true,
|
||||||
|
permission: Permission.USER,
|
||||||
|
content: Buffer.from('apps/Browser')
|
||||||
|
},
|
||||||
|
'ImageViewer.app': {
|
||||||
|
type: 'file',
|
||||||
|
deleteable: true,
|
||||||
|
permission: Permission.USER,
|
||||||
|
content: Buffer.from('apps/ImageViewer')
|
||||||
|
},
|
||||||
|
'Files.app': {
|
||||||
|
type: 'file',
|
||||||
|
deleteable: true,
|
||||||
|
permission: Permission.USER,
|
||||||
|
content: Buffer.from('apps/Files')
|
||||||
|
},
|
||||||
|
'Editor.app': {
|
||||||
|
type: 'file',
|
||||||
|
deleteable: true,
|
||||||
|
permission: Permission.USER,
|
||||||
|
content: Buffer.from('apps/Editor')
|
||||||
|
},
|
||||||
|
'Settings.app': {
|
||||||
|
type: 'file',
|
||||||
|
deleteable: true,
|
||||||
|
permission: Permission.USER,
|
||||||
|
content: Buffer.from('apps/Settings')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Desktop: {
|
||||||
|
type: 'directory',
|
||||||
|
deleteable: false,
|
||||||
|
permission: Permission.USER,
|
||||||
|
children: {
|
||||||
|
'README.md': {
|
||||||
|
type: 'file',
|
||||||
|
deleteable: true,
|
||||||
|
permission: Permission.USER,
|
||||||
|
content: Buffer.from('# Welcome to FlowOS!')
|
||||||
|
},
|
||||||
|
'Info.lnk': {
|
||||||
|
type: 'file',
|
||||||
|
deleteable: true,
|
||||||
|
permission: Permission.USER,
|
||||||
|
content: Buffer.from('/home/Applications/Info.app')
|
||||||
|
},
|
||||||
|
'Manager.lnk': {
|
||||||
|
type: 'file',
|
||||||
|
deleteable: true,
|
||||||
|
permission: Permission.USER,
|
||||||
|
content: Buffer.from('/home/Applications/Manager.app')
|
||||||
|
},
|
||||||
|
'Store.lnk': {
|
||||||
|
type: 'file',
|
||||||
|
deleteable: true,
|
||||||
|
permission: Permission.USER,
|
||||||
|
content: Buffer.from('/home/Applications/Store.app')
|
||||||
|
},
|
||||||
|
'TaskManager.lnk': {
|
||||||
|
type: 'file',
|
||||||
|
deleteable: true,
|
||||||
|
permission: Permission.USER,
|
||||||
|
content: Buffer.from('/home/Applications/TaskManager.app')
|
||||||
|
},
|
||||||
|
'Browser.lnk': {
|
||||||
|
type: 'file',
|
||||||
|
deleteable: true,
|
||||||
|
permission: Permission.USER,
|
||||||
|
content: Buffer.from('/home/Applications/Browser.app')
|
||||||
|
},
|
||||||
|
'ImageViewer.lnk': {
|
||||||
|
type: 'file',
|
||||||
|
deleteable: true,
|
||||||
|
permission: Permission.USER,
|
||||||
|
content: Buffer.from('/home/Applications/ImageViewer.app')
|
||||||
|
},
|
||||||
|
'Files.lnk': {
|
||||||
|
type: 'file',
|
||||||
|
deleteable: true,
|
||||||
|
permission: Permission.USER,
|
||||||
|
content: Buffer.from('/home/Applications/Files.app')
|
||||||
|
},
|
||||||
|
'Editor.lnk': {
|
||||||
|
type: 'file',
|
||||||
|
deleteable: true,
|
||||||
|
permission: Permission.USER,
|
||||||
|
content: Buffer.from('/home/Applications/Editor.app')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Pictures: {
|
||||||
|
type: 'directory',
|
||||||
|
deleteable: false,
|
||||||
|
permission: Permission.USER,
|
||||||
|
children: {}
|
||||||
|
},
|
||||||
|
Videos: {
|
||||||
|
type: 'directory',
|
||||||
|
deleteable: false,
|
||||||
|
permission: Permission.USER,
|
||||||
|
children: {}
|
||||||
|
},
|
||||||
|
Documents: {
|
||||||
|
type: 'directory',
|
||||||
|
deleteable: false,
|
||||||
|
permission: Permission.USER,
|
||||||
|
children: {}
|
||||||
|
},
|
||||||
|
Music: {
|
||||||
|
type: 'directory',
|
||||||
|
deleteable: false,
|
||||||
|
permission: Permission.USER,
|
||||||
|
children: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
var: {
|
||||||
|
type: 'directory',
|
||||||
|
deleteable: false,
|
||||||
|
permission: Permission.SYSTEM,
|
||||||
|
children: {}
|
||||||
|
},
|
||||||
|
etc: {
|
||||||
|
type: 'directory',
|
||||||
|
deleteable: false,
|
||||||
|
permission: Permission.SYSTEM,
|
||||||
|
children: {
|
||||||
|
flow: {
|
||||||
|
type: 'file',
|
||||||
|
deleteable: false,
|
||||||
|
permission: Permission.ELEVATED,
|
||||||
|
content: Buffer.from([
|
||||||
|
'SERVER=https://server.flow-works.me',
|
||||||
|
'24HOUR=FALSE'
|
||||||
|
].join('\n'))
|
||||||
|
},
|
||||||
|
hostname: {
|
||||||
|
type: 'file',
|
||||||
|
deleteable: false,
|
||||||
|
permission: Permission.ELEVATED,
|
||||||
|
content: Buffer.from('flow')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
opt: {
|
||||||
|
type: 'directory',
|
||||||
|
deleteable: false,
|
||||||
|
permission: Permission.SYSTEM,
|
||||||
|
children: {
|
||||||
|
apps: {
|
||||||
|
type: 'directory',
|
||||||
|
deleteable: false,
|
||||||
|
permission: Permission.SYSTEM,
|
||||||
|
children: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class VirtualFS {
|
||||||
|
private fileSystem: { root: Directory } = defaultFS
|
||||||
|
private db: IDBDatabase | null = null
|
||||||
|
async init (dbName = 'virtualfs'): Promise<VirtualFS> {
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
indexedDB.deleteDatabase(dbName)
|
||||||
|
const request = indexedDB.open(dbName)
|
||||||
|
request.onerror = () => {
|
||||||
|
reject(new Error('Failed to open database'))
|
||||||
|
}
|
||||||
|
request.onsuccess = () => {
|
||||||
|
this.db = request.result
|
||||||
|
resolve(this)
|
||||||
|
}
|
||||||
|
request.onupgradeneeded = () => {
|
||||||
|
const db = request.result
|
||||||
|
db.createObjectStore('fs')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private setFileSystem (fileSystemObject: { root: Directory }): void {
|
||||||
|
this.fileSystem = fileSystemObject
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly read = async (): Promise<any> => {
|
||||||
|
const transaction = this.db?.transaction(['fs'], 'readonly')
|
||||||
|
const store = transaction?.objectStore('fs')
|
||||||
|
const getRequest = store?.get('fs')
|
||||||
|
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
if (getRequest == null) return
|
||||||
|
getRequest.onsuccess = () => {
|
||||||
|
resolve(getRequest.result)
|
||||||
|
}
|
||||||
|
|
||||||
|
getRequest.onerror = () => {
|
||||||
|
reject(getRequest.error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly write = async (fileSystemObject: { root: Directory }): Promise<void> => {
|
||||||
|
this.fileSystem = fileSystemObject
|
||||||
|
await this.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly save = async (): Promise<void> => {
|
||||||
|
const transaction = this.db?.transaction(['fs'], 'readwrite')
|
||||||
|
const store = transaction?.objectStore('fs')
|
||||||
|
const putRequest = store?.put(this.fileSystem, 'fs')
|
||||||
|
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
if (putRequest == null) return
|
||||||
|
putRequest.onsuccess = () => {
|
||||||
|
document.dispatchEvent(new CustomEvent('fs_update', {}))
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
putRequest.onerror = () => {
|
||||||
|
reject(putRequest.error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly navigatePath = async (path: string): Promise<{ current: Directory | File, parts: string[] }> => {
|
||||||
|
const parts = path.split('/').filter(x => x !== '')
|
||||||
|
let current = this.fileSystem.root
|
||||||
|
for (const part of parts) {
|
||||||
|
current = current.children[part] as Directory
|
||||||
|
}
|
||||||
|
return { current, parts }
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly navigatePathParent = async (path: string): Promise<{ current: Directory, parts: string[], filename: string }> => {
|
||||||
|
const parts = path.split('/').filter(x => x !== '')
|
||||||
|
const filename = parts.pop() as string
|
||||||
|
let current = this.fileSystem.root
|
||||||
|
for (const part of parts) {
|
||||||
|
current = current.children[part] as Directory
|
||||||
|
}
|
||||||
|
return { current, parts, filename }
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly handlePermissions = async (path: string): Promise<void> => {
|
||||||
|
const { current } = await this.navigatePath(path)
|
||||||
|
if (current.permission === Permission.SYSTEM) throw new Error(Errors.EPERM)
|
||||||
|
}
|
||||||
|
|
||||||
|
unlink = async (path: string): Promise<void> => {
|
||||||
|
const { current, filename } = await this.navigatePathParent(path)
|
||||||
|
|
||||||
|
if (!current.children[filename].deleteable) throw new Error(Errors.EPERM)
|
||||||
|
await this.handlePermissions(path)
|
||||||
|
|
||||||
|
Reflect.deleteProperty(current.children, filename)
|
||||||
|
|
||||||
|
console.debug(`unlink ${path}`)
|
||||||
|
await this.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
readFile = async (path: string): Promise<Buffer> => {
|
||||||
|
const { current } = await this.navigatePath(path)
|
||||||
|
|
||||||
|
await this.handlePermissions(path)
|
||||||
|
|
||||||
|
if (current.type !== 'file') throw new Error(Errors.EISDIR)
|
||||||
|
|
||||||
|
console.debug(`read ${path}`)
|
||||||
|
return current.content
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFile = async (path: string, content: string | Buffer): Promise<void> => {
|
||||||
|
const { current, filename } = await this.navigatePathParent(path)
|
||||||
|
|
||||||
|
let permission
|
||||||
|
|
||||||
|
if (typeof current.children[filename] === 'undefined') {
|
||||||
|
permission = Permission.USER
|
||||||
|
} else {
|
||||||
|
await this.handlePermissions(path)
|
||||||
|
permission = current.children[filename].permission
|
||||||
|
}
|
||||||
|
|
||||||
|
current.children[filename] = {
|
||||||
|
type: 'file',
|
||||||
|
deleteable: true,
|
||||||
|
permission,
|
||||||
|
content: Buffer.from(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.debug(`write ${path}`)
|
||||||
|
await this.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdir = async (path: string): Promise<void> => {
|
||||||
|
const { current, filename } = await this.navigatePathParent(path)
|
||||||
|
|
||||||
|
let permission
|
||||||
|
|
||||||
|
if (typeof current.children[filename] === 'undefined') {
|
||||||
|
permission = Permission.USER
|
||||||
|
} else {
|
||||||
|
await this.handlePermissions(path)
|
||||||
|
permission = current.children[filename].permission
|
||||||
|
}
|
||||||
|
|
||||||
|
current.children[filename] = {
|
||||||
|
type: 'directory',
|
||||||
|
deleteable: true,
|
||||||
|
permission: path === '/tmp' ? Permission.USER : permission,
|
||||||
|
children: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.debug(`mkdir ${path}`)
|
||||||
|
await this.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
rmdir = async (path: string): Promise<void> => {
|
||||||
|
const { current, filename } = await this.navigatePathParent(path)
|
||||||
|
|
||||||
|
if (!current.deleteable) throw new Error(Errors.EPERM)
|
||||||
|
await this.handlePermissions(path)
|
||||||
|
|
||||||
|
if (current.children[filename].type !== 'directory') throw new Error(Errors.ENOTDIR)
|
||||||
|
|
||||||
|
Reflect.deleteProperty(current.children, filename)
|
||||||
|
|
||||||
|
console.debug(`rmdir ${path}`)
|
||||||
|
await this.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
readdir = async (path: string): Promise<string[]> => {
|
||||||
|
const { current } = await this.navigatePath(path)
|
||||||
|
|
||||||
|
if (current.type === 'file') throw new Error(Errors.ENOTDIR)
|
||||||
|
const result = await Promise.all(Object.keys(current.children ?? {}))
|
||||||
|
|
||||||
|
console.debug(`readdir ${path}`)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
stat = async (path: string): Promise<Stats> => {
|
||||||
|
const { current } = await this.navigatePath(path)
|
||||||
|
|
||||||
|
console.debug(`stat ${path}`)
|
||||||
|
return {
|
||||||
|
isDirectory: () => current.type === 'directory',
|
||||||
|
isFile: () => current.type === 'file'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rename = async (oldPath: string, newPath: string): Promise<void> => {
|
||||||
|
const { current: oldCurrent, filename: oldFilename } = await this.navigatePathParent(oldPath)
|
||||||
|
const { current: newCurrent, filename: newFilename } = await this.navigatePathParent(newPath)
|
||||||
|
|
||||||
|
if (!oldCurrent.deleteable) throw new Error(Errors.EPERM)
|
||||||
|
if (!newCurrent.deleteable) throw new Error(Errors.EPERM)
|
||||||
|
|
||||||
|
await this.handlePermissions(oldPath)
|
||||||
|
await this.handlePermissions(newPath)
|
||||||
|
|
||||||
|
newCurrent.children[newFilename] = oldCurrent.children[oldFilename]
|
||||||
|
Reflect.deleteProperty(oldCurrent.children, oldFilename)
|
||||||
|
|
||||||
|
console.debug(`rename ${oldPath} -> ${newPath}`)
|
||||||
|
await this.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
exists = async (path: string): Promise<boolean> => {
|
||||||
|
console.debug(`exists ${path}`)
|
||||||
|
try {
|
||||||
|
const { current } = await this.navigatePath(path)
|
||||||
|
return current !== undefined
|
||||||
|
} catch (e) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VirtualFS
|
||||||
|
|
@ -51,7 +51,7 @@ const Editor: Process = {
|
||||||
}, process)
|
}, process)
|
||||||
})
|
})
|
||||||
|
|
||||||
const fs = await process.loadLibrary('lib/VirtualFS')
|
const fs = process.fs
|
||||||
|
|
||||||
const data = process.data as EditorConfig
|
const data = process.data as EditorConfig
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ const Files: Process = {
|
||||||
}, process)
|
}, process)
|
||||||
})
|
})
|
||||||
|
|
||||||
const fs = await process.loadLibrary('lib/VirtualFS')
|
const fs = process.fs
|
||||||
const MIMETypes = await process.loadLibrary('lib/MIMETypes')
|
const MIMETypes = await process.loadLibrary('lib/MIMETypes')
|
||||||
|
|
||||||
win.content.style.display = 'flex'
|
win.content.style.display = 'flex'
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ const ImageViewer: Process = {
|
||||||
}, process)
|
}, process)
|
||||||
})
|
})
|
||||||
|
|
||||||
const fs = await process.loadLibrary('lib/VirtualFS')
|
const fs = process.fs
|
||||||
const MIMETypes: Record<string, { type: string }> = await process.loadLibrary('lib/MIMETypes')
|
const MIMETypes: Record<string, { type: string }> = await process.loadLibrary('lib/MIMETypes')
|
||||||
const HTML = await process.loadLibrary('lib/HTML')
|
const HTML = await process.loadLibrary('lib/HTML')
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ const Settings: Process = {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const fs = await process.loadLibrary('lib/VirtualFS')
|
const { fs } = process
|
||||||
const HTML = await process.loadLibrary('lib/HTML')
|
const HTML = await process.loadLibrary('lib/HTML')
|
||||||
|
|
||||||
const { Input, Button } = await process.loadLibrary('lib/Components')
|
const { Input, Button } = await process.loadLibrary('lib/Components')
|
||||||
|
|
@ -50,17 +50,20 @@ const Settings: Process = {
|
||||||
})
|
})
|
||||||
.appendMany(
|
.appendMany(
|
||||||
input,
|
input,
|
||||||
Button.new().text('Save').on('click', async () => {
|
Button.new().text('Save').on('click', () => {
|
||||||
config[item] = input.getValue()
|
config[item] = input.getValue()
|
||||||
process.kernel.setConfig(config)
|
process.kernel.setConfig(config)
|
||||||
await fs.writeFile('/etc/flow', stringify(config))
|
fs.writeFile('/etc/flow', stringify(config))
|
||||||
document.dispatchEvent(
|
.then(() => {
|
||||||
new CustomEvent('config_update', {
|
document.dispatchEvent(
|
||||||
detail: {
|
new CustomEvent('config_update', {
|
||||||
config
|
detail: {
|
||||||
}
|
config
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
})
|
})
|
||||||
)
|
.catch(e => console.error(e))
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ const Store: Process = {
|
||||||
}, process)
|
}, process)
|
||||||
})
|
})
|
||||||
|
|
||||||
const fs = await process.loadLibrary('lib/VirtualFS')
|
const { fs } = process
|
||||||
const HTML = await process.loadLibrary('lib/HTML')
|
const HTML = await process.loadLibrary('lib/HTML')
|
||||||
const { Button, Icon } = await process.loadLibrary('lib/Components')
|
const { Button, Icon } = await process.loadLibrary('lib/Components')
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,461 +0,0 @@
|
||||||
import Kernel from '../../kernel'
|
|
||||||
import ProcessLib from '../../structures/ProcessLib'
|
|
||||||
import { Directory, Errors, File, Library, Permission, Stats } from '../../types'
|
|
||||||
|
|
||||||
console.debug = (...args: any[]) => {
|
|
||||||
console.log('[VirtualFS]', ...args)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const defaultFS: { root: Directory } = {
|
|
||||||
root: {
|
|
||||||
type: 'directory',
|
|
||||||
deleteable: false,
|
|
||||||
permission: Permission.SYSTEM,
|
|
||||||
children: {
|
|
||||||
home: {
|
|
||||||
type: 'directory',
|
|
||||||
deleteable: false,
|
|
||||||
permission: Permission.SYSTEM,
|
|
||||||
children: {
|
|
||||||
Downloads: {
|
|
||||||
type: 'directory',
|
|
||||||
deleteable: false,
|
|
||||||
permission: Permission.USER,
|
|
||||||
children: {}
|
|
||||||
},
|
|
||||||
Applications: {
|
|
||||||
type: 'directory',
|
|
||||||
deleteable: false,
|
|
||||||
permission: Permission.USER,
|
|
||||||
children: {
|
|
||||||
'Info.app': {
|
|
||||||
type: 'file',
|
|
||||||
deleteable: true,
|
|
||||||
permission: Permission.USER,
|
|
||||||
content: Buffer.from('apps/Info')
|
|
||||||
},
|
|
||||||
'Manager.app': {
|
|
||||||
type: 'file',
|
|
||||||
deleteable: true,
|
|
||||||
permission: Permission.USER,
|
|
||||||
content: Buffer.from('apps/Manager')
|
|
||||||
},
|
|
||||||
'Store.app': {
|
|
||||||
type: 'file',
|
|
||||||
deleteable: true,
|
|
||||||
permission: Permission.USER,
|
|
||||||
content: Buffer.from('apps/Store')
|
|
||||||
},
|
|
||||||
'TaskManager.app': {
|
|
||||||
type: 'file',
|
|
||||||
deleteable: true,
|
|
||||||
permission: Permission.USER,
|
|
||||||
content: Buffer.from('apps/TaskManager')
|
|
||||||
},
|
|
||||||
'Browser.app': {
|
|
||||||
type: 'file',
|
|
||||||
deleteable: true,
|
|
||||||
permission: Permission.USER,
|
|
||||||
content: Buffer.from('apps/Browser')
|
|
||||||
},
|
|
||||||
'ImageViewer.app': {
|
|
||||||
type: 'file',
|
|
||||||
deleteable: true,
|
|
||||||
permission: Permission.USER,
|
|
||||||
content: Buffer.from('apps/ImageViewer')
|
|
||||||
},
|
|
||||||
'Files.app': {
|
|
||||||
type: 'file',
|
|
||||||
deleteable: true,
|
|
||||||
permission: Permission.USER,
|
|
||||||
content: Buffer.from('apps/Files')
|
|
||||||
},
|
|
||||||
'Editor.app': {
|
|
||||||
type: 'file',
|
|
||||||
deleteable: true,
|
|
||||||
permission: Permission.USER,
|
|
||||||
content: Buffer.from('apps/Editor')
|
|
||||||
},
|
|
||||||
'Settings.app': {
|
|
||||||
type: 'file',
|
|
||||||
deleteable: true,
|
|
||||||
permission: Permission.USER,
|
|
||||||
content: Buffer.from('apps/Settings')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Desktop: {
|
|
||||||
type: 'directory',
|
|
||||||
deleteable: false,
|
|
||||||
permission: Permission.USER,
|
|
||||||
children: {
|
|
||||||
'README.md': {
|
|
||||||
type: 'file',
|
|
||||||
deleteable: true,
|
|
||||||
permission: Permission.USER,
|
|
||||||
content: Buffer.from('# Welcome to FlowOS!')
|
|
||||||
},
|
|
||||||
'Info.lnk': {
|
|
||||||
type: 'file',
|
|
||||||
deleteable: true,
|
|
||||||
permission: Permission.USER,
|
|
||||||
content: Buffer.from('/home/Applications/Info.app')
|
|
||||||
},
|
|
||||||
'Manager.lnk': {
|
|
||||||
type: 'file',
|
|
||||||
deleteable: true,
|
|
||||||
permission: Permission.USER,
|
|
||||||
content: Buffer.from('/home/Applications/Manager.app')
|
|
||||||
},
|
|
||||||
'Store.lnk': {
|
|
||||||
type: 'file',
|
|
||||||
deleteable: true,
|
|
||||||
permission: Permission.USER,
|
|
||||||
content: Buffer.from('/home/Applications/Store.app')
|
|
||||||
},
|
|
||||||
'TaskManager.lnk': {
|
|
||||||
type: 'file',
|
|
||||||
deleteable: true,
|
|
||||||
permission: Permission.USER,
|
|
||||||
content: Buffer.from('/home/Applications/TaskManager.app')
|
|
||||||
},
|
|
||||||
'Browser.lnk': {
|
|
||||||
type: 'file',
|
|
||||||
deleteable: true,
|
|
||||||
permission: Permission.USER,
|
|
||||||
content: Buffer.from('/home/Applications/Browser.app')
|
|
||||||
},
|
|
||||||
'ImageViewer.lnk': {
|
|
||||||
type: 'file',
|
|
||||||
deleteable: true,
|
|
||||||
permission: Permission.USER,
|
|
||||||
content: Buffer.from('/home/Applications/ImageViewer.app')
|
|
||||||
},
|
|
||||||
'Files.lnk': {
|
|
||||||
type: 'file',
|
|
||||||
deleteable: true,
|
|
||||||
permission: Permission.USER,
|
|
||||||
content: Buffer.from('/home/Applications/Files.app')
|
|
||||||
},
|
|
||||||
'Editor.lnk': {
|
|
||||||
type: 'file',
|
|
||||||
deleteable: true,
|
|
||||||
permission: Permission.USER,
|
|
||||||
content: Buffer.from('/home/Applications/Editor.app')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Pictures: {
|
|
||||||
type: 'directory',
|
|
||||||
deleteable: false,
|
|
||||||
permission: Permission.USER,
|
|
||||||
children: {}
|
|
||||||
},
|
|
||||||
Videos: {
|
|
||||||
type: 'directory',
|
|
||||||
deleteable: false,
|
|
||||||
permission: Permission.USER,
|
|
||||||
children: {}
|
|
||||||
},
|
|
||||||
Documents: {
|
|
||||||
type: 'directory',
|
|
||||||
deleteable: false,
|
|
||||||
permission: Permission.USER,
|
|
||||||
children: {}
|
|
||||||
},
|
|
||||||
Music: {
|
|
||||||
type: 'directory',
|
|
||||||
deleteable: false,
|
|
||||||
permission: Permission.USER,
|
|
||||||
children: {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
var: {
|
|
||||||
type: 'directory',
|
|
||||||
deleteable: false,
|
|
||||||
permission: Permission.SYSTEM,
|
|
||||||
children: {}
|
|
||||||
},
|
|
||||||
etc: {
|
|
||||||
type: 'directory',
|
|
||||||
deleteable: false,
|
|
||||||
permission: Permission.SYSTEM,
|
|
||||||
children: {
|
|
||||||
flow: {
|
|
||||||
type: 'file',
|
|
||||||
deleteable: false,
|
|
||||||
permission: Permission.ELEVATED,
|
|
||||||
content: Buffer.from([
|
|
||||||
'SERVER=https://server.flow-works.me',
|
|
||||||
'24HOUR=FALSE'
|
|
||||||
].join('\n'))
|
|
||||||
},
|
|
||||||
hostname: {
|
|
||||||
type: 'file',
|
|
||||||
deleteable: false,
|
|
||||||
permission: Permission.ELEVATED,
|
|
||||||
content: Buffer.from('flow')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
opt: {
|
|
||||||
type: 'directory',
|
|
||||||
deleteable: false,
|
|
||||||
permission: Permission.SYSTEM,
|
|
||||||
children: {
|
|
||||||
apps: {
|
|
||||||
type: 'directory',
|
|
||||||
deleteable: false,
|
|
||||||
permission: Permission.SYSTEM,
|
|
||||||
children: {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const setFileSystem = async (fileSystemObject: { root: Directory }): Promise<void> => {
|
|
||||||
fileSystem = fileSystemObject
|
|
||||||
}
|
|
||||||
|
|
||||||
export let db: IDBDatabase
|
|
||||||
let fileSystem: { root: Directory }
|
|
||||||
let kernel: Kernel
|
|
||||||
let process: ProcessLib
|
|
||||||
|
|
||||||
export const initializeDatabase = async (dbName: string): Promise<boolean> => {
|
|
||||||
return await new Promise((resolve, reject) => {
|
|
||||||
const request = window.indexedDB.open(dbName)
|
|
||||||
|
|
||||||
request.onupgradeneeded = (event: Event) => {
|
|
||||||
const target = event.target as IDBRequest
|
|
||||||
const db = target.result
|
|
||||||
db.createObjectStore('fs')
|
|
||||||
}
|
|
||||||
|
|
||||||
request.onerror = (event: Event) => {
|
|
||||||
reject(new Error('[VirtualFS] Error opening database.'))
|
|
||||||
}
|
|
||||||
|
|
||||||
request.onsuccess = () => {
|
|
||||||
db = request.result
|
|
||||||
resolve(true)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const read = async (): Promise<any> => {
|
|
||||||
const transaction = db.transaction(['fs'], 'readonly')
|
|
||||||
const store = transaction.objectStore('fs')
|
|
||||||
const getRequest = store.get('fs')
|
|
||||||
|
|
||||||
return await new Promise((resolve, reject) => {
|
|
||||||
getRequest.onsuccess = () => {
|
|
||||||
resolve(getRequest.result)
|
|
||||||
}
|
|
||||||
|
|
||||||
getRequest.onerror = () => {
|
|
||||||
reject(getRequest.error)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const write = async (fileSystemObject: { root: Directory }): Promise<void> => {
|
|
||||||
fileSystem = fileSystemObject
|
|
||||||
await save()
|
|
||||||
}
|
|
||||||
|
|
||||||
const save = async (): Promise<void> => {
|
|
||||||
const transaction = db.transaction(['fs'], 'readwrite')
|
|
||||||
const store = transaction.objectStore('fs')
|
|
||||||
const putRequest = store.put(fileSystem, 'fs')
|
|
||||||
|
|
||||||
return await new Promise((resolve, reject) => {
|
|
||||||
putRequest.onsuccess = () => {
|
|
||||||
document.dispatchEvent(new CustomEvent('fs_update', {}))
|
|
||||||
resolve()
|
|
||||||
}
|
|
||||||
|
|
||||||
putRequest.onerror = () => {
|
|
||||||
reject(putRequest.error)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePermissions = async (path: string): Promise<void> => {
|
|
||||||
let { current } = (await navigatePath(path))
|
|
||||||
|
|
||||||
if (current === undefined) current = (await navigatePathParent(path)).current
|
|
||||||
|
|
||||||
if (current.permission === Permission.USER && current.permission > process.permission) {
|
|
||||||
const uac = await kernel.startExecutable('UserAccessControl', Permission.SYSTEM, { type: 'fs', process, path })
|
|
||||||
if (uac.value === false) {
|
|
||||||
throw new Error(Errors.EACCES)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (current.permission === Permission.ELEVATED && current.permission > process.permission) {
|
|
||||||
const uac = await kernel.startExecutable('UserAccessControl', Permission.SYSTEM, { type: 'fs', process, path })
|
|
||||||
if (uac.value === false) {
|
|
||||||
throw new Error(Errors.EACCES)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (current.permission === Permission.SYSTEM && current.permission > process.permission) throw new Error(Errors.EPERM)
|
|
||||||
}
|
|
||||||
|
|
||||||
const navigatePath = async (path: string): Promise<{ current: Directory | File, parts: string[] }> => {
|
|
||||||
const parts = path.split('/').filter(x => x !== '')
|
|
||||||
let current = fileSystem.root
|
|
||||||
for (const part of parts) {
|
|
||||||
current = current.children[part] as Directory
|
|
||||||
}
|
|
||||||
return { current, parts }
|
|
||||||
}
|
|
||||||
|
|
||||||
const navigatePathParent = async (path: string): Promise<{ current: Directory, parts: string[], filename: string }> => {
|
|
||||||
const parts = path.split('/').filter(x => x !== '')
|
|
||||||
const filename = parts.pop() as string
|
|
||||||
let current = fileSystem.root
|
|
||||||
for (const part of parts) {
|
|
||||||
current = current.children[part] as Directory
|
|
||||||
}
|
|
||||||
return { current, parts, filename }
|
|
||||||
}
|
|
||||||
|
|
||||||
const VirtualFS: Library = {
|
|
||||||
config: {
|
|
||||||
name: 'VirtualFS',
|
|
||||||
type: 'library',
|
|
||||||
targetVer: '1.0.0-indev.0'
|
|
||||||
},
|
|
||||||
init: (l, k, p) => {
|
|
||||||
kernel = k
|
|
||||||
process = p
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
unlink: async (path: string): Promise<void> => {
|
|
||||||
const { current, filename } = await navigatePathParent(path)
|
|
||||||
|
|
||||||
if (!current.children[filename].deleteable) throw new Error(Errors.EPERM)
|
|
||||||
await handlePermissions(path)
|
|
||||||
|
|
||||||
Reflect.deleteProperty(current.children, filename)
|
|
||||||
|
|
||||||
console.debug(`unlink ${path}`)
|
|
||||||
await save()
|
|
||||||
},
|
|
||||||
readFile: async (path: string): Promise<Buffer> => {
|
|
||||||
const { current } = await navigatePath(path)
|
|
||||||
|
|
||||||
await handlePermissions(path)
|
|
||||||
|
|
||||||
if (current.type !== 'file') throw new Error(Errors.EISDIR)
|
|
||||||
|
|
||||||
console.debug(`read ${path}`)
|
|
||||||
return current.content
|
|
||||||
},
|
|
||||||
writeFile: async (path: string, content: string | Buffer): Promise<void> => {
|
|
||||||
const { current, filename } = await navigatePathParent(path)
|
|
||||||
|
|
||||||
let permission
|
|
||||||
|
|
||||||
if (typeof current.children[filename] === 'undefined') {
|
|
||||||
permission = Permission.USER
|
|
||||||
} else {
|
|
||||||
await handlePermissions(path)
|
|
||||||
permission = current.children[filename].permission
|
|
||||||
}
|
|
||||||
|
|
||||||
current.children[filename] = {
|
|
||||||
type: 'file',
|
|
||||||
deleteable: true,
|
|
||||||
permission,
|
|
||||||
content: Buffer.from(content)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.debug(`write ${path}`)
|
|
||||||
await save()
|
|
||||||
},
|
|
||||||
mkdir: async (path: string): Promise<void> => {
|
|
||||||
const { current, filename } = await navigatePathParent(path)
|
|
||||||
|
|
||||||
let permission
|
|
||||||
|
|
||||||
if (typeof current.children[filename] === 'undefined') {
|
|
||||||
permission = Permission.USER
|
|
||||||
} else {
|
|
||||||
await handlePermissions(path)
|
|
||||||
permission = current.children[filename].permission
|
|
||||||
}
|
|
||||||
|
|
||||||
current.children[filename] = {
|
|
||||||
type: 'directory',
|
|
||||||
deleteable: true,
|
|
||||||
permission,
|
|
||||||
children: {}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.debug(`mkdir ${path}`)
|
|
||||||
await save()
|
|
||||||
},
|
|
||||||
rmdir: async (path: string): Promise<void> => {
|
|
||||||
const { current, filename } = await navigatePathParent(path)
|
|
||||||
|
|
||||||
if (!current.deleteable) throw new Error(Errors.EPERM)
|
|
||||||
await handlePermissions(path)
|
|
||||||
|
|
||||||
if (current.children[filename].type !== 'directory') throw new Error(Errors.ENOTDIR)
|
|
||||||
|
|
||||||
Reflect.deleteProperty(current.children, filename)
|
|
||||||
|
|
||||||
console.debug(`rmdir ${path}`)
|
|
||||||
await save()
|
|
||||||
},
|
|
||||||
readdir: async (path: string): Promise<string[]> => {
|
|
||||||
const { current } = await navigatePath(path)
|
|
||||||
|
|
||||||
if (current.type === 'file') throw new Error(Errors.ENOTDIR)
|
|
||||||
const result = await Promise.all(Object.keys(current.children ?? {}))
|
|
||||||
|
|
||||||
console.debug(`readdir ${path}`)
|
|
||||||
return result
|
|
||||||
},
|
|
||||||
stat: async (path: string): Promise<Stats> => {
|
|
||||||
const { current } = await navigatePath(path)
|
|
||||||
|
|
||||||
console.debug(`stat ${path}`)
|
|
||||||
return {
|
|
||||||
isDirectory: () => current.type === 'directory',
|
|
||||||
isFile: () => current.type === 'file'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
rename: async (oldPath: string, newPath: string): Promise<void> => {
|
|
||||||
const { current: oldCurrent, filename: oldFilename } = await navigatePathParent(oldPath)
|
|
||||||
const { current: newCurrent, filename: newFilename } = await navigatePathParent(newPath)
|
|
||||||
|
|
||||||
if (!oldCurrent.deleteable) throw new Error(Errors.EPERM)
|
|
||||||
if (!newCurrent.deleteable) throw new Error(Errors.EPERM)
|
|
||||||
|
|
||||||
await handlePermissions(oldPath)
|
|
||||||
await handlePermissions(newPath)
|
|
||||||
|
|
||||||
newCurrent.children[newFilename] = oldCurrent.children[oldFilename]
|
|
||||||
Reflect.deleteProperty(oldCurrent.children, oldFilename)
|
|
||||||
|
|
||||||
console.debug(`rename ${oldPath} -> ${newPath}`)
|
|
||||||
await save()
|
|
||||||
},
|
|
||||||
exists: async (path: string): Promise<boolean> => {
|
|
||||||
console.debug(`exists ${path}`)
|
|
||||||
try {
|
|
||||||
const { current } = await navigatePath(path)
|
|
||||||
return current !== undefined
|
|
||||||
} catch (e) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default VirtualFS
|
|
||||||
10
src/types.ts
10
src/types.ts
|
|
@ -3,7 +3,6 @@ import Kernel from './kernel'
|
||||||
import FlowWindow from './structures/FlowWindow'
|
import FlowWindow from './structures/FlowWindow'
|
||||||
import LibraryLib from './structures/LibraryLib'
|
import LibraryLib from './structures/LibraryLib'
|
||||||
import ProcessLib from './structures/ProcessLib'
|
import ProcessLib from './structures/ProcessLib'
|
||||||
import Components from './system/lib/Components'
|
|
||||||
import MIMETypes from './system/lib/MIMETypes'
|
import MIMETypes from './system/lib/MIMETypes'
|
||||||
|
|
||||||
export interface AppClosedEvent extends CustomEvent {
|
export interface AppClosedEvent extends CustomEvent {
|
||||||
|
|
@ -173,15 +172,20 @@ export interface StatusBar {
|
||||||
updateIcon: (ms: number) => void
|
updateIcon: (ms: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IComponents {
|
||||||
|
[key: string]: {
|
||||||
|
new: (...args: any[]) => InstanceType<typeof HTML>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type LoadedLibrary<T> =
|
export type LoadedLibrary<T> =
|
||||||
T extends 'lib/VirtualFS' ? FileSystem :
|
|
||||||
T extends 'lib/WindowManager' ? WindowManager :
|
T extends 'lib/WindowManager' ? WindowManager :
|
||||||
T extends 'lib/HTML' ? typeof HTML :
|
T extends 'lib/HTML' ? typeof HTML :
|
||||||
T extends 'lib/Launcher' ? Launcher :
|
T extends 'lib/Launcher' ? Launcher :
|
||||||
T extends 'lib/XOR' ? XOR :
|
T extends 'lib/XOR' ? XOR :
|
||||||
T extends 'lib/StatusBar' ? StatusBar :
|
T extends 'lib/StatusBar' ? StatusBar :
|
||||||
T extends 'lib/MIMETypes' ? typeof MIMETypes.data :
|
T extends 'lib/MIMETypes' ? typeof MIMETypes.data :
|
||||||
T extends 'lib/Components' ? typeof Components.data :
|
T extends 'lib/Components' ? IComponents :
|
||||||
any
|
any
|
||||||
|
|
||||||
export type LibraryPath = 'lib/VirtualFS' | 'lib/WindowManager' | string
|
export type LibraryPath = 'lib/VirtualFS' | 'lib/WindowManager' | string
|
||||||
|
|
|
||||||
|
|
@ -16,18 +16,16 @@ export const getTime = async (): Promise<string> => {
|
||||||
if (hours === 0) {
|
if (hours === 0) {
|
||||||
hours = 12
|
hours = 12
|
||||||
} else if (hours > 12) {
|
} else if (hours > 12) {
|
||||||
hours = hours % 12
|
hours %= 12
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
hours = (hours < 10) ? `0${hours}` : hours
|
hours = (hours < 10) ? `0${hours}` : hours
|
||||||
minutes = (minutes < 10) ? `0${minutes}` : minutes
|
minutes = (minutes < 10) ? `0${minutes}` : minutes
|
||||||
|
|
||||||
const timeString = use24hrs
|
return use24hrs
|
||||||
? `${hours}:${minutes}`
|
? `${hours}:${minutes}`
|
||||||
: `${hours}:${minutes} ${period}`
|
: `${hours}:${minutes} ${period}`
|
||||||
|
|
||||||
return timeString
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,6 @@ module.exports = {
|
||||||
name: 'FlowOS',
|
name: 'FlowOS',
|
||||||
plugin: ['typedoc-material-theme'],
|
plugin: ['typedoc-material-theme'],
|
||||||
themeColor: '#1e1e2e',
|
themeColor: '#1e1e2e',
|
||||||
entryPoints: ['src/kernel.ts'],
|
entryPoints: ['src/bootloader.ts'],
|
||||||
entryPointStrategy: 'expand'
|
entryPointStrategy: 'expand'
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,5 +29,8 @@ export default defineConfig({
|
||||||
disable: false,
|
disable: false,
|
||||||
verbose: true
|
verbose: true
|
||||||
})
|
})
|
||||||
]
|
],
|
||||||
|
build: {
|
||||||
|
target: 'ESNEXT'
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue