Compare commits

..

3 Commits

Author SHA1 Message Date
149ed13c5f chore: update gitignore 2026-03-23 12:45:13 +01:00
fd6edf9d09 feat: create app base 2026-03-23 12:44:50 +01:00
e70a8a2e25 chore: update gitignore to reflect vercel preferences 2026-03-23 12:43:48 +01:00
22 changed files with 39 additions and 655 deletions

9
.gitignore vendored
View File

@@ -7,18 +7,12 @@ yarn-error.log*
pnpm-debug.log* pnpm-debug.log*
lerna-debug.log* lerna-debug.log*
bun.lock
node_modules node_modules
dist dist
dist-ssr dist-ssr
*.local *.local
# Runtime
bun.lock
# Vercel
.vercel
*.env
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json
@@ -29,3 +23,4 @@ bun.lock
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
.vercel

View File

@@ -6,18 +6,16 @@ Aplikace je zdarma hostovaná přes Vercel a je veřejně dostupná na adrese ht
## buildování ## buildování
0. Pro buildování je potřeba software třetích stran: 0. Pro buildování je potřeba software třetích stran:
- Bun -> JavaScriptový/TypeScriptový runtime. Stáhnout lze z více zdrojů. Po instalaci doporučuji přidat Bun executable do PATH. Je možno použít Node.JS a npm. - Bun -> JavaScriptový/TypeScriptový runtime. Stáhnout lze z více zdrojů. Po instalaci doporučuji přidat Bun executable do PATH. Je možno použít Node.JS a npm.
- Univerzálně: - Univerzálně: pomocí npm příkazem `npm i -g bun`
1. pomocí npm příkazem `npm i -g bun` - Win NT:
2. z oficiální stránky https://bun.sh/ 1. ze stránky https://bun.sh/
- M$ Win: 2. přes winget powershellovým příkazem `winget install --id Oven-sh.Bun`
1. z powershellu příkazem `powershell -c "irm bun.sh/install.ps1 | iex"`
2. (NT 10+) přes winget powershellovým příkazem `winget install --id Oven-sh.Bun`
3. pomocí chocolatey příkazem `choco install bun` 3. pomocí chocolatey příkazem `choco install bun`
- Linux: - Arch Linux:
1. z terminálu příkazem `curl -fsSL https://bun.sh/install | bash` 1. ze stránky https://bun.sh/
2. (Arch) z `extra` repozitáře příkazem `pacman -S bun` 2. z `extra` repozitáře příkazem `pacman -S bun`
- MacOS: - MacOS:
1. z terminálu příkazem `curl -fsSL https://bun.sh/install | bash` 1. ze stránky https://bun.sh/
2. pomocí homebrew příkazem `brew tap oven-sh/bun && brew install bun` 2. pomocí homebrew příkazem `brew tap oven-sh/bun && brew install bun`
- Vite -> Builder a packer pro webové aplikace - Vite -> Builder a packer pro webové aplikace
- Již definováno jako závislost v projektovém `package.json` - Již definováno jako závislost v projektovém `package.json`
@@ -31,11 +29,22 @@ Aplikace je zdarma hostovaná přes Vercel a je veřejně dostupná na adrese ht
4. Stránka je nyní lokálně dostupná na http://localhost:3000/ 4. Stránka je nyní lokálně dostupná na http://localhost:3000/
5. (volitelné) Apliakci je možné buildnout pomocí `<bun/npm> run build`. Statická verze stránky je nyní dostupná v adresáři `webshell/dist/` 5. (volitelné) Apliakci je možné buildnout pomocí `<bun/npm> run build`. Statická verze stránky je nyní dostupná v adresáři `webshell/dist/`
## testování
Aplikace je testována v následujících prohlížečích:
- `Zen Browser 1.19.3b (Firefox 148.0.2) (64-bit)`
## licence ## licence
Tato webová aplikace včetně jejího zdrojového kódu je veřejně dostupná pod licencí Apache 2.0 (SPDX: `Apache-2.0`), jejíž celé znění je dostupné v LICENSE.md. Zdrojový kód je veřejně dostupný na https://git.martinpetr.dev/binekrasik/webshell Tato webová aplikace včetně jejího zdrojového kódu je veřejně dostupná pod licencí Apache 2.0 (SPDX: `Apache-2.0`). Zdrojový kód je veřejně dostupný na https://git.martinpetr.dev/binekrasik/webshell
```md
Copyright 2026 Vendelín Mžik
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
```

View File

@@ -2,29 +2,14 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- google fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&display=swap" rel="stylesheet" />
<!-- import main stylesheet -->
<link rel="stylesheet" href="/src/styles/app.scss" />
<title>webshell</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webshell</title>
</head> </head>
<body> <body>
<div id="app"> <div id="app">
<div id="cursor"></div> <h1>hmmm</h1>
<div id="terminal">
<div id="lines"></div>
</div> </div>
</div>
<!-- run main script -->
<script type="module" src="/src/app.ts"></script> <script type="module" src="/src/app.ts"></script>
</body> </body>
</html> </html>

View File

@@ -11,8 +11,5 @@
"devDependencies": { "devDependencies": {
"typescript": "~5.9.3", "typescript": "~5.9.3",
"vite": "^8.0.1" "vite": "^8.0.1"
},
"dependencies": {
"sass": "^1.98.0"
} }
} }

View File

@@ -1,20 +0,0 @@
import { CreateKeyboardListeners } from './input/keyboard'
import { Wush } from './shell/Wush'
import { Terminal } from './terminal/Terminal'
import { EventBroadcaster } from './utils/EventBroadcaster'
// Initializes the app
const init = () => {
const localBroadcaster = new EventBroadcaster()
// creates keyboard listeners for the local event broadcaster
CreateKeyboardListeners(
(key: string, isCharacter: boolean) => localBroadcaster.emit('keydown', key, isCharacter),
(key: string, isCharacter: boolean) => localBroadcaster.emit('keyup', key, isCharacter),
)
const terminal = new Terminal()
terminal.LoadShell(new Wush(localBroadcaster, terminal))
}
init()

View File

View File

@@ -1,15 +0,0 @@
// creates keyboard listeners
export const CreateKeyboardListeners = (
onKeyDown: (key: string, isCharacter: boolean) => void,
onKeyUp: (key: string, isCharacter: boolean) => void,
) => {
document.addEventListener('keydown', event => {
onKeyDown(event.key, event.key.length === 1)
event.preventDefault()
})
document.addEventListener('keyup', event => {
onKeyUp(event.key, event.key.length === 1)
event.preventDefault()
})
}

View File

@@ -1,7 +0,0 @@
import type { SimpleStream } from '../utils/SimpleStream'
export const ClearExec = async (stdin: SimpleStream<string>, stdout: SimpleStream<string>): Promise<number> => {
stdout.emit("\f")
return 0
}

View File

@@ -1,8 +0,0 @@
// stdlibwsh - (st)andard (lib)rary (w)eb (sh)ell
// - céčkoismy at their peak
import type { SimpleStream } from '../utils/SimpleStream'
export const io = {
}

View File

@@ -1,4 +0,0 @@
// a representation of ascii control codes
export const ControlCode = {
FormFeed: 12,
}

View File

@@ -1,15 +0,0 @@
import type { Terminal } from '../terminal/Terminal'
import type { EventBroadcaster } from '../utils/EventBroadcaster'
export abstract class Shell {
broadcaster: EventBroadcaster
terminal: Terminal
constructor(broadcaster: EventBroadcaster, terminal: Terminal) {
this.broadcaster = broadcaster
this.terminal = terminal
}
abstract Init(): void
abstract HandleKeyInput(key: string, isCharacter: boolean): void
}

View File

@@ -1,179 +0,0 @@
// Web-Uno Shell
// the best name I could come up with lmao
import { ClearExec } from '../program/clear'
import { Terminal } from '../terminal/Terminal'
import { EventBroadcaster } from '../utils/EventBroadcaster'
import { SimpleStream } from '../utils/SimpleStream'
import { ControlCode } from './ControlCode'
import { Shell } from './Shell'
export class Wush extends Shell {
// buffer
private buffer: string[] = []
private bufferPos: number = 0
// exec stuff
/**
* -1 if the exec is currently running and anything else when it's not
*/
private execExitCode: number = 0
// streams
readonly stdin: SimpleStream<string>
readonly stdout: SimpleStream<string>
constructor(broadcaster: EventBroadcaster, terminal: Terminal) {
super(broadcaster, terminal)
// create streams
this.stdin = new SimpleStream<string>()
this.stdout = new SimpleStream<string>()
}
Init() {
this.broadcaster.on('keydown', (key: string, isCharacter: boolean) =>
this.HandleKeyInput(key, isCharacter),
)
this.stdout.on(data => this.WriteEscapedString(data))
this.Prompt()
}
Prompt() {
this.terminal.Write(`hi [${this.execExitCode}] -> `)
}
WriteStdin(data: string) {
this.stdin.emit(data)
}
WriteEscapedString(data: string) {
let buf = data.split('')
buf.forEach((char, i) => {
if (this.ProcessControlCode(char)) {
buf.splice(i, 1)
}
})
}
PushToBuffer(text: string) {
text.split('').forEach(char => {
this.buffer.splice(this.bufferPos, 0, char)
this.bufferPos++
})
console.log(this.buffer)
}
RemoveLastCharsFromBuffer(amount: number) {
this.buffer.splice(this.buffer.length - amount, amount)
this.bufferPos -= amount
console.log(this.buffer)
}
MoveBufferPos(index: number, absolute: boolean = false) {
this.bufferPos = Math.max(absolute ? index : this.bufferPos + index, 0)
}
FlushBuffer() {
this.buffer = []
this.bufferPos = 0
}
ExecuteBuffer() {
const args = this.buffer.join('').split(' ')
this.FlushBuffer()
console.log(`Executing ${args[0]} with args '${args}'`)
this.execExitCode = -1
if (args[0] === 'clear') {
ClearExec(this.stdin, this.stdout)
.then(code => {
this.execExitCode = code != -1 ? code : -2
})
.finally(() => {
// check if the exec actually exited with an exit code
// and if not, set it to -2 to indicate that it didn't exit with a valid code
if (this.execExitCode != -1)
this.execExitCode == -2
// this.terminal.Write(`The program exited with exit code ${this.execExitCode}.`)
this.Prompt()
})
} else {
this.execExitCode = 0
// don't print anything if there are no arguments
if (args[0].length !== 0) {
this.terminal.Write(`Program ${args[0]} was not found.`)
this.terminal.MoveCursor(0, 1, { x: true, y: false })
}
this.Prompt()
}
}
ProcessControlCode(code: string): boolean {
switch (code) {
case '\f':
this.terminal.NewPage()
return true
default:
return false
}
}
HandleKeyInput(key: string, isCharacter: boolean) {
if (this.execExitCode === -1) console.log('program running')
if (!isCharacter) {
switch (key) {
case 'ArrowLeft':
if (this.bufferPos > 0) {
this.terminal.MoveCursor(-1, 0)
this.MoveBufferPos(-1)
}
break
case 'ArrowRight':
if (this.bufferPos < this.buffer.length) {
this.terminal.MoveCursor(1, 0)
this.MoveBufferPos(1)
}
break
case 'Backspace':
// don't erase anything if there's nothing left in the buffer
if (this.bufferPos === 0)
break
this.terminal.RemoveCell()
this.RemoveLastCharsFromBuffer(1)
this.terminal.MoveCursor(-1, 0)
break
case 'Enter':
// send the buffer to stdin if an exec is running
if (this.execExitCode === -1) {
this.WriteStdin(`${this.buffer.join('')}\n`)
this.FlushBuffer()
} else {
// "execute" the buffer
this.terminal.MoveCursor(0, 1, { x: true, y: false })
this.ExecuteBuffer()
}
break
case 'F5':
location.reload()
break
}
} else {
// push the character into the buffer
this.terminal.InsertCell(key)
this.PushToBuffer(key)
this.terminal.MoveCursor(1, 0)
}
}
}

View File

@@ -1,11 +0,0 @@
@forward "terminal.scss";
@use "./colors.scss" as colors;
body {
margin: 0;
background: colors.$terminal-background;
}
* {
box-sizing: border-box;
}

View File

@@ -1,2 +0,0 @@
$terminal-background: #141414;
$terminal-white: #fff;

View File

@@ -1,12 +0,0 @@
@use "colors.scss" as colors;
#cursor {
top: 0;
left: 0;
width: 1px;
height: 20px;
position: absolute;
background: colors.$terminal-white;
transition: .2s cubic-bezier(0, 1, 0.3, 1);
}

View File

@@ -1,29 +0,0 @@
@use "colors.scss" as colors;
@forward "cursor.scss";
#terminal {
background: colors.$terminal-background;
color: colors.$terminal-white;
font-family: "JetBrains Mono", "Cascadia Mono", "Consolas", monospace;
::-moz-selection {
background: colors.$terminal-white;
color: colors.$terminal-background;
}
p {
margin: 0;
height: fit-content;
word-wrap: break-word;
text-wrap: nowrap;
span {
display: inline-block;
}
}
.line {
width: fit-content
}
}

View File

@@ -1,6 +0,0 @@
export type CursorPosition = {
col: number,
row: number,
}
export type CursorStyle = 'bar' | 'underline' | 'cell' | 'cell_hollow'

View File

@@ -1,183 +0,0 @@
import type { Shell } from '../shell/Shell'
import sqs from '../utils/sqs'
import type { CursorPosition, CursorStyle } from './CursorProperties'
export class Terminal {
private terminal: HTMLElement
private cursor: HTMLElement
private cellHeight = 16
private cellWidth = 8
private cursorPosition: CursorPosition = {
col: 0,
row: 0,
}
private shell?: Shell
constructor() {
this.terminal = sqs('#terminal')
this.cursor = sqs('#cursor')
this.ResetCellSize()
this.SetCursorStyle('bar')
this.NewPage()
}
LoadShell(shell: Shell) {
this.shell = shell
this.shell.Init()
}
NewPage() {
this.terminal.innerHTML = ''
this.SetCursorPosition(0, 0)
}
AppendLine() {
const paragraph = document.createElement('p')
paragraph.style.height = `${this.cellHeight}px`
paragraph.style.lineHeight = `${this.cellHeight}px`
paragraph.id = `line-${this.cursorPosition.row}`
paragraph.className = 'line'
this.terminal.appendChild(paragraph)
this.UpdateLines()
paragraph.scrollIntoView({behavior: 'smooth'})
}
UpdateLines() {
const lines = new Array(...this.terminal.children)
lines.forEach((line, i) => line.id = `line-${i}`)
}
/**
* @returns index of the last line on the page. -1 if there are no lines
*/
GetLastLineIndex(): number {
return this.terminal.children.length - 1
}
Write(text: string) {
text.split('').forEach((char) => {
this.SetCell(char)
this.MoveCursor(1, 0)
})
}
SetCell(char: string) {
const selector = `#line-${this.cursorPosition.row} .cell-${this.cursorPosition.col}`
// adjust for the cursor
if (!document.querySelector(`#line-${this.cursorPosition.row}`)) {
for (let i = this.terminal.children.length - 1; i < this.cursorPosition.row; i++) {
this.AppendLine()
}
}
if (!document.querySelector(selector)) {
const line = sqs(`#line-${this.cursorPosition.row}`)
for (let i = line.children.length; i < this.cursorPosition.col + 1; i++) {
const cell = document.createElement('span')
cell.className = `cell-${i}`
cell.style.width = `${this.cellWidth}px`
cell.style.height = `${this.cellHeight}px`
cell.style.lineHeight = `${this.cellHeight}px`
line.appendChild(cell)
}
}
sqs(selector).innerText = char[0]
}
UpdateCells() {
const cells = new Array(...sqs(`#line-${this.cursorPosition.row}`).children)
cells.forEach((cell, i) => cell.className = `cell-${i}`)
}
InsertCell(char: string) {
const cell = document.createElement('span')
cell.className = `cell-${this.cursorPosition.col}`
cell.style.width = `${this.cellWidth}px`
cell.style.height = `${this.cellHeight}px`
cell.style.lineHeight = `${this.cellHeight}px`
cell.innerText = char[0]
sqs(`#line-${this.cursorPosition.row} .cell-${this.cursorPosition.col}`).insertAdjacentElement('beforebegin', cell)
this.UpdateCells()
}
RemoveCell() {
try {
sqs(`#line-${this.cursorPosition.row} .cell-${this.cursorPosition.col - 1}`).remove()
} catch (_) {
} finally {
this.UpdateCells()
}
}
ResetCellSize() {
// dynamically determine cell size using dom
const cell = document.createElement('span')
cell.textContent = 'A'
this.terminal.appendChild(cell)
this.cellWidth = cell.scrollWidth
this.cellHeight = cell.scrollHeight
cell.remove()
}
GetHeightCells(): number {
return this.terminal.clientHeight / this.cellHeight
}
GetWidthCells(): number {
return this.terminal.clientWidth / this.cellWidth
}
UpdateCursor() {
this.cursor.style.left = `${this.cursorPosition.col * this.cellWidth}px`
this.cursor.style.top = `${this.cursorPosition.row * this.cellHeight}px`
}
GetCursorPosition(): CursorPosition {
return this.cursorPosition
}
SetCursorPosition(col: number, row: number) {
this.cursorPosition.col = Math.max(col, 0)
this.cursorPosition.row = Math.max(row, 0)
try {
sqs(`#line-${this.cursorPosition.row} .cell-${this.cursorPosition.col}`)
} catch (_) {
this.SetCell(' ')
} finally {
this.UpdateCursor()
}
}
MoveCursor(col: number, row: number, absolute: { x: boolean; y: boolean } = { x: false, y: false }) {
this.SetCursorPosition(
!absolute.x ? this.cursorPosition.col + col : col,
!absolute.y ? this.cursorPosition.row + row : row,
)
}
SetCursorStyle(style: CursorStyle) {
switch (style) {
default:
this.cursor.style.width = `${this.cellWidth}px`
this.cursor.style.height = `${this.cellHeight}px`
}
}
}

View File

@@ -1,43 +0,0 @@
export class EventBroadcaster {
private listeners: Map<string, Set<Function>> = new Map()
/**
* Assigns a listener for the specified event
* @param event event name
* @param listener function to call when the event is emitted
*/
on(event: string, listener: Function) {
if (!this.listeners.has(event))
this.listeners.set(event, new Set())
this.listeners.get(event)!.add(listener)
}
/**
* Removes a listener from the specified event
* @param event event name
* @param listener function to remove
*/
off(event: string, listener: Function) {
if (this.listeners.has(event))
this.listeners.get(event)!.delete(listener)
}
/**
* Removes all listeners for the specified event
* @param event event name
*/
clear(event: string) {
if (this.listeners.has(event))
this.listeners.get(event)!.clear()
}
/**
* Emits an event with the given arguments
* @param event event name
* @param args arguments to pass to the listeners
*/
emit(event: string, ...args: any[]) {
if (this.listeners.has(event))
this.listeners.get(event)!.forEach((listener) => listener(...args))
}
}

View File

@@ -1,50 +0,0 @@
export class SimpleStream<T> {
private listeners: Set<Function> = new Set()
/**
* Attaches a listener to the stream
* @param listener the function to call when new data is broadcasted
*/
on(listener: (data: T) => any) {
this.listeners.add(listener)
}
/**
* Attaches a one-time listener to the stream
* @param listener the function to call when new data is broadcasted
*/
once(listener: (data: T) => any) {
const func = (data: T) => {
listener(data)
this.off(func)
}
this.on(func)
}
/**
* Patiently waits until data is streamed, then returns it
* @returns a promise of a single chunk of streamed data
*/
wait(): Promise<T> {
return new Promise<T>(resolve => {
this.once(data => resolve(data))
})
}
/**
* Removes a listener from the stream
* @param listener the function to remove
*/
off(listener: Function) {
this.listeners.delete(listener)
}
/**
* Streams data
* @param data the data to stream
*/
emit(data: T) {
this.listeners.forEach((listener) => listener(data))
}
}

View File

@@ -1,13 +0,0 @@
/**
* a "safe" wrapper for querySelector. ugly as hell but whatever
* @param query css selector query
* @returns first element matching the query
*/
export default function sqs<T extends HTMLElement>(query: string): T {
const element = document.querySelector(query)
if (!(element instanceof HTMLElement))
throw new Error(`Failed to process a short element query: ${query}`)
return element as T
}

View File

@@ -1,5 +0,0 @@
export default {
server: {
port: 3000,
},
}