Compare commits

...

15 Commits

Author SHA1 Message Date
21a1a34309 feat: streams, 'clear' command 2026-03-26 21:05:35 +01:00
686abeafb4 docs: remove chrome from tested browsers 2026-03-26 15:40:07 +01:00
ed15d94255 feat: working-ish shell and terminal 2026-03-26 13:57:58 +01:00
1d54c8b650 chore: cleanup Terminal.ts 2026-03-26 11:57:11 +01:00
141204164a sync: umm progress 2026-03-25 23:07:44 +01:00
e03aa97716 sync: wip terminal cell system 2026-03-24 23:49:51 +01:00
475ba9d8ab sync: wip changes 2026-03-24 13:58:59 +01:00
6cc4837a8e feat: basic terminal printing 2026-03-23 23:42:24 +01:00
64717cc652 chore: tidy up things 2026-03-23 21:59:36 +01:00
ca5d60b714 chore: add sass 2026-03-23 21:55:11 +01:00
d8863654f9 docs: what in the legal he- 2026-03-23 17:16:25 +01:00
0152e4da73 docs: clarify dependency installation 2026-03-23 16:24:01 +01:00
8e0db465a7 chore: create app base 2026-03-23 16:21:20 +01:00
788edc6d61 chore: add stuff to gitignore 2026-03-23 16:20:31 +01:00
9a90da9cc9 docs: add testing notice in README.md 2026-03-23 16:15:28 +01:00
22 changed files with 654 additions and 36 deletions

7
.gitignore vendored
View File

@@ -12,6 +12,13 @@ dist
dist-ssr
*.local
# Runtime
bun.lock
# Vercel
.vercel
*.env
# Editor directories and files
.vscode/*
!.vscode/extensions.json

View File

@@ -6,16 +6,18 @@ Aplikace je zdarma hostovaná přes Vercel a je veřejně dostupná na adrese ht
## buildování
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.
- Univerzálně: pomocí npm příkazem `npm i -g bun`
- Win NT:
1. ze stránky https://bun.sh/
2. přes winget powershellovým příkazem `winget install --id Oven-sh.Bun`
- Univerzálně:
1. pomocí npm příkazem `npm i -g bun`
2. z oficiální stránky https://bun.sh/
- M$ Win:
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`
- Arch Linux:
1. ze stránky https://bun.sh/
2. z `extra` repozitáře příkazem `pacman -S bun`
- Linux:
1. z terminálu příkazem `curl -fsSL https://bun.sh/install | bash`
2. (Arch) z `extra` repozitáře příkazem `pacman -S bun`
- MacOS:
1. ze stránky https://bun.sh/
1. z terminálu příkazem `curl -fsSL https://bun.sh/install | bash`
2. pomocí homebrew příkazem `brew tap oven-sh/bun && brew install bun`
- Vite -> Builder a packer pro webové aplikace
- Již definováno jako závislost v projektovém `package.json`
@@ -29,22 +31,11 @@ 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/
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
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.
```
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

View File

@@ -1,15 +1,30 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<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>
<body>
<div id="app">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</div>
<script type="module" src="/src/main.ts"></script>
</body>
<!-- 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" />
</head>
<body>
<div id="app">
<div id="cursor"></div>
<div id="terminal">
<div id="lines"></div>
</div>
</div>
<!-- run main script -->
<script type="module" src="/src/app.ts"></script>
</body>
</html>

View File

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

20
src/app.ts Normal file
View File

@@ -0,0 +1,20 @@
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()

0
src/init.ts Normal file
View File

15
src/input/keyboard.ts Normal file
View File

@@ -0,0 +1,15 @@
// 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()
})
}

7
src/program/clear.ts Normal file
View File

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

8
src/program/stdlibwsh.ts Normal file
View File

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

4
src/shell/ControlCode.ts Normal file
View File

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

15
src/shell/Shell.ts Normal file
View File

@@ -0,0 +1,15 @@
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
}

179
src/shell/Wush.ts Normal file
View File

@@ -0,0 +1,179 @@
// 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)
}
}
}

11
src/styles/app.scss Normal file
View File

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

2
src/styles/colors.scss Normal file
View File

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

12
src/styles/cursor.scss Normal file
View File

@@ -0,0 +1,12 @@
@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);
}

29
src/styles/terminal.scss Normal file
View File

@@ -0,0 +1,29 @@
@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

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

183
src/terminal/Terminal.ts Normal file
View File

@@ -0,0 +1,183 @@
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

@@ -0,0 +1,43 @@
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))
}
}

50
src/utils/SimpleStream.ts Normal file
View File

@@ -0,0 +1,50 @@
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))
}
}

13
src/utils/sqs.ts Normal file
View File

@@ -0,0 +1,13 @@
/**
* 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
}

5
vite.config.ts Normal file
View File

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