Compare commits

..

19 Commits

Author SHA1 Message Date
vasyansk 75134b6fac add deploy
Build / Build & push landing (push) Successful in 15s
Build / Deploy to prod (push) Successful in 5s
Build / Notify Max (push) Successful in 1s
2026-06-15 13:47:50 +07:00
vasyansk 09e7a2b526 Merge sidebar-rail: collapsed icon rail 2026-06-15 13:41:05 +07:00
vasyansk 5d7a80e2a2 feat(app): collapsed sidebar becomes an icon rail (keeps activity visible)
Toggling the sidebar off used to hide it entirely, losing the per-workspace
status rings. It now collapses to a 48px rail showing each workspace's
aggregate status ring (and unread dot), still clickable to switch, plus the
new-workspace button and the daemon live/offline dot. Full sidebar returns
when toggled back on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 13:41:05 +07:00
vasyansk 569aa39444 Merge events-clear-and-settings-x: clear events + settings close button 2026-06-15 13:38:35 +07:00
vasyansk f9a565a712 feat(app): clear all events from the Event Center (red trash icon)
Adds Cmd::ClearEvents + Evt::EventsCleared: the daemon drops the persistent
event log (keeping next_id monotonic), persists, and broadcasts so every
client empties its list. A red trash icon next to 'Mark all read' triggers it;
disabled when the list is empty. Threaded through proto, the daemon handler,
the Tauri bridge, and socketBridge. Includes an EventLog::clear test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 13:38:35 +07:00
vasyansk bcc88b6be7 fix(app): add a close (X) button to the settings modal
Esc and click-outside already closed it, but there was no visible affordance.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 13:38:35 +07:00
vasyansk 3dc3da072c fix(app): make the bell badge click-through (number opens the log too)
pointer-events:none lets clicks/hover pass through the unread badge to the
bell button beneath, which the badge was previously swallowing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 13:31:47 +07:00
vasyansk 897a3be659 Merge version-handshake: auto-restart stale daemon on GUI launch 2026-06-15 12:39:46 +07:00
vasyansk cf7410b46a feat(app): version handshake — GUI restarts a stale running daemon
The daemon outlives the GUI, so after an update an OLD daemon can keep serving
the socket and the new GUI just connects to it (stale code — e.g. the missing
TERM fix). Both binaries are now stamped with the git build id (build.rs):
the daemon reports it in `health.build`, and on connect the bridge compares it
to the GUI's own SPACESH_BUILD; on mismatch it shuts the daemon down and lets
ensure_daemon respawn the bundled (matching) one. No-op for unstamped dev
builds or daemons too old to report a build. Build id is shown in Settings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:39:46 +07:00
vasyansk 8f431eaa40 fix(build): clean sidecar dir each build; native-focused install/reinstall
The stale bin/spaceshd-universal-apple-darwin sidecar (left over from an
earlier approach) poisoned the universal bundle — tauri shipped that old
daemon instead of the freshly built one, so the packaged daemon lacked the
TERM fix. dmg/dmg-native now wipe the sidecar dir first. install copies the
native bundle (was preferring the universal one, which could be stale) and
kills the running daemon; reinstall = native rebuild + install for fast
self-updates. Universal stays for distribution via dmg / install-universal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:34:57 +07:00
vasyansk 79b47d42e7 build: make kill-daemon/install/reinstall — daemon survives reinstall
Rebuilding the .app never replaced the RUNNING daemon (it outlives the GUI),
so a stale spaceshd kept serving old code (e.g. the pre-TERM-fix daemon).
`make install` now stops the daemon, copies the fresh bundle to /Applications,
and clears quarantine; `make reinstall` does dmg+install in one shot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:26:49 +07:00
vasyansk df0389b38f Merge fix-double-echo: single reader across reconnects 2026-06-15 11:56:35 +07:00
vasyansk c84b96abc0 fix(app): abort the old reader on reconnect (fixes doubled keystroke echo)
reconnect() spawned a new reader/writer but left the previous reader task
running. A reconnect triggered while the old connection was still alive (e.g.
a request timing out during a slow daemon start) left TWO live connections;
the daemon broadcast Output to both, so every byte — including input echo —
arrived twice ("ccucurcurl"). The bridge now stores the reader's JoinHandle
and aborts it before establishing the new connection, guaranteeing a single
live reader.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 11:56:35 +07:00
vasyansk ee969371c9 Merge rename-and-term: workspace rename + TERM env fix 2026-06-15 11:47:21 +07:00
vasyansk 07cf7f9ed4 fix(pty): always set TERM/COLORTERM for spawned shells
A GUI/launchd-spawned daemon has no TERM in its environment, so child shells
inherited none and tput/zsh/ncurses failed ('tput: No value for $TERM').
The PTY now defaults TERM=xterm-256color and COLORTERM=truecolor (matching
xterm.js) unless the caller already provides them. Adds a regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 11:47:21 +07:00
vasyansk a929c166a3 feat(app): rename a workspace by double-clicking its name
Double-click a sidebar workspace name to edit it inline; Enter/blur commits
via setWorkspaceMeta({name}) (empty/unchanged is a no-op), Esc cancels. The
input stops pointer/key propagation so it doesn't trigger select or drag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 11:47:21 +07:00
vasyansk 99a916fed6 fix(bundle): provide per-arch spaceshd sidecars for universal build
Tauri's universal target builds each arch separately and resolves externalBin
with that arch's triple (spaceshd-aarch64-apple-darwin / -x86_64-apple-darwin),
lipo'ing them itself. The previous single -universal-apple-darwin sidecar made
the per-arch sub-build fail with 'resource path ... doesn't exist'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 11:41:57 +07:00
vasyansk 2fc58105a5 Merge bundle-daemon: package spaceshd in the macOS app 2026-06-15 11:39:03 +07:00
vasyansk a7272fc92e fix(bundle): ship spaceshd inside the .app (packaged GUI was offline)
tauri build bundled only the GUI binary, so the packaged app had no daemon:
find_daemon() looks for a sibling `spaceshd` next to the GUI
(Contents/MacOS/spaceshd) and ensure_daemon failed to spawn it → "offline".
(Dev worked only because find_daemon falls back to the repo-root target path.)

- tauri.bundle.conf.json: a build-only overlay adding bundle.externalBin
  ["bin/spaceshd"], kept out of tauri.conf.json so `tauri dev` doesn't require
  a sidecar file.
- Makefile: `make dmg` now builds spaceshd for both arches, lipo's a universal
  sidecar into src-tauri/bin/spaceshd-universal-apple-darwin, and passes
  --config so it lands in Contents/MacOS/spaceshd. `make dmg-native` does the
  host-arch equivalent.
- .gitignore: ignore the generated app/src-tauri/bin/.

After install, the unsigned helper runs once quarantine is cleared recursively:
xattr -dr com.apple.quarantine /Applications/spacesh.app

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 11:39:03 +07:00
22 changed files with 454 additions and 24 deletions
+37 -1
View File
@@ -45,9 +45,39 @@ jobs:
${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/spacesh-landing:${{ steps.version.outputs.VERSION }}
${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/spacesh-landing:latest
# Push the compose stack to the prod host and roll the landing container.
# (DMG is uploaded separately from macOS via `make deploy-dmg`.)
deploy:
name: Deploy to prod
needs: landing
runs-on: ubuntu-22.04
container: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v3
- name: Setup SSH
run: |
which ssh || (apt-get update && apt-get install -y openssh-client)
mkdir -p ~/.ssh && chmod 700 ~/.ssh
printf '%s\n' "${{ secrets.SSH_KEY }}" > ~/.ssh/id_deploy
chmod 600 ~/.ssh/id_deploy
ssh-keyscan "${{ secrets.SSH_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null || true
- name: Sync compose stack
run: |
ssh -i ~/.ssh/id_deploy "${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}" \
"mkdir -p '${{ secrets.SSH_REMOTE_DIR }}/download'"
# Pin the exact image CI just pushed so the server pulls the right path/tag.
printf 'LANDING_IMAGE=%s/%s/spacesh-landing:%s\n' \
"${{ env.REGISTRY }}" "${{ env.IMAGE_PREFIX }}" "${{ needs.landing.outputs.version }}" > .env
scp -i ~/.ssh/id_deploy deploy/docker-compose.yaml deploy/proxy.conf .env \
"${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:${{ secrets.SSH_REMOTE_DIR }}/"
- name: Pull & up
run: |
ssh -i ~/.ssh/id_deploy "${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}" \
"cd '${{ secrets.SSH_REMOTE_DIR }}' && docker compose pull && docker compose up -d"
notify:
name: Notify Max
needs: landing
needs: [landing, deploy]
if: always()
runs-on: ubuntu-22.04
container: catthehacker/ubuntu:act-latest
@@ -59,6 +89,12 @@ jobs:
failure) line="❌ spacesh-landing — ошибка сборки";;
*) line="❔ spacesh-landing — ${{ needs.landing.result }}";;
esac
case "${{ needs.deploy.result }}" in
success) dline="🚀 задеплоен на прод";;
failure) dline="❌ деплой упал";;
*) dline="❔ деплой — ${{ needs.deploy.result }}";;
esac
line="$line"$'\n'"$dline"
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 }}" \
+3
View File
@@ -3,3 +3,6 @@
node_modules/
app/dist/
.DS_Store
# Generated daemon sidecar for DMG bundling (make dmg)
app/src-tauri/bin/
+60 -2
View File
@@ -5,6 +5,11 @@ 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
NATIVE_TRIPLE := $(shell rustc -vV 2>/dev/null | awk '/^host:/{print $$2}')
SIDECAR_DIR := $(APP_DIR)/src-tauri/bin
BUNDLE_CONFIG := src-tauri/tauri.bundle.conf.json
APP_BUNDLE := $(APP_DIR)/src-tauri/target/$(TAURI_TARGET)/release/bundle/macos/spacesh.app
NATIVE_APP_BUNDLE := $(APP_DIR)/src-tauri/target/release/bundle/macos/spacesh.app
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
@@ -12,6 +17,13 @@ LANDING_VERSION := $(shell cat landing/VERSION 2>/dev/null || echo 0.0.0)
REGISTRY ?= git.realmanual.ru
REPO ?= spacesh
# ---- Prod deploy (SSH) ----
SSH_HOST ?= 192.168.8.5
SSH_USER ?= deploy
SSH_REMOTE_DIR ?= /opt/spacesh
SSH_KEY ?= $(HOME)/.ssh/id_rsa
SSH_OPTS := -i $(SSH_KEY) -o StrictHostKeyChecking=accept-new
.DEFAULT_GOAL := help
.PHONY: help
@@ -32,12 +44,23 @@ targets: ## add rust targets for the universal build
.PHONY: dmg
dmg: targets ## build the universal (Intel + Apple Silicon) .dmg — UNSIGNED
cd $(APP_DIR) && npm run tauri build -- --target $(TAURI_TARGET)
# Tauri's universal build compiles each arch separately and expects a sidecar
# named with THAT arch's triple; it lipo's them into the universal bundle and
# ships spaceshd inside spacesh.app/Contents/MacOS (else the GUI is offline).
cargo build --release -p spaceshd --target aarch64-apple-darwin
cargo build --release -p spaceshd --target x86_64-apple-darwin
rm -rf $(SIDECAR_DIR) && mkdir -p $(SIDECAR_DIR) # avoid stale sidecars poisoning the bundle
cp target/aarch64-apple-darwin/release/spaceshd $(SIDECAR_DIR)/spaceshd-aarch64-apple-darwin
cp target/x86_64-apple-darwin/release/spaceshd $(SIDECAR_DIR)/spaceshd-x86_64-apple-darwin
cd $(APP_DIR) && npm run tauri build -- --target $(TAURI_TARGET) --config $(BUNDLE_CONFIG)
@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
cargo build --release -p spaceshd
rm -rf $(SIDECAR_DIR) && mkdir -p $(SIDECAR_DIR) # avoid stale sidecars poisoning the bundle
cp target/release/spaceshd $(SIDECAR_DIR)/spaceshd-$(NATIVE_TRIPLE)
cd $(APP_DIR) && npm run tauri build -- --config $(BUNDLE_CONFIG)
@ls -lh $(NATIVE_DMG_DIR)/*.dmg
.PHONY: dev
@@ -48,6 +71,27 @@ dev: ## run the app in dev mode (tauri dev)
daemon: ## build & run the daemon
cargo run -p spaceshd
.PHONY: kill-daemon
kill-daemon: ## stop a running spaceshd so a freshly-built one takes over
-pkill -x spaceshd
-rm -f $$HOME/.spacesh/sock
.PHONY: install
install: kill-daemon ## install the native .app to /Applications, restart daemon, clear quarantine
rm -rf /Applications/spacesh.app
cp -R "$(NATIVE_APP_BUNDLE)" /Applications/
xattr -dr com.apple.quarantine /Applications/spacesh.app
@echo "Installed (native). Quit & relaunch spacesh; the bundled daemon restarts."
.PHONY: install-universal
install-universal: kill-daemon ## install the universal .app to /Applications
rm -rf /Applications/spacesh.app
cp -R "$(APP_BUNDLE)" /Applications/
xattr -dr com.apple.quarantine /Applications/spacesh.app
.PHONY: reinstall
reinstall: dmg-native install ## native rebuild + reinstall + restart daemon (fast self-update)
# ---- Tests ----
.PHONY: test
@@ -72,6 +116,20 @@ landing-push: landing-image ## tag & push the landing image to the registry
docker push $(REGISTRY)/$(REPO)/$(LANDING_IMAGE):$(LANDING_VERSION)
docker push $(REGISTRY)/$(REPO)/$(LANDING_IMAGE):latest
# ---- Prod deploy ----
.PHONY: deploy-dmg
deploy-dmg: dmg ## upload the universal .dmg to the prod download dir (stable spacesh.dmg)
ssh $(SSH_OPTS) $(SSH_USER)@$(SSH_HOST) "mkdir -p $(SSH_REMOTE_DIR)/download"
scp $(SSH_OPTS) $(DMG_DIR)/*.dmg "$(SSH_USER)@$(SSH_HOST):$(SSH_REMOTE_DIR)/download/spacesh.dmg"
@echo "Uploaded → https://spaceshell.ru/download/spacesh.dmg"
.PHONY: deploy-stack
deploy-stack: ## sync compose+proxy.conf to prod and pull/up (manual; CI does this on push)
ssh $(SSH_OPTS) $(SSH_USER)@$(SSH_HOST) "mkdir -p $(SSH_REMOTE_DIR)/download"
scp $(SSH_OPTS) deploy/docker-compose.yaml deploy/proxy.conf "$(SSH_USER)@$(SSH_HOST):$(SSH_REMOTE_DIR)/"
ssh $(SSH_OPTS) $(SSH_USER)@$(SSH_HOST) "cd $(SSH_REMOTE_DIR) && docker compose pull && docker compose up -d"
# ---- Clean ----
.PHONY: clean
+25
View File
@@ -1,3 +1,28 @@
use std::process::Command;
// Stamp the GUI with the same git build id as the bundled daemon so the bridge
// can detect and restart a stale running daemon. Matches crates/spaceshd/build.rs.
fn main() {
println!("cargo:rustc-env=SPACESH_BUILD={}", git_build());
println!("cargo:rerun-if-changed=../../.git/HEAD");
println!("cargo:rerun-if-changed=../../.git/index");
tauri_build::build()
}
fn git_build() -> String {
let sha = Command::new("git")
.args(["rev-parse", "--short=12", "HEAD"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|s| !s.is_empty());
let Some(sha) = sha else { return "dev".into() };
let dirty = Command::new("git")
.args(["status", "--porcelain"])
.output()
.ok()
.map(|o| !o.stdout.is_empty())
.unwrap_or(false);
if dirty { format!("{sha}-dirty") } else { sha }
}
+49 -9
View File
@@ -29,6 +29,9 @@ pub struct Bridge {
tx: Mutex<mpsc::Sender<Envelope>>,
/// Serializes reconnect attempts.
reconnect_lock: Mutex<()>,
/// The current reader task; aborted on reconnect so a stale connection can't
/// keep delivering duplicate output (which doubled keystroke echo).
reader: Mutex<tokio::task::JoinHandle<()>>,
/// Pending request id → reply slot.
pending: Arc<Mutex<HashMap<u64, oneshot::Sender<Envelope>>>>,
/// surface id → output channel into the webview.
@@ -102,13 +105,13 @@ async fn spawn_connection(
app: &AppHandle,
pending: Arc<Mutex<HashMap<u64, oneshot::Sender<Envelope>>>>,
out_channels: Arc<Mutex<HashMap<String, Channel<Vec<u8>>>>>,
) -> Result<mpsc::Sender<Envelope>> {
) -> Result<(mpsc::Sender<Envelope>, tokio::task::JoinHandle<()>)> {
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)
let reader = spawn_reader(read_half, app.clone(), pending, out_channels);
Ok((tx, reader))
}
impl Bridge {
@@ -116,17 +119,45 @@ impl Bridge {
let sock = socket_path()?;
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 {
let (tx, reader) = spawn_connection(&sock, &app, pending.clone(), out_channels.clone()).await?;
let bridge = Self {
next_id: AtomicU64::new(1),
app,
sock,
gen: AtomicU64::new(0),
tx: Mutex::new(tx),
reconnect_lock: Mutex::new(()),
reader: Mutex::new(reader),
pending,
out_channels,
})
};
bridge.ensure_matching_daemon().await;
Ok(bridge)
}
/// If a previously-running daemon is a different build than the one bundled
/// with this GUI (the daemon outlives the GUI, so an old one can still be
/// serving the socket), restart it so our matching daemon takes over. No-op
/// for unstamped dev builds or when the daemon is too old to report a build.
async fn ensure_matching_daemon(&self) {
let want = option_env!("SPACESH_BUILD").unwrap_or("dev");
if want == "dev" {
return;
}
let got = match self.request(Cmd::Health).await {
Ok(env) => data_of(env)
.ok()
.and_then(|v| v.get("build").and_then(|b| b.as_str()).map(str::to_string))
.unwrap_or_default(),
Err(_) => return,
};
if got.is_empty() || got == want {
return;
}
// Stale daemon — stop it and respawn our bundled one (matching code).
let seen = self.gen.load(Ordering::Acquire);
let _ = self.request(Cmd::Shutdown).await; // daemon exits; reply may not arrive
let _ = self.reconnect(seen).await; // ensure_daemon respawns the bundled daemon
}
/// Re-establish the daemon connection. Single-flight: callers pass the `gen`
@@ -139,8 +170,12 @@ impl Bridge {
}
// 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?;
// Kill the old reader FIRST so it can't keep delivering output on a stale
// connection alongside the new one (the cause of doubled keystroke echo).
self.reader.lock().await.abort();
let (new_tx, new_reader) = spawn_connection(&self.sock, &self.app, self.pending.clone(), self.out_channels.clone()).await?;
*self.tx.lock().await = new_tx;
*self.reader.lock().await = new_reader;
self.gen.fetch_add(1, Ordering::Release);
let _ = self.app.emit("spacesh:reconnected", ());
Ok(())
@@ -205,7 +240,7 @@ fn spawn_reader(
app: AppHandle,
pending: Arc<Mutex<HashMap<u64, oneshot::Sender<Envelope>>>>,
out_channels: Arc<Mutex<HashMap<String, Channel<Vec<u8>>>>>,
) {
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
loop {
match read_frame(&mut read_half).await {
@@ -232,7 +267,7 @@ fn spawn_reader(
}
}
}
});
})
}
// ---- Tauri commands ----
@@ -395,6 +430,11 @@ pub async fn mark_read(state: BridgeState<'_>, target: Value) -> Result<Value, S
data_of(state.request(Cmd::MarkRead { target }).await.map_err(|e| e.to_string())?)
}
#[tauri::command]
pub async fn clear_events(state: BridgeState<'_>) -> Result<Value, String> {
data_of(state.request(Cmd::ClearEvents).await.map_err(|e| e.to_string())?)
}
#[tauri::command]
pub async fn health(state: BridgeState<'_>) -> Result<Value, String> {
data_of(state.request(Cmd::Health).await.map_err(|e| e.to_string())?)
+1
View File
@@ -52,6 +52,7 @@ pub fn run() {
bridge::set_zoom,
bridge::event_log,
bridge::mark_read,
bridge::clear_events,
bridge::health,
bridge::get_config,
bridge::set_config,
+6
View File
@@ -0,0 +1,6 @@
{
"$schema": "https://schema.tauri.app/config/2",
"bundle": {
"externalBin": ["bin/spaceshd"]
}
}
+5 -2
View File
@@ -9,7 +9,7 @@ import { Settings } from "./Settings";
import { EventCenter } from "./EventCenter";
import { maybeNotify } from "./notify";
import { COLORS, applyTheme, resolvePalette } from "./theme";
import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent, setWorkspaceMeta, focusSurface, getEventLog, markEventsRead, getHealth, closeWorkspaceCmd, getConfig } from "./socketBridge";
import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent, setWorkspaceMeta, focusSurface, getEventLog, markEventsRead, clearEvents, getHealth, closeWorkspaceCmd, getConfig } from "./socketBridge";
import type { EventRecord, DaemonHealth, ConfigView } from "./socketBridge";
import { leafIds } from "./layoutTypes";
import type { Group, WorkspaceView, SurfaceState } from "./layoutTypes";
@@ -95,6 +95,8 @@ export function App() {
} else if (evt.evt === "events_read") {
const ids = new Set(evt.data.ids);
setEvents((es) => es.map((e) => (ids.has(e.id) ? { ...e, read: true } : e)));
} else if (evt.evt === "events_cleared") {
setEvents([]);
} else if (evt.evt === "state") {
setStates((m) => ({ ...m, [evt.data.surface_id]: evt.data.state }));
void refresh();
@@ -162,7 +164,7 @@ export function App() {
<div style={{ display: "flex", flexDirection: "column", height: "100vh", background: COLORS.bgApp }}>
<TopBar active={active} eventsOpen={eventsOpen} onToggleEvents={() => setEventsOpen((v) => !v)} onShowEvents={() => setEventsOpen(true)} sidebarOpen={sidebarOpen} onToggleSidebar={() => setSidebarOpen((v) => !v)} unread={unread} onOpenSettings={() => { if (config) setSettingsOpen(true); }} />
<div style={{ flex: 1, display: "flex", minHeight: 0 }}>
{sidebarOpen && <Sidebar groups={groups} workspaces={workspaces} activeId={activeId} onSelect={selectWorkspace} onNew={() => setWizard(true)} onDelete={setDeleteTarget} health={health} connected={connected} />}
<Sidebar railMode={!sidebarOpen} groups={groups} workspaces={workspaces} activeId={activeId} onSelect={selectWorkspace} onNew={() => setWizard(true)} onDelete={setDeleteTarget} health={health} connected={connected} />
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
{active && (
<CenterToolbar selected="" onSelect={(p) => { if (active) void applyPreset(active.id, p, []); }} onOpenSearch={() => { if (effectiveFocus) { setSearchSurfaceId(effectiveFocus); setSearchNonce((n) => n + 1); } }} />
@@ -177,6 +179,7 @@ export function App() {
<EventCenter
events={events}
onMarkAllRead={() => { void markEventsRead({ target: "all" }); }}
onClear={() => { void clearEvents(); }}
onSelect={(sid, id) => { void focusSurface(sid); void markEventsRead({ target: "ids", value: [id] }); }}
/>
)}
+11 -3
View File
@@ -1,5 +1,5 @@
import { useState } from "react";
import { Check, Hourglass, X, Power, Send, MessageSquare } from "lucide-react";
import { Check, Hourglass, X, Power, Send, MessageSquare, Trash2 } from "lucide-react";
import { COLORS, FONT } from "./theme";
import type { EventRecord } from "./socketBridge";
@@ -26,10 +26,11 @@ function rel(ts: number): string {
}
export function EventCenter({
events, onMarkAllRead, onSelect,
events, onMarkAllRead, onClear, onSelect,
}: {
events: EventRecord[];
onMarkAllRead: () => void;
onClear: () => void;
onSelect: (surfaceId: string, id: number) => void;
}) {
const [tab, setTab] = useState<Tab>("all");
@@ -41,7 +42,14 @@ export function EventCenter({
<div style={{ display: "flex", flexDirection: "column", width: 300, flex: "0 0 300px", background: COLORS.bgSidebar, height: "100%", padding: 14, boxSizing: "border-box", borderLeft: `1px solid ${COLORS.borderSubtle}` }}>
<div style={{ display: "flex", alignItems: "center", marginBottom: 12 }}>
<span style={{ fontFamily: FONT.ui, fontSize: 13, fontWeight: 700, color: COLORS.textPrimary, flex: 1 }}>Event Center</span>
<span onClick={onMarkAllRead} style={{ fontFamily: FONT.ui, fontSize: 11, color: COLORS.accent, cursor: "pointer" }}>Mark all read</span>
<span onClick={onMarkAllRead} style={{ fontFamily: FONT.ui, fontSize: 11, color: COLORS.accent, cursor: "pointer", marginRight: 10 }}>Mark all read</span>
<span
title="Clear all events"
onClick={() => { if (events.length) onClear(); }}
style={{ display: "flex", cursor: events.length ? "pointer" : "default", opacity: events.length ? 1 : 0.4 }}
>
<Trash2 size={14} color={COLORS.stError} aria-label="Clear all" />
</span>
</div>
<div style={{ display: "flex", gap: 6, marginBottom: 12 }}>
+9 -2
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { X } from "lucide-react";
import { COLORS, FONT, ACCENTS } from "./theme";
import { setConfig, restartDaemon } from "./socketBridge";
import type { ConfigView, DaemonHealth } from "./socketBridge";
@@ -20,7 +21,13 @@ export function Settings({ config, health, onClose, onReload }: { config: Config
<div onMouseDown={onClose} style={{ position: "fixed", inset: 0, zIndex: 2000, background: "#000A", display: "flex", alignItems: "center", justifyContent: "center" }}>
<div ref={ref} tabIndex={-1} onMouseDown={(e) => e.stopPropagation()} onKeyDown={(e) => { e.stopPropagation(); if (e.key === "Escape") onClose(); }}
style={{ width: 520, maxHeight: "80vh", overflowY: "auto", background: COLORS.bgApp, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 14, padding: 24, color: COLORS.textPrimary, fontFamily: FONT.ui }}>
<div style={{ fontWeight: 700, fontSize: 16, marginBottom: 16 }}>Settings</div>
<div style={{ display: "flex", alignItems: "center", marginBottom: 16 }}>
<span style={{ fontWeight: 700, fontSize: 16, flex: 1 }}>Settings</span>
<button onClick={onClose} aria-label="Close" title="Close (Esc)"
style={{ display: "flex", alignItems: "center", justifyContent: "center", width: 26, height: 26, borderRadius: 6, background: "transparent", border: "none", color: COLORS.textMuted, cursor: "pointer" }}>
<X size={16} />
</button>
</div>
<div style={{ fontSize: 12, color: COLORS.textSecondary, marginBottom: 6 }}>Terminal font</div>
<select value={config.font_family} onChange={(e) => void setConfig({ font_family: e.target.value })}
@@ -83,7 +90,7 @@ function DaemonSection({ health, onReload }: { health: DaemonHealth | null; onRe
<div style={{ fontSize: 12, color: COLORS.textSecondary, marginBottom: 8 }}>Daemon</div>
<div style={{ fontFamily: FONT.mono, fontSize: 12, color: COLORS.textSecondary, lineHeight: 1.7 }}>
{health ? (<>
<div>version {health.version} · pid {health.pid}</div>
<div>version {health.version}{health.build ? ` · ${health.build}` : ""} · pid {health.pid}</div>
<div>uptime {fmtUptime(health.started_at_ms)}</div>
</>) : <div>offline</div>}
</div>
+69 -2
View File
@@ -26,8 +26,9 @@ function aggregate(w: WorkspaceView): SurfaceState | "stopped" {
interface DropAt { section: string; index: number }
export function Sidebar({
groups, workspaces, activeId, onSelect, onNew, onDelete, health, connected,
railMode, groups, workspaces, activeId, onSelect, onNew, onDelete, health, connected,
}: {
railMode: boolean;
groups: Group[];
workspaces: WorkspaceView[];
activeId: string | null;
@@ -41,6 +42,7 @@ export function Sidebar({
const [hovered, setHovered] = useState<string | null>(null);
const [drag, setDrag] = useState<{ id: string; section: string } | null>(null);
const [dropAt, setDropAt] = useState<DropAt | null>(null);
const [editing, setEditing] = useState<{ id: string; draft: string } | null>(null);
const dragRef = useRef<{ id: string; section: string } | null>(null);
const dropRef = useRef<DropAt | null>(null);
const [, setTick] = useState(0);
@@ -59,6 +61,17 @@ export function Sidebar({
const togglePin = (w: WorkspaceView) => { void setWorkspaceMeta(w.id, { pinned: !w.pinned }); };
const commitRename = () => {
setEditing((cur) => {
if (cur) {
const name = cur.draft.trim();
const w = workspaces.find((x) => x.id === cur.id);
if (name && w && name !== w.name) void setWorkspaceMeta(cur.id, { name });
}
return null;
});
};
// Persist a new ordering for one section by reassigning sequential `order`
// values (per-section; values never compared across sections).
const commitReorder = (items: WorkspaceView[], fromId: string, toIndex: number) => {
@@ -123,7 +136,30 @@ export function Sidebar({
color: isActive ? COLORS.textPrimary : COLORS.textSecondary,
}}>
<span style={{ width: 10, height: 10, borderRadius: "50%", border: `2px solid ${STATE_COLOR[aggregate(w)]}`, boxSizing: "border-box", flex: "0 0 10px" }} />
<span style={{ flex: 1, fontWeight: isActive ? 600 : 400, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{w.name}</span>
{editing?.id === w.id ? (
<input
autoFocus
value={editing.draft}
onFocus={(e) => e.target.select()}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
onChange={(e) => setEditing({ id: w.id, draft: e.target.value })}
onBlur={commitRename}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Enter") { e.preventDefault(); commitRename(); }
else if (e.key === "Escape") { e.preventDefault(); setEditing(null); }
}}
style={{ flex: 1, minWidth: 0, background: COLORS.bgApp, color: COLORS.textPrimary, border: `1px solid ${COLORS.accent}`, borderRadius: 4, padding: "2px 6px", fontFamily: FONT.ui, fontSize: 13, outline: "none" }}
/>
) : (
<span
onDoubleClick={(e) => { e.stopPropagation(); setEditing({ id: w.id, draft: w.name }); }}
title="Двойной клик — переименовать"
style={{ flex: 1, fontWeight: isActive ? 600 : 400, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{w.name}
</span>
)}
{(hovered === w.id || w.pinned) && (
<Star size={14} fill={w.pinned ? COLORS.stWait : "none"} color={w.pinned ? COLORS.stWait : COLORS.textMuted}
style={{ cursor: "pointer", flex: "0 0 14px" }}
@@ -148,6 +184,37 @@ export function Sidebar({
const section = (key: string, items: WorkspaceView[]) => items.map((w, i) => row(w, key, items, i));
// Collapsed: a narrow rail of status rings so terminal activity stays visible.
if (railMode) {
const rail = [
...pinned,
...groups.slice().sort((a, b) => a.order - b.order).flatMap((g) => byGroup(g.id)),
...ungrouped,
];
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", width: 48, flex: "0 0 48px", background: COLORS.bgSidebar, height: "100%", padding: "10px 0", boxSizing: "border-box", borderRight: `1px solid ${COLORS.borderSubtle}`, gap: 8 }}>
<button onClick={onNew} title="New workspace"
style={{ display: "flex", alignItems: "center", justifyContent: "center", width: 30, height: 30, borderRadius: 8, background: COLORS.bgElevated, border: `1px solid ${COLORS.borderStrong}`, color: COLORS.textPrimary, cursor: "pointer" }}>
<Plus size={15} />
</button>
<div style={{ flex: 1, overflowY: "auto", display: "flex", flexDirection: "column", alignItems: "center", gap: 6, minHeight: 0 }}>
{rail.map((w) => {
const isActive = w.id === activeId;
return (
<button key={w.id} onClick={() => onSelect(w.id)} title={w.name}
style={{ position: "relative", display: "flex", alignItems: "center", justifyContent: "center", width: 32, height: 32, borderRadius: 8, cursor: "pointer", background: isActive ? COLORS.bgElevated : "transparent", border: `1px solid ${isActive ? COLORS.borderStrong : "transparent"}` }}>
<span style={{ width: 12, height: 12, borderRadius: "50%", border: `2px solid ${STATE_COLOR[aggregate(w)]}`, boxSizing: "border-box" }} />
{w.unread && <span style={{ position: "absolute", top: 3, right: 3, width: 7, height: 7, borderRadius: "50%", background: COLORS.accent }} />}
</button>
);
})}
</div>
<span title={connected ? "spaceshd · live" : "spaceshd · offline"}
style={{ width: 8, height: 8, borderRadius: "50%", background: connected ? COLORS.stDone : COLORS.textMuted, flex: "0 0 8px" }} />
</div>
);
}
return (
<div style={{ display: "flex", flexDirection: "column", width: 248, flex: "0 0 248px", background: COLORS.bgSidebar, height: "100%", padding: 14, boxSizing: "border-box" }}>
<button onClick={onNew}
+1
View File
@@ -80,6 +80,7 @@ export function TopBar({
borderRadius: 7, background: COLORS.stError, color: "#fff",
fontFamily: FONT.ui, fontSize: 9, fontWeight: 700,
display: "flex", alignItems: "center", justifyContent: "center", boxSizing: "border-box",
pointerEvents: "none", // let clicks/hover pass through to the bell button beneath
}}>
{unread > 99 ? "99+" : unread}
</span>
+7 -2
View File
@@ -86,6 +86,10 @@ export async function markEventsRead(target: MarkReadTarget): Promise<void> {
await invoke("mark_read", { target });
}
export async function clearEvents(): Promise<void> {
await invoke("clear_events");
}
export type DaemonEvt =
| { evt: "exit"; data: { surface_id: string; code: number } }
| { evt: "surface_created"; data: { surface_id: string; workspace_id: string } }
@@ -96,7 +100,8 @@ export type DaemonEvt =
| { evt: "groups_changed"; data: unknown }
| { evt: "config_changed"; data: { config: ConfigView } }
| { evt: "event"; data: { record: EventRecord } }
| { evt: "events_read"; data: { ids: number[] } };
| { evt: "events_read"; data: { ids: number[] } }
| { evt: "events_cleared"; data: unknown };
export function onDaemonEvent(handler: (evt: DaemonEvt) => void): Promise<() => void> {
return listen<DaemonEvt>("spacesh:evt", (e) => handler(e.payload));
@@ -178,7 +183,7 @@ export async function closeSurfaceCmd(surfaceId: string): Promise<void> {
await invoke("close_surface", { surfaceId });
}
export interface DaemonHealth { version: string; pid: number; started_at_ms: number }
export interface DaemonHealth { version: string; build?: string; pid: number; started_at_ms: number }
export async function getHealth(): Promise<DaemonHealth> {
return await invoke<DaemonHealth>("health");
+2
View File
@@ -124,6 +124,7 @@ pub enum Cmd {
limit: Option<u32>,
},
MarkRead { target: MarkReadTarget },
ClearEvents,
SetZoom {
workspace_id: WorkspaceId,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -163,6 +164,7 @@ pub enum Evt {
State { surface_id: SurfaceId, state: SurfaceState },
Event { record: EventRecord },
EventsRead { ids: Vec<u64> },
EventsCleared,
ConfigChanged { config: crate::config_view::ConfigView },
}
+23
View File
@@ -38,6 +38,16 @@ impl PtyHandle {
cmd.arg(a);
}
cmd.cwd(&spec.cwd);
// Guarantee a terminal environment even when the daemon was launched
// without one (GUI/launchd have no TERM, which breaks tput/zsh/ncurses).
// xterm.js renders an xterm-256color/truecolor terminal. Caller-provided
// values in spec.env win.
if !spec.env.iter().any(|(k, _)| k == "TERM") {
cmd.env("TERM", "xterm-256color");
}
if !spec.env.iter().any(|(k, _)| k == "COLORTERM") {
cmd.env("COLORTERM", "truecolor");
}
for (k, v) in &spec.env {
cmd.env(k, v);
}
@@ -125,6 +135,19 @@ mod tests {
assert!(text.contains("SPACESH_OK"), "got: {text:?}");
}
#[tokio::test]
async fn term_is_set_even_without_inherited_env() {
// Clear TERM in the parent to emulate a GUI/launchd-spawned daemon.
std::env::remove_var("TERM");
let mut handle = PtyHandle::spawn(shell_spec("printf %s \"$TERM\"")).unwrap();
let mut collected = Vec::new();
while let Some(chunk) = handle.output.recv().await {
collected.extend_from_slice(&chunk);
}
let text = String::from_utf8_lossy(&collected);
assert!(text.contains("xterm-256color"), "got: {text:?}");
}
#[tokio::test]
async fn resize_does_not_error() {
let handle = PtyHandle::spawn(shell_spec("sleep 0.2")).unwrap();
+27
View File
@@ -0,0 +1,27 @@
use std::process::Command;
// Stamp the binary with the current git build id so the GUI can detect a stale
// running daemon (different code) and restart it. Matches app/src-tauri/build.rs.
fn main() {
println!("cargo:rustc-env=SPACESH_BUILD={}", git_build());
println!("cargo:rerun-if-changed=../../.git/HEAD");
println!("cargo:rerun-if-changed=../../.git/index");
}
fn git_build() -> String {
let sha = Command::new("git")
.args(["rev-parse", "--short=12", "HEAD"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|s| !s.is_empty());
let Some(sha) = sha else { return "dev".into() };
let dirty = Command::new("git")
.args(["status", "--porcelain"])
.output()
.ok()
.map(|o| !o.stdout.is_empty())
.unwrap_or(false);
if dirty { format!("{sha}-dirty") } else { sha }
}
+17
View File
@@ -88,6 +88,11 @@ impl EventLog {
changed
}
/// Drop all records. `next_id` stays monotonic so ids are never reused.
pub fn clear(&mut self) {
self.records.clear();
}
pub fn unread_count(&self) -> u32 {
self.records.iter().filter(|r| !r.read).count() as u32
}
@@ -164,6 +169,18 @@ mod tests {
assert!(log.mark_read(&MarkReadTarget::All).is_empty());
}
#[test]
fn clear_drops_records_but_keeps_next_id() {
let mut log = EventLog::new(10);
rec(&mut log, "s_1", EventKind::Done);
rec(&mut log, "s_2", EventKind::Done);
log.clear();
assert_eq!(log.recent(None).len(), 0);
assert_eq!(log.unread_count(), 0);
// ids continue from where they were — no reuse after a clear.
assert_eq!(rec(&mut log, "s_3", EventKind::Done).id, 3);
}
#[test]
fn snapshot_restore_preserves_next_id_and_records() {
let mut log = EventLog::new(10);
+8
View File
@@ -622,6 +622,7 @@ async fn handle_request(
Cmd::Health => {
let _ = out.send(ok(id, serde_json::json!({
"version": env!("CARGO_PKG_VERSION"),
"build": option_env!("SPACESH_BUILD").unwrap_or("dev"),
"pid": std::process::id(),
"started_at_ms": started_at_ms,
}))).await;
@@ -647,6 +648,13 @@ async fn handle_request(
let _ = out.send(ok(id, serde_json::Value::Null)).await;
}
Cmd::ClearEvents => {
event_log.clear();
event_persister.mark_dirty(event_log.snapshot());
broadcast_evt(clients, &Envelope::Evt(Evt::EventsCleared));
let _ = out.send(ok(id, serde_json::Value::Null)).await;
}
Cmd::Shutdown => {
let _ = out.send(ok(id, serde_json::Value::Null)).await;
std::process::exit(0);
+31
View File
@@ -0,0 +1,31 @@
# Deploy — spaceshell.ru
Front nginx (`proxy`) → landing container, plus `/download/spacesh.dmg`.
## Layout on the server (`$SSH_REMOTE_DIR`, e.g. `/opt/spacesh`)
```
docker-compose.yaml # synced by CI
proxy.conf # synced by CI
download/spacesh.dmg # uploaded locally via `make deploy-dmg`
```
## One-time server setup
```bash
# The landing image lives in a private registry — log the server in once:
docker login git.realmanual.ru
mkdir -p $SSH_REMOTE_DIR/download
```
## What runs where
- **Landing image + compose deploy** → automatic in `.gitea/workflows/build.yaml`
on push to `landing/**`. CI builds/pushes the image, scps `deploy/*` to the
server, then `docker compose pull && up -d`.
- **DMG** → built on macOS, uploaded by hand: `make deploy-dmg`
(sets the stable `download/spacesh.dmg`). Tauri can't build a macOS bundle in CI.
## Gitea secrets required
`SSH_HOST`, `SSH_USER`, `SSH_REMOTE_DIR`, `SSH_KEY` (private key, key-based auth).
+32
View File
@@ -0,0 +1,32 @@
# spacesh prod — front nginx proxies to the landing container and serves the DMG.
# Deployed by .gitea/workflows/build.yaml (image + this file); the DMG is uploaded
# separately via `make deploy-dmg` (Tauri can't cross-compile a macOS bundle in CI).
services:
landing:
# LANDING_IMAGE is written to .env by the CI deploy job (exact registry path + tag).
image: ${LANDING_IMAGE:-git.realmanual.ru/spacesh/spacesh-landing:latest}
restart: unless-stopped
expose:
- "80"
proxy:
image: nginx:1.27-alpine
restart: unless-stopped
depends_on:
- landing
ports:
- "80:80"
volumes:
- ./proxy.conf:/etc/nginx/conf.d/default.conf:ro
- ./download:/srv/download:ro
networks:
spaceshell-network:
webproxy:
ipv4_address: 172.18.0.28
networks:
spaceshell-network:
driver: bridge
webproxy:
name: webproxy
external: true
+30
View File
@@ -0,0 +1,30 @@
# Front nginx for spaceshell.ru — reverse-proxies the landing container and
# serves macOS .dmg downloads from the host-mounted ./download volume.
upstream landing_upstream {
server landing:80;
}
server {
listen 80;
listen [::]:80;
server_name spaceshell.ru www.spaceshell.ru;
# Stable download URL: /download/spacesh.dmg → ./download/spacesh.dmg on host.
location /download/ {
alias /srv/download/;
autoindex off;
default_type application/octet-stream;
add_header Content-Disposition "attachment";
types {
application/x-apple-diskimage dmg;
}
}
location / {
proxy_pass http://landing_upstream;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
+1 -1
View File
@@ -1098,7 +1098,7 @@
<div class="container">
<h2 class="cta-title reveal">Готов гонять агентов пачками?</h2>
<div class="cta-buttons reveal">
<a href="#" class="btn btn-primary btn-large">
<a href="/download/spacesh.dmg" class="btn btn-primary btn-large">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 0a8 8 0 100 16A8 8 0 008 0zm3.5 9l-3 3a.7.7 0 01-1 0l-3-3a.7.7 0 011-1L7 9.5V4a1 1 0 012 0v5.5L10.5 8a.7.7 0 011 1z"/>
</svg>