Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
d9ea6206c8
|
|||
|
8015f329ed
|
|||
|
4dad6075a5
|
|||
|
78b2e2a162
|
|||
|
ad09ea6c01
|
|||
|
2f2159a468
|
|||
|
a9836f28b7
|
|||
|
9ca0164d0b
|
|||
|
99f5708cbf
|
|||
|
f8d3876c68
|
@@ -0,0 +1,67 @@
|
||||
name: Build
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
paths:
|
||||
- "landing/**"
|
||||
- ".gitea/workflows/build.yaml"
|
||||
|
||||
env:
|
||||
REGISTRY: git.realmanual.ru
|
||||
IMAGE_PREFIX: ${{ gitea.repository }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
# Landing → static nginx image pushed to the Gitea registry.
|
||||
# (The macOS .dmg is built locally via `make dmg` — Tauri can't cross-compile
|
||||
# a macOS bundle on a Linux runner, and there is no self-hosted macOS runner.)
|
||||
landing:
|
||||
name: Build & push landing
|
||||
runs-on: ubuntu-22.04
|
||||
container: catthehacker/ubuntu:act-latest
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.VERSION }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Read version
|
||||
id: version
|
||||
run: echo "VERSION=$(cat ./landing/VERSION)" >> $GITHUB_OUTPUT
|
||||
- name: Log in to Gitea Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.TOKEN }}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./landing
|
||||
file: ./landing/Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/spacesh-landing:${{ steps.version.outputs.VERSION }}
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/spacesh-landing:latest
|
||||
|
||||
notify:
|
||||
name: Notify Max
|
||||
needs: landing
|
||||
if: always()
|
||||
runs-on: ubuntu-22.04
|
||||
container: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Compose & send summary
|
||||
run: |
|
||||
case "${{ needs.landing.result }}" in
|
||||
success) line="✅ spacesh-landing собран (\`${{ needs.landing.outputs.version }}\`)";;
|
||||
failure) line="❌ spacesh-landing — ошибка сборки";;
|
||||
*) line="❔ spacesh-landing — ${{ needs.landing.result }}";;
|
||||
esac
|
||||
url="${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_number }}"
|
||||
text=$(printf '**Build landing**\n\n%s\n\n[лог](%s)' "$line" "$url")
|
||||
curl -s -X POST "https://platform-api.max.ru/messages?chat_id=${{ secrets.MAX_CHAT_ID }}" \
|
||||
-H "Authorization: ${{ secrets.MAX_BOT_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg t "$text" '{text:$t,format:"markdown"}')"
|
||||
@@ -0,0 +1,183 @@
|
||||
# spacesh — лендинг (бриф + текст)
|
||||
|
||||
Домен: **spaceshell.ru** · Язык: русский · Стиль: terminal-dark · CTA: Скачать для macOS
|
||||
|
||||
---
|
||||
|
||||
## 1. Промпт для разработки
|
||||
|
||||
> Скопировать целиком в AI-билдер (v0 / Lovable / Claude) или дать фронтенд-разработчику.
|
||||
|
||||
```
|
||||
Построй одностраничный лендинг для продукта «spacesh» — терминал-воркспейс
|
||||
для параллельного запуска AI-агентов на macOS. Домен spaceshell.ru. Язык
|
||||
интерфейса — русский. Стадия — early access (pre-v1), будь честен в формулировках.
|
||||
|
||||
# Стек и поставка
|
||||
- Astro + Tailwind CSS. Один статический маршрут, деплой на любой статический хост.
|
||||
- Анимации hero — один React-остров (Astro island) или чистый CSS/Canvas; остальная
|
||||
страница — zero-JS. Цель Lighthouse: 95+ по всем осям, LCP < 1.5s.
|
||||
- Семантический HTML, корректная иерархия заголовков, prefers-reduced-motion.
|
||||
|
||||
# Позиционирование (одной фразой)
|
||||
«spacesh держит живые сессии AI-агентов в фоновом демоне — закрой окно, обнови
|
||||
приложение, словив краш: агенты продолжают работать».
|
||||
|
||||
# Визуальный язык — terminal-dark
|
||||
- Фон: #0A0D12 (база), панели #0E1116 / #11161F, границы #232A33 / #323C49.
|
||||
- Текст: #E6EDF3 (основной), #8B97A6 (вторичный), #5A6573 (приглушённый).
|
||||
- Акцент: бирюзовый неон #34D3C2 (primary), синий #4F9CF9 (secondary). Статусы:
|
||||
work #4C8DFF, wait #F2B84B, done #3FB950, error #F4544E.
|
||||
- Шрифты: JetBrains Mono (заголовки-акценты, код, лейблы, цифры) + Inter (тело).
|
||||
- Текстуры: едва заметная сетка/scanlines на фоне hero, мягкое свечение (glow) под
|
||||
акцентными элементами, скруглённые панели radius 8–14 как в самом приложении.
|
||||
- Курсор-каретка (мигающий блок ▌) как лейтмотив бренда; иконка приложения —
|
||||
тёмная плитка с промптом «>_» бирюзой (есть в app/src-tauri/icons/icon.svg).
|
||||
|
||||
# Структура секций (сверху вниз)
|
||||
1. Хедер: лого «spacesh» (mono) + nav (Возможности · Как работает · CLI · GitHub) +
|
||||
кнопка «Скачать для macOS». Sticky, прозрачный → размытие при скролле.
|
||||
2. Hero:
|
||||
- Eyebrow: «Терминал-воркспейс для AI-агентов · macOS».
|
||||
- H1 (крупно, mono-акцент в части слова): см. текст ниже.
|
||||
- Подзаголовок, две кнопки: primary «Скачать для macOS», secondary «Как это работает».
|
||||
- Микро-строка под кнопками: «macOS 13+ · Apple Silicon и Intel · открытый исходник».
|
||||
- Справа/снизу — АНИМИРОВАННЫЙ макет приложения: сетка из 3–4 терминал-панелей,
|
||||
в каждой «агент» (Claude Code, Codex, Gemini, shell) со status-кольцом; в одной
|
||||
идёт печать вывода (typewriter), кольца переключаются work→done. Ключевой момент
|
||||
анимации: окно «закрывается» (затемняется), а маленький бейдж «daemon · alive»
|
||||
продолжает гореть, затем окно возвращается и мгновенно перерисовывается из снапшота.
|
||||
3. Лента агентов: «Работает с: Claude Code · Codex · Gemini · opencode · shell».
|
||||
4. Проблема → решение (короткий контраст-блок): «GUI падает — агент умирает вместе с
|
||||
ним» → ответ spacesh.
|
||||
5. Сетка возможностей (6 карточек, terminal-dark, с mono-заголовками и иконкой):
|
||||
демон-источник истины, параллельные агенты в гриде, статусы пушем, гибридный
|
||||
терминал (поиск/снапшоты), CLI, тема/настройки. Тексты — ниже.
|
||||
6. «Как это работает» — 3 шага с мини-диаграммой (spawn → daemon владеет PTY → reattach
|
||||
из снапшота). Подпись про один Unix-сокет и length-prefixed JSON.
|
||||
7. Полоса «В планах» (roadmap, честно): внешние уведомления Telegram + MAX,
|
||||
diff-просмотр изменений агента, remote через SSH-туннель.
|
||||
8. Tech-полоса: «Rust · Tauri 2 · tokio · xterm.js · alacritty» + бюджет «< 16 мс на нажатие».
|
||||
9. Финальный CTA: крупная кнопка «Скачать для macOS» + ссылка «Исходники на GitHub».
|
||||
Опционально мини-форма email «Сообщить о релизе».
|
||||
10. Футер: spaceshell.ru, © 2026, ссылки (GitHub, Документация, Лицензия), строка
|
||||
«Сделано для тех, кто гоняет агентов пачками».
|
||||
|
||||
# Интерактив и анимации
|
||||
- Hero-терминал: печать вывода через requestAnimationFrame, мигающая каретка,
|
||||
плавная смена status-колец. Уважать prefers-reduced-motion (показывать статичный кадр).
|
||||
- Карточки возможностей: лёгкий lift + свечение границы на hover.
|
||||
- Скролл-ревилы (fade/translate, ≤ 300мс), без тяжёлых либ.
|
||||
|
||||
# SEO / мета (RU)
|
||||
- <title>spacesh — терминал-воркспейс для AI-агентов на macOS</title>
|
||||
- description: «Запускай Claude Code, Codex, Gemini и shell параллельно. Фоновый демон
|
||||
держит сессии живыми: закрыл окно — агенты работают. Скачать для macOS.»
|
||||
- canonical https://spaceshell.ru, og:title/description/image (тёмный OG 1200×630 со
|
||||
скрином сетки панелей и каретки), lang=ru, theme-color #0A0D12, favicon из иконки app.
|
||||
|
||||
# Адаптив
|
||||
- Desktop-first, но полностью отзывчиво. На мобильном hero-сетка сворачивается в
|
||||
одну панель + краткий список возможностей; кнопка CTA закреплена снизу.
|
||||
|
||||
# Не делать
|
||||
- Никаких стоковых «AI-градиентов», 3D-блобов, фейковых логотипов компаний.
|
||||
- Не обещать фич из roadmap как готовых — секция «В планах» отдельно.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Текст лендинга (готовая копирайт-копия)
|
||||
|
||||
### Хедер
|
||||
- Лого: `spacesh`
|
||||
- Меню: Возможности · Как работает · CLI · GitHub
|
||||
- Кнопка: **Скачать для macOS**
|
||||
|
||||
### Hero
|
||||
- Надстрочник: `Терминал-воркспейс для AI-агентов · macOS`
|
||||
- **H1:** Гоняй десяток AI-агентов параллельно. **Не теряй ни одного.**
|
||||
- Подзаголовок: spacesh держит живые сессии Claude Code, Codex, Gemini и shell в
|
||||
фоновом демоне. Закрыл окно, обновил приложение, словил краш — агенты продолжают работать.
|
||||
- Кнопки: **Скачать для macOS** · Как это работает
|
||||
- Микро-строка: macOS 13+ · Apple Silicon и Intel · открытый исходник · early access
|
||||
|
||||
### Лента агентов
|
||||
Работает с: **Claude Code · Codex · Gemini · opencode · shell**
|
||||
|
||||
### Проблема → решение
|
||||
**Обычный терминал привязывает агента к окну.** Закрыл вкладку, перезапустил приложение,
|
||||
упал GUI — длинная сессия агента умирает вместе с ним.
|
||||
|
||||
**spacesh разрывает эту связь.** Сессиями владеет фоновый демон, а не окно. Интерфейс —
|
||||
всего лишь вид поверх него.
|
||||
|
||||
### Возможности (6 карточек)
|
||||
|
||||
**`daemon` Демон — источник истины**
|
||||
`spaceshd` владеет живыми PTY-сессиями. GUI и CLI — тонкие клиенты поверх одного
|
||||
Unix-сокета. Убей интерфейс — агент жив. Открой заново — экран восстановится из
|
||||
снапшота за доли секунды.
|
||||
|
||||
**`grid` Параллельные агенты в одной сетке**
|
||||
Несколько агентов в раскладке-гриде: сплиты, зум панели, перетаскивание, пресеты
|
||||
(2×2, 1+2, 2×3…), воркспейсы и избранное. Один клик в GUI и `spacesh focus` из
|
||||
скрипта — одна и та же команда.
|
||||
|
||||
**`status` Статусы без догадок**
|
||||
`work · wait · done · error · idle` приходят пушем — от хуков агентов, маркеров
|
||||
OSC 133 и паттернов как запасной вариант. Кольца, бейджи, центр событий и нативные
|
||||
уведомления macOS.
|
||||
|
||||
**`search` Гибридный терминал**
|
||||
xterm.js рисует, грид alacritty в демоне анализирует. Отсюда — поиск по скроллбэку
|
||||
(⌘F) с подсветкой, извлечение последней команды и мгновенные снапшоты для reattach.
|
||||
|
||||
**`cli` CLI как первый класс**
|
||||
`spacesh status --json`, `focus`, `new-surface`, `notify` — те же команды, что и в
|
||||
интерфейсе, плюс shell-completions. Встраивай spacesh в свои пайплайны.
|
||||
|
||||
**`theme` Под себя**
|
||||
Тёмная и светлая темы, акцентные цвета, шрифт и размер терминала, дефолтный shell —
|
||||
всё хранится демоном в `config.toml` и применяется на лету ко всем окнам.
|
||||
|
||||
### Как это работает (3 шага)
|
||||
1. **Запуск.** Создаёшь воркспейс и панели — демон спавнит PTY-сессии под агентов.
|
||||
2. **Демон владеет.** Байты летают GUI ↔ демон ↔ PTY по одному сокету. Интерфейс
|
||||
состояния не хранит — только команды и события.
|
||||
3. **Reattach.** Закрыл и открыл приложение — демон отдаёт снапшот экрана, окно
|
||||
перерисовывается мгновенно, дальше идёт живой вывод.
|
||||
|
||||
### В планах
|
||||
Внешние уведомления в Telegram и MAX · diff-просмотр изменений агента · удалённая
|
||||
работа через SSH-туннель к демону.
|
||||
|
||||
### Tech
|
||||
Rust · Tauri 2 · tokio · xterm.js · alacritty — нативно и быстро. Бюджет отклика:
|
||||
меньше 16 мс на нажатие клавиши.
|
||||
|
||||
### Финальный CTA
|
||||
**Готов гонять агентов пачками?**
|
||||
Кнопка: **Скачать для macOS** · Исходники на GitHub
|
||||
(опц.) Форма: «Оставь email — сообщим о релизе»
|
||||
|
||||
### Футер
|
||||
spaceshell.ru · © 2026 spacesh · GitHub · Документация · Лицензия
|
||||
«Сделано для тех, кто запускает агентов пачками.»
|
||||
|
||||
---
|
||||
|
||||
## 3. SEO-мета (готово к вставке)
|
||||
|
||||
```html
|
||||
<html lang="ru">
|
||||
<title>spacesh — терминал-воркспейс для AI-агентов на macOS</title>
|
||||
<meta name="description" content="Запускай Claude Code, Codex, Gemini и shell параллельно. Фоновый демон держит сессии живыми: закрыл окно — агенты работают. Скачать для macOS.">
|
||||
<link rel="canonical" href="https://spaceshell.ru">
|
||||
<meta property="og:title" content="spacesh — терминал-воркспейс для AI-агентов">
|
||||
<meta property="og:description" content="Десяток AI-агентов параллельно. Демон держит сессии живыми — закрой окно, агенты работают.">
|
||||
<meta property="og:url" content="https://spaceshell.ru">
|
||||
<meta property="og:image" content="https://spaceshell.ru/og.png">
|
||||
<meta property="og:type" content="website">
|
||||
<meta name="theme-color" content="#0A0D12">
|
||||
```
|
||||
@@ -0,0 +1,80 @@
|
||||
# spacesh — local build helpers (macOS).
|
||||
# `make` or `make help` lists targets.
|
||||
|
||||
APP_DIR := app
|
||||
TAURI_TARGET := universal-apple-darwin
|
||||
DMG_DIR := $(APP_DIR)/src-tauri/target/$(TAURI_TARGET)/release/bundle/dmg
|
||||
NATIVE_DMG_DIR := $(APP_DIR)/src-tauri/target/release/bundle/dmg
|
||||
APP_VERSION := $(shell node -p "require('./$(APP_DIR)/src-tauri/tauri.conf.json').version" 2>/dev/null || echo 0.0.0)
|
||||
|
||||
LANDING_IMAGE := spacesh-landing
|
||||
LANDING_VERSION := $(shell cat landing/VERSION 2>/dev/null || echo 0.0.0)
|
||||
REGISTRY ?= git.realmanual.ru
|
||||
REPO ?= spacesh
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
.PHONY: help
|
||||
help: ## show this help
|
||||
@echo "spacesh — make targets (app v$(APP_VERSION)):"
|
||||
@grep -hE '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | \
|
||||
awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
# ---- App / DMG (macOS) ----
|
||||
|
||||
.PHONY: deps
|
||||
deps: ## install frontend deps (npm ci)
|
||||
cd $(APP_DIR) && npm ci
|
||||
|
||||
.PHONY: targets
|
||||
targets: ## add rust targets for the universal build
|
||||
rustup target add aarch64-apple-darwin x86_64-apple-darwin
|
||||
|
||||
.PHONY: dmg
|
||||
dmg: targets ## build the universal (Intel + Apple Silicon) .dmg — UNSIGNED
|
||||
cd $(APP_DIR) && npm run tauri build -- --target $(TAURI_TARGET)
|
||||
@echo "→ $(DMG_DIR)" && ls -lh $(DMG_DIR)/*.dmg
|
||||
|
||||
.PHONY: dmg-native
|
||||
dmg-native: ## build a .dmg for the current arch only (faster)
|
||||
cd $(APP_DIR) && npm run tauri build
|
||||
@ls -lh $(NATIVE_DMG_DIR)/*.dmg
|
||||
|
||||
.PHONY: dev
|
||||
dev: ## run the app in dev mode (tauri dev)
|
||||
cd $(APP_DIR) && npm run tauri dev
|
||||
|
||||
.PHONY: daemon
|
||||
daemon: ## build & run the daemon
|
||||
cargo run -p spaceshd
|
||||
|
||||
# ---- Tests ----
|
||||
|
||||
.PHONY: test
|
||||
test: ## run all tests (cargo + tsc)
|
||||
cargo test
|
||||
cd $(APP_DIR) && npx tsc --noEmit
|
||||
|
||||
# ---- Landing ----
|
||||
|
||||
.PHONY: landing-image
|
||||
landing-image: ## build the landing nginx image
|
||||
docker build -t $(LANDING_IMAGE):$(LANDING_VERSION) -t $(LANDING_IMAGE):latest landing
|
||||
|
||||
.PHONY: landing-run
|
||||
landing-run: landing-image ## serve the landing locally on http://localhost:8088
|
||||
docker run --rm -p 8088:80 $(LANDING_IMAGE):latest
|
||||
|
||||
.PHONY: landing-push
|
||||
landing-push: landing-image ## tag & push the landing image to the registry
|
||||
docker tag $(LANDING_IMAGE):latest $(REGISTRY)/$(REPO)/$(LANDING_IMAGE):$(LANDING_VERSION)
|
||||
docker tag $(LANDING_IMAGE):latest $(REGISTRY)/$(REPO)/$(LANDING_IMAGE):latest
|
||||
docker push $(REGISTRY)/$(REPO)/$(LANDING_IMAGE):$(LANDING_VERSION)
|
||||
docker push $(REGISTRY)/$(REPO)/$(LANDING_IMAGE):latest
|
||||
|
||||
# ---- Clean ----
|
||||
|
||||
.PHONY: clean
|
||||
clean: ## remove build artifacts
|
||||
cargo clean
|
||||
rm -rf $(APP_DIR)/dist
|
||||
+85
-13
@@ -2,6 +2,7 @@ use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use base64::Engine;
|
||||
@@ -18,8 +19,16 @@ use tokio::sync::{mpsc, oneshot, Mutex};
|
||||
|
||||
pub struct Bridge {
|
||||
next_id: AtomicU64,
|
||||
/// Outbound frames to the daemon.
|
||||
tx: mpsc::Sender<Envelope>,
|
||||
/// For respawning/reconnecting the daemon connection after it drops.
|
||||
app: AppHandle,
|
||||
sock: PathBuf,
|
||||
/// Bumped on every successful reconnect; lets concurrent failing requests
|
||||
/// collapse into a single reconnect (single-flight).
|
||||
gen: AtomicU64,
|
||||
/// Outbound frames to the daemon. Swapped on reconnect.
|
||||
tx: Mutex<mpsc::Sender<Envelope>>,
|
||||
/// Serializes reconnect attempts.
|
||||
reconnect_lock: Mutex<()>,
|
||||
/// Pending request id → reply slot.
|
||||
pending: Arc<Mutex<HashMap<u64, oneshot::Sender<Envelope>>>>,
|
||||
/// surface id → output channel into the webview.
|
||||
@@ -85,28 +94,91 @@ async fn ensure_daemon(sock: &PathBuf) -> Result<UnixStream> {
|
||||
anyhow::bail!("daemon spawned ({}) but did not bind {} in time", daemon.display(), sock.display())
|
||||
}
|
||||
|
||||
/// Connect (spawning the daemon if needed) and start the reader/writer tasks,
|
||||
/// returning the outbound sender. Shared `pending`/`out_channels` are reused so
|
||||
/// replies and live output keep routing across reconnects.
|
||||
async fn spawn_connection(
|
||||
sock: &PathBuf,
|
||||
app: &AppHandle,
|
||||
pending: Arc<Mutex<HashMap<u64, oneshot::Sender<Envelope>>>>,
|
||||
out_channels: Arc<Mutex<HashMap<String, Channel<Vec<u8>>>>>,
|
||||
) -> Result<mpsc::Sender<Envelope>> {
|
||||
let stream = ensure_daemon(sock).await?;
|
||||
let (read_half, write_half) = stream.into_split();
|
||||
let (tx, rx) = mpsc::channel::<Envelope>(256);
|
||||
spawn_writer(write_half, rx);
|
||||
spawn_reader(read_half, app.clone(), pending, out_channels);
|
||||
Ok(tx)
|
||||
}
|
||||
|
||||
impl Bridge {
|
||||
pub async fn connect(app: AppHandle) -> Result<Self> {
|
||||
let sock = socket_path()?;
|
||||
let stream = ensure_daemon(&sock).await?;
|
||||
let (read_half, write_half) = stream.into_split();
|
||||
|
||||
let (tx, rx) = mpsc::channel::<Envelope>(256);
|
||||
let pending: Arc<Mutex<HashMap<u64, oneshot::Sender<Envelope>>>> = Arc::default();
|
||||
let out_channels: Arc<Mutex<HashMap<String, Channel<Vec<u8>>>>> = Arc::default();
|
||||
let tx = spawn_connection(&sock, &app, pending.clone(), out_channels.clone()).await?;
|
||||
Ok(Self {
|
||||
next_id: AtomicU64::new(1),
|
||||
app,
|
||||
sock,
|
||||
gen: AtomicU64::new(0),
|
||||
tx: Mutex::new(tx),
|
||||
reconnect_lock: Mutex::new(()),
|
||||
pending,
|
||||
out_channels,
|
||||
})
|
||||
}
|
||||
|
||||
spawn_writer(write_half, rx);
|
||||
spawn_reader(read_half, app, pending.clone(), out_channels.clone());
|
||||
/// Re-establish the daemon connection. Single-flight: callers pass the `gen`
|
||||
/// they observed; if another caller already reconnected (gen advanced), this
|
||||
/// is a no-op so we never open duplicate connections.
|
||||
async fn reconnect(&self, seen_gen: u64) -> Result<()> {
|
||||
let _guard = self.reconnect_lock.lock().await;
|
||||
if self.gen.load(Ordering::Acquire) != seen_gen {
|
||||
return Ok(());
|
||||
}
|
||||
// Drop in-flight reply slots — their connection is gone; they'll error out.
|
||||
self.pending.lock().await.clear();
|
||||
let new_tx = spawn_connection(&self.sock, &self.app, self.pending.clone(), self.out_channels.clone()).await?;
|
||||
*self.tx.lock().await = new_tx;
|
||||
self.gen.fetch_add(1, Ordering::Release);
|
||||
let _ = self.app.emit("spacesh:reconnected", ());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Ok(Self { next_id: AtomicU64::new(1), tx, pending, out_channels })
|
||||
/// Send one request and await its reply with a timeout. Errors if the writer
|
||||
/// is gone, the reply slot is dropped, or no reply arrives in time.
|
||||
async fn send_once(&self, id: u64, env: Envelope) -> Result<Envelope> {
|
||||
let (reply_tx, reply_rx) = oneshot::channel();
|
||||
self.pending.lock().await.insert(id, reply_tx);
|
||||
let tx = self.tx.lock().await.clone();
|
||||
if tx.send(env).await.is_err() {
|
||||
self.pending.lock().await.remove(&id);
|
||||
anyhow::bail!("daemon writer closed");
|
||||
}
|
||||
match tokio::time::timeout(Duration::from_secs(5), reply_rx).await {
|
||||
Ok(Ok(env)) => Ok(env),
|
||||
Ok(Err(_)) => anyhow::bail!("reply slot dropped"),
|
||||
Err(_) => {
|
||||
self.pending.lock().await.remove(&id);
|
||||
anyhow::bail!("request timed out")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn request(&self, cmd: Cmd) -> Result<Envelope> {
|
||||
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
let (reply_tx, reply_rx) = oneshot::channel();
|
||||
self.pending.lock().await.insert(id, reply_tx);
|
||||
self.tx.send(Envelope::Req { id, cmd }).await?;
|
||||
Ok(reply_rx.await?)
|
||||
let seen_gen = self.gen.load(Ordering::Acquire);
|
||||
let env = Envelope::Req { id, cmd };
|
||||
match self.send_once(id, env.clone()).await {
|
||||
Ok(reply) => Ok(reply),
|
||||
Err(_) => {
|
||||
// Connection likely dropped — reconnect (respawns the daemon if
|
||||
// it exited) and retry once.
|
||||
self.reconnect(seen_gen).await?;
|
||||
self.send_once(id, env).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn register_output(&self, surface_id: String, channel: Channel<Vec<u8>>) {
|
||||
|
||||
+14
-3
@@ -37,6 +37,9 @@ export function App() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(() => loadFlag("spacesh.sidebarOpen", true));
|
||||
const [health, setHealth] = useState<DaemonHealth | null>(null);
|
||||
const [config, setConfigState] = useState<ConfigView | null>(null);
|
||||
// Bumped when the daemon connection is re-established; used to remount the
|
||||
// layout so terminals re-attach (snapshot + live stream) to the restarted daemon.
|
||||
const [connEpoch, setConnEpoch] = useState(0);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [focusedId, setFocusedId] = useState<string | null>(null);
|
||||
const [searchSurfaceId, setSearchSurfaceId] = useState<string | null>(null);
|
||||
@@ -112,7 +115,15 @@ export function App() {
|
||||
void loadHealth();
|
||||
void getConfig().then((c) => { setConfigState(c); applyTheme(c.theme, c.accent); }).catch(() => {});
|
||||
});
|
||||
return () => { void unlisten.then((f) => f()); void reconnect.then((f) => f()); };
|
||||
const reconnected = onDaemonRawEvent("spacesh:reconnected", () => {
|
||||
setConnected(true);
|
||||
setConnEpoch((n) => n + 1); // remount layout → terminals re-attach to the new daemon
|
||||
void refresh();
|
||||
void seedEvents();
|
||||
void loadHealth();
|
||||
void getConfig().then((c) => { setConfigState(c); applyTheme(c.theme, c.accent); }).catch(() => {});
|
||||
});
|
||||
return () => { void unlisten.then((f) => f()); void reconnect.then((f) => f()); void reconnected.then((f) => f()); };
|
||||
}, [refresh, seedEvents, loadHealth]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -158,7 +169,7 @@ export function App() {
|
||||
)}
|
||||
<div style={{ flex: 1, minHeight: 0, position: "relative" }}>
|
||||
{active
|
||||
? <LayoutEngine workspaceId={active.id} layout={active.layout} running={running} states={states} surfaces={active.surfaces} focusedId={effectiveFocus} onFocus={setFocusedId} zoomed={active.zoomed} searchSurfaceId={searchSurfaceId} searchNonce={searchNonce} onCloseSearch={() => setSearchSurfaceId(null)} font={termFont} palette={termPalette} />
|
||||
? <LayoutEngine key={connEpoch} workspaceId={active.id} layout={active.layout} running={running} states={states} surfaces={active.surfaces} focusedId={effectiveFocus} onFocus={setFocusedId} zoomed={active.zoomed} searchSurfaceId={searchSurfaceId} searchNonce={searchNonce} onCloseSearch={() => setSearchSurfaceId(null)} font={termFont} palette={termPalette} />
|
||||
: <div style={{ color: COLORS.textMuted, padding: 24 }}>No workspace — create one to begin.</div>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -170,7 +181,7 @@ export function App() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{settingsOpen && config && <Settings config={config} health={health} onClose={() => setSettingsOpen(false)} />}
|
||||
{settingsOpen && config && <Settings config={config} health={health} onClose={() => setSettingsOpen(false)} onReload={() => { void loadHealth(); void refresh(); }} />}
|
||||
{wizard && <Wizard onDone={(id) => { setWizard(false); setActiveId(id); void refresh(); }} onCancel={() => setWizard(false)} />}
|
||||
{deleteTarget && (
|
||||
<ConfirmDelete
|
||||
|
||||
+16
-11
@@ -1,11 +1,11 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { COLORS, FONT, ACCENTS } from "./theme";
|
||||
import { setConfig, shutdownDaemon, restartDaemon } from "./socketBridge";
|
||||
import { setConfig, restartDaemon } from "./socketBridge";
|
||||
import type { ConfigView, DaemonHealth } from "./socketBridge";
|
||||
|
||||
const FONTS = ["JetBrains Mono", "Menlo", "Monaco", "SF Mono", "Fira Code", "Cascadia Code"];
|
||||
|
||||
export function Settings({ config, health, onClose }: { config: ConfigView; health: DaemonHealth | null; onClose: () => void }) {
|
||||
export function Settings({ config, health, onClose, onReload }: { config: ConfigView; health: DaemonHealth | null; onClose: () => void; onReload: () => void }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => { ref.current?.focus(); }, []);
|
||||
|
||||
@@ -57,7 +57,7 @@ export function Settings({ config, health, onClose }: { config: ConfigView; heal
|
||||
<input value={shellLocal} onChange={(e) => setShellLocal(e.target.value)} onBlur={() => void setConfig({ default_shell: shellLocal })}
|
||||
style={{ width: "100%", padding: 8, marginBottom: 18, background: COLORS.bgPanel, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 8 }} />
|
||||
|
||||
<DaemonSection health={health} />
|
||||
<DaemonSection health={health} onReload={onReload} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -70,8 +70,14 @@ function fmtUptime(ms: number): string {
|
||||
return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`;
|
||||
}
|
||||
|
||||
function DaemonSection({ health }: { health: DaemonHealth | null }) {
|
||||
const [confirm, setConfirm] = useState<null | "stop" | "restart">(null);
|
||||
function DaemonSection({ health, onReload }: { health: DaemonHealth | null; onReload: () => void }) {
|
||||
const [confirm, setConfirm] = useState(false);
|
||||
// Tick so uptime counts up live while the modal is open.
|
||||
const [, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setTick((n) => n + 1), 1000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
return (
|
||||
<div style={{ marginTop: 8, paddingTop: 16, borderTop: `1px solid ${COLORS.borderSubtle}` }}>
|
||||
<div style={{ fontSize: 12, color: COLORS.textSecondary, marginBottom: 8 }}>Daemon</div>
|
||||
@@ -82,19 +88,18 @@ function DaemonSection({ health }: { health: DaemonHealth | null }) {
|
||||
</>) : <div>offline</div>}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8, marginTop: 12 }}>
|
||||
<button onClick={() => setConfirm("restart")} style={{ padding: "7px 14px", background: COLORS.bgElevated, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 7, fontSize: 13 }}>Restart</button>
|
||||
<button onClick={() => setConfirm("stop")} style={{ padding: "7px 14px", background: "transparent", color: COLORS.stError, border: `1px solid ${COLORS.stError}`, borderRadius: 7, fontSize: 13 }}>Stop</button>
|
||||
<button onClick={() => setConfirm(true)} style={{ padding: "7px 14px", background: COLORS.bgElevated, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 7, fontSize: 13 }}>Restart</button>
|
||||
</div>
|
||||
{confirm && (
|
||||
<div style={{ marginTop: 10, padding: 10, borderRadius: 8, background: COLORS.bgPanel, border: `1px solid ${COLORS.borderStrong}` }}>
|
||||
<div style={{ fontSize: 12, color: COLORS.textSecondary, marginBottom: 8 }}>
|
||||
{confirm === "stop" ? "Stop the daemon? All sessions end." : "Restart the daemon? Sessions end and respawn."}
|
||||
Restart the daemon? Running sessions end and respawn; panels re-attach automatically.
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
|
||||
<button onClick={() => setConfirm(null)} style={{ padding: "5px 12px", background: COLORS.bgElevated, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 6, fontSize: 12 }}>Cancel</button>
|
||||
<button onClick={() => { const c = confirm; setConfirm(null); void (c === "stop" ? shutdownDaemon() : restartDaemon()); }}
|
||||
<button onClick={() => setConfirm(false)} style={{ padding: "5px 12px", background: COLORS.bgElevated, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 6, fontSize: 12 }}>Cancel</button>
|
||||
<button onClick={() => { setConfirm(false); void restartDaemon().then(onReload); }}
|
||||
style={{ padding: "5px 12px", background: COLORS.stError, color: "#fff", border: "none", borderRadius: 6, fontSize: 12, fontWeight: 600 }}>
|
||||
{confirm === "stop" ? "Stop" : "Restart"}
|
||||
Restart
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,7 @@ export function TerminalView({ surfaceId, font, palette }: { surfaceId: string;
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const termRef = useRef<Terminal | null>(null);
|
||||
const fitRef = useRef<FitAddon | null>(null);
|
||||
const webglRef = useRef<WebglAddon | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
@@ -39,7 +40,9 @@ export function TerminalView({ surfaceId, font, palette }: { surfaceId: string;
|
||||
termRef.current = term;
|
||||
|
||||
try {
|
||||
term.loadAddon(new WebglAddon());
|
||||
const webgl = new WebglAddon();
|
||||
term.loadAddon(webgl);
|
||||
webglRef.current = webgl;
|
||||
} catch {
|
||||
// webgl unavailable → fall back to canvas/dom renderer silently
|
||||
}
|
||||
@@ -99,6 +102,7 @@ export function TerminalView({ surfaceId, font, palette }: { surfaceId: string;
|
||||
term.dispose();
|
||||
termRef.current = null;
|
||||
fitRef.current = null;
|
||||
webglRef.current = null;
|
||||
};
|
||||
}, [surfaceId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -110,6 +114,10 @@ export function TerminalView({ surfaceId, font, palette }: { surfaceId: string;
|
||||
if (font) {
|
||||
t.options.fontFamily = `'${font.family}', monospace`;
|
||||
t.options.fontSize = font.size;
|
||||
// The WebGL renderer caches rasterized glyphs in a texture atlas keyed by
|
||||
// the old font/size; without clearing it the grid keeps rendering stale
|
||||
// glyphs after a font change.
|
||||
webglRef.current?.clearTextureAtlas();
|
||||
}
|
||||
if (palette) t.options.theme = xtermTheme(palette);
|
||||
requestAnimationFrame(() => { try { fitRef.current?.fit(); } catch { /* ignore */ } });
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
VERSION
|
||||
*.md
|
||||
@@ -0,0 +1,9 @@
|
||||
# Static landing for spaceshell.ru — served by nginx.
|
||||
# Build context is ./landing (see .gitea/workflows/build.yaml).
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY index.html /usr/share/nginx/html/index.html
|
||||
COPY pics/ /usr/share/nginx/html/pics/
|
||||
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1 @@
|
||||
0.1.0
|
||||
+1188
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
gzip on;
|
||||
gzip_comp_level 6;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
|
||||
|
||||
# Long cache for fingerprint-free static assets; HTML stays revalidated.
|
||||
location ~* \.(?:css|js|png|jpe?g|gif|svg|webp|woff2?)$ {
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
access_log off;
|
||||
}
|
||||
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache";
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 651 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 279 KiB |
Reference in New Issue
Block a user