Compare commits

...

28 Commits

Author SHA1 Message Date
aea0c5558e breakout game 2025-09-28 19:20:10 -07:00
f5fb571823 more fun functions 2025-09-28 19:20:06 -07:00
09267bcd12 actually center text 2025-09-28 18:38:11 -07:00
413149172b yes 2025-09-28 18:36:57 -07:00
1643dca7a0 fix snake 2025-09-28 18:35:02 -07:00
de990f312d use c64 font 2025-09-28 18:34:18 -07:00
0ec9edd595 snake! 2025-09-28 18:26:56 -07:00
cea3fa32ed update game engine 2025-09-28 18:26:51 -07:00
7a2af832f4 sniff sniff 2025-09-28 17:53:45 -07:00
7385900445 piddly little docs 2025-09-28 16:56:11 -07:00
3e02b3d348 ls & projects have links 2025-09-28 16:38:53 -07:00
26855ce388 #hyperlinks 2025-09-28 16:38:43 -07:00
2fce2d914a run multiple commands 2025-09-28 16:32:24 -07:00
9dded30417 wut 2025-09-28 16:32:12 -07:00
960f92b829 don't worry about links 2025-09-28 16:32:00 -07:00
1197af67ef insert output after input 2025-09-28 16:31:51 -07:00
bdb6fcdf05 fix cwd loading, cat imgs 2025-09-28 16:05:58 -07:00
d3504d09f3 cd up/and/down 2025-09-28 16:03:13 -07:00
8544327f42 cd and pwd 2025-09-28 15:53:45 -07:00
30dde3349f rm/rmdir/touch/mkdir 2025-09-28 15:15:04 -07:00
9fe788a35f project helpers 2025-09-28 14:49:06 -07:00
682e0c29f3 webapps: serve static files 2025-09-28 14:35:37 -07:00
0202ebb217 appDir 2025-09-28 14:19:08 -07:00
88640d7acf no dns in dev mode 2025-09-28 14:18:00 -07:00
b44c4d10fb hmmm 2025-09-28 13:57:24 -07:00
64e76502e8 make games crisp 2025-09-28 13:48:09 -07:00
ebb1afebdb typescript 2025-09-28 13:46:27 -07:00
Chris Wanstrath
e0572a9353 that too 2025-09-27 20:59:24 -07:00
46 changed files with 857 additions and 126 deletions

View File

@ -55,3 +55,4 @@ https://wakamaifondue.com/
- [ ] pico8-style games - [ ] pico8-style games
- [ ] public tunnel for your NOSE webapps - [ ] public tunnel for your NOSE webapps
- [ ] self updating - [ ] self updating
- [ ] `pub/` static hosting in webapps

View File

@ -1,3 +1,5 @@
// Show the webapps hosted on this NOSEputer.
import { $ } from "bun" import { $ } from "bun"
import { apps } from "app/src/webapp" import { apps } from "app/src/webapp"
@ -12,6 +14,6 @@ export default async function () {
port = port === "80" ? "" : `:${port}` port = port === "80" ? "" : `:${port}`
return <> return <>
{apps().map(app => <><a href={`http://${app}.${domain}${port}`}>{app}</a>{" "}</>)} {apps().map(app => <a href={`http://${app}.${domain}${port}`}>{app}</a>)}
</> </>
} }

148
app/nose/bin/breakout.ts Normal file
View File

@ -0,0 +1,148 @@
/// <reference lib="dom" />
export const game = true
import type { GameContext, InputState } from "@/shared/game"
const CELL = 20
const COLS = 30 // 600 px wide
const ROWS = 12 // 240 px tall of bricks
const PADDLE_W = 6
const PADDLE_H = 1
const BALL_SPEED = 4
const ROW_COLORS = ["red", "orange", "yellow", "green", "blue", "magenta"]
let paddleX = 0
let ball = { x: 0, y: 0, dx: BALL_SPEED, dy: -BALL_SPEED }
let bricks: { x: number, y: number, alive: boolean }[] = []
let score = 0
let dead = false
export function init() {
paddleX = (COLS * CELL - PADDLE_W * CELL) / 2
ball = {
x: COLS * CELL / 2,
y: ROWS * CELL + 60,
dx: BALL_SPEED,
dy: -BALL_SPEED
}
bricks = []
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c += 2) {
bricks.push({
x: c * CELL,
y: r * CELL,
alive: true
})
}
}
dead = false
}
export function update(_delta: number, input: InputState) {
if (dead) return
if (input.pressed.has("ArrowLeft") || input.pressed.has("a")) {
paddleX -= 6
}
if (input.pressed.has("ArrowRight") || input.pressed.has("d")) {
paddleX += 6
}
// keep paddle on screen
paddleX = Math.max(0, Math.min(paddleX, COLS * CELL - PADDLE_W * CELL))
// move ball
ball.x += ball.dx
ball.y += ball.dy
// wall bounce
if (ball.x < 0 || ball.x > COLS * CELL) ball.dx *= -1
if (ball.y < 0) ball.dy *= -1
// paddle bounce
const paddleY = ROWS * CELL + 60
const paddleH = CELL
if (
ball.x > paddleX &&
ball.x < paddleX + PADDLE_W * CELL &&
ball.y + 6 >= paddleY &&
ball.y - 6 <= paddleY + paddleH
) {
ball.dy *= -1
ball.y = paddleY - 6
}
// brick collision
for (const b of bricks) {
if (!b.alive) continue
if (
ball.x > b.x &&
ball.x < b.x + (CELL * 2) &&
ball.y > b.y &&
ball.y < b.y + CELL
) {
score += 100
b.alive = false
ball.dy *= -1
}
}
// death
if (ball.y > ROWS * CELL + 100) dead = true
}
export function draw(ctx: GameContext) {
ctx.clear("#6C6FF6")
const boardW = COLS * CELL
const boardH = ROWS * CELL + 100
const offsetX = (ctx.width - boardW) / 2
const offsetY = (ctx.height - boardH) / 2
const c = ctx.ctx
c.save()
c.translate(offsetX, offsetY)
// background
ctx.rectfill(0, 0, boardW, boardH, "black")
// paddle
ctx.rectfill(
paddleX,
ROWS * CELL + 60,
paddleX + PADDLE_W * CELL,
ROWS * CELL + 60 + CELL,
"white"
)
// ball
ctx.circfill(ball.x, ball.y, 6, "red")
// bricks
for (const b of bricks) {
if (b.alive) {
const color = pickColor(b.x, b.y)
ctx.rectfill(b.x, b.y, (b.x + (CELL * 2)) - .5, (b.y + CELL) - .5, color)
}
}
// score!
ctx.text(`Score: ${score}`, 5, boardH - 18, "cyan", 12)
c.restore()
// ya dead
if (dead) {
ctx.centerTextX("GAME OVER", boardH + 30, "red", 24)
}
}
function pickColor(x: number, y: number): string {
const row = Math.floor(y / CELL)
const colorIndex = Math.floor(row / 2) % ROW_COLORS.length
return ROW_COLORS[colorIndex] || "white"
}

View File

@ -1,23 +1,19 @@
// Show the contents of a file, if possible.
import { escapeHTML } from "bun" import { escapeHTML } from "bun"
import { readdirSync } from "fs" import { readdirSync } from "fs"
import { join, extname } from "path" import { join, extname } from "path"
import type { CommandOutput } from "app/src/shared/types" import type { CommandOutput } from "app/src/shared/types"
import { NOSE_WWW } from "app/src/config" import { NOSE_WWW } from "app/src/config"
import { getState } from "@/session"
import { appPath } from "app/src/webapp"
import { isBinaryFile } from "app/src/utils" import { isBinaryFile } from "app/src/utils"
import { highlight } from "../lib/highlight" import { highlight } from "../lib/highlight"
import { projectName, projectDir } from "@/project"
import { getState } from "@/session"
export default async function (path: string) { export default async function (path: string) {
const state = getState() const project = projectName()
if (!state) return { error: "no state" } const root = getState("cwd") || projectDir()
const project = state.project
if (!project) return { error: "no project loaded" }
const root = appPath(project)
if (!root) return { error: "error loading project" }
let files: string[] = [] let files: string[] = []
@ -42,7 +38,7 @@ async function readFile(path: string): Promise<CommandOutput> {
switch (ext) { switch (ext) {
case "jpeg": case "jpg": case "png": case "gif": case "webp": case "jpeg": case "jpg": case "png": case "gif": case "webp":
const img = await file.arrayBuffer() const img = await file.arrayBuffer()
return { html: `<img src="data:image/${ext};base64,${Buffer.from(img).toString('base64')}" />` } return { html: `<img src="data:image/${ext};base64,${Buffer.from(img).toString('base64')}" style="max-height:270px;max-width:480px;" />` }
case "mp3": case "wav": case "mp3": case "wav":
case "mp4": case "mov": case "avi": case "mkv": case "webm": case "mp4": case "mov": case "avi": case "mkv": case "webm":
return "Not implemented" return "Not implemented"

45
app/nose/bin/cd.ts Normal file
View File

@ -0,0 +1,45 @@
// Change directory.
import { dirname, resolve, isAbsolute } from "path"
import { statSync } from "fs"
import { projectDir } from "@/project"
import { getState, setState } from "@/session"
export default async function (path?: string) {
const root = projectDir()
const cwd = getState("cwd") || root
if (!path || path.trim() === "") {
setState("cwd", root)
return
}
if (path.endsWith("/")) path = path.slice(0, -1)
if (path === ".") return
if (path === "..") {
if (cwd !== root) {
const parent = dirname(cwd)
if (parent.startsWith(root)) {
setState("cwd", parent)
}
}
return
}
const target = isAbsolute(path) ? resolve(path) : resolve(cwd, path)
if (!target.startsWith(root))
return ""
try {
const stat = statSync(target)
if (stat.isDirectory()) {
setState("cwd", target)
return
}
} catch {
}
return { error: `${path} doesn't exist` }
}

View File

@ -1,3 +1,5 @@
// stream() demo. Counts.
import { stream } from "@/stream" import { stream } from "@/stream"
export default async function () { export default async function () {

View File

@ -1,3 +1,5 @@
// Monkey see, monkey do.
export default function (...args: string[]): string { export default function (...args: string[]): string {
return args.join(" ") return args.join(" ")
} }

View File

@ -1,23 +1,18 @@
import { escapeHTML } from "bun" // Open the Advanced Text Editor and start editing a file.
import { readdirSync } from "fs" import { readdirSync } from "fs"
import { join, extname } from "path" import { join, extname } from "path"
import type { CommandOutput } from "app/src/shared/types" import type { CommandOutput } from "app/src/shared/types"
import { NOSE_WWW } from "app/src/config" import { NOSE_WWW } from "app/src/config"
import { getState } from "@/session"
import { appPath } from "app/src/webapp"
import { isBinaryFile } from "app/src/utils" import { isBinaryFile } from "app/src/utils"
import { countChar } from "app/src/shared/utils" import { countChar } from "app/src/shared/utils"
import { projectName, projectDir } from "@/project"
import { getState } from "@/session"
export default async function (path: string) { export default async function (path: string) {
const state = getState() const project = projectName()
if (!state) return { error: "no state" } const root = getState("cwd") || projectDir()
const project = state.project
if (!project) return { error: "no project loaded" }
const root = appPath(project)
if (!root) return { error: "error loading project" }
let files: string[] = [] let files: string[] = []

View File

@ -1,3 +1,7 @@
// NOSE developer feature.
//
// Show some debugging information.
import { NOSE_STARTED, NOSE_SYS, NOSE_DIR, GIT_SHA } from "@/config" import { NOSE_STARTED, NOSE_SYS, NOSE_DIR, GIT_SHA } from "@/config"
export default function () { export default function () {

View File

@ -1,8 +1,9 @@
/// <reference lib="dom" /> /// <reference lib="dom" />
// Small game demo.
export const game = true export const game = true
import type { InputState } from "@/shared/types" import type { GameContext, InputState } from "@/shared/game"
import type { GameContext } from "@/shared/game"
import { rng } from "@/shared/utils.ts" import { rng } from "@/shared/utils.ts"
const WIDTH = 960 const WIDTH = 960

View File

@ -1,3 +0,0 @@
export default function (name: string): string {
return `Hi, ${name || "stranger"}!!!!`
}

26
app/nose/bin/help.ts Normal file
View File

@ -0,0 +1,26 @@
// Show helpful information about a command.
//
// (Hopefully.)
import { commandPath } from "@/commands"
export default async function (cmd: string) {
const path = commandPath(cmd)
if (!path) throw `${cmd} not found`
const code = (await Bun.file(path).text()).split("\n")
let docs = []
for (const line of code) {
if (line.startsWith("///")) {
docs.push("Runs in the browser.\n")
continue
} else if (line.startsWith("//")) {
docs.push(line.slice(2).trim())
} else if (line.trim()) {
break
}
}
return docs.join("\n")
}

View File

@ -1,12 +1,16 @@
// Load a project so you can work on it.
import { apps } from "app/src/webapp" import { apps } from "app/src/webapp"
import { getState } from "@/session" import { getState } from "@/session"
export default function (project: string) { export default function (project: string) {
const state = getState() const state = getState()
if (!project) throw `usage: load <project name>`
if (state && apps().includes(project)) { if (state && apps().includes(project)) {
state.project = project state.project = project
state.cwd = ""
} else {
return { error: `failed to load ${project}` }
} }
return state?.project ? `loaded ${project}` : { error: `failed to load ${project}` }
} }

View File

@ -1,27 +0,0 @@
import { readdirSync } from "fs"
import { NOSE_WWW } from "app/src/config"
import { getState } from "@/session"
import { appPath } from "app/src/webapp"
export default function () {
const state = getState()
if (!state) return { error: "no state" }
const project = state.project
if (!project) return { error: "no project loaded" }
const root = appPath(project)
if (!root) return { error: "error loading project" }
let files: string[] = []
for (const file of readdirSync(root, { withFileTypes: true })) {
files.push(file.name)
}
if (root === NOSE_WWW) {
files = files.filter(file => file.endsWith(`${project}.ts`) || file.endsWith(`${project}.tsx`))
}
return files.join(" ")
}

28
app/nose/bin/ls.tsx Normal file
View File

@ -0,0 +1,28 @@
// Look around.
import { readdirSync } from "fs"
import { NOSE_WWW } from "app/src/config"
import { projectName, projectDir } from "@/project"
import { getState } from "@/session"
export default function () {
const project = projectName()
const root = getState("cwd") || projectDir()
let files: string[] = []
for (const file of readdirSync(root, { withFileTypes: true })) {
files.push(file.isDirectory() ? `${file.name}/` : file.name)
}
if (root === NOSE_WWW) {
files = files.filter(file => file.endsWith(`${project}.ts`) || file.endsWith(`${project}.tsx`))
}
return <>
{root !== projectDir() && <a href="#cd ..;ls">..</a>}
{files.map(file =>
<a href={file.endsWith('/') ? `#cd ${file};ls` : `#cat ${file}`}>{file}</a>
)}
</>
}

20
app/nose/bin/mkdir.ts Normal file
View File

@ -0,0 +1,20 @@
// Makes a directory inside the current directory.
//
// Essentially `mkdir -p`.
import { mkdirSync } from "fs"
import { join } from "path"
import { projectDir } from "@/project"
import { readdirSync } from "fs"
import { getState } from "@/session"
export default async function (path: string) {
if (path.endsWith("/")) path = path.slice(0, path.length - 1)
const root = getState("cwd") || projectDir()
for (const file of readdirSync(root, { withFileTypes: true }))
if (file.name === path) throw `${path} exists`
mkdirSync(join(root, path), { recursive: true })
return `${path} created`
}

View File

@ -1,3 +1,7 @@
// Create a new project.
//
// We should probably rename this...
import { mkdirSync, writeFileSync } from "fs" import { mkdirSync, writeFileSync } from "fs"
import { join } from "path" import { join } from "path"
@ -8,7 +12,7 @@ import { isDir } from "app/src/utils"
import load from "./load" import load from "./load"
export default function (project: string) { export default function (project: string) {
if (!project) throw "Please provide a name for your new project\n> new <project>" if (!project) throw "usage: new <project name>"
if (apps().includes(project)) throw `${project} already exists` if (apps().includes(project)) throw `${project} already exists`

View File

@ -1,3 +1,5 @@
// Print the currently loaded project.
import { getState } from "@/session" import { getState } from "@/session"
export default function () { export default function () {

View File

@ -1,3 +1,5 @@
// Show the projects on this NOSEputer.
import { apps } from "app/src/webapp" import { apps } from "app/src/webapp"
import { getState } from "@/session" import { getState } from "@/session"
@ -5,5 +7,7 @@ export default function () {
const state = getState() const state = getState()
if (!state) return { error: "no state" } if (!state) return { error: "no state" }
return { html: apps().map(app => app === state.project ? `<b class="cyan">${app}</b>` : app).join(" ") } return <>
{apps().map(app => <a href={`#load ${app}`} class={app === state.project ? "magenta" : ""}>{app}</a>)}
</>
} }

10
app/nose/bin/pwd.ts Normal file
View File

@ -0,0 +1,10 @@
// Show the current working directory.
import { dirname } from "path"
import { projectDir } from "@/project"
import { getState } from "@/session"
export default async function () {
const root = projectDir()
return (getState("cwd") || root).replace(dirname(root), "")
}

27
app/nose/bin/rm.ts Normal file
View File

@ -0,0 +1,27 @@
// Remove a file.
import { unlinkSync, readdirSync } from "fs"
import { join } from "path"
import { projectDir } from "@/project"
import { getState } from "@/session"
export default function (path: string) {
let target = ""
if (path.endsWith("/")) path = path.slice(0, path.length - 1)
const root = getState("cwd") || projectDir()
for (const file of readdirSync(root, { withFileTypes: true }))
if (file.name === path) {
if (file.isDirectory())
return { error: "Use `rmdir` to remove directory" }
target = file.name
break
}
if (!target)
return { error: `${path} not found` }
unlinkSync(join(root, path))
return `${path} removed`
}

27
app/nose/bin/rmdir.ts Normal file
View File

@ -0,0 +1,27 @@
// Remove a directory, and all its children.
import { rmdirSync, readdirSync } from "fs"
import { join } from "path"
import { projectDir } from "@/project"
import { getState } from "@/session"
export default function (path: string) {
let target = ""
if (path.endsWith("/")) path = path.slice(0, path.length - 1)
const root = getState("cwd") || projectDir()
for (const file of readdirSync(root, { withFileTypes: true }))
if (file.name === path) {
if (file.isFile())
return { error: "Use `rm` to remove files" }
target = file.name
break
}
if (!target)
return { error: `${path} not found` }
rmdirSync(join(root, path), { recursive: true })
return `${path} removed`
}

View File

@ -1,3 +1,5 @@
// Share a webapp with the public internet.
import { apps } from "app/src/webapp" import { apps } from "app/src/webapp"
import { connectSneaker, sneakers, sneakerUrl } from "app/src/sneaker" import { connectSneaker, sneakers, sneakerUrl } from "app/src/sneaker"

102
app/nose/bin/snake.ts Normal file
View File

@ -0,0 +1,102 @@
/// <reference lib="dom" />
export const game = true
import type { GameContext, InputState } from "@/shared/game"
import { rng } from "@/shared/utils.ts"
const CELL = 20
const WIDTH = 30
const HEIGHT = 20
const TICK = 5
let snake: { x: number; y: number }[] = [{ x: 5, y: 5 }]
let dir = { x: 1, y: 0 }
let food = { x: rng(WIDTH) - 1, y: rng(HEIGHT) - 1 }
let dead = false
let tick = 0
export function init() {
snake = [{ x: 5, y: 5 }]
dir = { x: 1, y: 0 }
food = { x: rng(WIDTH) - 1, y: rng(HEIGHT) - 1 }
dead = false
tick = 0
}
export function update(_delta: number, input: InputState) {
if (dead) return
const keys = input.pressed
if (keys.has("ArrowUp") || keys.has("w")) dir = { x: 0, y: -1 }
if (keys.has("ArrowDown") || keys.has("s")) dir = { x: 0, y: 1 }
if (keys.has("ArrowLeft") || keys.has("a")) dir = { x: -1, y: 0 }
if (keys.has("ArrowRight") || keys.has("d")) dir = { x: 1, y: 0 }
// move every $TICK ticks
if (++tick % TICK !== 0) return
const head = { x: snake[0]!.x + dir.x, y: snake[0]!.y + dir.y }
// death checks
dead = head.x < 0 || head.x >= WIDTH || head.y < 0 || head.y >= HEIGHT ||
snake.some(s => s.x === head.x && s.y === head.y)
if (dead) return
snake.unshift(head)
// eat
if (head.x === food.x && head.y === food.y) {
food = { x: rng(WIDTH) - 1, y: rng(HEIGHT) - 1 }
} else {
snake.pop()
}
}
export function draw(ctx: GameContext) {
ctx.clear()
ctx.rectfill(0, 0, ctx.width, ctx.height, "black")
const boardW = WIDTH * CELL
const boardH = HEIGHT * CELL
// move board center → canvas center
const offsetX = (ctx.width - boardW) / 2
const offsetY = (ctx.height - boardH) / 2
console.log("X", offsetX)
console.log("Y", offsetY)
const c = ctx.ctx
c.save()
c.translate(offsetX, offsetY)
// board background (now local 0,0 is board top-left)
ctx.rectfill(0, 0, boardW, boardH, "green")
// food
ctx.rectfill(
food.x * CELL, food.y * CELL,
(food.x + 1) * CELL, (food.y + 1) * CELL,
"lime"
)
// snake
for (const s of snake) {
ctx.rectfill(
s.x * CELL, s.y * CELL,
((s.x + 1) * CELL) + .5, ((s.y + 1) * CELL) + .5,
"magenta"
)
}
// score!
ctx.text(`Score: ${snake.length - 1}`, 5, boardH - 18, "cyan", 12)
c.restore()
if (dead) {
ctx.centerText("GAME OVER", "red", 50)
}
}

17
app/nose/bin/touch.ts Normal file
View File

@ -0,0 +1,17 @@
// Create an empty text file.
import { join } from "path"
import { readdirSync } from "fs"
import { projectDir } from "@/project"
import { getState } from "@/session"
export default async function (path: string) {
if (path.endsWith("/")) path = path.slice(0, path.length - 1)
const root = getState("cwd") || projectDir()
for (const file of readdirSync(root, { withFileTypes: true }))
if (file.name === path) throw `${path} exists`
await Bun.write(join(root, path), "")
return `${path} created`
}

View File

@ -1,3 +1,5 @@
// Stop sharing a webapp with the public internet.
import { apps } from "app/src/webapp" import { apps } from "app/src/webapp"
import { disconnectSneaker, sneakers } from "app/src/sneaker" import { disconnectSneaker, sneakers } from "app/src/sneaker"

View File

@ -1,3 +1,5 @@
// Update NOSE itself and restart.
import { $ } from "bun" import { $ } from "bun"
export default async function () { export default async function () {

View File

@ -1,3 +1,7 @@
// How long has the NOSE app been running?
//
// Totally different from the host computer's uptime. Usually.
import { NOSE_STARTED } from "@/config" import { NOSE_STARTED } from "@/config"
export default function () { export default function () {

19
app/src/css/game.css Normal file
View File

@ -0,0 +1,19 @@
canvas {
display: block;
}
canvas:focus {
outline: none;
}
.game {
background-color: white;
z-index: 10;
}
.game.active {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}

View File

@ -71,10 +71,6 @@
color: var(--c64-dark-blue); color: var(--c64-dark-blue);
} }
canvas:focus {
outline: none;
}
a { a {
color: var(--cyan); color: var(--cyan);
} }
@ -88,9 +84,10 @@ body {
font-family: var(--font-family); font-family: var(--font-family);
margin: 0; margin: 0;
height: 100%; height: 100%;
/* black bars */
background: var(--c64-light-blue); background: var(--c64-light-blue);
color: var(--c64-light-blue); color: var(--c64-light-blue);
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;

View File

@ -141,6 +141,10 @@
text-align: center; text-align: center;
} }
#scrollback .output a {
margin-right: 15px;
}
#scrollback .input .content { #scrollback .input .content {
margin-left: var(--cli-status-width); margin-left: var(--cli-status-width);
} }
@ -148,15 +152,3 @@
#scrollback .output { #scrollback .output {
white-space: pre-wrap; white-space: pre-wrap;
} }
/* games */
.game {
background-color: white;
}
.game.active {
position: absolute;
top: 0;
left: 0;
}

View File

@ -16,6 +16,7 @@ const host = hostRaw.toString().trim()
let dnsInit = false let dnsInit = false
export async function initDNS() { export async function initDNS() {
if (process.env.NODE_ENV !== "production") return
dnsInit = true dnsInit = true
apps().forEach(publishAppDNS) apps().forEach(publishAppDNS)
@ -31,8 +32,8 @@ export async function initDNS() {
} }
export function publishAppDNS(app: string) { export function publishAppDNS(app: string) {
if (!dnsInit) throw "publishAppDNS() must be called after initDNS()"
if (process.env.NODE_ENV !== "production") return if (process.env.NODE_ENV !== "production") return
if (!dnsInit) throw "publishAppDNS() must be called after initDNS()"
if (!dnsEntries[app]) if (!dnsEntries[app])
dnsEntries[app] = Bun.spawn(["avahi-publish", "-a", `${app}.${host}.local`, "-R", ip]) dnsEntries[app] = Bun.spawn(["avahi-publish", "-a", `${app}.${host}.local`, "-R", ip])

View File

@ -9,12 +9,10 @@ export const Layout: FC = async ({ children, title }) => (
<link href="/css/reset.css" rel="stylesheet" /> <link href="/css/reset.css" rel="stylesheet" />
<link href="/css/main.css" rel="stylesheet" /> <link href="/css/main.css" rel="stylesheet" />
<link href="/css/game.css" rel="stylesheet" />
<script type="importmap" dangerouslySetInnerHTML={{ <script type="importmap" dangerouslySetInnerHTML={{ __html: `{ "imports": { "@/": "/" } }` }} />
__html: `{ "imports": { "@/": "/" } }`
}} />
<script src="/js/main.js" type="module" async></script> <script src="/js/main.js" type="module" async></script>
</head> </head>
<body data-mode="tall"> <body data-mode="tall">
<main> <main>

View File

@ -23,18 +23,12 @@ export function focusHandler(e: MouseEvent) {
return return
} }
// let them click on links if (["INPUT", "TEXTAREA", "CANVAS", "A"].includes(target.tagName))
const a = target.closest("a") return false
if (!a) { const selection = window.getSelection() || ""
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "CANVAS") if (selection.toString() === "")
return false focusInput()
const selection = window.getSelection() || "" e.preventDefault()
if (selection.toString() === "")
focusInput()
e.preventDefault()
return true
}
} }

View File

@ -1,17 +1,20 @@
import type { Message, InputState } from "../shared/types.js" import type { Message, InputState } from "../shared/types.js"
import { GameContext } from "../shared/game.js" import { GameContext } from "../shared/game.js"
import { focusInput } from "./focus.js" import { focusInput } from "./focus.js"
import { $ } from "./dom.js" import { $$, scrollback } from "./dom.js"
import { randomId } from "../shared/utils.js" import { randomId } from "../shared/utils.js"
import { setStatus, addOutput } from "./scrollback.js" import { setStatus, addOutput } from "./scrollback.js"
import { browserCommands } from "./commands.js" import { browserCommands } from "./commands.js"
const FPS = 30 const FPS = 30
const HEIGHT = 540
const WIDTH = 980
type Game = { init?: () => void, update?: (delta: number, input: InputState) => void, draw?: (ctx: GameContext) => void }
let oldMode = "cinema" let oldMode = "cinema"
let running = false let running = false
let canvas: HTMLCanvasElement let canvas: HTMLCanvasElement
type Game = { init?: () => void, update?: (delta: number, input: InputState) => void, draw?: (ctx: GameContext) => void }
const pressedStack = new Set<string>() const pressedStack = new Set<string>()
let pressed: InputState = { let pressed: InputState = {
@ -35,34 +38,41 @@ export async function handleGameStart(msg: Message) {
return return
} }
const canvasId = randomId()
addOutput(msgId, { html: `<canvas id="${canvasId}" class="game active" height="540" width="960" tabindex="0"></canvas>` })
if (document.body.dataset.mode === "tall") { if (document.body.dataset.mode === "tall") {
browserCommands.mode?.() browserCommands.mode?.()
oldMode = "tall" oldMode = "tall"
} }
canvas = $(canvasId) as HTMLCanvasElement canvas = createCanvas()
canvas.focus() canvas.focus()
setStatus(msgId, "ok") setStatus(msgId, "ok")
canvas.addEventListener("keydown", handleKeydown) canvas.addEventListener("keydown", handleKeydown)
canvas.addEventListener("keyup", handleKeyup) canvas.addEventListener("keyup", handleKeyup)
window.addEventListener("resize", resizeCanvas)
resizeCanvas()
gameLoop(new GameContext(canvas.getContext("2d")!), game) gameLoop(new GameContext(canvas.getContext("2d")!), game)
} }
function createCanvas(): HTMLCanvasElement {
const canvas = $$("canvas.game.active") as HTMLCanvasElement
canvas.id = randomId()
canvas.height = HEIGHT
canvas.width = WIDTH
canvas.tabIndex = 0
const main = document.querySelector("main")
main?.parentNode?.insertBefore(canvas, main)
return canvas
}
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
pressedStack.add(e.key)
e.preventDefault() e.preventDefault()
if (e.key === "Escape" || (e.ctrlKey && e.key === "c")) { if (e.key === "Escape" || (e.ctrlKey && e.key === "c")) {
running = false endGame()
if (oldMode === "tall") browserCommands.mode?.()
canvas.classList.remove("active")
canvas.style.height = canvas.height / 2 + "px"
canvas.style.width = canvas.width / 2 + "px"
focusInput()
} else { } else {
pressedStack.add(e.key)
pressed.key = e.key pressed.key = e.key
pressed.ctrl = e.ctrlKey pressed.ctrl = e.ctrlKey
pressed.shift = e.shiftKey pressed.shift = e.shiftKey
@ -80,6 +90,19 @@ function handleKeyup(e: KeyboardEvent) {
} }
} }
function resizeCanvas() {
const scale = Math.min(
window.innerWidth / 960,
window.innerHeight / 540
)
canvas.width = 960 * scale
canvas.height = 540 * scale
const ctx = canvas.getContext("2d")!
ctx.setTransform(scale, 0, 0, scale, 0, 0)
}
function gameLoop(ctx: GameContext, game: Game) { function gameLoop(ctx: GameContext, game: Game) {
running = true running = true
let last = 0 let last = 0
@ -100,3 +123,19 @@ function gameLoop(ctx: GameContext, game: Game) {
requestAnimationFrame(loop) requestAnimationFrame(loop)
} }
function endGame() {
running = false
if (oldMode === "tall") browserCommands.mode?.()
canvas.classList.remove("active")
canvas.style.height = HEIGHT / 2 + "px"
canvas.style.width = WIDTH / 2 + "px"
const output = $$("li.output")
output.append(canvas)
scrollback.append(output)
focusInput()
}

24
app/src/js/hyperlink.ts Normal file
View File

@ -0,0 +1,24 @@
import { runCommand } from "./shell.js"
import { focusInput } from "./focus.js"
export function initHyperlink() {
window.addEventListener("click", handleClick)
}
function handleClick(e: MouseEvent) {
const target = e.target
if (!(target instanceof HTMLElement)) return
const a = target.closest("a")
if (!a) return
const href = a.getAttribute("href")
if (!href) return
if (href.startsWith("#")) {
e.preventDefault()
runCommand(href.slice(1))
focusInput()
}
}

View File

@ -3,6 +3,7 @@ import { initCursor } from "./cursor.js"
import { initEditor } from "./editor.js" import { initEditor } from "./editor.js"
import { initFocus } from "./focus.js" import { initFocus } from "./focus.js"
import { initHistory } from "./history.js" import { initHistory } from "./history.js"
import { initHyperlink } from "./hyperlink.js"
import { initInput } from "./input.js" import { initInput } from "./input.js"
import { initResize } from "./resize.js" import { initResize } from "./resize.js"
import { startVramCounter } from "./vram.js" import { startVramCounter } from "./vram.js"
@ -13,6 +14,7 @@ initCursor()
initFocus() initFocus()
initEditor() initEditor()
initHistory() initHistory()
initHyperlink()
initInput() initInput()
initResize() initResize()

View File

@ -58,7 +58,12 @@ export function addOutput(id: string, output: CommandOutput) {
else else
item.textContent = content item.textContent = content
scrollback.append(item) const input = document.querySelector(`[data-id="${id}"].input`)
if (input instanceof HTMLLIElement)
input.parentNode!.insertBefore(item, input.nextSibling)
else
scrollback.append(item)
autoScroll() autoScroll()
} }
@ -111,7 +116,7 @@ function processOutput(output: CommandOutput): ["html" | "text", string] {
content = output content = output
} else if (Array.isArray(output)) { } else if (Array.isArray(output)) {
content = output.join(" ") content = output.join(" ")
} else if (output.html !== undefined) { } else if ("html" in output) {
html = true html = true
content = output.html content = output.html
} else { } else {

View File

@ -12,6 +12,11 @@ import { handleGameStart } from "./game.js"
export function runCommand(input: string) { export function runCommand(input: string) {
if (!input.trim()) return if (!input.trim()) return
if (input.includes(";")) {
input.split(";").forEach(cmd => runCommand(cmd.trim()))
return
}
const id = randomId() const id = randomId()
addToHistory(input) addToHistory(input)

27
app/src/project.ts Normal file
View File

@ -0,0 +1,27 @@
////
// Helpers for working with projects in the CLI.
import { readdirSync, type Dirent } from "fs"
import { getState } from "./session"
import { appDir } from "./webapp"
export function projectName(): string {
const state = getState()
if (!state) throw "no state"
const project = state.project
if (!project) throw "no project loaded"
return project
}
export function projectDir(): string {
const root = appDir(projectName())
if (!root) throw "error loading project"
return root
}
export function projectFiles(): Dirent[] {
return readdirSync(projectDir(), { recursive: true, withFileTypes: true })
}

View File

@ -7,6 +7,7 @@ export type Session = {
taskId?: string taskId?: string
sessionId?: string sessionId?: string
project?: string project?: string
cwd?: string
ws?: any ws?: any
} }
@ -14,6 +15,17 @@ export type Session = {
const g = globalThis as typeof globalThis & { __thread?: AsyncLocalStorage<Session> } const g = globalThis as typeof globalThis & { __thread?: AsyncLocalStorage<Session> }
export const ALS = g.__thread ??= new AsyncLocalStorage<Session>() export const ALS = g.__thread ??= new AsyncLocalStorage<Session>()
export function getState(): Session | undefined { export function getState(key?: keyof Session): Session | any | undefined {
return ALS.getStore() const store = ALS.getStore()
if (!store) return
if (key) return store[key]
return store
}
export function setState(key: keyof Session, value: any) {
const store = ALS.getStore()
if (!store) return
store[key] = value
} }

View File

@ -1,12 +1,59 @@
export type InputState = { key: string, shift: boolean, ctrl: boolean, meta: boolean, pressed: Set<string> }
export class GameContext { export class GameContext {
constructor(public ctx: CanvasRenderingContext2D) { } constructor(public ctx: CanvasRenderingContext2D) { }
get width() { return this.ctx.canvas.width } width = 960
get height() { return this.ctx.canvas.height } height = 540
clear() { clear(color?: string) {
this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height) if (color)
this.rectfill(0, 0, this.ctx.canvas.width, this.ctx.canvas.height, color)
else
this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height)
}
text(msg: string, x: number, y: number, color = "black", size = 16, font = "C64ProMono") {
const c = this.ctx
c.save()
c.fillStyle = color
c.font = `${size}px ${font}`
c.textBaseline = "top"
c.fillText(msg, x, y)
c.restore()
}
centerText(msg: string, color = "black", size = 16, font = "C64ProMono") {
const c = this.ctx
c.save()
c.fillStyle = color
c.font = `${size}px ${font}`
c.textBaseline = "middle"
c.textAlign = "center"
c.fillText(msg, this.width / 2, this.height / 2)
c.restore()
}
centerTextX(msg: string, y: number, color = "black", size = 16, font = "C64ProMono") {
const c = this.ctx
c.save()
c.fillStyle = color
c.font = `${size}px ${font}`
c.textBaseline = "middle"
c.textAlign = "center"
c.fillText(msg, this.width / 2, y)
c.restore()
}
centerTextY(msg: string, x: number, color = "black", size = 16, font = "C64ProMono") {
const c = this.ctx
c.save()
c.fillStyle = color
c.font = `${size}px ${font}`
c.textBaseline = "middle"
c.textAlign = "center"
c.fillText(msg, x, this.height / 2)
c.restore()
} }
circ(x: number, y: number, r: number, color = "black") { circ(x: number, y: number, r: number, color = "black") {

View File

@ -15,5 +15,3 @@ export type CommandResult = {
status: "ok" | "error" status: "ok" | "error"
output: CommandOutput output: CommandOutput
} }
export type InputState = { key: string, shift: boolean, ctrl: boolean, meta: boolean, pressed: Set<string> }

105
app/src/sniff.ts Normal file
View File

@ -0,0 +1,105 @@
// sniffs the types and params of a file's exports, including const values
import ts from "typescript"
export type Signature = {
type: string,
returnType: string | null,
params: Param[]
}
export type Param = {
name: string,
type: string,
optional: boolean,
rest: boolean,
default: string | null
}
export class SniffError extends Error {
constructor(message: string) {
super(message)
this.name = "TypeError"
Object.setPrototypeOf(this, SniffError.prototype)
}
}
export type ExportInfo =
| { kind: "function", name: string, signatures: Signature[] }
| { kind: "value", name: string, type: string, value: string | null }
let prevProgram: ts.Program | undefined
export async function allExports(file: string): Promise<ExportInfo[]> {
const program = ts.createProgram([file], {
target: ts.ScriptTarget.ESNext,
module: ts.ModuleKind.ESNext,
moduleResolution: ts.ModuleResolutionKind.NodeNext,
noLib: true,
types: [],
skipDefaultLibCheck: true,
skipLibCheck: true
}, undefined, prevProgram)
prevProgram = program
const checker = program.getTypeChecker()
const sf = program.getSourceFile(file)
if (!sf) throw new SniffError(`File not found: ${file}`)
const moduleSym = (sf as any).symbol as ts.Symbol | undefined
if (!moduleSym) return []
const exportSymbols = checker.getExportsOfModule(moduleSym)
const result: ExportInfo[] = []
for (const sym of exportSymbols) {
const decl = sym.valueDeclaration ?? sym.declarations?.[0] ?? sf
const type = checker.getTypeOfSymbolAtLocation(sym, decl)
const sigs = checker.getSignaturesOfType(type, ts.SignatureKind.Call)
if (sigs.length > 0) {
result.push({
kind: "function",
name: sym.getName(),
signatures: sigs.map(sig => ({
type: checker.typeToString(type, decl),
returnType: checker.typeToString(checker.getReturnTypeOfSignature(sig), decl),
params: sig.getParameters().map(p => {
const pd: any = p.getDeclarations()?.[0] ?? decl
const pt = checker.getTypeOfSymbolAtLocation(p, pd)
return {
name: p.getName(),
type: checker.typeToString(pt, pd),
optional: !!pd.questionToken || !!pd.initializer,
rest: !!pd.dotDotDotToken,
default: pd.initializer ? pd.initializer.getText() : null
}
})
}))
})
} else {
let value: string | null = null
if (ts.isVariableDeclaration(decl) && decl.initializer) {
value = decl.initializer.getText()
}
result.push({
kind: "value",
name: sym.getName(),
type: checker.typeToString(type, decl),
value
})
}
}
return result
}
if (import.meta.main) {
const path = Bun.argv[2]
if (!path) {
console.error("usage: sniff <path>")
process.exit(1)
}
console.log(await allExports(path))
}

View File

@ -37,6 +37,11 @@ export function isDir(path: string): boolean {
} }
} }
export async function mtime(path: string): Promise<Date> {
const { mtime } = await stat(path)
return mtime
}
// is the given file binary? // is the given file binary?
export async function isBinaryFile(path: string): Promise<boolean> { export async function isBinaryFile(path: string): Promise<boolean> {
// Create a stream to read just the beginning // Create a stream to read just the beginning

View File

@ -8,15 +8,23 @@ import { join, dirname } from "path"
import { readdirSync } from "fs" import { readdirSync } from "fs"
import { NOSE_WWW } from "./config" import { NOSE_WWW } from "./config"
import { isFile } from "./utils" import { isFile, isDir } from "./utils"
export type Handler = (r: Context) => string | Child | Response | Promise<Response> export type Handler = (r: Context) => string | Child | Response | Promise<Response>
export type App = Hono | Handler export type App = Hono | Handler
export async function serveApp(c: Context, subdomain: string): Promise<Response> { export async function serveApp(c: Context, subdomain: string): Promise<Response> {
const app = await findApp(subdomain) const app = await findApp(subdomain)
const path = appDir(subdomain)
if (!app) return c.text(`App Not Found: ${subdomain}`, 404) if (!path) return c.text(`App not found: ${subdomain}`, 404)
const staticPath = join(path, "pub", c.req.path === "/" ? "/index.html" : c.req.path)
if (isFile(staticPath))
return serveStatic(staticPath)
if (!app) return c.text(`App not found: ${subdomain}`, 404)
if (app instanceof Hono) if (app instanceof Hono)
return app.fetch(c.req.raw) return app.fetch(c.req.raw)
@ -33,20 +41,19 @@ export function apps(): string[] {
return apps.sort() return apps.sort()
} }
export function appPath(name: string): string | undefined { export function appDir(name: string): string | undefined {
const path = [ const path = [
`${name}.ts`, `${name}.ts`,
`${name}.tsx`, `${name}.tsx`,
join(name, "index.ts"), name
join(name, "index.tsx")
] ]
.map(path => join(NOSE_WWW, path)) .map(path => join(NOSE_WWW, path))
.flat() .flat()
.filter(path => isFile(path))[0] .filter(path => /\.tsx?$/.test(path) ? isFile(path) : isDir(path))[0]
if (!path) return if (!path) return
return dirname(path) return /\.tsx?$/.test(path) ? dirname(path) : path
} }
async function findApp(name: string): Promise<App | undefined> { async function findApp(name: string): Promise<App | undefined> {
@ -63,8 +70,6 @@ async function findApp(name: string): Promise<App | undefined> {
app = await loadApp(join(NOSE_WWW, path)) app = await loadApp(join(NOSE_WWW, path))
if (app) return app if (app) return app
} }
console.error("can't find app:", name)
} }
async function loadApp(path: string): Promise<App | undefined> { async function loadApp(path: string): Promise<App | undefined> {
@ -87,3 +92,12 @@ export function toResponse(source: string | Child | Response): Response {
} }
}) })
} }
function serveStatic(path: string): Response {
const file = Bun.file(path)
return new Response(file, {
headers: {
"Content-Type": file.type
}
})
}