Compare commits
No commits in common. "966d14a5b1a2e10f8eb2ca51d17b66566d433c4a" and "7d7febea39b6b7c6b81abcdc3f924b959580f31f" have entirely different histories.
966d14a5b1
...
7d7febea39
|
|
@ -83,10 +83,8 @@ https://wakamaifondue.com/
|
|||
- [x] public tunnel for your NOSE webapps
|
||||
- [x] public tunnel lives through reboots
|
||||
- [ ] tunnel to the terminal
|
||||
- [x] web browser
|
||||
- [x] remember your "mode"
|
||||
- [x] status bar on terminal UX
|
||||
- [ ] quickly open the current webapp
|
||||
- [x] "project"-based rehaul
|
||||
- [x] self updating NOSE server
|
||||
- [x] `pub/` static hosting in webapps
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Reboot the whole computer! Careful!
|
||||
export default async function reboot() {
|
||||
setTimeout(async () => await Bun.$`sudo reboot`, 1000)
|
||||
setTimeout(async () => await Bun.$`reboot`, 1000)
|
||||
|
||||
return {
|
||||
text: "Rebooting... This will take about 10 seconds.",
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ export default function () {
|
|||
const sec = Math.floor(ms / 1000)
|
||||
const min = Math.floor(sec / 60)
|
||||
const hrs = Math.floor(min / 60)
|
||||
const days = Math.floor(hrs / 24)
|
||||
|
||||
return `${days}d ${hrs % 24}h ${min % 60}m ${sec % 60}s`
|
||||
}
|
||||
return `${hrs}h ${min % 60}m ${sec % 60}s`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,9 +13,6 @@
|
|||
<div class="text-content">
|
||||
<h1>@defunkt</h1>
|
||||
<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 class="tile burger">
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
<html>
|
||||
<head><title>other</title></head>
|
||||
<body><h1>other</h1></body>
|
||||
</html>
|
||||
2
nose/ping/index.ts
Normal file
2
nose/ping/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export default () =>
|
||||
"pong"
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export default () =>
|
||||
<html><head><title>pong</title></head><body>pong - {Date.now()}</body></html>
|
||||
|
|
@ -4,15 +4,17 @@
|
|||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"check": "bunx tsc --noEmit",
|
||||
"dev": "env BUN_HOT=1 bun --hot src/server.tsx",
|
||||
"start": "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",
|
||||
"build": "./scripts/build.sh",
|
||||
|
||||
"runner": "env NODE_ENV=production bun run src/runner.ts",
|
||||
|
||||
"deploy": "./scripts/deploy.sh",
|
||||
"push": "./scripts/deploy.sh",
|
||||
|
||||
"remote:install": "./scripts/remote-install.sh",
|
||||
"remote:start": "./scripts/remote-start.sh",
|
||||
"remote:stop": "./scripts/remote-stop.sh",
|
||||
|
|
@ -24,4 +26,4 @@
|
|||
"dependencies": {
|
||||
"kleur": "^4.1.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,110 +0,0 @@
|
|||
// 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 }
|
||||
}, '*')
|
||||
})
|
||||
}
|
||||
})()
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
////
|
||||
// version: 7d7febe
|
||||
// version: 66c75ff
|
||||
|
||||
// src/js/dom.ts
|
||||
var content2 = $("content");
|
||||
|
|
@ -1058,25 +1058,6 @@ 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
|
||||
function initHyperlink() {
|
||||
window.addEventListener("click", handleClick);
|
||||
|
|
@ -1095,9 +1076,6 @@ async function handleClick(e) {
|
|||
e.preventDefault();
|
||||
await runCommand(href.slice(1));
|
||||
focusInput();
|
||||
} else {
|
||||
e.preventDefault();
|
||||
status(href);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1155,7 +1133,7 @@ function clearInput() {
|
|||
// src/js/vram.ts
|
||||
var vramCounter = $("vram-size");
|
||||
var startVramCounter = () => {
|
||||
const timer2 = setInterval(() => {
|
||||
const timer = setInterval(() => {
|
||||
const count = parseInt(vramCounter.textContent) + 1;
|
||||
let val = count + "KB";
|
||||
if (count < 10)
|
||||
|
|
@ -1163,7 +1141,7 @@ var startVramCounter = () => {
|
|||
vramCounter.textContent = val;
|
||||
if (count >= 64) {
|
||||
vramCounter.textContent += " OK";
|
||||
clearInterval(timer2);
|
||||
clearInterval(timer);
|
||||
}
|
||||
}, 15);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,66 +0,0 @@
|
|||
: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;
|
||||
}
|
||||
|
|
@ -16,8 +16,6 @@
|
|||
--purple: #7C3AED;
|
||||
--blue: #1565C0;
|
||||
--magenta: #ff66cc;
|
||||
--gray: #BEBEBE;
|
||||
--grey: #BEBEBE;
|
||||
|
||||
--c64-light-blue: #6C6FF6;
|
||||
--c64-dark-blue: #40318D;
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@
|
|||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
#statusbar {
|
||||
#statusline {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
|
|
@ -177,24 +177,7 @@
|
|||
justify-content: space-between;
|
||||
}
|
||||
|
||||
#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 {
|
||||
#statusline a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
|
@ -11,15 +11,13 @@ export async function dispatchMessage(ws: any, msg: Message) {
|
|||
console.log("<- receive", msg)
|
||||
switch (msg.type) {
|
||||
case "input":
|
||||
await inputMessage(ws, msg); break
|
||||
await inputMessage(ws, msg as InputMessage); break
|
||||
|
||||
case "save-file":
|
||||
await saveFileMessage(ws, msg); break
|
||||
await saveFileMessage(ws, msg as SaveFileMessage); break
|
||||
|
||||
case "session:update":
|
||||
for (const key of Object.keys(msg.data))
|
||||
setState(key, msg.data[key]);
|
||||
break
|
||||
case "ui:mode":
|
||||
setState("ui:mode", msg.data); break
|
||||
|
||||
default:
|
||||
send(ws, { type: "error", data: `unknown message: ${msg.type}` })
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
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>
|
||||
</>
|
||||
)
|
||||
|
|
@ -1,13 +1,9 @@
|
|||
import type { FC } from "hono/jsx"
|
||||
import { BrowserControls } from "./browser/controls"
|
||||
|
||||
export const Terminal: FC = async () => (
|
||||
<>
|
||||
<link rel="stylesheet" href="/css/terminal.css" />
|
||||
<link rel="stylesheet" href="/css/editor.css" />
|
||||
<link rel="stylesheet" href="/css/browser.css" />
|
||||
|
||||
<BrowserControls />
|
||||
|
||||
<div id="command-line">
|
||||
<span id="command-prompt">></span>
|
||||
|
|
@ -39,9 +35,6 @@ export const Terminal: FC = async () => (
|
|||
<li class="center">VRAM <span id="vram-size">000KB</span></li>
|
||||
</ul>
|
||||
|
||||
<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>
|
||||
<div id="statusline"><div><a href="#projects" id="project-name">root</a>: <a href="#ls" id="project-cwd">/</a></div></div>
|
||||
</>
|
||||
)
|
||||
|
|
@ -1,178 +0,0 @@
|
|||
////
|
||||
// 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}`)
|
||||
}
|
||||
|
|
@ -2,17 +2,16 @@
|
|||
// temporary hack for browser commands
|
||||
|
||||
import type { CommandOutput } from "../shared/types"
|
||||
import { openBrowser } from "./browser"
|
||||
import { scrollback, content } from "./dom"
|
||||
import { focusInput } from "./focus"
|
||||
import { resize } from "./resize"
|
||||
import { autoScroll } from "./scrollback"
|
||||
import { sessionId } from "./session"
|
||||
import { send } from "./websocket"
|
||||
import { focusInput } from "./focus"
|
||||
|
||||
export const commands: string[] = []
|
||||
|
||||
export const browserCommands: Record<string, (...args: string[]) => void | Promise<void> | CommandOutput> = {
|
||||
browse: (url: string) => openBrowser(url, "command"),
|
||||
"browser-session": () => sessionId,
|
||||
clear: () => scrollback.innerHTML = "",
|
||||
commands: () => {
|
||||
|
|
@ -22,12 +21,13 @@ export const browserCommands: Record<string, (...args: string[]) => void | Promi
|
|||
mode: (mode?: string) => {
|
||||
if (!mode) {
|
||||
mode = document.body.dataset.mode === "tall" ? "cinema" : "tall"
|
||||
send({ type: "session:update", data: { "ui:mode": mode } })
|
||||
send({ type: "ui:mode", data: mode })
|
||||
}
|
||||
|
||||
content.style.display = ""
|
||||
document.body.dataset.mode = mode
|
||||
resize()
|
||||
autoScroll()
|
||||
focusInput()
|
||||
},
|
||||
reload: () => window.location.reload(),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import type { SaveFileMessage } from "../shared/types"
|
||||
import { scrollback } from "./dom"
|
||||
import { send } from "./websocket"
|
||||
import { focusInput } from "./focus"
|
||||
|
|
@ -50,10 +49,10 @@ function keydownHandler(e: KeyboardEvent) {
|
|||
} else if ((e.ctrlKey && e.key === "s") || (e.ctrlKey && e.key === "Enter")) {
|
||||
e.preventDefault()
|
||||
send({
|
||||
id: editor.dataset.path || "/tmp.txt",
|
||||
id: editor.dataset.path,
|
||||
type: "save-file",
|
||||
data: editor.value
|
||||
} as SaveFileMessage)
|
||||
})
|
||||
} else if (e.key === "{") {
|
||||
if (editor.selectionStart !== editor.selectionEnd) {
|
||||
insertAroundSelection(editor, '{', '}')
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { GameStartMessage } from "../shared/types"
|
||||
import type { Message } from "../shared/types"
|
||||
import { GameContext, type InputState } from "../shared/game"
|
||||
import { focusInput } from "./focus"
|
||||
import { $$ } from "./dom"
|
||||
|
|
@ -9,7 +9,7 @@ import { sessionId } from "./session"
|
|||
|
||||
const FPS = 30
|
||||
const HEIGHT = 540
|
||||
const WIDTH = 960
|
||||
const WIDTH = 980
|
||||
|
||||
type Game = { init?: () => void, update?: (delta: number, input: InputState) => void, draw?: (ctx: GameContext) => void }
|
||||
|
||||
|
|
@ -29,7 +29,7 @@ let pressed: InputState = {
|
|||
justReleased: new Set(),
|
||||
}
|
||||
|
||||
export async function handleGameStart(msg: GameStartMessage) {
|
||||
export async function handleGameStart(msg: Message) {
|
||||
const msgId = msg.id as string
|
||||
const name = msg.data as string
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,5 @@
|
|||
////
|
||||
// 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 { focusInput } from "./focus"
|
||||
import { openBrowser, isBrowsing } from "./browser"
|
||||
|
||||
export function initHyperlink() {
|
||||
window.addEventListener("click", handleClick)
|
||||
|
|
@ -21,12 +16,9 @@ async function handleClick(e: MouseEvent) {
|
|||
const href = a.getAttribute("href")
|
||||
if (!href) return
|
||||
|
||||
if (href.startsWith("#") && href.length > 1) {
|
||||
if (href.startsWith("#")) {
|
||||
e.preventDefault()
|
||||
await runCommand(href.slice(1))
|
||||
focusInput()
|
||||
} else if (!isBrowsing()) {
|
||||
e.preventDefault()
|
||||
openBrowser(href)
|
||||
}
|
||||
}
|
||||
|
|
@ -7,26 +7,25 @@ import { scrollback, cmdInput, $$ } from "./dom"
|
|||
import { randomId } from "../shared/utils"
|
||||
|
||||
type InputStatus = "waiting" | "streaming" | "ok" | "error"
|
||||
const statusColors = {
|
||||
waiting: "yellow",
|
||||
streaming: "purple",
|
||||
ok: "green",
|
||||
error: "red"
|
||||
}
|
||||
|
||||
export function initScrollback() {
|
||||
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) {
|
||||
scrollback.append(node)
|
||||
}
|
||||
|
||||
export function addInput(id: string, input: string, status?: InputStatus) {
|
||||
export function addInput(id: string, input: string) {
|
||||
const parent = $$("li.input")
|
||||
const statusSpan = $$(`span.status.${statusColors[status || "waiting"]}`, "•")
|
||||
const status = $$("span.status.yellow", "•")
|
||||
const content = $$("span.content", input)
|
||||
parent.append(statusSpan, content)
|
||||
parent.append(status, content)
|
||||
parent.dataset.id = id
|
||||
|
||||
insert(parent)
|
||||
|
|
@ -37,8 +36,15 @@ export function setStatus(id: string, status: InputStatus) {
|
|||
const statusEl = document.querySelector(`[data-id="${id}"].input .status`)
|
||||
if (!statusEl) return
|
||||
|
||||
statusEl.classList.remove(...Object.values(statusColors))
|
||||
statusEl.classList.add(statusColors[status])
|
||||
const colors = {
|
||||
waiting: "yellow",
|
||||
streaming: "purple",
|
||||
ok: "green",
|
||||
error: "red"
|
||||
}
|
||||
|
||||
statusEl.classList.remove(...Object.values(colors))
|
||||
statusEl.classList.add(colors[status])
|
||||
}
|
||||
|
||||
export function addOutput(id: string, output: CommandOutput) {
|
||||
|
|
@ -59,6 +65,8 @@ export function addOutput(id: string, output: CommandOutput) {
|
|||
} else {
|
||||
insert(item)
|
||||
}
|
||||
|
||||
autoScroll()
|
||||
}
|
||||
|
||||
export function addErrorMessage(message: string) {
|
||||
|
|
@ -79,6 +87,8 @@ export function appendOutput(id: string, output: CommandOutput) {
|
|||
item.innerHTML += content
|
||||
else
|
||||
item.textContent += content
|
||||
|
||||
autoScroll()
|
||||
}
|
||||
|
||||
export function replaceOutput(id: string, output: CommandOutput) {
|
||||
|
|
@ -95,6 +105,8 @@ export function replaceOutput(id: string, output: CommandOutput) {
|
|||
item.innerHTML = content
|
||||
else
|
||||
item.textContent = content
|
||||
|
||||
autoScroll()
|
||||
}
|
||||
|
||||
function processOutput(output: CommandOutput): ["html" | "text", string] {
|
||||
|
|
@ -133,7 +145,6 @@ function handleInputClick(e: MouseEvent) {
|
|||
|
||||
export function handleOutput(msg: Message) {
|
||||
const result = msg.data as CommandResult
|
||||
const id = "id" in msg ? msg.id || "" : ""
|
||||
setStatus(id, result.status)
|
||||
addOutput(id, result.output)
|
||||
setStatus(msg.id!, result.status)
|
||||
addOutput(msg.id!, result.output)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
////
|
||||
// The shell runs on the server and processes input, returning output.
|
||||
|
||||
import type { InputMessage } from "../shared/types"
|
||||
import { addInput, setStatus, addOutput } from "./scrollback"
|
||||
import { send } from "./websocket"
|
||||
import { randomId } from "../shared/utils"
|
||||
|
|
@ -28,7 +27,7 @@ export async function runCommand(input: string) {
|
|||
if (result) addOutput(id, result)
|
||||
setStatus(id, "ok")
|
||||
} else {
|
||||
send({ id, type: "input", data: input } as InputMessage)
|
||||
send({ id, type: "input", data: input })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,27 +0,0 @@
|
|||
////
|
||||
// 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 = ""
|
||||
}
|
||||
|
|
@ -1,25 +1,25 @@
|
|||
import type { StreamMessage } from "@/shared/types"
|
||||
import type { Message, CommandOutput } from "@/shared/types"
|
||||
import { addOutput, appendOutput, replaceOutput } from "./scrollback"
|
||||
|
||||
export function handleStreamStart(msg: StreamMessage) {
|
||||
const id = msg.id
|
||||
export function handleStreamStart(msg: Message) {
|
||||
const id = msg.id!
|
||||
|
||||
const status = document.querySelector(`[data-id="${id}"].input .status`)
|
||||
if (!status) return
|
||||
|
||||
addOutput(id, msg.data)
|
||||
addOutput(id, msg.data as CommandOutput)
|
||||
|
||||
status.classList.remove("yellow")
|
||||
status.classList.add("purple")
|
||||
}
|
||||
|
||||
export function handleStreamAppend(msg: StreamMessage) {
|
||||
appendOutput(msg.id, msg.data)
|
||||
export function handleStreamAppend(msg: Message) {
|
||||
appendOutput(msg.id!, msg.data as CommandOutput)
|
||||
}
|
||||
|
||||
export function handleStreamReplace(msg: StreamMessage) {
|
||||
replaceOutput(msg.id, msg.data)
|
||||
export function handleStreamReplace(msg: Message) {
|
||||
replaceOutput(msg.id!, msg.data as CommandOutput)
|
||||
}
|
||||
|
||||
export function handleStreamEnd(_msg: StreamMessage) {
|
||||
export function handleStreamEnd(_msg: Message) {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { addErrorMessage } from "./scrollback"
|
|||
const MAX_RETRIES = 5
|
||||
let retries = 0
|
||||
let connected = false
|
||||
let msgQueue: Omit<Message, "session">[] = []
|
||||
let msgQueue: Message[] = []
|
||||
|
||||
let ws: WebSocket | null = null
|
||||
|
||||
|
|
@ -30,15 +30,14 @@ export function startConnection() {
|
|||
}
|
||||
|
||||
// send any message
|
||||
export function send(msg: Omit<Message, "session">) {
|
||||
export function send(msg: Message) {
|
||||
if (!connected) {
|
||||
msgQueue.push(msg)
|
||||
startConnection()
|
||||
return
|
||||
}
|
||||
|
||||
if (!(msg as any).session) (msg as any).session = sessionId
|
||||
|
||||
if (!msg.session) msg.session = sessionId
|
||||
ws?.readyState === 1 && ws.send(JSON.stringify(msg))
|
||||
console.log("-> send", msg)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,39 +78,6 @@ app.on("GET", ["/js/:path{.+}", "/shared/:path{.+}"], async c => {
|
|||
// 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) => {
|
||||
const url = new URL(c.req.url)
|
||||
const localhost = url.hostname.endsWith("localhost")
|
||||
|
|
|
|||
|
|
@ -1,13 +1,18 @@
|
|||
export type Message =
|
||||
| ErrorMessage
|
||||
export type Message = {
|
||||
session?: string
|
||||
id?: string
|
||||
type: MessageType
|
||||
data?: CommandResult | CommandOutput
|
||||
}
|
||||
| InputMessage
|
||||
| OutputMessage
|
||||
| SaveFileMessage
|
||||
| SessionStartMessage
|
||||
| SessionUpdateMessage
|
||||
| GameStartMessage
|
||||
| StreamMessage
|
||||
| CommandsMessage
|
||||
|
||||
export type MessageType = "error" | "input" | "output" | "commands" | "save-file"
|
||||
| "game:start"
|
||||
| "stream:start" | "stream:end" | "stream:append" | "stream:replace"
|
||||
| "ui:mode"
|
||||
|
||||
export type CommandOutput = string | string[]
|
||||
| { text: string, script?: string }
|
||||
|
|
@ -20,22 +25,6 @@ export type CommandResult = {
|
|||
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 = {
|
||||
type: "input"
|
||||
id: string
|
||||
|
|
@ -64,16 +53,3 @@ export type SessionUpdateMessage = {
|
|||
type: "session:update"
|
||||
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
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import { send as sendWs } from "./websocket"
|
|||
import { sessionGet } from "./session"
|
||||
import { processExecOutput } from "./shell"
|
||||
import type { Child } from "hono/jsx"
|
||||
import type { CommandOutput, StreamMessage } from "./shared/types"
|
||||
import type { CommandOutput, Message } from "./shared/types"
|
||||
|
||||
type StreamFn = (output: Child) => Promise<void>
|
||||
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 session = state.sessionId
|
||||
|
||||
const send = (msg: Omit<StreamMessage, "id" | "session">) => {
|
||||
const send = (msg: Message) => {
|
||||
sendWs(state.ws, { ...msg, id: taskId, session })
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user