Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac18f65094 | |||
| a7b5c13d4b | |||
| 6c273feda7 | |||
| 35fa7b5111 | |||
| d9da2a5b85 | |||
| 1859700f71 | |||
| 6a2c1609cd | |||
| ea1ab6b2ce | |||
| 1696391441 | |||
| 541e0e1fe8 |
@@ -21,3 +21,6 @@ bin/
|
||||
# Frontend
|
||||
node_modules/
|
||||
bun.lockb
|
||||
webterm/static/js/terminal.js
|
||||
|
||||
.bkit/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Project Structure & Module Organization
|
||||
|
||||
`cmd/webterm/` contains the CLI entrypoint. Core server, session, Docker, replay, screenshot, and static-serving code lives in `webterm/`. Shared internal helpers live in `internal/`. Frontend terminal code is in `webterm/static/js/terminal.ts`, with the bundled output committed as `webterm/static/js/terminal.js`. Static assets such as fonts, icons, and WASM files live under `webterm/static/`. Documentation and reference media live in `docs/`.
|
||||
`cmd/webterm/` contains the CLI entrypoint. Core server, session, Docker, replay, screenshot, and static-serving code lives in `webterm/`. Shared internal helpers live in `internal/`. Frontend terminal code is in `webterm/static/js/terminal.ts`, which bundles to generated output at `webterm/static/js/terminal.js`. Static assets such as fonts, icons, and WASM files live under `webterm/static/`. Documentation and reference media live in `docs/`.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
|
||||
@@ -18,12 +18,16 @@
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
|
||||
Use `gofmt` for Go formatting; run `make format` before submitting Go-heavy changes. Follow existing Go naming: exported identifiers use `CamelCase`, internal helpers use `camelCase`, and tests live beside source files. TypeScript in `webterm/static/js/` uses strict mode, 2-space indentation, and direct DOM-oriented code rather than framework abstractions. Keep generated bundles in sync with source changes.
|
||||
Use `gofmt` for Go formatting; run `make format` before submitting Go-heavy changes. Follow existing Go naming: exported identifiers use `CamelCase`, internal helpers use `camelCase`, and tests live beside source files. TypeScript in `webterm/static/js/` uses strict mode, 2-space indentation, and direct DOM-oriented code rather than framework abstractions. Rebuild generated bundles when frontend source changes.
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
Go tests use the standard `testing` package. Name tests `TestXxx` and fuzz tests `FuzzXxx`; keep them next to the code they validate. Prefer focused unit tests for `webterm/` and `internal/` changes. Run `make test` for normal work and `make check` before opening a PR. Frontend changes should at minimum pass `bun run typecheck` via `make build`.
|
||||
|
||||
## Debugging Rule
|
||||
|
||||
Before making a second patch for the same bug, identify the exact callsite that causes the side effect. Trace the real owner of the behavior end-to-end, including library or dependency code when needed, instead of only patching app-level symptoms.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
Recent history uses short imperative subjects, sometimes with prefixes such as `feat:`, `fix:`, and `deps:`. Keep commits focused, e.g. `fix: restore websocket reconnect on hidden-tab resume`. PRs should explain user-visible behavior, note test coverage, and include screenshots or recordings for terminal/UI changes. Link related issues when applicable.
|
||||
|
||||
@@ -54,3 +54,7 @@ Run `go run ./cmd/webterm` to start the server on port 8080.
|
||||
- The `SessionConnector` interface decouples session I/O from the WebSocket layer
|
||||
- Docker integration uses raw HTTP against the Docker socket (no Docker SDK dependency)
|
||||
- Version is read from the `VERSION` file and injected via `-ldflags` at build time
|
||||
|
||||
## Debugging Rule
|
||||
|
||||
Before making a second patch for the same bug, identify the exact callsite that causes the side effect. Trace the real owner of the behavior end-to-end, including dependency code such as `ghostty-web` when necessary, instead of stacking app-level symptom fixes.
|
||||
|
||||
@@ -57,7 +57,7 @@ bundle-watch: node_modules ## Watch mode for frontend development
|
||||
@test -f $(GHOSTTY_WASM) || bun run copy-wasm
|
||||
bun run watch
|
||||
|
||||
build-go: ## Build Go CLI binary
|
||||
build-go: build-fast ## Build Go CLI binary
|
||||
cd $(GO_DIR) && mkdir -p bin && go build -ldflags "$(GO_VERSION_LDFLAGS)" -o ./bin/webterm ./cmd/webterm
|
||||
|
||||
clean: ## Remove coverage artifacts
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
# Deferred: Ghostty mobile long-press copy
|
||||
|
||||
## Summary
|
||||
|
||||
Mobile long-press copy for highlighted terminal text should be implemented in Ghostty ownership layer, not in `webterm/static/js/terminal.ts`.
|
||||
|
||||
## Note
|
||||
|
||||
- Selection, copy, context menu, and touch-selection semantics are owned by `ghostty-web`, mainly `lib/selection-manager.ts`.
|
||||
- Wrapper-level gesture handling in `webterm/static/js/terminal.ts` is not correct long-term fix for “long press highlighted text to copy”.
|
||||
- Future work should patch Ghostty selection manager directly so long-press copy uses Ghostty’s real selection state and copy path.
|
||||
|
||||
# Bug: Render loop dies silently on uncaught exception
|
||||
|
||||
## Summary
|
||||
|
||||
@@ -35,23 +35,24 @@ go install github.com/rcarmo/webterm/cmd/webterm@latest
|
||||
```bash
|
||||
git clone https://github.com/rcarmo/webterm.git
|
||||
cd webterm
|
||||
mkdir -p bin
|
||||
go build -o ./bin/webterm ./cmd/webterm
|
||||
make build-go
|
||||
```
|
||||
|
||||
The command above produces `bin/webterm`; you can also build it from repo root with `make build-go`.
|
||||
That produces `bin/webterm` and rebuilds generated frontend assets first.
|
||||
|
||||
## Quick start
|
||||
|
||||
Run a default shell session:
|
||||
|
||||
```bash
|
||||
make build-fast
|
||||
go run ./cmd/webterm
|
||||
```
|
||||
|
||||
Run a specific command:
|
||||
|
||||
```bash
|
||||
make build-fast
|
||||
go run ./cmd/webterm -- htop
|
||||
```
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
"name": "webterm-frontend",
|
||||
"dependencies": {
|
||||
"ghostty-web": "github:rcarmo/ghostty-web#fcc47d423a7fce1c02c702b6464d0b1ab89175f1",
|
||||
"simple-keyboard": "^3.8.141",
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.0",
|
||||
@@ -16,8 +15,6 @@
|
||||
"packages": {
|
||||
"ghostty-web": ["ghostty-web@github:rcarmo/ghostty-web#fcc47d4", {}, "rcarmo-ghostty-web-fcc47d4", "sha512-tq0cFciI32VTyOXDoLHQQDndeA6jhFuZ/3TWYx3VlYDzRhYkWAtTBi6t29isYPzdiKNIWggjkn3Ve/+Qub/wBg=="],
|
||||
|
||||
"simple-keyboard": ["simple-keyboard@3.8.141", "", {}, "sha512-5kS9RUYk89alam3XsLzOeLHtPMO0km3Lt1d92+Z4P/mhBqomXanFkMDnb6A4EUHA+v3/dbH/IcBT/85l1rnTiA=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
`webterm` is a Go HTTP/WebSocket server that hosts one or more terminal sessions and renders screenshot/telemetry surfaces for a dashboard UI.
|
||||
|
||||
```
|
||||
Browser (terminal.js + ghostty-vt.wasm)
|
||||
Browser (generated terminal.js + ghostty-vt.wasm)
|
||||
│
|
||||
│ WS / HTTP / SSE
|
||||
▼
|
||||
@@ -42,7 +42,7 @@ webterm/server.go (LocalServer)
|
||||
Assets live in `webterm/static`:
|
||||
|
||||
- `js/terminal.ts` source
|
||||
- `js/terminal.js` bundled client
|
||||
- `js/terminal.js` generated bundled client
|
||||
- `js/ghostty-vt.wasm`
|
||||
- `monospace.css`, icons, `manifest.json`
|
||||
|
||||
|
||||
+3
-3
@@ -5,9 +5,9 @@
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "bun run typecheck && bun build webterm/static/js/terminal.ts --outfile=webterm/static/js/terminal.js --minify --target=browser && cp node_modules/ghostty-web/ghostty-vt.wasm webterm/static/js/",
|
||||
"build:fast": "bun build webterm/static/js/terminal.ts --outfile=webterm/static/js/terminal.js --minify --target=browser",
|
||||
"watch": "bun build webterm/static/js/terminal.ts --outfile=webterm/static/js/terminal.js --watch --target=browser",
|
||||
"build": "bun run typecheck && bun build webterm/static/js/terminal.ts --outfile=webterm/static/js/terminal.js --minify --target=browser --define __WEBTERM_BUILD_VERSION__=\\\"$(cat VERSION 2>/dev/null || echo dev)\\\" && cp node_modules/ghostty-web/ghostty-vt.wasm webterm/static/js/",
|
||||
"build:fast": "bun build webterm/static/js/terminal.ts --outfile=webterm/static/js/terminal.js --minify --target=browser --define __WEBTERM_BUILD_VERSION__=\\\"$(cat VERSION 2>/dev/null || echo dev)\\\"",
|
||||
"watch": "bun build webterm/static/js/terminal.ts --outfile=webterm/static/js/terminal.js --watch --target=browser --define __WEBTERM_BUILD_VERSION__=\\\"$(cat VERSION 2>/dev/null || echo dev)\\\"",
|
||||
"typecheck": "bun x tsc --noEmit -p tsconfig.json",
|
||||
"copy-wasm": "cp node_modules/ghostty-web/ghostty-vt.wasm webterm/static/js/"
|
||||
},
|
||||
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
NGINX_CONF="${NGINX_CONF:-/etc/nginx/nginx.conf}"
|
||||
LLM_PROXY_PATH="${LLM_PROXY_PATH:-/llm/}"
|
||||
LLM_UPSTREAM="${LLM_UPSTREAM:-http://127.0.0.1:11435/}"
|
||||
|
||||
if [[ ! -f "$NGINX_CONF" ]]; then
|
||||
echo "nginx config not found: $NGINX_CONF" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "run as root: sudo $0" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
backup_path="${NGINX_CONF}.webterm-llm-$(date +%Y%m%d-%H%M%S).bak"
|
||||
cp "$NGINX_CONF" "$backup_path"
|
||||
echo "backup created: $backup_path"
|
||||
|
||||
python3 - "$NGINX_CONF" "$LLM_PROXY_PATH" "$LLM_UPSTREAM" <<'PY'
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
config_path = Path(sys.argv[1])
|
||||
location_path = sys.argv[2]
|
||||
upstream = sys.argv[3]
|
||||
text = config_path.read_text()
|
||||
|
||||
if f"location {location_path}" in text:
|
||||
print(f"proxy location already present: {location_path}")
|
||||
raise SystemExit(0)
|
||||
|
||||
target = """ location / {\n if ($valid_origin = "0") { return 403; }\n proxy_pass http://127.0.0.1:8080;\n proxy_http_version 1.1;\n proxy_set_header Upgrade $http_upgrade;\n proxy_set_header Connection "upgrade";\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n }\n"""
|
||||
|
||||
replacement = f""" location {location_path} {{\n if ($valid_origin = "0") {{ return 403; }}\n proxy_pass {upstream};\n proxy_http_version 1.1;\n proxy_connect_timeout 30s;\n proxy_send_timeout 300s;\n proxy_read_timeout 300s;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n }}\n\n{target}"""
|
||||
|
||||
if target not in text:
|
||||
print("target webterm location block not found in nginx.conf", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
config_path.write_text(text.replace(target, replacement, 1))
|
||||
print(f"inserted proxy location {location_path} -> {upstream}")
|
||||
PY
|
||||
|
||||
nginx -t
|
||||
echo "nginx config valid"
|
||||
echo "reload when ready: sudo systemctl reload nginx"
|
||||
@@ -3,6 +3,97 @@ set -e
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
SERVICE_FILE="$HOME/.config/systemd/user/webterm.service"
|
||||
DEFAULT_USERNAME="izackp"
|
||||
DEFAULT_TTL_SECONDS="86400"
|
||||
UPDATE_AUTH_PASSWORD=0
|
||||
|
||||
if [ ! -f "$SERVICE_FILE" ]; then
|
||||
echo "Missing service file: $SERVICE_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
while getopts ":p" opt; do
|
||||
case "$opt" in
|
||||
p)
|
||||
UPDATE_AUTH_PASSWORD=1
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 [-p]" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
shift $((OPTIND - 1))
|
||||
|
||||
if [ "$UPDATE_AUTH_PASSWORD" -eq 1 ]; then
|
||||
echo "Enter password for webterm login."
|
||||
read -rsp "Password for $DEFAULT_USERNAME: " WEBTERM_PASSWORD
|
||||
echo
|
||||
if [ -z "$WEBTERM_PASSWORD" ]; then
|
||||
echo "Password cannot be empty." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
CURRENT_SECRET="$(sed -n 's/^Environment=WEBTERM_AUTH_COOKIE_SECRET=//p' "$SERVICE_FILE" | tail -n 1)"
|
||||
if [ -z "$CURRENT_SECRET" ]; then
|
||||
CURRENT_SECRET="$(openssl rand -hex 32)"
|
||||
fi
|
||||
|
||||
if [ "$UPDATE_AUTH_PASSWORD" -eq 1 ]; then
|
||||
TMP_SERVICE="$(mktemp)"
|
||||
awk '
|
||||
BEGIN {
|
||||
skip["Environment=WEBTERM_AUTH_USERNAME"]=1
|
||||
skip["Environment=WEBTERM_AUTH_PASSWORD"]=1
|
||||
skip["Environment=WEBTERM_AUTH_COOKIE_SECRET"]=1
|
||||
skip["Environment=WEBTERM_AUTH_SESSION_TTL_SECONDS"]=1
|
||||
}
|
||||
{
|
||||
for (prefix in skip) {
|
||||
if (index($0, prefix) == 1) next
|
||||
}
|
||||
print
|
||||
}
|
||||
' "$SERVICE_FILE" > "$TMP_SERVICE"
|
||||
|
||||
python3 - "$TMP_SERVICE" "$WEBTERM_PASSWORD" "$CURRENT_SECRET" "$DEFAULT_TTL_SECONDS" <<'PYEOF'
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
|
||||
path = Path(sys.argv[1])
|
||||
username = "izackp"
|
||||
password = sys.argv[2]
|
||||
secret = sys.argv[3]
|
||||
ttl = sys.argv[4]
|
||||
|
||||
lines = path.read_text().splitlines()
|
||||
insert = [
|
||||
f"Environment=WEBTERM_AUTH_USERNAME={shlex.quote(username)}",
|
||||
f"Environment=WEBTERM_AUTH_PASSWORD={shlex.quote(password)}",
|
||||
f"Environment=WEBTERM_AUTH_COOKIE_SECRET={shlex.quote(secret)}",
|
||||
f"Environment=WEBTERM_AUTH_SESSION_TTL_SECONDS={shlex.quote(ttl)}",
|
||||
]
|
||||
|
||||
out = []
|
||||
inserted = False
|
||||
for line in lines:
|
||||
out.append(line)
|
||||
if line.strip() == "[Service]":
|
||||
out.extend(insert)
|
||||
inserted = True
|
||||
|
||||
if not inserted:
|
||||
raise SystemExit("Could not find [Service] section in webterm.service")
|
||||
|
||||
path.write_text("\n".join(out) + "\n")
|
||||
PYEOF
|
||||
|
||||
mv "$TMP_SERVICE" "$SERVICE_FILE"
|
||||
fi
|
||||
|
||||
echo "Building frontend..."
|
||||
make build
|
||||
|
||||
@@ -16,6 +107,9 @@ cp bin/webterm "$tmp_target"
|
||||
chmod +x "$tmp_target"
|
||||
mv "$tmp_target" ~/go/bin/webterm
|
||||
|
||||
echo "Reloading user systemd config..."
|
||||
systemctl --user daemon-reload
|
||||
|
||||
echo "Restarting service..."
|
||||
systemctl --user restart webterm.service
|
||||
|
||||
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
package webterm
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
authCookieName = "webterm_session"
|
||||
defaultAuthUsername = "izackp"
|
||||
defaultAuthSessionTTL = 24 * time.Hour
|
||||
)
|
||||
|
||||
type authConfig struct {
|
||||
username string
|
||||
password string
|
||||
secret []byte
|
||||
sessionTTL time.Duration
|
||||
}
|
||||
|
||||
func newAuthConfig() *authConfig {
|
||||
username := strings.TrimSpace(os.Getenv(AuthUsernameEnv))
|
||||
if username == "" {
|
||||
username = defaultAuthUsername
|
||||
}
|
||||
|
||||
password := strings.TrimSpace(os.Getenv(AuthPasswordEnv))
|
||||
if password == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
sessionTTL := defaultAuthSessionTTL
|
||||
if raw := strings.TrimSpace(os.Getenv(AuthSessionTTLSecondsEnv)); raw != "" {
|
||||
if seconds, err := strconv.Atoi(raw); err == nil && seconds > 0 {
|
||||
sessionTTL = time.Duration(seconds) * time.Second
|
||||
}
|
||||
}
|
||||
|
||||
secretRaw := strings.TrimSpace(os.Getenv(AuthCookieSecretEnv))
|
||||
if secretRaw == "" {
|
||||
sum := sha256.Sum256([]byte("webterm:" + password))
|
||||
secretRaw = hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
return &authConfig{
|
||||
username: username,
|
||||
password: password,
|
||||
secret: []byte(secretRaw),
|
||||
sessionTTL: sessionTTL,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *authConfig) cookieValue(now time.Time) string {
|
||||
expiresAt := now.Add(a.sessionTTL).Unix()
|
||||
payload := fmt.Sprintf("v1:%d", expiresAt)
|
||||
mac := hmac.New(sha256.New, a.secret)
|
||||
_, _ = mac.Write([]byte(payload))
|
||||
signature := hex.EncodeToString(mac.Sum(nil))
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(payload + ":" + signature))
|
||||
}
|
||||
|
||||
func (a *authConfig) validCookieValue(value string, now time.Time) bool {
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(value)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
parts := strings.Split(string(decoded), ":")
|
||||
if len(parts) != 3 || parts[0] != "v1" {
|
||||
return false
|
||||
}
|
||||
|
||||
expiresAt, err := strconv.ParseInt(parts[1], 10, 64)
|
||||
if err != nil || now.Unix() > expiresAt {
|
||||
return false
|
||||
}
|
||||
|
||||
payload := parts[0] + ":" + parts[1]
|
||||
mac := hmac.New(sha256.New, a.secret)
|
||||
_, _ = mac.Write([]byte(payload))
|
||||
expected := hex.EncodeToString(mac.Sum(nil))
|
||||
return subtle.ConstantTimeCompare([]byte(parts[2]), []byte(expected)) == 1
|
||||
}
|
||||
|
||||
func (a *authConfig) passwordMatches(password string) bool {
|
||||
return subtle.ConstantTimeCompare([]byte(password), []byte(a.password)) == 1
|
||||
}
|
||||
|
||||
func (a *authConfig) usernameMatches(username string) bool {
|
||||
return subtle.ConstantTimeCompare([]byte(username), []byte(a.username)) == 1
|
||||
}
|
||||
|
||||
func (a *authConfig) sessionCookie(value string, r *http.Request) *http.Cookie {
|
||||
return &http.Cookie{
|
||||
Name: authCookieName,
|
||||
Value: value,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: isSecureRequest(r),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: int(a.sessionTTL / time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *authConfig) clearCookie(r *http.Request) *http.Cookie {
|
||||
return &http.Cookie{
|
||||
Name: authCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: isSecureRequest(r),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: -1,
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,17 @@ const (
|
||||
DefaultFontSize = 16
|
||||
DefaultTerminalWidth = 132
|
||||
DefaultTerminalHeight = 45
|
||||
DefaultVoiceLLMBaseURL = "/llm"
|
||||
|
||||
ScreenshotForceRedrawEnv = "WEBTERM_SCREENSHOT_FORCE_REDRAW"
|
||||
ScreenshotModeEnv = "WEBTERM_SCREENSHOT_MODE"
|
||||
VoiceLLMBaseURLEnv = "WEBTERM_VOICE_LLM_BASE_URL"
|
||||
VoiceLLMModelEnv = "WEBTERM_VOICE_LLM_MODEL"
|
||||
AuthUsernameEnv = "WEBTERM_AUTH_USERNAME"
|
||||
AuthPasswordEnv = "WEBTERM_AUTH_PASSWORD"
|
||||
AuthCookieSecretEnv = "WEBTERM_AUTH_COOKIE_SECRET"
|
||||
AuthSessionTTLSecondsEnv = "WEBTERM_AUTH_SESSION_TTL_SECONDS"
|
||||
FrontendLogDirEnv = "WEBTERM_FRONTEND_LOG_DIR"
|
||||
DockerUsernameEnv = "WEBTERM_DOCKER_USERNAME"
|
||||
DockerAutoCommandEnv = "WEBTERM_DOCKER_AUTO_COMMAND"
|
||||
DockerHostEnv = "DOCKER_HOST"
|
||||
|
||||
+210
-3
@@ -173,6 +173,7 @@ type LocalServer struct {
|
||||
fontSize int
|
||||
screenshotMode string
|
||||
staticAssetCacheBust string
|
||||
frontendLogDir string
|
||||
|
||||
sessionManager *SessionManager
|
||||
landingApps []App
|
||||
@@ -257,6 +258,7 @@ func NewLocalServer(config Config, options ServerOptions) *LocalServer {
|
||||
fontFamily: options.FontFamily,
|
||||
fontSize: fontSize,
|
||||
screenshotMode: screenshotMode,
|
||||
frontendLogDir: defaultFrontendLogDir(),
|
||||
|
||||
sessionManager: NewSessionManager(apps),
|
||||
landingApps: append([]App{}, options.LandingApps...),
|
||||
@@ -323,6 +325,95 @@ func findStaticPath() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func defaultFrontendLogDir() string {
|
||||
if p := strings.TrimSpace(os.Getenv(FrontendLogDirEnv)); p != "" {
|
||||
return p
|
||||
}
|
||||
if stateHome := strings.TrimSpace(os.Getenv("XDG_STATE_HOME")); stateHome != "" {
|
||||
return filepath.Join(stateHome, "webterm", "frontend-logs")
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" {
|
||||
return filepath.Join(home, ".local", "state", "webterm", "frontend-logs")
|
||||
}
|
||||
return filepath.Join(os.TempDir(), "webterm", "frontend-logs")
|
||||
}
|
||||
|
||||
func sanitizeFrontendLogSessionID(value string) (string, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "", false
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
b.WriteRune(r)
|
||||
case r >= 'A' && r <= 'Z':
|
||||
b.WriteRune(r)
|
||||
case r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
case r == '-', r == '_':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
if b.Len() >= 128 {
|
||||
break
|
||||
}
|
||||
}
|
||||
sanitized := b.String()
|
||||
if sanitized == "" || sanitized != value {
|
||||
return "", false
|
||||
}
|
||||
return sanitized, true
|
||||
}
|
||||
|
||||
type frontendLogRequest struct {
|
||||
SessionID string `json:"session_id"`
|
||||
Text string `json:"text"`
|
||||
FrontendVersion string `json:"frontend_version"`
|
||||
Level string `json:"level"`
|
||||
Context string `json:"context"`
|
||||
}
|
||||
|
||||
func (s *LocalServer) appendFrontendLog(sessionID, text, frontendVersion, level, context string, now time.Time) (string, error) {
|
||||
if err := os.MkdirAll(s.frontendLogDir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
filename := fmt.Sprintf("%s.log", now.Format("2006-01-02"))
|
||||
path := filepath.Join(s.frontendLogDir, filename)
|
||||
file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if frontendVersion == "" {
|
||||
frontendVersion = "unknown"
|
||||
}
|
||||
record := map[string]string{
|
||||
"timestamp": now.UTC().Format(time.RFC3339Nano),
|
||||
"session_id": sessionID,
|
||||
"text": text,
|
||||
"frontend_version": frontendVersion,
|
||||
"server_version": Version,
|
||||
}
|
||||
if strings.TrimSpace(level) != "" {
|
||||
record["level"] = strings.TrimSpace(level)
|
||||
}
|
||||
if strings.TrimSpace(context) != "" {
|
||||
record["context"] = strings.TrimSpace(context)
|
||||
}
|
||||
line, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := file.Write(append(line, '\n')); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func (s *LocalServer) markRouteActivity(routeKey string) {
|
||||
now := time.Now()
|
||||
s.mu.Lock()
|
||||
@@ -527,6 +618,62 @@ func (s *LocalServer) handleAuthLogout(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *LocalServer) handleFrontendLog(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var payload frontendLogRequest
|
||||
contentType := strings.ToLower(strings.TrimSpace(strings.Split(r.Header.Get("Content-Type"), ";")[0]))
|
||||
switch contentType {
|
||||
case "", "application/x-www-form-urlencoded", "multipart/form-data":
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
payload.SessionID = r.Form.Get("session_id")
|
||||
payload.Text = r.Form.Get("text")
|
||||
payload.FrontendVersion = r.Form.Get("frontend_version")
|
||||
payload.Level = r.Form.Get("level")
|
||||
payload.Context = r.Form.Get("context")
|
||||
default:
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&payload); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
sessionID, ok := sanitizeFrontendLogSessionID(payload.SessionID)
|
||||
if !ok {
|
||||
http.Error(w, "missing session_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
text := strings.TrimSpace(payload.Text)
|
||||
if text == "" {
|
||||
http.Error(w, "missing text", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
path, err := s.appendFrontendLog(
|
||||
sessionID,
|
||||
text,
|
||||
strings.TrimSpace(payload.FrontendVersion),
|
||||
strings.TrimSpace(payload.Level),
|
||||
strings.TrimSpace(payload.Context),
|
||||
time.Now(),
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("frontend log append failed session_id=%s err=%v", sessionID, err)
|
||||
http.Error(w, "log write failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "ok",
|
||||
"path": path,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *LocalServer) renderLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||
errorBanner := ""
|
||||
if r.URL.Query().Get("auth_error") == "1" {
|
||||
@@ -1319,6 +1466,8 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
|
||||
let searchQuery = '';
|
||||
let activeResultIndex = -1;
|
||||
let filteredResults = [];
|
||||
const clientLogSessionID = 'dashboard-' + Math.random().toString(36).slice(2, 14);
|
||||
const clientBuildVersion = %q;
|
||||
const floatingResultsEl = document.getElementById('floating-results');
|
||||
const keyIndicatorEl = document.getElementById('key-indicator');
|
||||
const thumbnailCache = {};
|
||||
@@ -1332,6 +1481,31 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
|
||||
const grid = document.getElementById('grid');
|
||||
const subtitle = document.getElementById('subtitle');
|
||||
|
||||
function clientLog(level, text, context) {
|
||||
const message = (text || '').toString().trim();
|
||||
if (!message) return;
|
||||
const payload = {
|
||||
session_id: clientLogSessionID,
|
||||
text: message,
|
||||
frontend_version: clientBuildVersion,
|
||||
level: level || 'info',
|
||||
};
|
||||
if (context) {
|
||||
try {
|
||||
payload.context = JSON.stringify(context);
|
||||
} catch (_) {
|
||||
payload.context = String(context);
|
||||
}
|
||||
}
|
||||
fetch('/api/client-log', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
keepalive: true,
|
||||
credentials: 'same-origin',
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function bellStorageKey(slug) {
|
||||
return bellStoragePrefix + slug;
|
||||
}
|
||||
@@ -1797,7 +1971,7 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
subtitle.textContent = '';
|
||||
if (dockerWatchMode) {
|
||||
console.log(tiles.length + ' container(s) found');
|
||||
clientLog('info', tiles.length + ' container(s) found', { tile_count: tiles.length });
|
||||
}
|
||||
for (const tile of tiles) {
|
||||
const card = makeTile(tile);
|
||||
@@ -1845,8 +2019,9 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
|
||||
setInterval(refreshSparklines, 30000);
|
||||
}
|
||||
</script>
|
||||
<script>if("serviceWorker"in navigator){navigator.serviceWorker.register("/sw.js").catch(function(){})}</script>
|
||||
</body>
|
||||
</html>`, string(tilesJSON), composeModeJS, dockerWatchJS, screenshotEndpoint, screenshotDownloadEndpoint, screenshotDownloadQuery, screenshotDownloadExt)
|
||||
</html>`, string(tilesJSON), composeModeJS, dockerWatchJS, screenshotEndpoint, screenshotDownloadEndpoint, screenshotDownloadQuery, screenshotDownloadExt, Version)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = io.WriteString(w, html)
|
||||
return
|
||||
@@ -1888,7 +2063,19 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
|
||||
fontFamily = "var(--webterm-mono)"
|
||||
}
|
||||
escapedFont := strings.ReplaceAll(fontFamily, `"`, """)
|
||||
dataAttrs := fmt.Sprintf(`data-session-websocket-url="%s" data-session-route-key="%s" data-session-name="%s" data-font-size="%d" data-scrollback="1000" data-theme="%s" data-font-family="%s"`, htmlAttrEscape(wsURL), htmlAttrEscape(routeKey), htmlAttrEscape(app.Name), s.fontSize, htmlAttrEscape(theme), escapedFont)
|
||||
voiceLLMBaseURL := strings.TrimSpace(os.Getenv(VoiceLLMBaseURLEnv))
|
||||
voiceLLMModel := strings.TrimSpace(os.Getenv(VoiceLLMModelEnv))
|
||||
dataAttrs := fmt.Sprintf(
|
||||
`data-session-websocket-url="%s" data-session-route-key="%s" data-session-name="%s" data-font-size="%d" data-scrollback="1000" data-theme="%s" data-font-family="%s" data-voice-llm-base-url="%s" data-voice-llm-model="%s"`,
|
||||
htmlAttrEscape(wsURL),
|
||||
htmlAttrEscape(routeKey),
|
||||
htmlAttrEscape(app.Name),
|
||||
s.fontSize,
|
||||
htmlAttrEscape(theme),
|
||||
escapedFont,
|
||||
htmlAttrEscape(voiceLLMBaseURL),
|
||||
htmlAttrEscape(voiceLLMModel),
|
||||
)
|
||||
cacheBust := "?v=" + s.staticAssetCacheBust
|
||||
page := fmt.Sprintf(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>%s</title><link rel="stylesheet" href="/static/monospace.css%s"><style>html,body{width:100%%;height:100%%}body{background:%s;margin:0;padding:0;overflow:hidden;font-family:var(--webterm-mono);display:flex;flex-direction:column;height:100vh;height:100dvh}.webterm-terminal{width:100%%;flex:1;min-height:0;display:block;overflow:hidden}</style></head><body><div id="terminal" class="webterm-terminal" %s></div><script type="module" src="/static/js/terminal.js%s"></script></body></html>`, htmlEscape(app.Name), cacheBust, themeBG, dataAttrs, cacheBust)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
@@ -1896,6 +2083,24 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, page)
|
||||
}
|
||||
|
||||
func (s *LocalServer) handleServiceWorker(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
var data []byte
|
||||
var err error
|
||||
if s.staticPath != "" {
|
||||
data, err = os.ReadFile(filepath.Join(s.staticPath, "sw.js"))
|
||||
} else {
|
||||
data, err = embeddedStaticAssets.ReadFile("static/sw.js")
|
||||
}
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
content := strings.ReplaceAll(string(data), "{{SHELL_VERSION}}", s.staticAssetCacheBust)
|
||||
_, _ = io.WriteString(w, content)
|
||||
}
|
||||
|
||||
func htmlEscape(value string) string {
|
||||
return strings.NewReplacer("&", "&", "<", "<", ">", ">").Replace(value)
|
||||
}
|
||||
@@ -1988,7 +2193,9 @@ func (s *LocalServer) Handler() http.Handler {
|
||||
mux.HandleFunc("/cpu-sparkline.svg", s.handleCPUSparkline)
|
||||
mux.HandleFunc("/events", s.handleEvents)
|
||||
mux.HandleFunc("/health", s.handleHealth)
|
||||
mux.HandleFunc("/api/client-log", s.handleFrontendLog)
|
||||
mux.HandleFunc("/tiles", s.handleTiles)
|
||||
mux.HandleFunc("/sw.js", s.handleServiceWorker)
|
||||
mux.HandleFunc("/", s.handleRoot)
|
||||
if strings.TrimSpace(s.staticPath) != "" {
|
||||
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(s.staticPath))))
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -46,6 +48,7 @@ func newServerForTests(t *testing.T, withLanding bool) (*LocalServer, *httptest.
|
||||
options.LandingApps = []App{{Name: "Shell", Slug: "shell", Command: "/bin/sh", Terminal: true}}
|
||||
}
|
||||
server := NewLocalServer(config, options)
|
||||
server.frontendLogDir = t.TempDir()
|
||||
sessions := &syncSessionMap{m: map[string]*fakeSession{}}
|
||||
server.sessionManager.SetSessionFactory(func(app App, sessionID string) Session {
|
||||
s := newFakeSession()
|
||||
@@ -128,6 +131,69 @@ func TestHealthAndTilesEndpoints(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrontendLogEndpointAppendsJSONLine(t *testing.T) {
|
||||
_, httpServer, _ := newServerForTests(t, false)
|
||||
|
||||
body := strings.NewReader(`{"session_id":"frontend-abc123","text":"button clicked","frontend_version":"1.3.39"}`)
|
||||
req, err := http.NewRequest(http.MethodPost, httpServer.URL+"/api/client-log", body)
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("post frontend log: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 200, got %d body=%q", resp.StatusCode, string(data))
|
||||
}
|
||||
|
||||
var payload map[string]string
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
logPath := payload["path"]
|
||||
if filepath.Base(logPath) != time.Now().Format("2006-01-02")+".log" {
|
||||
t.Fatalf("unexpected log path: %q", logPath)
|
||||
}
|
||||
data, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read log file: %v", err)
|
||||
}
|
||||
var record map[string]string
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(string(data))), &record); err != nil {
|
||||
t.Fatalf("decode log line: %v", err)
|
||||
}
|
||||
if record["session_id"] != "frontend-abc123" || record["text"] != "button clicked" {
|
||||
t.Fatalf("unexpected log record: %+v", record)
|
||||
}
|
||||
if record["frontend_version"] != "1.3.39" || record["server_version"] == "" {
|
||||
t.Fatalf("missing version fields: %+v", record)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrontendLogEndpointRejectsInvalidSessionID(t *testing.T) {
|
||||
_, httpServer, _ := newServerForTests(t, false)
|
||||
|
||||
body := strings.NewReader(`{"session_id":"../../bad","text":"oops"}`)
|
||||
req, err := http.NewRequest(http.MethodPost, httpServer.URL+"/api/client-log", body)
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("post frontend log: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 400, got %d body=%q", resp.StatusCode, string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSocketPingResizeAndStdin(t *testing.T) {
|
||||
server, httpServer, sessions := newServerForTests(t, false)
|
||||
wsURL := "ws" + strings.TrimPrefix(httpServer.URL, "http") + "/ws/shell"
|
||||
@@ -588,6 +654,26 @@ func TestRootTerminalPageAndSparklineValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootTerminalPageIncludesVoiceLLMConfig(t *testing.T) {
|
||||
t.Setenv(VoiceLLMBaseURLEnv, "http://cachy.lan:11434")
|
||||
t.Setenv(VoiceLLMModelEnv, "llama-cleanup")
|
||||
|
||||
_, httpServer, _ := newServerForTests(t, false)
|
||||
resp, err := http.Get(httpServer.URL + "/?route_key=shell")
|
||||
if err != nil {
|
||||
t.Fatalf("root request error = %v", err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
text := string(body)
|
||||
if !strings.Contains(text, `data-voice-llm-base-url="http://cachy.lan:11434"`) {
|
||||
t.Fatalf("expected voice LLM base URL in page attrs, got %q", text)
|
||||
}
|
||||
if !strings.Contains(text, `data-voice-llm-model="llama-cleanup"`) {
|
||||
t.Fatalf("expected voice LLM model in page attrs, got %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkRouteActivityBroadcastsWithoutBlockingGlobalLock(t *testing.T) {
|
||||
server := NewLocalServer(Config{}, ServerOptions{})
|
||||
ready := make(chan string, 1)
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
declare const __WEBTERM_BUILD_VERSION__: string;
|
||||
|
||||
export type FrontendLogLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
export interface WebtermScopedLogger {
|
||||
debug: (text: string, extra?: Record<string, unknown>) => void;
|
||||
info: (text: string, extra?: Record<string, unknown>) => void;
|
||||
warn: (text: string, extra?: Record<string, unknown>) => void;
|
||||
error: (text: string, extra?: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
webtermClientLog?: (text: string, extra?: Record<string, unknown>) => Promise<void>;
|
||||
webtermClientLogSessionID?: string;
|
||||
webtermClientBuildVersion?: string;
|
||||
webtermLogs?: {
|
||||
bug: WebtermScopedLogger;
|
||||
ui: WebtermScopedLogger;
|
||||
voice: WebtermScopedLogger;
|
||||
ws: WebtermScopedLogger;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const FRONTEND_BUILD_VERSION = __WEBTERM_BUILD_VERSION__;
|
||||
|
||||
function createFrontendLogSessionID(): string {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let out = "";
|
||||
for (let i = 0; i < 24; i += 1) {
|
||||
out += chars[Math.floor(Math.random() * chars.length)];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const frontendLogSessionID = createFrontendLogSessionID();
|
||||
|
||||
function normalizeFrontendLogValue(value: unknown): unknown {
|
||||
if (value instanceof Error) {
|
||||
return {
|
||||
name: value.name,
|
||||
message: value.message,
|
||||
stack: value.stack,
|
||||
};
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => normalizeFrontendLogValue(item));
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>).map(([key, item]) => [key, normalizeFrontendLogValue(item)])
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function serializeFrontendLogContext(extra: Record<string, unknown> = {}): string {
|
||||
const entries = Object.entries(extra);
|
||||
if (entries.length === 0) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(Object.fromEntries(entries.map(([key, value]) => [key, normalizeFrontendLogValue(value)])));
|
||||
} catch {
|
||||
return String(extra);
|
||||
}
|
||||
}
|
||||
|
||||
export async function postFrontendLog(
|
||||
level: FrontendLogLevel,
|
||||
text: string,
|
||||
extra: Record<string, unknown> = {}
|
||||
): Promise<void> {
|
||||
const message = typeof text === "string" ? text.trim() : "";
|
||||
if (!message) return;
|
||||
try {
|
||||
await fetch("/api/client-log", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({
|
||||
session_id: frontendLogSessionID,
|
||||
text: message,
|
||||
frontend_version: FRONTEND_BUILD_VERSION,
|
||||
level,
|
||||
context: serializeFrontendLogContext(extra),
|
||||
}),
|
||||
keepalive: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[webterm] Failed to post client log:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function logFrontend(level: FrontendLogLevel, text: string, extra: Record<string, unknown> = {}): void {
|
||||
void postFrontendLog(level, text, extra);
|
||||
}
|
||||
|
||||
function logFrontendDebug(text: string, extra: Record<string, unknown> = {}): void {
|
||||
logFrontend("debug", text, extra);
|
||||
}
|
||||
|
||||
function logFrontendInfo(text: string, extra: Record<string, unknown> = {}): void {
|
||||
logFrontend("info", text, extra);
|
||||
}
|
||||
|
||||
function logFrontendWarn(text: string, extra: Record<string, unknown> = {}): void {
|
||||
logFrontend("warn", text, extra);
|
||||
}
|
||||
|
||||
function logFrontendError(text: string, extra: Record<string, unknown> = {}): void {
|
||||
logFrontend("error", text, extra);
|
||||
}
|
||||
|
||||
export function createScopedFrontendLogger(scope: string): WebtermScopedLogger {
|
||||
const withScope = (extra: Record<string, unknown> = {}): Record<string, unknown> => ({
|
||||
scope,
|
||||
...extra,
|
||||
});
|
||||
return {
|
||||
debug: (text, extra = {}) => logFrontendDebug(text, withScope(extra)),
|
||||
info: (text, extra = {}) => logFrontendInfo(text, withScope(extra)),
|
||||
warn: (text, extra = {}) => logFrontendWarn(text, withScope(extra)),
|
||||
error: (text, extra = {}) => logFrontendError(text, withScope(extra)),
|
||||
};
|
||||
}
|
||||
|
||||
export const bugLog = createScopedFrontendLogger("bug");
|
||||
export const uiLog = createScopedFrontendLogger("ui");
|
||||
export const voiceLog = createScopedFrontendLogger("voice");
|
||||
export const wsLog = createScopedFrontendLogger("ws");
|
||||
|
||||
window.webtermClientLog = (text: string, extra: Record<string, unknown> = {}) =>
|
||||
postFrontendLog("info", text, extra);
|
||||
window.webtermClientLogSessionID = frontendLogSessionID;
|
||||
window.webtermClientBuildVersion = FRONTEND_BUILD_VERSION;
|
||||
window.webtermLogs = {
|
||||
bug: bugLog,
|
||||
ui: uiLog,
|
||||
voice: voiceLog,
|
||||
ws: wsLog,
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
const sharedScriptLoads = new Map<string, Promise<void>>();
|
||||
|
||||
/** Shared TextDecoder (stateless for UTF-8, safe to share) */
|
||||
export const sharedTextDecoder = new TextDecoder();
|
||||
|
||||
/** Get WASM path based on script location */
|
||||
export function getWasmPath(): string {
|
||||
const scripts = document.querySelectorAll('script[src*="terminal.js"]');
|
||||
if (scripts.length > 0) {
|
||||
const scriptSrc = (scripts[0] as HTMLScriptElement).src;
|
||||
const basePath = scriptSrc.substring(0, scriptSrc.lastIndexOf("/") + 1);
|
||||
return basePath + "ghostty-vt.wasm";
|
||||
}
|
||||
return "/static/js/ghostty-vt.wasm";
|
||||
}
|
||||
|
||||
export function getStaticJSBasePath(): string {
|
||||
const scripts = document.querySelectorAll('script[src*="terminal.js"]');
|
||||
if (scripts.length > 0) {
|
||||
const scriptSrc = (scripts[0] as HTMLScriptElement).src;
|
||||
return scriptSrc.substring(0, scriptSrc.lastIndexOf("/") + 1);
|
||||
}
|
||||
return "/static/js/";
|
||||
}
|
||||
|
||||
export function loadScriptOnce(src: string): Promise<void> {
|
||||
const existing = sharedScriptLoads.get(src);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const promise = new Promise<void>((resolve, reject) => {
|
||||
const script = document.createElement("script");
|
||||
script.src = src;
|
||||
script.async = true;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => {
|
||||
sharedScriptLoads.delete(src);
|
||||
reject(new Error(`Failed to load script: ${src}`));
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
|
||||
sharedScriptLoads.set(src, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
export function formatSherpaStatus(status: string): string {
|
||||
if (!status) {
|
||||
return "";
|
||||
}
|
||||
if (status === "Running...") {
|
||||
return "Model ready";
|
||||
}
|
||||
const downloadMatch = status.match(/Downloading data... \((\d+)\/(\d+)\)/);
|
||||
if (!downloadMatch) {
|
||||
return status;
|
||||
}
|
||||
|
||||
const downloaded = BigInt(downloadMatch[1]);
|
||||
const total = BigInt(downloadMatch[2]);
|
||||
const percent =
|
||||
total === 0n ? 0 : Number((downloaded * 10_000n) / total) / 100;
|
||||
return `Downloading ${percent.toFixed(2)}%`;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { ITheme } from "ghostty-web";
|
||||
|
||||
import { uiLog } from "./frontend-log";
|
||||
import { DEFAULT_FONT_FAMILY, THEMES } from "./terminal-themes";
|
||||
|
||||
export interface TerminalConfig {
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
scrollback?: number;
|
||||
theme?: ITheme;
|
||||
}
|
||||
|
||||
/** Parse configuration from element data attributes */
|
||||
export function parseConfig(element: HTMLElement): TerminalConfig {
|
||||
const config: TerminalConfig = {};
|
||||
|
||||
if (element.dataset.fontFamily) {
|
||||
let fontFamily = element.dataset.fontFamily;
|
||||
// Resolve CSS variables - Canvas 2D context doesn't understand var(--name) syntax
|
||||
if (fontFamily.startsWith("var(")) {
|
||||
const varMatch = fontFamily.match(/var\(([^)]+)\)/);
|
||||
if (varMatch) {
|
||||
const varName = varMatch[1].trim();
|
||||
const resolved = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
|
||||
if (resolved) {
|
||||
fontFamily = resolved;
|
||||
} else {
|
||||
uiLog.warn("CSS variable not found; using default font", { varName });
|
||||
fontFamily = DEFAULT_FONT_FAMILY;
|
||||
}
|
||||
}
|
||||
}
|
||||
config.fontFamily = fontFamily;
|
||||
}
|
||||
if (element.dataset.fontSize) {
|
||||
config.fontSize = parseInt(element.dataset.fontSize, 10);
|
||||
}
|
||||
if (element.dataset.scrollback) {
|
||||
config.scrollback = parseInt(element.dataset.scrollback, 10);
|
||||
}
|
||||
if (element.dataset.theme) {
|
||||
const themeName = element.dataset.theme.toLowerCase();
|
||||
if (themeName in THEMES) {
|
||||
config.theme = THEMES[themeName];
|
||||
} else {
|
||||
try {
|
||||
config.theme = JSON.parse(element.dataset.theme) as ITheme;
|
||||
} catch (error) {
|
||||
uiLog.warn("Unknown theme configuration", { theme: element.dataset.theme, error });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
export function isMobileDevice(): boolean {
|
||||
const touchPoints = navigator.maxTouchPoints || 0;
|
||||
const coarsePointer =
|
||||
window.matchMedia?.("(pointer: coarse)").matches ||
|
||||
window.matchMedia?.("(any-pointer: coarse)").matches ||
|
||||
false;
|
||||
const isiPadDesktopMode =
|
||||
/Macintosh/i.test(navigator.userAgent) && touchPoints > 1;
|
||||
const hasTouchEvents = "ontouchstart" in window;
|
||||
|
||||
return (
|
||||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
||||
navigator.userAgent
|
||||
) ||
|
||||
isiPadDesktopMode ||
|
||||
touchPoints > 0 ||
|
||||
hasTouchEvents ||
|
||||
coarsePointer
|
||||
);
|
||||
}
|
||||
|
||||
const SHIFT_KEY_MAP: Record<string, string> = {
|
||||
"`": "~",
|
||||
"1": "!",
|
||||
"2": "@",
|
||||
"3": "#",
|
||||
"4": "$",
|
||||
"5": "%",
|
||||
"6": "^",
|
||||
"7": "&",
|
||||
"8": "*",
|
||||
"9": "(",
|
||||
"0": ")",
|
||||
"-": "_",
|
||||
"=": "+",
|
||||
"[": "{",
|
||||
"]": "}",
|
||||
"\\": "|",
|
||||
";": ":",
|
||||
"'": "\"",
|
||||
",": "<",
|
||||
".": ">",
|
||||
"/": "?",
|
||||
};
|
||||
|
||||
const CTRL_KEY_MAP: Record<string, string> = {
|
||||
"2": "@",
|
||||
"3": "[",
|
||||
"4": "\\",
|
||||
"5": "]",
|
||||
"6": "^",
|
||||
"7": "_",
|
||||
"8": "?",
|
||||
};
|
||||
|
||||
export type VirtualKeyboardActionId =
|
||||
| "mode-alpha"
|
||||
| "mode-symbol"
|
||||
| "toggle-voice";
|
||||
|
||||
export type VirtualKeyboardKey = {
|
||||
kind: "char" | "modifier" | "action" | "arrow" | "space";
|
||||
label: string;
|
||||
modifier?: "ctrl" | "alt" | "shift" | "fn";
|
||||
shiftLabel?: string;
|
||||
shiftValue?: string;
|
||||
value?: string;
|
||||
seq?: string;
|
||||
width?: number;
|
||||
actionId?: VirtualKeyboardActionId;
|
||||
};
|
||||
|
||||
export const VIRTUAL_KEYBOARD_ALPHA_LAYOUT: VirtualKeyboardKey[][] = [
|
||||
[
|
||||
{ kind: "char", label: "q", shiftLabel: "Q" },
|
||||
{ kind: "char", label: "w", shiftLabel: "W" },
|
||||
{ kind: "char", label: "e", shiftLabel: "E" },
|
||||
{ kind: "char", label: "r", shiftLabel: "R" },
|
||||
{ kind: "char", label: "t", shiftLabel: "T" },
|
||||
{ kind: "char", label: "y", shiftLabel: "Y" },
|
||||
{ kind: "char", label: "u", shiftLabel: "U" },
|
||||
{ kind: "char", label: "i", shiftLabel: "I" },
|
||||
{ kind: "char", label: "o", shiftLabel: "O" },
|
||||
{ kind: "char", label: "p", shiftLabel: "P" },
|
||||
],
|
||||
[
|
||||
{ kind: "char", label: "a", shiftLabel: "A" },
|
||||
{ kind: "char", label: "s", shiftLabel: "S" },
|
||||
{ kind: "char", label: "d", shiftLabel: "D" },
|
||||
{ kind: "char", label: "f", shiftLabel: "F" },
|
||||
{ kind: "char", label: "g", shiftLabel: "G" },
|
||||
{ kind: "char", label: "h", shiftLabel: "H" },
|
||||
{ kind: "char", label: "j", shiftLabel: "J" },
|
||||
{ kind: "char", label: "k", shiftLabel: "K" },
|
||||
{ kind: "char", label: "l", shiftLabel: "L" },
|
||||
],
|
||||
[
|
||||
{ kind: "modifier", label: "⇧", modifier: "shift" },
|
||||
{ kind: "char", label: "z", shiftLabel: "Z" },
|
||||
{ kind: "char", label: "x", shiftLabel: "X" },
|
||||
{ kind: "char", label: "c", shiftLabel: "C" },
|
||||
{ kind: "char", label: "v", shiftLabel: "V" },
|
||||
{ kind: "char", label: "b", shiftLabel: "B" },
|
||||
{ kind: "char", label: "n", shiftLabel: "N" },
|
||||
{ kind: "char", label: "m", shiftLabel: "M" },
|
||||
{ kind: "action", label: "⌫", value: "Backspace", seq: "\x7f" },
|
||||
],
|
||||
[
|
||||
{ kind: "action", label: "123", actionId: "mode-symbol", width: 1.05 },
|
||||
{ kind: "modifier", label: "Ctrl", modifier: "ctrl", width: 1.05 },
|
||||
{ kind: "modifier", label: "Alt", modifier: "alt", width: 1.05 },
|
||||
{ kind: "space", label: "␣", value: " ", width: 3.1 },
|
||||
{ kind: "action", label: "Mic", actionId: "toggle-voice", width: 1.15 },
|
||||
{ kind: "action", label: "⏎", seq: "\r", width: 1.15 },
|
||||
],
|
||||
];
|
||||
|
||||
export const VIRTUAL_KEYBOARD_SYMBOL_LAYOUT: VirtualKeyboardKey[][] = [
|
||||
[
|
||||
{ kind: "char", label: "1", value: "1" },
|
||||
{ kind: "char", label: "2", value: "2" },
|
||||
{ kind: "char", label: "3", value: "3" },
|
||||
{ kind: "char", label: "4", value: "4" },
|
||||
{ kind: "char", label: "5", value: "5" },
|
||||
{ kind: "char", label: "6", value: "6" },
|
||||
{ kind: "char", label: "7", value: "7" },
|
||||
{ kind: "char", label: "8", value: "8" },
|
||||
{ kind: "char", label: "9", value: "9" },
|
||||
{ kind: "char", label: "0", value: "0" },
|
||||
],
|
||||
[
|
||||
{ kind: "char", label: "-", value: "-" },
|
||||
{ kind: "char", label: "/", value: "/" },
|
||||
{ kind: "char", label: ":", value: ":" },
|
||||
{ kind: "char", label: ";", value: ";" },
|
||||
{ kind: "char", label: "(", value: "(" },
|
||||
{ kind: "char", label: ")", value: ")" },
|
||||
{ kind: "char", label: "$", value: "$" },
|
||||
{ kind: "char", label: "&", value: "&" },
|
||||
{ kind: "char", label: "@", value: "@" },
|
||||
{ kind: "char", label: "\"", value: "\"" },
|
||||
],
|
||||
[
|
||||
{ kind: "action", label: "Esc", seq: "\x1b", width: 1.35 },
|
||||
{ kind: "char", label: ".", value: "." },
|
||||
{ kind: "char", label: ",", value: "," },
|
||||
{ kind: "char", label: "?", value: "?" },
|
||||
{ kind: "char", label: "!", value: "!" },
|
||||
{ kind: "char", label: "'", value: "'" },
|
||||
{ kind: "char", label: "[", value: "[" },
|
||||
{ kind: "char", label: "]", value: "]" },
|
||||
{ kind: "action", label: "⌫", value: "Backspace", seq: "\x7f", width: 1.35 },
|
||||
],
|
||||
[
|
||||
{ kind: "action", label: "ABC", actionId: "mode-alpha", width: 1.1 },
|
||||
{ kind: "modifier", label: "Ctrl", modifier: "ctrl", width: 1.1 },
|
||||
{ kind: "modifier", label: "Alt", modifier: "alt", width: 1.1 },
|
||||
{ kind: "space", label: "␣", value: " ", width: 4 },
|
||||
{ kind: "action", label: "⏎", seq: "\r", width: 1.35 },
|
||||
],
|
||||
];
|
||||
|
||||
export type VirtualKeyboardKeyBounds = {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
rowIndex: number;
|
||||
colIndex: number;
|
||||
key: VirtualKeyboardKey;
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type ActiveVirtualKeyboardPress = {
|
||||
pointerId: number;
|
||||
keyIndex: number;
|
||||
key: VirtualKeyboardKeyBounds | null;
|
||||
repeatTimeout?: number;
|
||||
};
|
||||
|
||||
const FN_NORMAL_KEYS = [
|
||||
"\x1bOP",
|
||||
"\x1bOQ",
|
||||
"\x1bOR",
|
||||
"\x1bOS",
|
||||
"\x1b[15~",
|
||||
"\x1b[17~",
|
||||
"\x1b[18~",
|
||||
"\x1b[19~",
|
||||
"\x1b[20~",
|
||||
"\x1b[21~",
|
||||
];
|
||||
|
||||
const FN_SHIFT_KEYS = [
|
||||
"\x1b[23~",
|
||||
"\x1b[24~",
|
||||
"\x1b[25~",
|
||||
"\x1b[26~",
|
||||
"\x1b[28~",
|
||||
"\x1b[29~",
|
||||
"\x1b[31~",
|
||||
"\x1b[32~",
|
||||
"\x1b[33~",
|
||||
"\x1b[34~",
|
||||
];
|
||||
|
||||
function applyShiftModifier(key: string): string {
|
||||
if (key.length !== 1) {
|
||||
return key;
|
||||
}
|
||||
if (key >= "a" && key <= "z") {
|
||||
return key.toUpperCase();
|
||||
}
|
||||
return SHIFT_KEY_MAP[key] ?? key;
|
||||
}
|
||||
|
||||
export function applyCtrlModifier(key: string): string {
|
||||
if (key.length !== 1) {
|
||||
return key;
|
||||
}
|
||||
const mapped = CTRL_KEY_MAP[key] ?? key;
|
||||
if (mapped === "?") {
|
||||
return "\x7f";
|
||||
}
|
||||
const code = mapped.toUpperCase().charCodeAt(0);
|
||||
if (code >= 64 && code <= 95) {
|
||||
return String.fromCharCode(code - 64);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
export function applyFnModifier(key: string, useShift: boolean): string | null {
|
||||
if (key.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
const index = "1234567890".indexOf(key);
|
||||
if (index < 0) {
|
||||
return null;
|
||||
}
|
||||
return useShift ? FN_SHIFT_KEYS[index] : FN_NORMAL_KEYS[index];
|
||||
}
|
||||
|
||||
export function applyAltModifier(text: string): string {
|
||||
if (!text || text.startsWith("\x1b")) {
|
||||
return text;
|
||||
}
|
||||
return `\x1b${text}`;
|
||||
}
|
||||
|
||||
export function applyModifiers(
|
||||
text: string,
|
||||
useShift: boolean,
|
||||
useCtrl: boolean,
|
||||
useAlt: boolean,
|
||||
useFn: boolean
|
||||
): string {
|
||||
if (text.length !== 1) {
|
||||
return text;
|
||||
}
|
||||
if (useFn) {
|
||||
const fnApplied = applyFnModifier(text, useShift);
|
||||
if (fnApplied) {
|
||||
return useAlt ? applyAltModifier(fnApplied) : fnApplied;
|
||||
}
|
||||
}
|
||||
if (useCtrl) {
|
||||
const ctrlApplied = applyCtrlModifier(text);
|
||||
if (ctrlApplied !== text) {
|
||||
return useAlt ? applyAltModifier(ctrlApplied) : ctrlApplied;
|
||||
}
|
||||
}
|
||||
if (useShift) {
|
||||
const shifted = applyShiftModifier(text);
|
||||
return useAlt ? applyAltModifier(shifted) : shifted;
|
||||
}
|
||||
return useAlt ? applyAltModifier(text) : text;
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
import type { ITheme } from "ghostty-web";
|
||||
|
||||
/** Default font stack - prefers system monospace, falls back through programming fonts */
|
||||
export const DEFAULT_FONT_FAMILY =
|
||||
'ui-monospace, "SFMono-Regular", "FiraCode Nerd Font", "FiraMono Nerd Font", ' +
|
||||
'"Fira Code", "Roboto Mono", Menlo, Monaco, Consolas, "Liberation Mono", ' +
|
||||
'"DejaVu Sans Mono", "Courier New", monospace';
|
||||
|
||||
/** Predefined terminal themes */
|
||||
export const THEMES: Record<string, ITheme> = {
|
||||
// Tango - default theme (GNOME/xterm.js colors)
|
||||
tango: {
|
||||
background: "#000000",
|
||||
foreground: "#d3d7cf",
|
||||
cursor: "#d3d7cf",
|
||||
cursorAccent: "#000000",
|
||||
selectionBackground: "#d3d7cf",
|
||||
selectionForeground: "#000000",
|
||||
black: "#2e3436",
|
||||
red: "#cc0000",
|
||||
green: "#4e9a06",
|
||||
yellow: "#c4a000",
|
||||
blue: "#3465a4",
|
||||
magenta: "#75507b",
|
||||
cyan: "#06989a",
|
||||
white: "#d3d7cf",
|
||||
brightBlack: "#555753",
|
||||
brightRed: "#ef2929",
|
||||
brightGreen: "#8ae234",
|
||||
brightYellow: "#fce94f",
|
||||
brightBlue: "#729fcf",
|
||||
brightMagenta: "#ad7fa8",
|
||||
brightCyan: "#34e2e2",
|
||||
brightWhite: "#eeeeec",
|
||||
},
|
||||
// Classic xterm (VGA colors, pure black background)
|
||||
xterm: {
|
||||
background: "#000000",
|
||||
foreground: "#e5e5e5",
|
||||
cursor: "#e5e5e5",
|
||||
cursorAccent: "#000000",
|
||||
selectionBackground: "#e5e5e5",
|
||||
selectionForeground: "#000000",
|
||||
black: "#000000",
|
||||
red: "#cd0000",
|
||||
green: "#00cd00",
|
||||
yellow: "#cdcd00",
|
||||
blue: "#0000cd",
|
||||
magenta: "#cd00cd",
|
||||
cyan: "#00cdcd",
|
||||
white: "#e5e5e5",
|
||||
brightBlack: "#4d4d4d",
|
||||
brightRed: "#ff0000",
|
||||
brightGreen: "#00ff00",
|
||||
brightYellow: "#ffff00",
|
||||
brightBlue: "#0000ff",
|
||||
brightMagenta: "#ff00ff",
|
||||
brightCyan: "#00ffff",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
// Monokai Classic
|
||||
monokai: {
|
||||
background: "#272822",
|
||||
foreground: "#f8f8f2",
|
||||
cursor: "#f8f8f2",
|
||||
cursorAccent: "#272822",
|
||||
selectionBackground: "#75715e",
|
||||
selectionForeground: "#f8f8f2",
|
||||
black: "#272822",
|
||||
red: "#f92672",
|
||||
green: "#a6e22e",
|
||||
yellow: "#f4bf75",
|
||||
blue: "#66d9ef",
|
||||
magenta: "#ae81ff",
|
||||
cyan: "#a1efe4",
|
||||
white: "#f8f8f2",
|
||||
brightBlack: "#75715e",
|
||||
brightRed: "#f92672",
|
||||
brightGreen: "#a6e22e",
|
||||
brightYellow: "#f4bf75",
|
||||
brightBlue: "#66d9ef",
|
||||
brightMagenta: "#ae81ff",
|
||||
brightCyan: "#a1efe4",
|
||||
brightWhite: "#f9f8f5",
|
||||
},
|
||||
"monokai-pro": {
|
||||
background: "#2d2a2e",
|
||||
foreground: "#fcfcfa",
|
||||
cursor: "#fcfcfa",
|
||||
cursorAccent: "#2d2a2e",
|
||||
selectionBackground: "#5b595c",
|
||||
selectionForeground: "#fcfcfa",
|
||||
black: "#403e41",
|
||||
red: "#ff6188",
|
||||
green: "#a9dc76",
|
||||
yellow: "#ffd866",
|
||||
blue: "#78dce8",
|
||||
magenta: "#ab9df2",
|
||||
cyan: "#78dce8",
|
||||
white: "#fcfcfa",
|
||||
brightBlack: "#727072",
|
||||
brightRed: "#ff6188",
|
||||
brightGreen: "#a9dc76",
|
||||
brightYellow: "#ffd866",
|
||||
brightBlue: "#78dce8",
|
||||
brightMagenta: "#ab9df2",
|
||||
brightCyan: "#78dce8",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
ristretto: {
|
||||
background: "#2c2c2c",
|
||||
foreground: "#eeeeee",
|
||||
cursor: "#eeeeee",
|
||||
cursorAccent: "#2c2c2c",
|
||||
selectionBackground: "#7f7f7f",
|
||||
selectionForeground: "#eeeeee",
|
||||
black: "#2c2c2c",
|
||||
red: "#cdaf95",
|
||||
green: "#a8ff60",
|
||||
yellow: "#bfbb1f",
|
||||
blue: "#75a5b0",
|
||||
magenta: "#ff73fd",
|
||||
cyan: "#5ffdff",
|
||||
white: "#b9b9b9",
|
||||
brightBlack: "#545454",
|
||||
brightRed: "#fcb08f",
|
||||
brightGreen: "#a8ff60",
|
||||
brightYellow: "#fffeb7",
|
||||
brightBlue: "#a5c7ff",
|
||||
brightMagenta: "#ff9cfe",
|
||||
brightCyan: "#d5ffff",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
dark: {
|
||||
background: "#1e1e1e",
|
||||
foreground: "#d4d4d4",
|
||||
cursor: "#d4d4d4",
|
||||
cursorAccent: "#1e1e1e",
|
||||
selectionBackground: "#264f78",
|
||||
selectionForeground: "#d4d4d4",
|
||||
black: "#000000",
|
||||
red: "#cd3131",
|
||||
green: "#0dbc79",
|
||||
yellow: "#e5e510",
|
||||
blue: "#2472c8",
|
||||
magenta: "#bc3fbc",
|
||||
cyan: "#11a8cd",
|
||||
white: "#e5e5e5",
|
||||
brightBlack: "#666666",
|
||||
brightRed: "#f14c4c",
|
||||
brightGreen: "#23d18b",
|
||||
brightYellow: "#f5f543",
|
||||
brightBlue: "#3b8eea",
|
||||
brightMagenta: "#d670d6",
|
||||
brightCyan: "#29b8db",
|
||||
brightWhite: "#e5e5e5",
|
||||
},
|
||||
light: {
|
||||
background: "#ffffff",
|
||||
foreground: "#000000",
|
||||
cursor: "#000000",
|
||||
cursorAccent: "#ffffff",
|
||||
selectionBackground: "#add6ff",
|
||||
selectionForeground: "#000000",
|
||||
black: "#000000",
|
||||
red: "#cd3131",
|
||||
green: "#00bc00",
|
||||
yellow: "#949800",
|
||||
blue: "#0451a5",
|
||||
magenta: "#bc05bc",
|
||||
cyan: "#0598bc",
|
||||
white: "#555555",
|
||||
brightBlack: "#666666",
|
||||
brightRed: "#cd3131",
|
||||
brightGreen: "#14ce14",
|
||||
brightYellow: "#b5ba00",
|
||||
brightBlue: "#0451a5",
|
||||
brightMagenta: "#bc05bc",
|
||||
brightCyan: "#0598bc",
|
||||
brightWhite: "#a5a5a5",
|
||||
},
|
||||
dracula: {
|
||||
background: "#282a36",
|
||||
foreground: "#f8f8f2",
|
||||
cursor: "#f8f8f2",
|
||||
cursorAccent: "#282a36",
|
||||
selectionBackground: "#44475a",
|
||||
selectionForeground: "#f8f8f2",
|
||||
black: "#21222c",
|
||||
red: "#ff5555",
|
||||
green: "#50fa7b",
|
||||
yellow: "#f1fa8c",
|
||||
blue: "#bd93f9",
|
||||
magenta: "#ff79c6",
|
||||
cyan: "#8be9fd",
|
||||
white: "#f8f8f2",
|
||||
brightBlack: "#6272a4",
|
||||
brightRed: "#ff6e6e",
|
||||
brightGreen: "#69ff94",
|
||||
brightYellow: "#ffffa5",
|
||||
brightBlue: "#d6acff",
|
||||
brightMagenta: "#ff92df",
|
||||
brightCyan: "#a4ffff",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
catppuccin: {
|
||||
background: "#1e1e2e",
|
||||
foreground: "#cdd6f4",
|
||||
cursor: "#f5e0dc",
|
||||
cursorAccent: "#1e1e2e",
|
||||
selectionBackground: "#585b70",
|
||||
selectionForeground: "#cdd6f4",
|
||||
black: "#45475a",
|
||||
red: "#f38ba8",
|
||||
green: "#a6e3a1",
|
||||
yellow: "#f9e2af",
|
||||
blue: "#89b4fa",
|
||||
magenta: "#f5c2e7",
|
||||
cyan: "#94e2d5",
|
||||
white: "#bac2de",
|
||||
brightBlack: "#585b70",
|
||||
brightRed: "#f38ba8",
|
||||
brightGreen: "#a6e3a1",
|
||||
brightYellow: "#f9e2af",
|
||||
brightBlue: "#89b4fa",
|
||||
brightMagenta: "#f5c2e7",
|
||||
brightCyan: "#94e2d5",
|
||||
brightWhite: "#a6adc8",
|
||||
},
|
||||
nord: {
|
||||
background: "#2e3440",
|
||||
foreground: "#d8dee9",
|
||||
cursor: "#d8dee9",
|
||||
cursorAccent: "#2e3440",
|
||||
selectionBackground: "#4c566a",
|
||||
selectionForeground: "#eceff4",
|
||||
black: "#3b4252",
|
||||
red: "#bf616a",
|
||||
green: "#a3be8c",
|
||||
yellow: "#ebcb8b",
|
||||
blue: "#81a1c1",
|
||||
magenta: "#b48ead",
|
||||
cyan: "#88c0d0",
|
||||
white: "#e5e9f0",
|
||||
brightBlack: "#4c566a",
|
||||
brightRed: "#bf616a",
|
||||
brightGreen: "#a3be8c",
|
||||
brightYellow: "#ebcb8b",
|
||||
brightBlue: "#81a1c1",
|
||||
brightMagenta: "#b48ead",
|
||||
brightCyan: "#8fbcbb",
|
||||
brightWhite: "#eceff4",
|
||||
},
|
||||
gruvbox: {
|
||||
background: "#282828",
|
||||
foreground: "#ebdbb2",
|
||||
cursor: "#ebdbb2",
|
||||
cursorAccent: "#282828",
|
||||
selectionBackground: "#504945",
|
||||
selectionForeground: "#fbf1c7",
|
||||
black: "#282828",
|
||||
red: "#cc241d",
|
||||
green: "#98971a",
|
||||
yellow: "#d79921",
|
||||
blue: "#458588",
|
||||
magenta: "#b16286",
|
||||
cyan: "#689d6a",
|
||||
white: "#a89984",
|
||||
brightBlack: "#928374",
|
||||
brightRed: "#fb4934",
|
||||
brightGreen: "#b8bb26",
|
||||
brightYellow: "#fabd2f",
|
||||
brightBlue: "#83a598",
|
||||
brightMagenta: "#d3869b",
|
||||
brightCyan: "#8ec07c",
|
||||
brightWhite: "#ebdbb2",
|
||||
},
|
||||
solarized: {
|
||||
background: "#002b36",
|
||||
foreground: "#839496",
|
||||
cursor: "#93a1a1",
|
||||
cursorAccent: "#002b36",
|
||||
selectionBackground: "#073642",
|
||||
selectionForeground: "#93a1a1",
|
||||
black: "#073642",
|
||||
red: "#dc322f",
|
||||
green: "#859900",
|
||||
yellow: "#b58900",
|
||||
blue: "#268bd2",
|
||||
magenta: "#d33682",
|
||||
cyan: "#2aa198",
|
||||
white: "#eee8d5",
|
||||
brightBlack: "#002b36",
|
||||
brightRed: "#cb4b16",
|
||||
brightGreen: "#586e75",
|
||||
brightYellow: "#657b83",
|
||||
brightBlue: "#839496",
|
||||
brightMagenta: "#6c71c4",
|
||||
brightCyan: "#93a1a1",
|
||||
brightWhite: "#fdf6e3",
|
||||
},
|
||||
tokyo: {
|
||||
background: "#1a1b26",
|
||||
foreground: "#c0caf5",
|
||||
cursor: "#c0caf5",
|
||||
cursorAccent: "#1a1b26",
|
||||
selectionBackground: "#33467c",
|
||||
selectionForeground: "#c0caf5",
|
||||
black: "#15161e",
|
||||
red: "#f7768e",
|
||||
green: "#9ece6a",
|
||||
yellow: "#e0af68",
|
||||
blue: "#7aa2f7",
|
||||
magenta: "#bb9af7",
|
||||
cyan: "#7dcfff",
|
||||
white: "#a9b1d6",
|
||||
brightBlack: "#414868",
|
||||
brightRed: "#f7768e",
|
||||
brightGreen: "#9ece6a",
|
||||
brightYellow: "#e0af68",
|
||||
brightBlue: "#7aa2f7",
|
||||
brightMagenta: "#bb9af7",
|
||||
brightCyan: "#7dcfff",
|
||||
brightWhite: "#c0caf5",
|
||||
},
|
||||
miasma: {
|
||||
background: "#222222",
|
||||
foreground: "#c2c2b0",
|
||||
cursor: "#c2c2b0",
|
||||
cursorAccent: "#222222",
|
||||
selectionBackground: "#5f5f5f",
|
||||
selectionForeground: "#ffffff",
|
||||
black: "#222222",
|
||||
red: "#685742",
|
||||
green: "#5f875f",
|
||||
yellow: "#b36d43",
|
||||
blue: "#78824b",
|
||||
magenta: "#bb7744",
|
||||
cyan: "#c9a554",
|
||||
white: "#c2c2b0",
|
||||
brightBlack: "#666666",
|
||||
brightRed: "#685742",
|
||||
brightGreen: "#5f875f",
|
||||
brightYellow: "#b36d43",
|
||||
brightBlue: "#78824b",
|
||||
brightMagenta: "#bb7744",
|
||||
brightCyan: "#c9a554",
|
||||
brightWhite: "#d7d7c7",
|
||||
},
|
||||
github: {
|
||||
background: "#0d1117",
|
||||
foreground: "#c9d1d9",
|
||||
cursor: "#c9d1d9",
|
||||
cursorAccent: "#0d1117",
|
||||
selectionBackground: "#264f78",
|
||||
selectionForeground: "#c9d1d9",
|
||||
black: "#484f58",
|
||||
red: "#ff7b72",
|
||||
green: "#7ee787",
|
||||
yellow: "#d29922",
|
||||
blue: "#58a6ff",
|
||||
magenta: "#bc8cff",
|
||||
cyan: "#39c5cf",
|
||||
white: "#b1bac4",
|
||||
brightBlack: "#6e7681",
|
||||
brightRed: "#ffa198",
|
||||
brightGreen: "#56d364",
|
||||
brightYellow: "#e3b341",
|
||||
brightBlue: "#79c0ff",
|
||||
brightMagenta: "#d2a8ff",
|
||||
brightCyan: "#56d4dd",
|
||||
brightWhite: "#f0f6fc",
|
||||
},
|
||||
gotham: {
|
||||
background: "#0a0f14",
|
||||
foreground: "#98d1ce",
|
||||
cursor: "#98d1ce",
|
||||
cursorAccent: "#0a0f14",
|
||||
selectionBackground: "#1f2233",
|
||||
selectionForeground: "#98d1ce",
|
||||
black: "#0a0f14",
|
||||
red: "#c33027",
|
||||
green: "#26a98b",
|
||||
yellow: "#edb54b",
|
||||
blue: "#195465",
|
||||
magenta: "#4e5165",
|
||||
cyan: "#33859e",
|
||||
white: "#98d1ce",
|
||||
brightBlack: "#10151b",
|
||||
brightRed: "#d26939",
|
||||
brightGreen: "#081f2d",
|
||||
brightYellow: "#245361",
|
||||
brightBlue: "#093748",
|
||||
brightMagenta: "#888ba5",
|
||||
brightCyan: "#599caa",
|
||||
brightWhite: "#d3ebe9",
|
||||
},
|
||||
};
|
||||
File diff suppressed because one or more lines are too long
+1140
-1035
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,94 @@
|
||||
const SHELL_CACHE = 'webterm-shell-{{SHELL_VERSION}}';
|
||||
const MODEL_CACHE = 'webterm-models-v1';
|
||||
|
||||
const SHELL_ASSETS = [
|
||||
'/static/js/terminal.js?v={{SHELL_VERSION}}',
|
||||
'/static/monospace.css',
|
||||
'/static/fonts/FiraCodeNerdFont-Regular.ttf',
|
||||
'/static/icons/webterm-192.png',
|
||||
'/static/manifest.json',
|
||||
];
|
||||
|
||||
const MODEL_PREFIXES = [
|
||||
'/static/js/sherpa-moonshine',
|
||||
'/static/js/ghostty-vt.wasm',
|
||||
];
|
||||
|
||||
// Pre-cache shell assets; ignore network failures during install
|
||||
self.addEventListener('install', (event) => {
|
||||
self.skipWaiting();
|
||||
event.waitUntil(
|
||||
caches.open(SHELL_CACHE).then((cache) =>
|
||||
Promise.allSettled(SHELL_ASSETS.map((url) => cache.add(url)))
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
// Delete old shell caches, then claim all clients immediately
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys()
|
||||
.then((keys) =>
|
||||
Promise.all(
|
||||
keys
|
||||
.filter((k) => k.startsWith('webterm-shell-') && k !== SHELL_CACHE)
|
||||
.map((k) => caches.delete(k))
|
||||
)
|
||||
)
|
||||
.then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
function isModelAsset(pathname) {
|
||||
return MODEL_PREFIXES.some((p) => pathname.startsWith(p));
|
||||
}
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
if (event.request.method !== 'GET') return;
|
||||
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
// Model files: large and stable, cache-first forever
|
||||
if (isModelAsset(url.pathname)) {
|
||||
event.respondWith(
|
||||
caches.open(MODEL_CACHE).then(async (cache) => {
|
||||
const cached = await cache.match(event.request);
|
||||
if (cached) return cached;
|
||||
const response = await fetch(event.request);
|
||||
if (response.ok) cache.put(event.request, response.clone());
|
||||
return response;
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Static assets and navigation: stale-while-revalidate
|
||||
// Serve from cache immediately; update cache in background
|
||||
if (url.pathname.startsWith('/static/') || event.request.mode === 'navigate') {
|
||||
event.respondWith(
|
||||
caches.open(SHELL_CACHE).then(async (cache) => {
|
||||
const cached = await cache.match(event.request);
|
||||
const networkFetch = fetch(event.request)
|
||||
.then((response) => {
|
||||
if (response.ok) cache.put(event.request, response.clone());
|
||||
return response;
|
||||
})
|
||||
.catch(() => null);
|
||||
if (cached) {
|
||||
// Serve cache now; network update happens in background
|
||||
networkFetch.catch(() => {});
|
||||
return cached;
|
||||
}
|
||||
// Nothing cached yet — must wait for network
|
||||
const response = await networkFetch;
|
||||
if (response) return response;
|
||||
// Offline and nothing cached: try root as fallback for navigation
|
||||
if (event.request.mode === 'navigate') {
|
||||
const root = await cache.match('/');
|
||||
if (root) return root;
|
||||
}
|
||||
return Response.error();
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user