[💥] Custom virtual filesystem
This commit is contained in:
parent
f7570368f4
commit
2aaf0475a0
11 changed files with 546 additions and 349 deletions
|
|
@ -7,7 +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="https://cdn.jsdelivr.net/npm/filer"></script>
|
|
||||||
<script src="./src/index.ts" type="module"></script>
|
<script src="./src/index.ts" type="module"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "flowos",
|
"name": "flowos",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0-indev.0",
|
||||||
"description": "The most aesthetic webOS.",
|
"description": "The most aesthetic webOS.",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -146,7 +146,7 @@ export default class EditorApp implements App {
|
||||||
const fileExtension = data.path.split('.').pop()?.toLowerCase() as string
|
const fileExtension = data.path.split('.').pop()?.toLowerCase() as string
|
||||||
const language = fileLanguageMap[fileExtension] ?? 'text'
|
const language = fileLanguageMap[fileExtension] ?? 'text'
|
||||||
|
|
||||||
const value = (await window.fs.promises.readFile(data.path)).toString()
|
const value = Buffer.from(await window.fs.readFile(data.path)).toString()
|
||||||
const editor = fullEditor(
|
const editor = fullEditor(
|
||||||
win.content.querySelector('.editor') as HTMLElement,
|
win.content.querySelector('.editor') as HTMLElement,
|
||||||
{
|
{
|
||||||
|
|
@ -191,7 +191,7 @@ export default class EditorApp implements App {
|
||||||
editor.extensions.searchWidget?.open()
|
editor.extensions.searchWidget?.open()
|
||||||
}
|
}
|
||||||
(win.content.querySelector('#save') as HTMLElement).onclick = async () => {
|
(win.content.querySelector('#save') as HTMLElement).onclick = async () => {
|
||||||
await window.fs.promises.writeFile(data.path, editor.value)
|
await window.fs.writeFile(data.path, editor.value)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await window.flow.openApp('flow.files')
|
await window.flow.openApp('flow.files')
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,6 @@ import { App } from '../../types'
|
||||||
|
|
||||||
import FlowWindow from '../../structures/FlowWindow'
|
import FlowWindow from '../../structures/FlowWindow'
|
||||||
|
|
||||||
import { Stats } from 'fs'
|
|
||||||
|
|
||||||
export default class FilesApp implements App {
|
export default class FilesApp implements App {
|
||||||
meta = {
|
meta = {
|
||||||
name: 'Files',
|
name: 'Files',
|
||||||
|
|
@ -26,7 +24,7 @@ export default class FilesApp implements App {
|
||||||
win.content.style.flexDirection = 'column'
|
win.content.style.flexDirection = 'column'
|
||||||
|
|
||||||
async function setDir (dir: string): Promise<void> {
|
async function setDir (dir: string): Promise<void> {
|
||||||
await window.fs.readdir(dir, (e: NodeJS.ErrnoException, files: string[]) => {
|
const files = await window.fs.readdir(dir)
|
||||||
const back = dir === '/' ? '<span class="material-symbols-rounded">first_page</span>' : '<span class="back material-symbols-rounded">chevron_left</span>'
|
const back = dir === '/' ? '<span class="material-symbols-rounded">first_page</span>' : '<span class="back material-symbols-rounded">chevron_left</span>'
|
||||||
|
|
||||||
win.content.innerHTML = `
|
win.content.innerHTML = `
|
||||||
|
|
@ -40,29 +38,25 @@ export default class FilesApp implements App {
|
||||||
|
|
||||||
if (back !== '<span class="material-symbols-rounded">first_page</span>') {
|
if (back !== '<span class="material-symbols-rounded">first_page</span>') {
|
||||||
(win.content.querySelector('.back') as HTMLElement).onclick = async () => {
|
(win.content.querySelector('.back') as HTMLElement).onclick = async () => {
|
||||||
if (dir.split('/')[1] === dir.replace('/', '')) {
|
await setDir(dir.split('/').slice(0, -1).join('/'))
|
||||||
await setDir(`/${dir.split('/')[0]}`)
|
|
||||||
} else {
|
|
||||||
await setDir(`/${dir.split('/')[1]}`)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(win.content.querySelector('.file') as HTMLElement).onclick = async () => {
|
(win.content.querySelector('.file') as HTMLElement).onclick = async () => {
|
||||||
const title: string = prompt('Enter file name') ?? 'new-file.txt'
|
const title: string = prompt('Enter file name') ?? 'new-file.txt'
|
||||||
await window.fs.promises.open(`${dir}/${title}`, 'w')
|
await window.fs.writeFile(`${dir}/${title}`, '')
|
||||||
await setDir(dir)
|
await setDir(dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
(win.content.querySelector('.folder') as HTMLElement).onclick = async () => {
|
(win.content.querySelector('.folder') as HTMLElement).onclick = async () => {
|
||||||
const title: string = prompt('Enter folder name') ?? 'new-folder'
|
const title: string = prompt('Enter folder name') ?? 'new-folder'
|
||||||
await window.fs.promises.mkdir(`${dir}/${title}`)
|
await window.fs.mkdir(`${dir}/${title}`)
|
||||||
await setDir(dir)
|
await setDir(dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const separator = dir === '/' ? '' : '/'
|
const seperator = dir === '/' ? '' : '/'
|
||||||
window.fs.stat(dir + separator + file, (e: NodeJS.ErrnoException, fileStat: Stats) => {
|
const fileStat = await window.fs.stat(dir + seperator + file)
|
||||||
const element = document.createElement('div')
|
const element = document.createElement('div')
|
||||||
element.setAttribute('style', 'display: flex;gap: 5px;align-items:center;padding: 5px;border-bottom: 1px solid var(--text);display:flex;align-items:center;gap: 5px;')
|
element.setAttribute('style', 'display: flex;gap: 5px;align-items:center;padding: 5px;border-bottom: 1px solid var(--text);display:flex;align-items:center;gap: 5px;')
|
||||||
|
|
||||||
|
|
@ -115,33 +109,31 @@ export default class FilesApp implements App {
|
||||||
(element.querySelector('.rename') as HTMLElement).onclick = async () => {
|
(element.querySelector('.rename') as HTMLElement).onclick = async () => {
|
||||||
const value = (prompt('Rename') as string)
|
const value = (prompt('Rename') as string)
|
||||||
if (value !== null || value !== undefined) {
|
if (value !== null || value !== undefined) {
|
||||||
await window.fs.promises.rename(dir + separator + file, dir + separator + value)
|
await window.fs.rename(dir + seperator + file, dir + seperator + value)
|
||||||
await setDir(dir)
|
await setDir(dir)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(element.querySelector('.delete') as HTMLElement).onclick = async () => {
|
(element.querySelector('.delete') as HTMLElement).onclick = async () => {
|
||||||
if (fileStat.isDirectory()) {
|
if (fileStat.isDirectory()) {
|
||||||
await window.fs.rmdir(dir + separator + file, () => {})
|
await window.fs.rmdir(dir + seperator + file)
|
||||||
} else {
|
} else {
|
||||||
await window.fs.promises.unlink(dir + separator + file)
|
await window.fs.unlink(dir + seperator + file)
|
||||||
}
|
}
|
||||||
await setDir(dir)
|
await setDir(dir)
|
||||||
}
|
}
|
||||||
element.ondblclick = async () => {
|
element.ondblclick = async () => {
|
||||||
if (fileStat.isDirectory()) {
|
if (fileStat.isDirectory()) {
|
||||||
await setDir(dir + separator + file)
|
await setDir(dir + seperator + file)
|
||||||
} else {
|
} else {
|
||||||
await window.flow.openApp('flow.editor', { path: dir + separator + file })
|
await window.flow.openApp('flow.editor', { path: dir + seperator + file })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
win.content.querySelector('.files')?.appendChild(element)
|
win.content.querySelector('.files')?.appendChild(element)
|
||||||
})
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await setDir('/')
|
await setDir('/home')
|
||||||
|
|
||||||
return win
|
return win
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,8 @@ export default class SettingsApp implements App {
|
||||||
'24HR_CLOCK': '24hr Clock'
|
'24HR_CLOCK': '24hr Clock'
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = await window.config()
|
// TODO: settings
|
||||||
|
const config = {}
|
||||||
|
|
||||||
for (const key of Object.keys(config)) {
|
for (const key of Object.keys(config)) {
|
||||||
const container = document.createElement('div')
|
const container = document.createElement('div')
|
||||||
|
|
@ -87,7 +88,7 @@ export default class SettingsApp implements App {
|
||||||
}
|
}
|
||||||
|
|
||||||
win.content.querySelector('.save')?.addEventListener('click', () => {
|
win.content.querySelector('.save')?.addEventListener('click', () => {
|
||||||
window.fs.promises.writeFile('/.config/flow.json', JSON.stringify(config))
|
window.fs.writeFile('/.config/flow.json', JSON.stringify(config))
|
||||||
.then(null)
|
.then(null)
|
||||||
.catch(e => console.error(e))
|
.catch(e => console.error(e))
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,8 @@ export default class MusicApp implements App {
|
||||||
|
|
||||||
win.content.style.background = 'var(--base)'
|
win.content.style.background = 'var(--base)'
|
||||||
|
|
||||||
const config = await window.config()
|
// TODO: Allow customization of server URL
|
||||||
|
fetch('https://server.flow-works.me' + '/apps/list/')
|
||||||
fetch(config.SERVER_URL + '/apps/list/')
|
|
||||||
.then(async (res) => await res.json())
|
.then(async (res) => await res.json())
|
||||||
.then(handle)
|
.then(handle)
|
||||||
.catch(e => console.error(e))
|
.catch(e => console.error(e))
|
||||||
|
|
@ -61,38 +60,33 @@ export default class MusicApp implements App {
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
|
|
||||||
window.fs.exists(`/Applications/${app.url.split('/').at(-1) as string}`, (exists) => {
|
window.fs.exists(`/home/Applications/${app.url.split('/').at(-1) as string}`).then((exists) => {
|
||||||
if (exists) {
|
if (exists) {
|
||||||
(win.content.querySelector(`div[data-pkg="${sanitize(app.pkg)}"] div > .material-symbols-rounded`) as HTMLElement).innerHTML = 'delete';
|
(win.content.querySelector(`div[data-pkg="${sanitize(app.pkg)}"] div > .material-symbols-rounded`) as HTMLElement).innerHTML = 'delete';
|
||||||
|
|
||||||
(win.content.querySelector(`div[data-pkg="${sanitize(app.pkg)}"] div > .material-symbols-rounded`) as HTMLElement).onclick = () => {
|
(win.content.querySelector(`div[data-pkg="${sanitize(app.pkg)}"] div > .material-symbols-rounded`) as HTMLElement).onclick = async () => {
|
||||||
window.fs.unlink(`/Applications/${app.url.split('/').at(-1) as string}`, () => {
|
await window.fs.unlink(`/Applications/${app.url.split('/').at(-1) as string}`)
|
||||||
window.location.reload()
|
window.location.reload()
|
||||||
})
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
(win.content.querySelector(`div[data-pkg="${sanitize(app.pkg)}"] div > .material-symbols-rounded`) as HTMLElement).onclick = () => {
|
(win.content.querySelector(`div[data-pkg="${sanitize(app.pkg)}"] div > .material-symbols-rounded`) as HTMLElement).onclick = () => {
|
||||||
install(app.url)
|
install(app.url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
}).catch(e => console.error(e))
|
||||||
|
|
||||||
(win.content.querySelector(`div[data-pkg="${sanitize(app.pkg)}"] div > .material-symbols-rounded`) as HTMLElement).onclick = () => {
|
|
||||||
install(app.url)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function install (url: string): void {
|
function install (url: string): void {
|
||||||
fetch(url).then(async (res) => await res.text())
|
fetch(url).then(async (res) => await res.text())
|
||||||
.then((data) => {
|
.then(async (data) => {
|
||||||
window.fs.exists('/Applications', (exists) => {
|
const exists = await window.fs.exists('/home/Applications')
|
||||||
if (!exists) window.fs.promises.mkdir('/Applications').catch(console.error)
|
|
||||||
|
|
||||||
window.fs.promises.writeFile(`/Applications/${url.split('/').at(-1) as string}`, data).then(() => window.location.reload()).catch(console.error)
|
if (!exists) window.fs.mkdir('/home/Applications').catch(console.error)
|
||||||
})
|
|
||||||
}).catch(console.error)
|
window.fs.writeFile(`/home/Applications/${url.split('/').at(-1) as string}`, data).then(() => window.location.reload()).catch(console.error)
|
||||||
|
}).catch(e => console.error(e))
|
||||||
}
|
}
|
||||||
|
|
||||||
return win
|
return win
|
||||||
|
|
|
||||||
|
|
@ -1,171 +0,0 @@
|
||||||
import * as fs from 'fs'
|
|
||||||
import { PlatformPath } from 'path'
|
|
||||||
|
|
||||||
export interface Path extends PlatformPath {
|
|
||||||
basename: (path: string, suffix?: string | undefined) => string
|
|
||||||
normalize: (path: string) => string
|
|
||||||
isNull: (path: string) => boolean
|
|
||||||
addTrailing: (path: string) => string
|
|
||||||
removeTrailing: (path: string) => string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FilerError extends Error {
|
|
||||||
code: string
|
|
||||||
errno: number
|
|
||||||
path?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Errors {
|
|
||||||
EACCES: FilerError
|
|
||||||
EBADF: FilerError
|
|
||||||
EBUSY: FilerError
|
|
||||||
EINVAL: FilerError
|
|
||||||
ENOTDIR: FilerError
|
|
||||||
EISDIR: FilerError
|
|
||||||
ENOENT: FilerError
|
|
||||||
EEXIST: FilerError
|
|
||||||
EPERM: FilerError
|
|
||||||
ELOOP: FilerError
|
|
||||||
ENOTEMPTY: FilerError
|
|
||||||
EIO: FilerError
|
|
||||||
ENOTMOUNTED: FilerError
|
|
||||||
EFILESYSTEMERROR: FilerError
|
|
||||||
ENOATTR: FilerError
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FileSystemOptionsFlags = 'FORMAT' | 'NOCTIME' | 'NOMTIME'
|
|
||||||
|
|
||||||
export interface FileSystemOptions {
|
|
||||||
name: string
|
|
||||||
flags: FileSystemOptionsFlags[]
|
|
||||||
/* TO-DO */
|
|
||||||
provider: any
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FileSystemCallback = (err: FilerError | null, fs: FileSystem) => {}
|
|
||||||
|
|
||||||
export interface FileSystem {
|
|
||||||
appendFile: typeof fs.appendFile
|
|
||||||
access: typeof fs.access
|
|
||||||
chown: typeof fs.chown
|
|
||||||
chmod: typeof fs.chmod
|
|
||||||
close: typeof fs.close
|
|
||||||
exists: typeof fs.exists
|
|
||||||
fchown: typeof fs.fchown
|
|
||||||
fchmod: typeof fs.fchmod
|
|
||||||
fstat: typeof fs.fstat
|
|
||||||
fsync: typeof fs.fsync
|
|
||||||
ftruncate: typeof fs.ftruncate
|
|
||||||
futimes: typeof fs.futimes
|
|
||||||
link: typeof fs.link
|
|
||||||
lstat: typeof fs.lstat
|
|
||||||
mkdir: typeof fs.mkdir
|
|
||||||
mkdtemp: typeof fs.mkdtemp
|
|
||||||
open: typeof fs.open
|
|
||||||
readdir: typeof fs.readdir
|
|
||||||
read: typeof fs.read
|
|
||||||
readFile: typeof fs.readFile
|
|
||||||
readlink: typeof fs.readlink
|
|
||||||
rename: typeof fs.rename
|
|
||||||
rmdir: typeof fs.rmdir
|
|
||||||
stat: typeof fs.stat
|
|
||||||
symlink: typeof fs.symlink
|
|
||||||
truncate: typeof fs.truncate
|
|
||||||
unlink: typeof fs.unlink
|
|
||||||
utimes: typeof fs.utimes
|
|
||||||
writeFile: typeof fs.writeFile
|
|
||||||
write: typeof fs.write
|
|
||||||
|
|
||||||
promises: FileSystemPromises
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FileSystemPromises {
|
|
||||||
appendFile: typeof fs.promises.appendFile
|
|
||||||
access: typeof fs.promises.access
|
|
||||||
chown: typeof fs.promises.chown
|
|
||||||
chmod: typeof fs.promises.chmod
|
|
||||||
link: typeof fs.promises.link
|
|
||||||
lstat: typeof fs.promises.lstat
|
|
||||||
mkdir: typeof fs.promises.mkdir
|
|
||||||
mkdtemp: typeof fs.promises.mkdtemp
|
|
||||||
open: typeof fs.promises.open
|
|
||||||
readdir: typeof fs.promises.readdir
|
|
||||||
readFile: typeof fs.promises.readFile
|
|
||||||
readlink: typeof fs.promises.readlink
|
|
||||||
rename: typeof fs.promises.rename
|
|
||||||
rmdir: typeof fs.promises.rmdir
|
|
||||||
stat: typeof fs.promises.stat
|
|
||||||
symlink: typeof fs.promises.symlink
|
|
||||||
truncate: typeof fs.promises.truncate
|
|
||||||
unlink: typeof fs.promises.unlink
|
|
||||||
utimes: typeof fs.promises.utimes
|
|
||||||
writeFile: typeof fs.promises.writeFile
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FileSystemShell {
|
|
||||||
cd: (path: string, callback: (err: FilerError | null) => void) => void
|
|
||||||
pwd: () => string
|
|
||||||
find: (
|
|
||||||
dir: string,
|
|
||||||
options: {
|
|
||||||
exec?: (path: string, next: () => void) => void
|
|
||||||
regex?: RegExp
|
|
||||||
name?: string
|
|
||||||
} | ((err: FilerError | null, found: any[]) => void) | undefined | null,
|
|
||||||
/* INCOMPLETE? */
|
|
||||||
callback: (err: FilerError | null, found: any[]) => void
|
|
||||||
) => void
|
|
||||||
ls: (
|
|
||||||
dir: string,
|
|
||||||
options: {
|
|
||||||
recursive?: boolean
|
|
||||||
} | ((err: FilerError | null) => void) | undefined | null,
|
|
||||||
callback: (err: FilerError | null) => void
|
|
||||||
) => void
|
|
||||||
exec: (
|
|
||||||
path: string,
|
|
||||||
args: Array<string | number> | ((err: FilerError | null, result: string) => void) | undefined | null,
|
|
||||||
callback: (err: FilerError | null, result: string) => void
|
|
||||||
) => void
|
|
||||||
touch: (
|
|
||||||
path: string,
|
|
||||||
options: {
|
|
||||||
updateOnly?: boolean
|
|
||||||
date: Date
|
|
||||||
} | ((err: FilerError | null) => void) | undefined | null,
|
|
||||||
callback: (err: FilerError | null) => void
|
|
||||||
) => void
|
|
||||||
cat: (
|
|
||||||
files: string[],
|
|
||||||
callback: (err: FilerError | null, data: string) => void
|
|
||||||
) => void
|
|
||||||
rm: (
|
|
||||||
path: string,
|
|
||||||
options: {
|
|
||||||
recursive?: boolean
|
|
||||||
} | ((err: FilerError | null) => void) | undefined | null,
|
|
||||||
callback: (err: FilerError | null) => void
|
|
||||||
) => void
|
|
||||||
tempDir: (
|
|
||||||
callback: (err: FilerError | null, tmp: string) => void
|
|
||||||
) => void
|
|
||||||
mkdirp: (
|
|
||||||
path: string,
|
|
||||||
callback: (err: FilerError | null) => void
|
|
||||||
) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FileSystemShellOptions {
|
|
||||||
env: {
|
|
||||||
[key: string]: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default interface Filer {
|
|
||||||
FileSystem: (options: FileSystemOptions, callback: (err: FilerError | null, guid: string) => void) => FileSystem
|
|
||||||
Buffer: Buffer
|
|
||||||
Path: Path
|
|
||||||
path: Path
|
|
||||||
Errors: Errors
|
|
||||||
Shell: (fs: FileSystem, options: FileSystemShellOptions) => FileSystemShell
|
|
||||||
}
|
|
||||||
414
src/fs.ts
Normal file
414
src/fs.ts
Normal file
|
|
@ -0,0 +1,414 @@
|
||||||
|
export enum Errors {
|
||||||
|
ENOENT = 'ENOENT',
|
||||||
|
EISDIR = 'EISDIR',
|
||||||
|
EEXIST = 'EEXIST',
|
||||||
|
EPERM = 'EPERM',
|
||||||
|
ENOTDIR = 'ENOTDIR',
|
||||||
|
EACCES = 'EACCES'
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum Permission {
|
||||||
|
ROOT,
|
||||||
|
ADMIN,
|
||||||
|
USER
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Directory {
|
||||||
|
type: 'directory'
|
||||||
|
permission: Permission
|
||||||
|
children: {
|
||||||
|
[key: string]: Directory | File
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface File {
|
||||||
|
type: 'file'
|
||||||
|
permission: Permission
|
||||||
|
content: Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultFS: { root: Directory } = {
|
||||||
|
root: {
|
||||||
|
type: 'directory',
|
||||||
|
permission: Permission.ROOT,
|
||||||
|
children: {
|
||||||
|
home: {
|
||||||
|
type: 'directory',
|
||||||
|
permission: Permission.ROOT,
|
||||||
|
children: {
|
||||||
|
Downloads: {
|
||||||
|
type: 'directory',
|
||||||
|
permission: Permission.ADMIN,
|
||||||
|
children: {}
|
||||||
|
},
|
||||||
|
Applications: {
|
||||||
|
type: 'directory',
|
||||||
|
permission: Permission.ADMIN,
|
||||||
|
children: {}
|
||||||
|
},
|
||||||
|
Desktop: {
|
||||||
|
type: 'directory',
|
||||||
|
permission: Permission.ADMIN,
|
||||||
|
children: {
|
||||||
|
'README.md': {
|
||||||
|
type: 'file',
|
||||||
|
permission: Permission.USER,
|
||||||
|
content: Buffer.from('# Welcome to FlowOS!')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Pictures: {
|
||||||
|
type: 'directory',
|
||||||
|
permission: Permission.ADMIN,
|
||||||
|
children: {}
|
||||||
|
},
|
||||||
|
Videos: {
|
||||||
|
type: 'directory',
|
||||||
|
permission: Permission.ADMIN,
|
||||||
|
children: {}
|
||||||
|
},
|
||||||
|
Documents: {
|
||||||
|
type: 'directory',
|
||||||
|
permission: Permission.ADMIN,
|
||||||
|
children: {}
|
||||||
|
},
|
||||||
|
Music: {
|
||||||
|
type: 'directory',
|
||||||
|
permission: Permission.ADMIN,
|
||||||
|
children: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
var: {
|
||||||
|
type: 'directory',
|
||||||
|
permission: Permission.ROOT,
|
||||||
|
children: {}
|
||||||
|
},
|
||||||
|
etc: {
|
||||||
|
type: 'directory',
|
||||||
|
permission: Permission.ROOT,
|
||||||
|
children: {}
|
||||||
|
},
|
||||||
|
boot: {
|
||||||
|
type: 'directory',
|
||||||
|
permission: Permission.ROOT,
|
||||||
|
children: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VirtualFS {
|
||||||
|
private db: IDBDatabase
|
||||||
|
private fileSystem: { root: Directory }
|
||||||
|
|
||||||
|
constructor (dbName = 'virtualfs') {
|
||||||
|
this.initializeDatabase(dbName)
|
||||||
|
.then(async () => {
|
||||||
|
this.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}`)
|
||||||
|
}
|
||||||
|
navigator.storage.persist().catch(e => console.error(e))
|
||||||
|
const fs = await this.read()
|
||||||
|
fs === undefined ? await this.write(defaultFS) : this.fileSystem = fs
|
||||||
|
console.log('[VirtualFS] Database initialized')
|
||||||
|
})
|
||||||
|
.catch(e => console.error(e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The function initializes a database using IndexedDB in TypeScript.
|
||||||
|
* @param {string} dbName - The `dbName` parameter is a string that represents the name of the
|
||||||
|
* database that you want to initialize.
|
||||||
|
* @returns a Promise<boolean>.
|
||||||
|
*/
|
||||||
|
private async initializeDatabase (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 = () => {
|
||||||
|
this.db = request.result
|
||||||
|
resolve(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The function reads data from an object store in a transaction and returns a promise that resolves
|
||||||
|
* with the result.
|
||||||
|
* @returns a Promise that resolves to the result of the `getRequest` operation.
|
||||||
|
*/
|
||||||
|
private async read (): 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) => {
|
||||||
|
getRequest.onsuccess = () => {
|
||||||
|
resolve(getRequest.result)
|
||||||
|
}
|
||||||
|
|
||||||
|
getRequest.onerror = () => {
|
||||||
|
reject(getRequest.error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `write` function is a private asynchronous method that takes a `fileSystem` object as a
|
||||||
|
* parameter, sets it as the current file system, and then saves it.
|
||||||
|
* @param fileSystem - The `fileSystem` parameter is an object that represents the file system. It
|
||||||
|
* has a property `root` which is a `Directory` object representing the root directory of the file
|
||||||
|
* system.
|
||||||
|
*/
|
||||||
|
private async write (fileSystem: { root: Directory }): Promise<void> {
|
||||||
|
this.fileSystem = fileSystem
|
||||||
|
await this.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
private async save (): 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) => {
|
||||||
|
putRequest.onsuccess = () => {
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
putRequest.onerror = () => {
|
||||||
|
reject(putRequest.error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The function handles permissions for a given path and permission level, throwing an error if the
|
||||||
|
* current permission level is higher than the requested permission level.
|
||||||
|
* @param {string} path - A string representing the path to a resource or file.
|
||||||
|
* @param {Permission} permission - The `permission` parameter is of type `Permission`, which is an
|
||||||
|
* enum representing different levels of permissions. It is used to determine if the current user has
|
||||||
|
* sufficient permissions to access a certain path.
|
||||||
|
*/
|
||||||
|
private async handlePermissions (path: string, permission: Permission): Promise<void> {
|
||||||
|
const { current } = await this.navigatePath(path)
|
||||||
|
|
||||||
|
if (current.permission === Permission.ADMIN && current.permission <= permission) throw new Error(Errors.EACCES)
|
||||||
|
if (current.permission === Permission.ROOT && current.permission <= permission) throw new Error(Errors.EPERM)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The function `navigatePath` takes a path as input and returns the current directory or file object
|
||||||
|
* and the individual parts of the path.
|
||||||
|
* @param {string} path - A string representing the path to navigate in the file system.
|
||||||
|
* @returns an object with two properties: "current" and "parts". The "current" property represents
|
||||||
|
* the current directory or file that was navigated to based on the given path. The "parts" property
|
||||||
|
* is an array of strings representing the individual parts of the path.
|
||||||
|
*/
|
||||||
|
private async navigatePath (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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The function `navigatePathParent` takes a path as input and returns the current directory, the
|
||||||
|
* parts of the path, and the filename.
|
||||||
|
* @param {string} path - The `path` parameter is a string that represents the file path. It is used
|
||||||
|
* to navigate through the file system and locate a specific file or directory.
|
||||||
|
* @returns an object with three properties: "current", "parts", and "filename".
|
||||||
|
*/
|
||||||
|
private async navigatePathParent (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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `unlink` function deletes a file from a given path and saves the changes.
|
||||||
|
* @param {string} path - The `path` parameter is a string that represents the file or directory path
|
||||||
|
* that you want to unlink (delete).
|
||||||
|
* @param permission - The `permission` parameter is an optional parameter that specifies the level
|
||||||
|
* of permission required to perform the unlink operation. It has a default value of
|
||||||
|
* `Permission.USER`, which means that the operation can be performed by the user.
|
||||||
|
*/
|
||||||
|
async unlink (path: string, permission = Permission.USER): Promise<void> {
|
||||||
|
const { current, filename } = await this.navigatePathParent(path)
|
||||||
|
|
||||||
|
await this.handlePermissions(path, permission)
|
||||||
|
|
||||||
|
Reflect.deleteProperty(current.children, filename)
|
||||||
|
await this.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The function reads a file from a given path and returns its content as a Buffer.
|
||||||
|
* @param {string} path - The `path` parameter is a string that represents the file path of the file
|
||||||
|
* to be read. It specifies the location of the file in the file system.
|
||||||
|
* @param permission - The `permission` parameter is an optional parameter that specifies the
|
||||||
|
* permission level required to read the file. It has a default value of `Permission.USER`, which
|
||||||
|
* means that the file can be read by the current user.
|
||||||
|
* @returns a Promise that resolves to a Buffer.
|
||||||
|
*/
|
||||||
|
async readFile (path: string, permission = Permission.USER): Promise<Buffer> {
|
||||||
|
const { current } = await this.navigatePath(path)
|
||||||
|
|
||||||
|
await this.handlePermissions(path, permission)
|
||||||
|
|
||||||
|
if (current.type !== 'file') throw new Error(Errors.EISDIR)
|
||||||
|
return current.content
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `writeFile` function writes content to a file at a specified path, with an optional permission
|
||||||
|
* parameter.
|
||||||
|
* @param {string} path - The `path` parameter is a string that represents the file path where the
|
||||||
|
* file will be written to.
|
||||||
|
* @param {string | Buffer} content - The `content` parameter is the data that you want to write to
|
||||||
|
* the file. It can be either a string or a Buffer object.
|
||||||
|
* @param permission - The `permission` parameter is an optional parameter that specifies the
|
||||||
|
* permission level for the file being written. It has a default value of `Permission.USER`, which
|
||||||
|
* indicates that the file can be accessed and modified only by the user who wrote it.
|
||||||
|
*/
|
||||||
|
async writeFile (path: string, content: string | Buffer, permission = Permission.USER): Promise<void> {
|
||||||
|
const { current, filename } = await this.navigatePathParent(path)
|
||||||
|
|
||||||
|
await this.handlePermissions(path, permission)
|
||||||
|
|
||||||
|
current.children[filename] = {
|
||||||
|
type: 'file',
|
||||||
|
permission: Permission.USER,
|
||||||
|
content: Buffer.from(content)
|
||||||
|
}
|
||||||
|
await this.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `mkdir` function creates a new directory at the specified path with the given permission.
|
||||||
|
* @param {string} path - The `path` parameter is a string that represents the directory path where
|
||||||
|
* the new directory will be created. It specifies the location where the new directory will be
|
||||||
|
* created.
|
||||||
|
* @param permission - The `permission` parameter is an optional parameter that specifies the
|
||||||
|
* permission level for the newly created directory. It has a default value of `Permission.USER`,
|
||||||
|
* which indicates that only the user has permission to access the directory.
|
||||||
|
*/
|
||||||
|
async mkdir (path: string, permission = Permission.USER): Promise<void> {
|
||||||
|
const { current, filename } = await this.navigatePathParent(path)
|
||||||
|
|
||||||
|
await this.handlePermissions(path, permission)
|
||||||
|
|
||||||
|
current.children[filename] = {
|
||||||
|
type: 'directory',
|
||||||
|
permission: Permission.USER,
|
||||||
|
children: {}
|
||||||
|
}
|
||||||
|
await this.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `rmdir` function removes a directory at the specified path, after checking permissions and
|
||||||
|
* ensuring that the path is a directory.
|
||||||
|
* @param {string} path - The `path` parameter is a string that represents the path of the directory
|
||||||
|
* to be removed.
|
||||||
|
* @param permission - The `permission` parameter is an optional parameter that specifies the level
|
||||||
|
* of permission required to remove a directory. It has a default value of `Permission.USER`, which
|
||||||
|
* means that the user must have permission to remove the directory.
|
||||||
|
*/
|
||||||
|
async rmdir (path: string, permission = Permission.USER): Promise<void> {
|
||||||
|
const { current, filename } = await this.navigatePathParent(path)
|
||||||
|
|
||||||
|
await this.handlePermissions(path, permission)
|
||||||
|
|
||||||
|
if (current.children[filename].type !== 'directory') throw new Error(Errors.ENOTDIR)
|
||||||
|
Reflect.deleteProperty(current.children, filename)
|
||||||
|
await this.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `readdir` function reads the contents of a directory and returns an array of file names.
|
||||||
|
* @param {string} path - The `path` parameter is a string that represents the directory path from
|
||||||
|
* which you want to read the files.
|
||||||
|
* @param permission - The `permission` parameter is an optional parameter that specifies the
|
||||||
|
* permission level for accessing the directory. It has a default value of `Permission.USER`.
|
||||||
|
* @returns The function `readdir` returns a Promise that resolves to an array of strings.
|
||||||
|
*/
|
||||||
|
async readdir (path: string, permission = Permission.USER): Promise<string[]> {
|
||||||
|
console.log(this.fileSystem)
|
||||||
|
const { current } = await this.navigatePath(path)
|
||||||
|
|
||||||
|
if (current.type === 'file') throw new Error(Errors.ENOTDIR)
|
||||||
|
const result = await Promise.all(Object.keys(current.children ?? {}))
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `stat` function in TypeScript returns an object with two methods, `isDirectory` and `isFile`,
|
||||||
|
* which determine if a given path is a directory or a file, respectively.
|
||||||
|
* @param {string} path - A string representing the path to a file or directory.
|
||||||
|
* @param permission - The `permission` parameter is an optional parameter that specifies the
|
||||||
|
* permission level for accessing the file or directory. It has a default value of `Permission.USER`,
|
||||||
|
* which means that the function will check the permission level for the current user.
|
||||||
|
* @returns an object with two methods: `isDirectory` and `isFile`.
|
||||||
|
*/
|
||||||
|
async stat (path: string, permission = Permission.USER): Promise<{ isDirectory: () => boolean, isFile: () => boolean }> {
|
||||||
|
const { current } = await this.navigatePath(path)
|
||||||
|
return {
|
||||||
|
isDirectory: () => current.type === 'directory',
|
||||||
|
isFile: () => current.type === 'file'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `rename` function renames a file or directory by moving it from the old path to the new path,
|
||||||
|
* while also handling permissions and updating the file system structure.
|
||||||
|
* @param {string} oldPath - The old path of the file or directory that needs to be renamed.
|
||||||
|
* @param {string} newPath - The `newPath` parameter is a string that represents the new path or
|
||||||
|
* location where the file or directory will be renamed to.
|
||||||
|
* @param permission - The `permission` parameter is an optional parameter that specifies the
|
||||||
|
* permission level for the file or directory being renamed. It has a default value of
|
||||||
|
* `Permission.USER`, which indicates that the user has permission to perform the rename operation.
|
||||||
|
*/
|
||||||
|
async rename (oldPath: string, newPath: string, permission = Permission.USER): Promise<void> {
|
||||||
|
const { current: oldCurrent, filename: oldFilename } = await this.navigatePathParent(oldPath)
|
||||||
|
const { current: newCurrent, filename: newFilename } = await this.navigatePathParent(newPath)
|
||||||
|
|
||||||
|
await this.handlePermissions(oldPath, permission)
|
||||||
|
await this.handlePermissions(newPath, permission)
|
||||||
|
|
||||||
|
newCurrent.children[newFilename] = oldCurrent.children[oldFilename]
|
||||||
|
Reflect.deleteProperty(oldCurrent.children, oldFilename)
|
||||||
|
await this.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The function checks if a file or directory exists at a given path.
|
||||||
|
* @param {string} path - A string representing the path to check for existence.
|
||||||
|
* @param permission - The `permission` parameter is an optional parameter that specifies the
|
||||||
|
* permission level for checking the existence of the path. It has a default value of
|
||||||
|
* `Permission.USER`.
|
||||||
|
* @returns a Promise that resolves to a boolean value.
|
||||||
|
*/
|
||||||
|
async exists (path: string, permission = Permission.USER): Promise<boolean> {
|
||||||
|
const { current } = await this.navigatePath(path)
|
||||||
|
return current !== undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/index.ts
52
src/index.ts
|
|
@ -6,17 +6,23 @@ import StatusBar from './instances/StatusBar'
|
||||||
import WindowManager from './instances/WindowManager'
|
import WindowManager from './instances/WindowManager'
|
||||||
import Flow from './instances/Flow'
|
import Flow from './instances/Flow'
|
||||||
|
|
||||||
import { FileSystem } from './filer-types'
|
import { VirtualFS } from './fs'
|
||||||
import { FlowConfig } from './types'
|
|
||||||
|
import { version } from '../package.json'
|
||||||
|
|
||||||
|
const flowDetails = {
|
||||||
|
version,
|
||||||
|
codename: 'Mochi'
|
||||||
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
|
flowDetails: typeof flowDetails
|
||||||
preloader: Preloader
|
preloader: Preloader
|
||||||
flow: Flow
|
flow: Flow
|
||||||
fs: FileSystem
|
fs: VirtualFS
|
||||||
statusBar: StatusBar
|
statusBar: StatusBar
|
||||||
wm: WindowManager
|
wm: WindowManager
|
||||||
config: () => Promise<FlowConfig>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -39,47 +45,13 @@ window.wm = new WindowManager();
|
||||||
|
|
||||||
(async function () {
|
(async function () {
|
||||||
window.preloader.setPending('filesystem')
|
window.preloader.setPending('filesystem')
|
||||||
window.fs = new (window as any).Filer.FileSystem()
|
window.fs = new VirtualFS()
|
||||||
|
|
||||||
const defaultConfig = {
|
|
||||||
SERVER_URL: 'https://server.flow-works.me',
|
|
||||||
HOSTNAME: 'flow',
|
|
||||||
USERNAME: 'user',
|
|
||||||
'24HR_CLOCK': false
|
|
||||||
}
|
|
||||||
|
|
||||||
window.fs.exists('/.config', (exists) => {
|
|
||||||
if (!exists) window.fs.promises.mkdir('/.config').then(null).catch(e => console.error)
|
|
||||||
|
|
||||||
window.fs.exists('/.config/flow.json', (exists) => {
|
|
||||||
if (!exists) {
|
|
||||||
window.fs.promises.writeFile('/.config/flow.json', JSON.stringify(defaultConfig)).then(null).catch(e => console.error)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the current FlowOS config.
|
|
||||||
*
|
|
||||||
* @returns The current FlowOS config.
|
|
||||||
*/
|
|
||||||
window.config = async (): Promise<FlowConfig> => {
|
|
||||||
return await new Promise((resolve, reject) => {
|
|
||||||
window.fs.exists('/.config/flow.json', (exists) => {
|
|
||||||
if (exists) {
|
|
||||||
window.fs.promises.readFile('/.config/flow.json')
|
|
||||||
.then(content => { resolve(JSON.parse(content.toString())) })
|
|
||||||
.catch(() => reject(new Error('Unable to read config file.')))
|
|
||||||
} else reject(new Error('Config file does not exist.'))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const registrations = await navigator.serviceWorker.getRegistrations()
|
const registrations = await navigator.serviceWorker.getRegistrations()
|
||||||
for (const registration of registrations) {
|
for (const registration of registrations) {
|
||||||
await registration.unregister()
|
await registration.unregister()
|
||||||
}
|
}
|
||||||
await navigator.serviceWorker.register('/uv-sw.js?url=' + encodeURIComponent(btoa((await window.config()).SERVER_URL)), {
|
await navigator.serviceWorker.register('/uv-sw.js?url=' + encodeURIComponent(btoa('https://server.flow-works.me')), {
|
||||||
scope: '/service/'
|
scope: '/service/'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,18 +26,15 @@ class Flow {
|
||||||
window.preloader.setPending('apps')
|
window.preloader.setPending('apps')
|
||||||
window.preloader.setStatus('importing apps...')
|
window.preloader.setStatus('importing apps...')
|
||||||
|
|
||||||
window.fs.exists('/Applications', (exists) => {
|
window.fs.readdir('/home/Applications/').then((list) => {
|
||||||
if (!exists) window.fs.promises.mkdir('/Applications').catch(e => console.error(e))
|
|
||||||
window.fs.promises.readdir('/Applications').then((list) => {
|
|
||||||
list.forEach((file) => {
|
list.forEach((file) => {
|
||||||
window.fs.promises.readFile('/Applications/' + file).then(content => {
|
window.fs.readFile('/home/Applications/' + file).then(content => {
|
||||||
if (!file.endsWith('.js') && !file.endsWith('.mjs')) return
|
if (!file.endsWith('.js') && !file.endsWith('.mjs')) return
|
||||||
if (content.toString() === '') return
|
if (content.toString() === '') return
|
||||||
this.appList.push(`data:text/javascript;base64,${btoa(content.toString())}`)
|
this.appList.push(`data:text/javascript;base64,${btoa(content.toString())}`)
|
||||||
}).catch((e) => console.error(e))
|
}).catch((e) => console.error(e))
|
||||||
})
|
})
|
||||||
}).catch(e => console.error(e))
|
}).catch(e => console.error(e))
|
||||||
})
|
|
||||||
|
|
||||||
for (const appPath of this.defaultAppList) {
|
for (const appPath of this.defaultAppList) {
|
||||||
window.preloader.setStatus(`importing default apps\n${appPath}`)
|
window.preloader.setStatus(`importing default apps\n${appPath}`)
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,7 @@
|
||||||
* @returns The time.
|
* @returns The time.
|
||||||
*/
|
*/
|
||||||
export const getTime = async (): Promise<string> => {
|
export const getTime = async (): Promise<string> => {
|
||||||
const config = await window.config()
|
const use24hrs = false
|
||||||
const use24hrs = config['24HOUR_CLOCK']
|
|
||||||
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
let hours: string | number = now.getHours()
|
let hours: string | number = now.getHours()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue