Compare commits

..

16 Commits

Author SHA1 Message Date
966d14a5b1 sudoers 2025-10-06 19:47:43 -07:00
4fe61ee773 days 2025-10-06 19:30:26 -07:00
2b66107866 narrow Message types 2025-10-03 07:00:25 -07:00
24a17f2b46 keep us honest 2025-10-03 06:41:51 -07:00
00603452bf narrow Message some 2025-10-03 06:38:30 -07:00
6fcffc65f9 show tiny browser 2025-10-03 06:38:21 -07:00
a87564b082 controls component 2025-10-03 06:37:48 -07:00
e8e94a4e3a note 2025-10-02 22:13:00 -07:00
e7b4d47456 fullscreen browser 2025-10-02 22:12:04 -07:00
0f49cbeabd fullscreen button (kinda) 2025-10-02 22:03:07 -07:00
740abc9f2c this was always the plan 2025-10-02 21:49:27 -07:00
eec06c1a24 explain 2025-10-02 21:48:08 -07:00
da61426fc9 always allow keyboards shortcuts 2025-10-02 21:47:30 -07:00
ba44f8e65b nasty! it's a browser 2025-10-02 21:44:55 -07:00
f598beb406 whoops.. shh 2025-10-02 19:52:08 -07:00
b90a76ebc8 statusbar message 2025-10-02 19:29:14 -07:00
29 changed files with 591 additions and 80 deletions

View File

@ -83,8 +83,10 @@ https://wakamaifondue.com/
- [x] public tunnel for your NOSE webapps - [x] public tunnel for your NOSE webapps
- [x] public tunnel lives through reboots - [x] public tunnel lives through reboots
- [ ] tunnel to the terminal - [ ] tunnel to the terminal
- [x] web browser
- [x] remember your "mode" - [x] remember your "mode"
- [x] status bar on terminal UX - [x] status bar on terminal UX
- [ ] quickly open the current webapp
- [x] "project"-based rehaul - [x] "project"-based rehaul
- [x] self updating NOSE server - [x] self updating NOSE server
- [x] `pub/` static hosting in webapps - [x] `pub/` static hosting in webapps

View File

@ -1,6 +1,6 @@
// Reboot the whole computer! Careful! // Reboot the whole computer! Careful!
export default async function reboot() { export default async function reboot() {
setTimeout(async () => await Bun.$`reboot`, 1000) setTimeout(async () => await Bun.$`sudo reboot`, 1000)
return { return {
text: "Rebooting... This will take about 10 seconds.", text: "Rebooting... This will take about 10 seconds.",

View File

@ -9,6 +9,7 @@ export default function () {
const sec = Math.floor(ms / 1000) const sec = Math.floor(ms / 1000)
const min = Math.floor(sec / 60) const min = Math.floor(sec / 60)
const hrs = Math.floor(min / 60) const hrs = Math.floor(min / 60)
const days = Math.floor(hrs / 24)
return `${hrs}h ${min % 60}m ${sec % 60}s` return `${days}d ${hrs % 24}h ${min % 60}m ${sec % 60}s`
} }

View File

@ -13,6 +13,9 @@
<div class="text-content"> <div class="text-content">
<h1>@defunkt</h1> <h1>@defunkt</h1>
<p>This is my website. I am Chris.</p> <p>This is my website. I am Chris.</p>
<p><a href="/other.html">Other.html</a></p>
<p><a href="http://corey.localhost:3000">corey</a></p>
<p><a href="https://google.com">google</a></p>
</div> </div>
</div> </div>
<div class="tile burger"> <div class="tile burger">

View File

@ -0,0 +1,4 @@
<html>
<head><title>other</title></head>
<body><h1>other</h1></body>
</html>

View File

@ -1,2 +0,0 @@
export default () =>
"pong"

2
nose/ping/index.tsx Normal file
View File

@ -0,0 +1,2 @@
export default () =>
<html><head><title>pong</title></head><body>pong - {Date.now()}</body></html>

View File

@ -4,17 +4,15 @@
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
"check": "bunx tsc --noEmit",
"dev": "env BUN_HOT=1 bun --hot src/server.tsx", "dev": "env BUN_HOT=1 bun --hot src/server.tsx",
"start": "bun src/server.tsx", "start": "bun src/server.tsx",
"prod": "env NODE_ENV=production bun src/server.tsx", "prod": "env NODE_ENV=production bun src/server.tsx",
"prod-nodns": "env NO_DNS=1 NODE_ENV=production bun src/server.tsx", "prod-nodns": "env NO_DNS=1 NODE_ENV=production bun src/server.tsx",
"build": "./scripts/build.sh", "build": "./scripts/build.sh",
"runner": "env NODE_ENV=production bun run src/runner.ts", "runner": "env NODE_ENV=production bun run src/runner.ts",
"deploy": "./scripts/deploy.sh", "deploy": "./scripts/deploy.sh",
"push": "./scripts/deploy.sh", "push": "./scripts/deploy.sh",
"remote:install": "./scripts/remote-install.sh", "remote:install": "./scripts/remote-install.sh",
"remote:start": "./scripts/remote-start.sh", "remote:start": "./scripts/remote-start.sh",
"remote:stop": "./scripts/remote-stop.sh", "remote:stop": "./scripts/remote-stop.sh",
@ -26,4 +24,4 @@
"dependencies": { "dependencies": {
"kleur": "^4.1.5" "kleur": "^4.1.5"
} }
} }

110
public/browser-nav.js Normal file
View File

@ -0,0 +1,110 @@
// browser-nav.js - Injected into NOSE apps to get the UI browser working.
(function () {
const ALLOWED_DOMAINS = ['localhost', '.local', '.nose.space']
function isAllowedOrigin(url) {
try {
const urlObj = new URL(url, window.location.href)
return ALLOWED_DOMAINS.some(domain =>
urlObj.hostname === domain || urlObj.hostname.endsWith(domain)
)
} catch {
return false
}
}
// Intercept navigation attempts
function interceptNavigation(e) {
const target = e.target.closest('a')
if (!target || !target.href) return
// Allow relative URLs and hash links
if (target.getAttribute('href').startsWith('#')) return
if (target.getAttribute('href').startsWith('/')) return
// Check if external
if (!isAllowedOrigin(target.href)) {
e.preventDefault()
if (window.parent !== window) {
window.parent.postMessage({
type: 'NAV_BLOCKED',
data: { url: target.href, reason: 'External domain not allowed' }
}, '*')
} else {
alert('Navigation to external sites is not allowed')
}
}
}
// Listen for clicks on links
document.addEventListener('click', interceptNavigation, true)
// Intercept programmatic navigation
const originalPushState = history.pushState
const originalReplaceState = history.replaceState
history.pushState = function (state, title, url) {
if (url && !isAllowedOrigin(url)) {
if (window.parent !== window) {
window.parent.postMessage({
type: 'NAV_BLOCKED',
data: { url, reason: 'External domain not allowed' }
}, '*')
}
return
}
return originalPushState.apply(this, arguments)
}
history.replaceState = function (state, title, url) {
if (url && !isAllowedOrigin(url)) {
if (window.parent !== window) {
window.parent.postMessage({
type: 'NAV_BLOCKED',
data: { url, reason: 'External domain not allowed' }
}, '*')
}
return
}
return originalReplaceState.apply(this, arguments)
}
// Listen for navigation commands from parent
window.addEventListener('message', (event) => {
console.log(event)
if (event.data.type === 'NAV_COMMAND') {
switch (event.data.action) {
case 'back': history.back(); break
case 'forward': history.forward(); break
case 'reload': window.location.reload(); break
case 'stop': window.stop(); break
}
}
})
// Send all keydown events to parent
window.addEventListener('keydown', (e) => {
window.parent.postMessage({
type: 'KEYDOWN',
data: {
key: e.key,
ctrlKey: e.ctrlKey,
shiftKey: e.shiftKey,
altKey: e.altKey,
metaKey: e.metaKey
}
}, '*')
})
if (window.parent !== window) {
window.parent.postMessage({ type: 'NAV_READY' }, '*')
window.addEventListener('popstate', () => {
window.parent.postMessage({
type: 'URL_CHANGED',
data: { url: location.href }
}, '*')
})
}
})()

View File

@ -1,5 +1,5 @@
//// ////
// version: 66c75ff // version: 7d7febe
// src/js/dom.ts // src/js/dom.ts
var content2 = $("content"); var content2 = $("content");
@ -1058,6 +1058,25 @@ async function runCommand(input) {
} }
} }
// src/js/statusbar.ts
var STATUS_MSG_LENGTH = 3000;
var statusbar = $("statusbar");
var statusbarMsg = $("statusbar-msg");
var timer;
function status(msg) {
showStatusMsg();
statusbarMsg.textContent = msg;
if (timer)
clearTimeout(timer);
timer = setTimeout(hideStatusMsg, STATUS_MSG_LENGTH);
}
function showStatusMsg() {
statusbar.classList.add("showing-msg");
}
function hideStatusMsg() {
statusbar.className = "";
}
// src/js/hyperlink.ts // src/js/hyperlink.ts
function initHyperlink() { function initHyperlink() {
window.addEventListener("click", handleClick); window.addEventListener("click", handleClick);
@ -1076,6 +1095,9 @@ async function handleClick(e) {
e.preventDefault(); e.preventDefault();
await runCommand(href.slice(1)); await runCommand(href.slice(1));
focusInput(); focusInput();
} else {
e.preventDefault();
status(href);
} }
} }
@ -1133,7 +1155,7 @@ function clearInput() {
// src/js/vram.ts // src/js/vram.ts
var vramCounter = $("vram-size"); var vramCounter = $("vram-size");
var startVramCounter = () => { var startVramCounter = () => {
const timer = setInterval(() => { const timer2 = setInterval(() => {
const count = parseInt(vramCounter.textContent) + 1; const count = parseInt(vramCounter.textContent) + 1;
let val = count + "KB"; let val = count + "KB";
if (count < 10) if (count < 10)
@ -1141,7 +1163,7 @@ var startVramCounter = () => {
vramCounter.textContent = val; vramCounter.textContent = val;
if (count >= 64) { if (count >= 64) {
vramCounter.textContent += " OK"; vramCounter.textContent += " OK";
clearInterval(timer); clearInterval(timer2);
} }
}, 15); }, 15);
}; };

66
src/css/browser.css Normal file
View File

@ -0,0 +1,66 @@
:root {
--browser-bar-height: 34px;
}
iframe.browser {
display: block;
background-color: white;
z-index: 10;
border: none;
}
iframe.browser.fullscreen {
margin-top: 0;
width: 100%;
height: 100%;
}
[data-mode="tall"] iframe.browser.active {
height: 100%;
}
iframe:focus {
outline: none;
}
.browser.active {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
margin-top: var(--browser-bar-height);
}
#browser-controls {
position: absolute;
top: 0;
z-index: 15;
width: 100%;
height: var(--browser-bar-height);
background-color: var(--gray);
}
#browser-controls span,
#browser-controls a {
display: inline-block;
margin-right: 10px;
padding: 5px;
background-color: var(--gray);
color: var(--c64-light-blue);
text-decoration: none;
}
#forward-button {
transform: scaleX(-1);
}
#fullscreen-button {
position: absolute;
right: 15;
top: -1;
}
#close-button {
position: absolute;
right: -10;
}

View File

@ -16,6 +16,8 @@
--purple: #7C3AED; --purple: #7C3AED;
--blue: #1565C0; --blue: #1565C0;
--magenta: #ff66cc; --magenta: #ff66cc;
--gray: #BEBEBE;
--grey: #BEBEBE;
--c64-light-blue: #6C6FF6; --c64-light-blue: #6C6FF6;
--c64-dark-blue: #40318D; --c64-dark-blue: #40318D;

View File

@ -162,7 +162,7 @@
white-space: pre-wrap; white-space: pre-wrap;
} }
#statusline { #statusbar {
position: absolute; position: absolute;
bottom: 0; bottom: 0;
left: 0; left: 0;
@ -177,7 +177,24 @@
justify-content: space-between; justify-content: space-between;
} }
#statusline a { #statusbar .line-msg {
display: none;
}
#statusbar.showing-msg .line-cwd {
display: none;
}
#statusbar.showing-msg {
background: var(--purple);
}
#statusbar.showing-msg .line-msg {
color: var(--cyan);
display: inherit;
}
#statusbar a {
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
} }

View File

@ -11,13 +11,15 @@ export async function dispatchMessage(ws: any, msg: Message) {
console.log("<- receive", msg) console.log("<- receive", msg)
switch (msg.type) { switch (msg.type) {
case "input": case "input":
await inputMessage(ws, msg as InputMessage); break await inputMessage(ws, msg); break
case "save-file": case "save-file":
await saveFileMessage(ws, msg as SaveFileMessage); break await saveFileMessage(ws, msg); break
case "ui:mode": case "session:update":
setState("ui:mode", msg.data); break for (const key of Object.keys(msg.data))
setState(key, msg.data[key]);
break
default: default:
send(ws, { type: "error", data: `unknown message: ${msg.type}` }) send(ws, { type: "error", data: `unknown message: ${msg.type}` })

View File

@ -0,0 +1,15 @@
import type { FC } from "hono/jsx"
export const BrowserControls: FC = async () => (
<>
<div id="browser-controls" style="display:none">
<a id="back-button" href="#"></a>
<a id="forward-button" href="#"></a>
<a id="stop-button" href="#"></a>
<a id="reload-button" href="#">@</a>
<a id="fullscreen-button" href="#"></a>
<a id="close-button" href="#"></a>
<span id="browser-address">your-app.nose-pluto.local</span>
</div>
</>
)

View File

@ -1,9 +1,13 @@
import type { FC } from "hono/jsx" import type { FC } from "hono/jsx"
import { BrowserControls } from "./browser/controls"
export const Terminal: FC = async () => ( export const Terminal: FC = async () => (
<> <>
<link rel="stylesheet" href="/css/terminal.css" /> <link rel="stylesheet" href="/css/terminal.css" />
<link rel="stylesheet" href="/css/editor.css" /> <link rel="stylesheet" href="/css/editor.css" />
<link rel="stylesheet" href="/css/browser.css" />
<BrowserControls />
<div id="command-line"> <div id="command-line">
<span id="command-prompt">&gt;</span> <span id="command-prompt">&gt;</span>
@ -35,6 +39,9 @@ export const Terminal: FC = async () => (
<li class="center">VRAM <span id="vram-size">000KB</span></li> <li class="center">VRAM <span id="vram-size">000KB</span></li>
</ul> </ul>
<div id="statusline"><div><a href="#projects" id="project-name">root</a>: <a href="#ls" id="project-cwd">/</a></div></div> <div id="statusbar">
<div class="line-cwd"><a href="#projects" id="project-name">root</a>: <a href="#ls" id="project-cwd">/</a></div>
<div class="line-msg"><span id="statusbar-msg"></span></div>
</div>
</> </>
) )

178
src/js/browser.ts Normal file
View File

@ -0,0 +1,178 @@
////
// Our tiny browser, only for NOSE apps.
// Check /public/browser-nav.js for the 2nd part.
import { $, $$ } from "./dom"
import { focusInput } from "./focus"
import { scrollback } from "./dom"
import { addInput } from "./scrollback"
import { randomId } from "../shared/utils"
const HEIGHT = 540
const WIDTH = 960
const controls = $("browser-controls") as HTMLDivElement
const address = $("browser-address") as HTMLSpanElement
let iframe: HTMLIFrameElement
let realUrl = ""
let showInput = true
export function isBrowsing(): boolean {
return document.querySelector("iframe.browser.active") !== null
}
export function openBrowser(url: string, openedVia: "click" | "command" = "click") {
showInput = openedVia === "click"
iframe = $$("iframe.browser.active") as HTMLIFrameElement
iframe.src = url
iframe.sandbox.add("allow-scripts", "allow-same-origin", "allow-forms")
iframe.height = String(HEIGHT)
iframe.width = String(WIDTH)
iframe.tabIndex = 0
window.addEventListener("message", handleAppMessage)
window.addEventListener("keydown", handleBrowserKeydown)
controls.addEventListener("click", handleClick)
iframe.addEventListener("load", handlePageLoad)
controls.style.display = ""
const main = document.querySelector("#content")
main?.prepend(iframe)
setAddress(url)
}
function closeBrowser() {
window.removeEventListener("keydown", handleBrowserKeydown)
window.removeEventListener("message", handleAppMessage)
controls.removeEventListener("click", handleClick)
iframe.removeEventListener("load", handlePageLoad)
const id = randomId()
if (showInput)
addInput(id, "browse " + realUrl, "ok")
iframe.height = String(HEIGHT / 2)
iframe.width = String(WIDTH / 2)
iframe.style.pointerEvents = "none"
iframe.tabIndex = -1
iframe.classList.remove("fullscreen", "active")
scrollback.append(iframe)
controls.style.display = "none"
focusInput()
}
function handleAppMessage(event: MessageEvent) {
const origin = event.origin
if (!origin.includes('localhost') && !origin.match(/\.local$/)) {
return
}
const { type, data } = event.data
switch (type) {
case 'NAV_READY':
break
case 'URL_CHANGED':
setAddress(data.url)
break
case 'TITLE_CHANGED':
// TODO: browser titles
console.log('Page title:', data.title)
break
case 'NAV_BLOCKED':
showNavigationError(data.url, data.reason)
break
case 'KEYDOWN':
const keyEvent = new KeyboardEvent('keydown', {
key: data.key,
ctrlKey: data.ctrlKey,
shiftKey: data.shiftKey,
altKey: data.altKey,
metaKey: data.metaKey,
bubbles: true
})
window.dispatchEvent(keyEvent)
break
}
}
function handleBrowserKeydown(e: KeyboardEvent) {
if (e.key === "Escape" || (e.ctrlKey && e.key === "c")) {
e.preventDefault()
closeBrowser()
}
}
function handleClick(e: MouseEvent) {
const target = e.target
if (!(target instanceof HTMLElement)) return
switch (target.id) {
case "back-button":
navigateBack(); break
case "forward-button":
navigateForward(); break
case "stop-button":
stopLoading(); break
case "reload-button":
reloadBrowser(); break
case "fullscreen-button":
fullscreenBrowser(); break
case "close-button":
closeBrowser(); break
default:
return
}
e.preventDefault()
}
function handlePageLoad() {
}
function setAddress(url: string) {
realUrl = url
address.textContent = url.replace(/https?:\/\//, "")
}
function navigateBack() {
sendNavCommand('back')
}
function navigateForward() {
sendNavCommand('forward')
}
function reloadBrowser() {
sendNavCommand('reload')
}
function stopLoading() {
sendNavCommand('stop')
}
function fullscreenBrowser() {
controls.style.display = 'none'
iframe.classList.add('fullscreen')
document.body.append(iframe)
}
function sendNavCommand(action: 'back' | 'forward' | 'reload' | 'stop') {
if (!iframe.contentWindow) return
iframe.contentWindow.postMessage({
type: 'NAV_COMMAND',
action
}, '*')
}
function showNavigationError(url: string, reason: string) {
alert(`NAVIGATION BLOCKED\n\n${url}\n\n${reason}`)
}

View File

@ -2,16 +2,17 @@
// temporary hack for browser commands // temporary hack for browser commands
import type { CommandOutput } from "../shared/types" import type { CommandOutput } from "../shared/types"
import { openBrowser } from "./browser"
import { scrollback, content } from "./dom" import { scrollback, content } from "./dom"
import { focusInput } from "./focus"
import { resize } from "./resize" import { resize } from "./resize"
import { autoScroll } from "./scrollback"
import { sessionId } from "./session" import { sessionId } from "./session"
import { send } from "./websocket" import { send } from "./websocket"
import { focusInput } from "./focus"
export const commands: string[] = [] export const commands: string[] = []
export const browserCommands: Record<string, (...args: string[]) => void | Promise<void> | CommandOutput> = { export const browserCommands: Record<string, (...args: string[]) => void | Promise<void> | CommandOutput> = {
browse: (url: string) => openBrowser(url, "command"),
"browser-session": () => sessionId, "browser-session": () => sessionId,
clear: () => scrollback.innerHTML = "", clear: () => scrollback.innerHTML = "",
commands: () => { commands: () => {
@ -21,13 +22,12 @@ export const browserCommands: Record<string, (...args: string[]) => void | Promi
mode: (mode?: string) => { mode: (mode?: string) => {
if (!mode) { if (!mode) {
mode = document.body.dataset.mode === "tall" ? "cinema" : "tall" mode = document.body.dataset.mode === "tall" ? "cinema" : "tall"
send({ type: "ui:mode", data: mode }) send({ type: "session:update", data: { "ui:mode": mode } })
} }
content.style.display = "" content.style.display = ""
document.body.dataset.mode = mode document.body.dataset.mode = mode
resize() resize()
autoScroll()
focusInput() focusInput()
}, },
reload: () => window.location.reload(), reload: () => window.location.reload(),

View File

@ -1,3 +1,4 @@
import type { SaveFileMessage } from "../shared/types"
import { scrollback } from "./dom" import { scrollback } from "./dom"
import { send } from "./websocket" import { send } from "./websocket"
import { focusInput } from "./focus" import { focusInput } from "./focus"
@ -49,10 +50,10 @@ function keydownHandler(e: KeyboardEvent) {
} else if ((e.ctrlKey && e.key === "s") || (e.ctrlKey && e.key === "Enter")) { } else if ((e.ctrlKey && e.key === "s") || (e.ctrlKey && e.key === "Enter")) {
e.preventDefault() e.preventDefault()
send({ send({
id: editor.dataset.path, id: editor.dataset.path || "/tmp.txt",
type: "save-file", type: "save-file",
data: editor.value data: editor.value
}) } as SaveFileMessage)
} else if (e.key === "{") { } else if (e.key === "{") {
if (editor.selectionStart !== editor.selectionEnd) { if (editor.selectionStart !== editor.selectionEnd) {
insertAroundSelection(editor, '{', '}') insertAroundSelection(editor, '{', '}')

View File

@ -1,4 +1,4 @@
import type { Message } from "../shared/types" import type { GameStartMessage } from "../shared/types"
import { GameContext, type InputState } from "../shared/game" import { GameContext, type InputState } from "../shared/game"
import { focusInput } from "./focus" import { focusInput } from "./focus"
import { $$ } from "./dom" import { $$ } from "./dom"
@ -9,7 +9,7 @@ import { sessionId } from "./session"
const FPS = 30 const FPS = 30
const HEIGHT = 540 const HEIGHT = 540
const WIDTH = 980 const WIDTH = 960
type Game = { init?: () => void, update?: (delta: number, input: InputState) => void, draw?: (ctx: GameContext) => void } type Game = { init?: () => void, update?: (delta: number, input: InputState) => void, draw?: (ctx: GameContext) => void }
@ -29,7 +29,7 @@ let pressed: InputState = {
justReleased: new Set(), justReleased: new Set(),
} }
export async function handleGameStart(msg: Message) { export async function handleGameStart(msg: GameStartMessage) {
const msgId = msg.id as string const msgId = msg.id as string
const name = msg.data as string const name = msg.data as string

View File

@ -1,5 +1,10 @@
////
// Links can be #command (will run in the prompt) or real links, which run in
// a NOSE browser so you always stay in the UI's safe embrace.
import { runCommand } from "./shell" import { runCommand } from "./shell"
import { focusInput } from "./focus" import { focusInput } from "./focus"
import { openBrowser, isBrowsing } from "./browser"
export function initHyperlink() { export function initHyperlink() {
window.addEventListener("click", handleClick) window.addEventListener("click", handleClick)
@ -16,9 +21,12 @@ async function handleClick(e: MouseEvent) {
const href = a.getAttribute("href") const href = a.getAttribute("href")
if (!href) return if (!href) return
if (href.startsWith("#")) { if (href.startsWith("#") && href.length > 1) {
e.preventDefault() e.preventDefault()
await runCommand(href.slice(1)) await runCommand(href.slice(1))
focusInput() focusInput()
} else if (!isBrowsing()) {
e.preventDefault()
openBrowser(href)
} }
} }

View File

@ -7,25 +7,26 @@ import { scrollback, cmdInput, $$ } from "./dom"
import { randomId } from "../shared/utils" import { randomId } from "../shared/utils"
type InputStatus = "waiting" | "streaming" | "ok" | "error" type InputStatus = "waiting" | "streaming" | "ok" | "error"
const statusColors = {
waiting: "yellow",
streaming: "purple",
ok: "green",
error: "red"
}
export function initScrollback() { export function initScrollback() {
window.addEventListener("click", handleInputClick) window.addEventListener("click", handleInputClick)
} }
export function autoScroll() {
// requestAnimationFrame(() => scrollback.scrollTop = scrollback.scrollHeight - scrollback.clientHeight)
// scrollback.scrollTop = scrollback.scrollHeight - scrollback.clientHeight
}
export function insert(node: HTMLElement) { export function insert(node: HTMLElement) {
scrollback.append(node) scrollback.append(node)
} }
export function addInput(id: string, input: string) { export function addInput(id: string, input: string, status?: InputStatus) {
const parent = $$("li.input") const parent = $$("li.input")
const status = $$("span.status.yellow", "•") const statusSpan = $$(`span.status.${statusColors[status || "waiting"]}`, "•")
const content = $$("span.content", input) const content = $$("span.content", input)
parent.append(status, content) parent.append(statusSpan, content)
parent.dataset.id = id parent.dataset.id = id
insert(parent) insert(parent)
@ -36,15 +37,8 @@ export function setStatus(id: string, status: InputStatus) {
const statusEl = document.querySelector(`[data-id="${id}"].input .status`) const statusEl = document.querySelector(`[data-id="${id}"].input .status`)
if (!statusEl) return if (!statusEl) return
const colors = { statusEl.classList.remove(...Object.values(statusColors))
waiting: "yellow", statusEl.classList.add(statusColors[status])
streaming: "purple",
ok: "green",
error: "red"
}
statusEl.classList.remove(...Object.values(colors))
statusEl.classList.add(colors[status])
} }
export function addOutput(id: string, output: CommandOutput) { export function addOutput(id: string, output: CommandOutput) {
@ -65,8 +59,6 @@ export function addOutput(id: string, output: CommandOutput) {
} else { } else {
insert(item) insert(item)
} }
autoScroll()
} }
export function addErrorMessage(message: string) { export function addErrorMessage(message: string) {
@ -87,8 +79,6 @@ export function appendOutput(id: string, output: CommandOutput) {
item.innerHTML += content item.innerHTML += content
else else
item.textContent += content item.textContent += content
autoScroll()
} }
export function replaceOutput(id: string, output: CommandOutput) { export function replaceOutput(id: string, output: CommandOutput) {
@ -105,8 +95,6 @@ export function replaceOutput(id: string, output: CommandOutput) {
item.innerHTML = content item.innerHTML = content
else else
item.textContent = content item.textContent = content
autoScroll()
} }
function processOutput(output: CommandOutput): ["html" | "text", string] { function processOutput(output: CommandOutput): ["html" | "text", string] {
@ -145,6 +133,7 @@ function handleInputClick(e: MouseEvent) {
export function handleOutput(msg: Message) { export function handleOutput(msg: Message) {
const result = msg.data as CommandResult const result = msg.data as CommandResult
setStatus(msg.id!, result.status) const id = "id" in msg ? msg.id || "" : ""
addOutput(msg.id!, result.output) setStatus(id, result.status)
addOutput(id, result.output)
} }

View File

@ -1,6 +1,7 @@
//// ////
// The shell runs on the server and processes input, returning output. // The shell runs on the server and processes input, returning output.
import type { InputMessage } from "../shared/types"
import { addInput, setStatus, addOutput } from "./scrollback" import { addInput, setStatus, addOutput } from "./scrollback"
import { send } from "./websocket" import { send } from "./websocket"
import { randomId } from "../shared/utils" import { randomId } from "../shared/utils"
@ -27,7 +28,7 @@ export async function runCommand(input: string) {
if (result) addOutput(id, result) if (result) addOutput(id, result)
setStatus(id, "ok") setStatus(id, "ok")
} else { } else {
send({ id, type: "input", data: input }) send({ id, type: "input", data: input } as InputMessage)
} }
} }

27
src/js/statusbar.ts Normal file
View File

@ -0,0 +1,27 @@
////
// Temporarily display a message to the user in the status bar.
import { $ } from "./dom"
const STATUS_MSG_LENGTH = 3000
const statusbar = $("statusbar") as HTMLDivElement
const statusbarMsg = $("statusbar-msg") as HTMLSpanElement
let timer: NodeJS.Timeout
export function status(msg: string) {
showStatusMsg()
statusbarMsg.textContent = msg
if (timer) clearTimeout(timer)
timer = setTimeout(hideStatusMsg, STATUS_MSG_LENGTH)
}
function showStatusMsg() {
statusbar.classList.add("showing-msg")
}
function hideStatusMsg() {
statusbar.className = ""
}

View File

@ -1,25 +1,25 @@
import type { Message, CommandOutput } from "@/shared/types" import type { StreamMessage } from "@/shared/types"
import { addOutput, appendOutput, replaceOutput } from "./scrollback" import { addOutput, appendOutput, replaceOutput } from "./scrollback"
export function handleStreamStart(msg: Message) { export function handleStreamStart(msg: StreamMessage) {
const id = msg.id! const id = msg.id
const status = document.querySelector(`[data-id="${id}"].input .status`) const status = document.querySelector(`[data-id="${id}"].input .status`)
if (!status) return if (!status) return
addOutput(id, msg.data as CommandOutput) addOutput(id, msg.data)
status.classList.remove("yellow") status.classList.remove("yellow")
status.classList.add("purple") status.classList.add("purple")
} }
export function handleStreamAppend(msg: Message) { export function handleStreamAppend(msg: StreamMessage) {
appendOutput(msg.id!, msg.data as CommandOutput) appendOutput(msg.id, msg.data)
} }
export function handleStreamReplace(msg: Message) { export function handleStreamReplace(msg: StreamMessage) {
replaceOutput(msg.id!, msg.data as CommandOutput) replaceOutput(msg.id, msg.data)
} }
export function handleStreamEnd(_msg: Message) { export function handleStreamEnd(_msg: StreamMessage) {
} }

View File

@ -9,7 +9,7 @@ import { addErrorMessage } from "./scrollback"
const MAX_RETRIES = 5 const MAX_RETRIES = 5
let retries = 0 let retries = 0
let connected = false let connected = false
let msgQueue: Message[] = [] let msgQueue: Omit<Message, "session">[] = []
let ws: WebSocket | null = null let ws: WebSocket | null = null
@ -30,14 +30,15 @@ export function startConnection() {
} }
// send any message // send any message
export function send(msg: Message) { export function send(msg: Omit<Message, "session">) {
if (!connected) { if (!connected) {
msgQueue.push(msg) msgQueue.push(msg)
startConnection() startConnection()
return return
} }
if (!msg.session) msg.session = sessionId if (!(msg as any).session) (msg as any).session = sessionId
ws?.readyState === 1 && ws.send(JSON.stringify(msg)) ws?.readyState === 1 && ws.send(JSON.stringify(msg))
console.log("-> send", msg) console.log("-> send", msg)
} }

View File

@ -78,6 +78,39 @@ app.on("GET", ["/js/:path{.+}", "/shared/:path{.+}"], async c => {
// app routes // app routes
// //
// inject browser-nav.js into NOSE apps when loaded in a NOSE iframe
app.use("*", async (c, next) => {
await next()
const url = new URL(c.req.url)
const domains = url.hostname.split(".")
const isSubdomain = domains.length > (url.hostname.endsWith("localhost") ? 1 : 2)
if (!isSubdomain) return
const contentType = c.res.headers.get('content-type')
if (!contentType?.includes('text/html')) return
const secFetchDest = c.req.header('Sec-Fetch-Dest')
const isIframe = secFetchDest === 'iframe'
if (!isIframe) return
const originalBody = await c.res.text()
const shimScript = `<script src="/browser-nav.js"></script>`
let modifiedBody = originalBody
if (originalBody.includes('</head>')) {
modifiedBody = originalBody.replace('</head>', `${shimScript}\n</head>`)
} else if (originalBody.includes('<body>')) {
modifiedBody = originalBody.replace('<body>', `<body>\n${shimScript}`)
}
c.res = new Response(modifiedBody, {
status: c.res.status,
headers: c.res.headers
})
})
app.use("*", async (c, next) => { app.use("*", async (c, next) => {
const url = new URL(c.req.url) const url = new URL(c.req.url)
const localhost = url.hostname.endsWith("localhost") const localhost = url.hostname.endsWith("localhost")

View File

@ -1,18 +1,13 @@
export type Message = { export type Message =
session?: string | ErrorMessage
id?: string
type: MessageType
data?: CommandResult | CommandOutput
}
| InputMessage | InputMessage
| OutputMessage
| SaveFileMessage | SaveFileMessage
| SessionStartMessage | SessionStartMessage
| SessionUpdateMessage | SessionUpdateMessage
| GameStartMessage
export type MessageType = "error" | "input" | "output" | "commands" | "save-file" | StreamMessage
| "game:start" | CommandsMessage
| "stream:start" | "stream:end" | "stream:append" | "stream:replace"
| "ui:mode"
export type CommandOutput = string | string[] export type CommandOutput = string | string[]
| { text: string, script?: string } | { text: string, script?: string }
@ -25,6 +20,22 @@ export type CommandResult = {
output: CommandOutput output: CommandOutput
} }
export type ErrorMessage = {
type: "error"
data: string
}
export type CommandsMessage = {
type: "commands"
data: string[]
}
export type OutputMessage = {
type: "output"
id?: string
data: CommandResult
}
export type InputMessage = { export type InputMessage = {
type: "input" type: "input"
id: string id: string
@ -53,3 +64,16 @@ export type SessionUpdateMessage = {
type: "session:update" type: "session:update"
data: Record<string, string> data: Record<string, string>
} }
export type GameStartMessage = {
type: "game:start"
id: string
data: string
}
export type StreamMessage = {
type: "stream:start" | "stream:end" | "stream:append" | "stream:replace"
id: string
session: string
data: CommandOutput
}

View File

@ -2,7 +2,7 @@ import { send as sendWs } from "./websocket"
import { sessionGet } from "./session" import { sessionGet } from "./session"
import { processExecOutput } from "./shell" import { processExecOutput } from "./shell"
import type { Child } from "hono/jsx" import type { Child } from "hono/jsx"
import type { CommandOutput, Message } from "./shared/types" import type { CommandOutput, StreamMessage } from "./shared/types"
type StreamFn = (output: Child) => Promise<void> type StreamFn = (output: Child) => Promise<void>
type StreamFns = { replace: StreamFn, append: StreamFn } type StreamFns = { replace: StreamFn, append: StreamFn }
@ -16,7 +16,7 @@ export async function stream(initOrFn: StreamParamFn | string | any, fn?: Stream
const taskId = state.taskId const taskId = state.taskId
const session = state.sessionId const session = state.sessionId
const send = (msg: Message) => { const send = (msg: Omit<StreamMessage, "id" | "session">) => {
sendWs(state.ws, { ...msg, id: taskId, session }) sendWs(state.ws, { ...msg, id: taskId, session })
} }