Compare commits
19 Commits
df6eabcd32
...
75134b6fac
| Author | SHA1 | Date | |
|---|---|---|---|
|
75134b6fac
|
|||
|
09e7a2b526
|
|||
|
5d7a80e2a2
|
|||
|
569aa39444
|
|||
|
f9a565a712
|
|||
|
bcc88b6be7
|
|||
|
3dc3da072c
|
|||
|
897a3be659
|
|||
|
cf7410b46a
|
|||
|
8f431eaa40
|
|||
|
79b47d42e7
|
|||
|
df0389b38f
|
|||
|
c84b96abc0
|
|||
|
ee969371c9
|
|||
|
07cf7f9ed4
|
|||
|
a929c166a3
|
|||
|
99a916fed6
|
|||
|
2fc58105a5
|
|||
|
a7272fc92e
|
@@ -45,9 +45,39 @@ jobs:
|
|||||||
${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/spacesh-landing:${{ steps.version.outputs.VERSION }}
|
${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/spacesh-landing:${{ steps.version.outputs.VERSION }}
|
||||||
${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/spacesh-landing:latest
|
${{ 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:
|
notify:
|
||||||
name: Notify Max
|
name: Notify Max
|
||||||
needs: landing
|
needs: [landing, deploy]
|
||||||
if: always()
|
if: always()
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
container: catthehacker/ubuntu:act-latest
|
container: catthehacker/ubuntu:act-latest
|
||||||
@@ -59,6 +89,12 @@ jobs:
|
|||||||
failure) line="❌ spacesh-landing — ошибка сборки";;
|
failure) line="❌ spacesh-landing — ошибка сборки";;
|
||||||
*) line="❔ spacesh-landing — ${{ needs.landing.result }}";;
|
*) line="❔ spacesh-landing — ${{ needs.landing.result }}";;
|
||||||
esac
|
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 }}"
|
url="${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_number }}"
|
||||||
text=$(printf '**Build landing**\n\n%s\n\n[лог](%s)' "$line" "$url")
|
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 }}" \
|
curl -s -X POST "https://platform-api.max.ru/messages?chat_id=${{ secrets.MAX_CHAT_ID }}" \
|
||||||
|
|||||||
@@ -3,3 +3,6 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
app/dist/
|
app/dist/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Generated daemon sidecar for DMG bundling (make dmg)
|
||||||
|
app/src-tauri/bin/
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ APP_DIR := app
|
|||||||
TAURI_TARGET := universal-apple-darwin
|
TAURI_TARGET := universal-apple-darwin
|
||||||
DMG_DIR := $(APP_DIR)/src-tauri/target/$(TAURI_TARGET)/release/bundle/dmg
|
DMG_DIR := $(APP_DIR)/src-tauri/target/$(TAURI_TARGET)/release/bundle/dmg
|
||||||
NATIVE_DMG_DIR := $(APP_DIR)/src-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)
|
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_IMAGE := spacesh-landing
|
||||||
@@ -12,6 +17,13 @@ LANDING_VERSION := $(shell cat landing/VERSION 2>/dev/null || echo 0.0.0)
|
|||||||
REGISTRY ?= git.realmanual.ru
|
REGISTRY ?= git.realmanual.ru
|
||||||
REPO ?= spacesh
|
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
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
.PHONY: help
|
.PHONY: help
|
||||||
@@ -32,12 +44,23 @@ targets: ## add rust targets for the universal build
|
|||||||
|
|
||||||
.PHONY: dmg
|
.PHONY: dmg
|
||||||
dmg: targets ## build the universal (Intel + Apple Silicon) .dmg — UNSIGNED
|
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
|
@echo "→ $(DMG_DIR)" && ls -lh $(DMG_DIR)/*.dmg
|
||||||
|
|
||||||
.PHONY: dmg-native
|
.PHONY: dmg-native
|
||||||
dmg-native: ## build a .dmg for the current arch only (faster)
|
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
|
@ls -lh $(NATIVE_DMG_DIR)/*.dmg
|
||||||
|
|
||||||
.PHONY: dev
|
.PHONY: dev
|
||||||
@@ -48,6 +71,27 @@ dev: ## run the app in dev mode (tauri dev)
|
|||||||
daemon: ## build & run the daemon
|
daemon: ## build & run the daemon
|
||||||
cargo run -p spaceshd
|
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 ----
|
# ---- Tests ----
|
||||||
|
|
||||||
.PHONY: test
|
.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):$(LANDING_VERSION)
|
||||||
docker push $(REGISTRY)/$(REPO)/$(LANDING_IMAGE):latest
|
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 ----
|
# ---- Clean ----
|
||||||
|
|
||||||
.PHONY: clean
|
.PHONY: clean
|
||||||
|
|||||||
@@ -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() {
|
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()
|
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 }
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ pub struct Bridge {
|
|||||||
tx: Mutex<mpsc::Sender<Envelope>>,
|
tx: Mutex<mpsc::Sender<Envelope>>,
|
||||||
/// Serializes reconnect attempts.
|
/// Serializes reconnect attempts.
|
||||||
reconnect_lock: Mutex<()>,
|
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 request id → reply slot.
|
||||||
pending: Arc<Mutex<HashMap<u64, oneshot::Sender<Envelope>>>>,
|
pending: Arc<Mutex<HashMap<u64, oneshot::Sender<Envelope>>>>,
|
||||||
/// surface id → output channel into the webview.
|
/// surface id → output channel into the webview.
|
||||||
@@ -102,13 +105,13 @@ async fn spawn_connection(
|
|||||||
app: &AppHandle,
|
app: &AppHandle,
|
||||||
pending: Arc<Mutex<HashMap<u64, oneshot::Sender<Envelope>>>>,
|
pending: Arc<Mutex<HashMap<u64, oneshot::Sender<Envelope>>>>,
|
||||||
out_channels: Arc<Mutex<HashMap<String, Channel<Vec<u8>>>>>,
|
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 stream = ensure_daemon(sock).await?;
|
||||||
let (read_half, write_half) = stream.into_split();
|
let (read_half, write_half) = stream.into_split();
|
||||||
let (tx, rx) = mpsc::channel::<Envelope>(256);
|
let (tx, rx) = mpsc::channel::<Envelope>(256);
|
||||||
spawn_writer(write_half, rx);
|
spawn_writer(write_half, rx);
|
||||||
spawn_reader(read_half, app.clone(), pending, out_channels);
|
let reader = spawn_reader(read_half, app.clone(), pending, out_channels);
|
||||||
Ok(tx)
|
Ok((tx, reader))
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Bridge {
|
impl Bridge {
|
||||||
@@ -116,17 +119,45 @@ impl Bridge {
|
|||||||
let sock = socket_path()?;
|
let sock = socket_path()?;
|
||||||
let pending: Arc<Mutex<HashMap<u64, oneshot::Sender<Envelope>>>> = Arc::default();
|
let pending: Arc<Mutex<HashMap<u64, oneshot::Sender<Envelope>>>> = Arc::default();
|
||||||
let out_channels: Arc<Mutex<HashMap<String, Channel<Vec<u8>>>>> = 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?;
|
let (tx, reader) = spawn_connection(&sock, &app, pending.clone(), out_channels.clone()).await?;
|
||||||
Ok(Self {
|
let bridge = Self {
|
||||||
next_id: AtomicU64::new(1),
|
next_id: AtomicU64::new(1),
|
||||||
app,
|
app,
|
||||||
sock,
|
sock,
|
||||||
gen: AtomicU64::new(0),
|
gen: AtomicU64::new(0),
|
||||||
tx: Mutex::new(tx),
|
tx: Mutex::new(tx),
|
||||||
reconnect_lock: Mutex::new(()),
|
reconnect_lock: Mutex::new(()),
|
||||||
|
reader: Mutex::new(reader),
|
||||||
pending,
|
pending,
|
||||||
out_channels,
|
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`
|
/// 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.
|
// Drop in-flight reply slots — their connection is gone; they'll error out.
|
||||||
self.pending.lock().await.clear();
|
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.tx.lock().await = new_tx;
|
||||||
|
*self.reader.lock().await = new_reader;
|
||||||
self.gen.fetch_add(1, Ordering::Release);
|
self.gen.fetch_add(1, Ordering::Release);
|
||||||
let _ = self.app.emit("spacesh:reconnected", ());
|
let _ = self.app.emit("spacesh:reconnected", ());
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -205,7 +240,7 @@ fn spawn_reader(
|
|||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
pending: Arc<Mutex<HashMap<u64, oneshot::Sender<Envelope>>>>,
|
pending: Arc<Mutex<HashMap<u64, oneshot::Sender<Envelope>>>>,
|
||||||
out_channels: Arc<Mutex<HashMap<String, Channel<Vec<u8>>>>>,
|
out_channels: Arc<Mutex<HashMap<String, Channel<Vec<u8>>>>>,
|
||||||
) {
|
) -> tokio::task::JoinHandle<()> {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
match read_frame(&mut read_half).await {
|
match read_frame(&mut read_half).await {
|
||||||
@@ -232,7 +267,7 @@ fn spawn_reader(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Tauri commands ----
|
// ---- 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())?)
|
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]
|
#[tauri::command]
|
||||||
pub async fn health(state: BridgeState<'_>) -> Result<Value, String> {
|
pub async fn health(state: BridgeState<'_>) -> Result<Value, String> {
|
||||||
data_of(state.request(Cmd::Health).await.map_err(|e| e.to_string())?)
|
data_of(state.request(Cmd::Health).await.map_err(|e| e.to_string())?)
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ pub fn run() {
|
|||||||
bridge::set_zoom,
|
bridge::set_zoom,
|
||||||
bridge::event_log,
|
bridge::event_log,
|
||||||
bridge::mark_read,
|
bridge::mark_read,
|
||||||
|
bridge::clear_events,
|
||||||
bridge::health,
|
bridge::health,
|
||||||
bridge::get_config,
|
bridge::get_config,
|
||||||
bridge::set_config,
|
bridge::set_config,
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"bundle": {
|
||||||
|
"externalBin": ["bin/spaceshd"]
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-2
@@ -9,7 +9,7 @@ import { Settings } from "./Settings";
|
|||||||
import { EventCenter } from "./EventCenter";
|
import { EventCenter } from "./EventCenter";
|
||||||
import { maybeNotify } from "./notify";
|
import { maybeNotify } from "./notify";
|
||||||
import { COLORS, applyTheme, resolvePalette } from "./theme";
|
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 type { EventRecord, DaemonHealth, ConfigView } from "./socketBridge";
|
||||||
import { leafIds } from "./layoutTypes";
|
import { leafIds } from "./layoutTypes";
|
||||||
import type { Group, WorkspaceView, SurfaceState } from "./layoutTypes";
|
import type { Group, WorkspaceView, SurfaceState } from "./layoutTypes";
|
||||||
@@ -95,6 +95,8 @@ export function App() {
|
|||||||
} else if (evt.evt === "events_read") {
|
} else if (evt.evt === "events_read") {
|
||||||
const ids = new Set(evt.data.ids);
|
const ids = new Set(evt.data.ids);
|
||||||
setEvents((es) => es.map((e) => (ids.has(e.id) ? { ...e, read: true } : e)));
|
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") {
|
} else if (evt.evt === "state") {
|
||||||
setStates((m) => ({ ...m, [evt.data.surface_id]: evt.data.state }));
|
setStates((m) => ({ ...m, [evt.data.surface_id]: evt.data.state }));
|
||||||
void refresh();
|
void refresh();
|
||||||
@@ -162,7 +164,7 @@ export function App() {
|
|||||||
<div style={{ display: "flex", flexDirection: "column", height: "100vh", background: COLORS.bgApp }}>
|
<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); }} />
|
<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 }}>
|
<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 }}>
|
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
|
||||||
{active && (
|
{active && (
|
||||||
<CenterToolbar selected="" onSelect={(p) => { if (active) void applyPreset(active.id, p, []); }} onOpenSearch={() => { if (effectiveFocus) { setSearchSurfaceId(effectiveFocus); setSearchNonce((n) => n + 1); } }} />
|
<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
|
<EventCenter
|
||||||
events={events}
|
events={events}
|
||||||
onMarkAllRead={() => { void markEventsRead({ target: "all" }); }}
|
onMarkAllRead={() => { void markEventsRead({ target: "all" }); }}
|
||||||
|
onClear={() => { void clearEvents(); }}
|
||||||
onSelect={(sid, id) => { void focusSurface(sid); void markEventsRead({ target: "ids", value: [id] }); }}
|
onSelect={(sid, id) => { void focusSurface(sid); void markEventsRead({ target: "ids", value: [id] }); }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+11
-3
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
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 { COLORS, FONT } from "./theme";
|
||||||
import type { EventRecord } from "./socketBridge";
|
import type { EventRecord } from "./socketBridge";
|
||||||
|
|
||||||
@@ -26,10 +26,11 @@ function rel(ts: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function EventCenter({
|
export function EventCenter({
|
||||||
events, onMarkAllRead, onSelect,
|
events, onMarkAllRead, onClear, onSelect,
|
||||||
}: {
|
}: {
|
||||||
events: EventRecord[];
|
events: EventRecord[];
|
||||||
onMarkAllRead: () => void;
|
onMarkAllRead: () => void;
|
||||||
|
onClear: () => void;
|
||||||
onSelect: (surfaceId: string, id: number) => void;
|
onSelect: (surfaceId: string, id: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const [tab, setTab] = useState<Tab>("all");
|
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", 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 }}>
|
<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 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>
|
||||||
|
|
||||||
<div style={{ display: "flex", gap: 6, marginBottom: 12 }}>
|
<div style={{ display: "flex", gap: 6, marginBottom: 12 }}>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
import { COLORS, FONT, ACCENTS } from "./theme";
|
import { COLORS, FONT, ACCENTS } from "./theme";
|
||||||
import { setConfig, restartDaemon } from "./socketBridge";
|
import { setConfig, restartDaemon } from "./socketBridge";
|
||||||
import type { ConfigView, DaemonHealth } 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 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(); }}
|
<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 }}>
|
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>
|
<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 })}
|
<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={{ fontSize: 12, color: COLORS.textSecondary, marginBottom: 8 }}>Daemon</div>
|
||||||
<div style={{ fontFamily: FONT.mono, fontSize: 12, color: COLORS.textSecondary, lineHeight: 1.7 }}>
|
<div style={{ fontFamily: FONT.mono, fontSize: 12, color: COLORS.textSecondary, lineHeight: 1.7 }}>
|
||||||
{health ? (<>
|
{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>uptime {fmtUptime(health.started_at_ms)}</div>
|
||||||
</>) : <div>offline</div>}
|
</>) : <div>offline</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+69
-2
@@ -26,8 +26,9 @@ function aggregate(w: WorkspaceView): SurfaceState | "stopped" {
|
|||||||
interface DropAt { section: string; index: number }
|
interface DropAt { section: string; index: number }
|
||||||
|
|
||||||
export function Sidebar({
|
export function Sidebar({
|
||||||
groups, workspaces, activeId, onSelect, onNew, onDelete, health, connected,
|
railMode, groups, workspaces, activeId, onSelect, onNew, onDelete, health, connected,
|
||||||
}: {
|
}: {
|
||||||
|
railMode: boolean;
|
||||||
groups: Group[];
|
groups: Group[];
|
||||||
workspaces: WorkspaceView[];
|
workspaces: WorkspaceView[];
|
||||||
activeId: string | null;
|
activeId: string | null;
|
||||||
@@ -41,6 +42,7 @@ export function Sidebar({
|
|||||||
const [hovered, setHovered] = useState<string | null>(null);
|
const [hovered, setHovered] = useState<string | null>(null);
|
||||||
const [drag, setDrag] = useState<{ id: string; section: string } | null>(null);
|
const [drag, setDrag] = useState<{ id: string; section: string } | null>(null);
|
||||||
const [dropAt, setDropAt] = useState<DropAt | 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 dragRef = useRef<{ id: string; section: string } | null>(null);
|
||||||
const dropRef = useRef<DropAt | null>(null);
|
const dropRef = useRef<DropAt | null>(null);
|
||||||
const [, setTick] = useState(0);
|
const [, setTick] = useState(0);
|
||||||
@@ -59,6 +61,17 @@ export function Sidebar({
|
|||||||
|
|
||||||
const togglePin = (w: WorkspaceView) => { void setWorkspaceMeta(w.id, { pinned: !w.pinned }); };
|
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`
|
// Persist a new ordering for one section by reassigning sequential `order`
|
||||||
// values (per-section; values never compared across sections).
|
// values (per-section; values never compared across sections).
|
||||||
const commitReorder = (items: WorkspaceView[], fromId: string, toIndex: number) => {
|
const commitReorder = (items: WorkspaceView[], fromId: string, toIndex: number) => {
|
||||||
@@ -123,7 +136,30 @@ export function Sidebar({
|
|||||||
color: isActive ? COLORS.textPrimary : COLORS.textSecondary,
|
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={{ 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) && (
|
{(hovered === w.id || w.pinned) && (
|
||||||
<Star size={14} fill={w.pinned ? COLORS.stWait : "none"} color={w.pinned ? COLORS.stWait : COLORS.textMuted}
|
<Star size={14} fill={w.pinned ? COLORS.stWait : "none"} color={w.pinned ? COLORS.stWait : COLORS.textMuted}
|
||||||
style={{ cursor: "pointer", flex: "0 0 14px" }}
|
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));
|
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 (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", width: 248, flex: "0 0 248px", background: COLORS.bgSidebar, height: "100%", padding: 14, boxSizing: "border-box" }}>
|
<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}
|
<button onClick={onNew}
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export function TopBar({
|
|||||||
borderRadius: 7, background: COLORS.stError, color: "#fff",
|
borderRadius: 7, background: COLORS.stError, color: "#fff",
|
||||||
fontFamily: FONT.ui, fontSize: 9, fontWeight: 700,
|
fontFamily: FONT.ui, fontSize: 9, fontWeight: 700,
|
||||||
display: "flex", alignItems: "center", justifyContent: "center", boxSizing: "border-box",
|
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}
|
{unread > 99 ? "99+" : unread}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -86,6 +86,10 @@ export async function markEventsRead(target: MarkReadTarget): Promise<void> {
|
|||||||
await invoke("mark_read", { target });
|
await invoke("mark_read", { target });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function clearEvents(): Promise<void> {
|
||||||
|
await invoke("clear_events");
|
||||||
|
}
|
||||||
|
|
||||||
export type DaemonEvt =
|
export type DaemonEvt =
|
||||||
| { evt: "exit"; data: { surface_id: string; code: number } }
|
| { evt: "exit"; data: { surface_id: string; code: number } }
|
||||||
| { evt: "surface_created"; data: { surface_id: string; workspace_id: string } }
|
| { evt: "surface_created"; data: { surface_id: string; workspace_id: string } }
|
||||||
@@ -96,7 +100,8 @@ export type DaemonEvt =
|
|||||||
| { evt: "groups_changed"; data: unknown }
|
| { evt: "groups_changed"; data: unknown }
|
||||||
| { evt: "config_changed"; data: { config: ConfigView } }
|
| { evt: "config_changed"; data: { config: ConfigView } }
|
||||||
| { evt: "event"; data: { record: EventRecord } }
|
| { 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> {
|
export function onDaemonEvent(handler: (evt: DaemonEvt) => void): Promise<() => void> {
|
||||||
return listen<DaemonEvt>("spacesh:evt", (e) => handler(e.payload));
|
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 });
|
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> {
|
export async function getHealth(): Promise<DaemonHealth> {
|
||||||
return await invoke<DaemonHealth>("health");
|
return await invoke<DaemonHealth>("health");
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ pub enum Cmd {
|
|||||||
limit: Option<u32>,
|
limit: Option<u32>,
|
||||||
},
|
},
|
||||||
MarkRead { target: MarkReadTarget },
|
MarkRead { target: MarkReadTarget },
|
||||||
|
ClearEvents,
|
||||||
SetZoom {
|
SetZoom {
|
||||||
workspace_id: WorkspaceId,
|
workspace_id: WorkspaceId,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
@@ -163,6 +164,7 @@ pub enum Evt {
|
|||||||
State { surface_id: SurfaceId, state: SurfaceState },
|
State { surface_id: SurfaceId, state: SurfaceState },
|
||||||
Event { record: EventRecord },
|
Event { record: EventRecord },
|
||||||
EventsRead { ids: Vec<u64> },
|
EventsRead { ids: Vec<u64> },
|
||||||
|
EventsCleared,
|
||||||
ConfigChanged { config: crate::config_view::ConfigView },
|
ConfigChanged { config: crate::config_view::ConfigView },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,16 @@ impl PtyHandle {
|
|||||||
cmd.arg(a);
|
cmd.arg(a);
|
||||||
}
|
}
|
||||||
cmd.cwd(&spec.cwd);
|
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 {
|
for (k, v) in &spec.env {
|
||||||
cmd.env(k, v);
|
cmd.env(k, v);
|
||||||
}
|
}
|
||||||
@@ -125,6 +135,19 @@ mod tests {
|
|||||||
assert!(text.contains("SPACESH_OK"), "got: {text:?}");
|
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]
|
#[tokio::test]
|
||||||
async fn resize_does_not_error() {
|
async fn resize_does_not_error() {
|
||||||
let handle = PtyHandle::spawn(shell_spec("sleep 0.2")).unwrap();
|
let handle = PtyHandle::spawn(shell_spec("sleep 0.2")).unwrap();
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -88,6 +88,11 @@ impl EventLog {
|
|||||||
changed
|
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 {
|
pub fn unread_count(&self) -> u32 {
|
||||||
self.records.iter().filter(|r| !r.read).count() as 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());
|
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]
|
#[test]
|
||||||
fn snapshot_restore_preserves_next_id_and_records() {
|
fn snapshot_restore_preserves_next_id_and_records() {
|
||||||
let mut log = EventLog::new(10);
|
let mut log = EventLog::new(10);
|
||||||
|
|||||||
@@ -622,6 +622,7 @@ async fn handle_request(
|
|||||||
Cmd::Health => {
|
Cmd::Health => {
|
||||||
let _ = out.send(ok(id, serde_json::json!({
|
let _ = out.send(ok(id, serde_json::json!({
|
||||||
"version": env!("CARGO_PKG_VERSION"),
|
"version": env!("CARGO_PKG_VERSION"),
|
||||||
|
"build": option_env!("SPACESH_BUILD").unwrap_or("dev"),
|
||||||
"pid": std::process::id(),
|
"pid": std::process::id(),
|
||||||
"started_at_ms": started_at_ms,
|
"started_at_ms": started_at_ms,
|
||||||
}))).await;
|
}))).await;
|
||||||
@@ -647,6 +648,13 @@ async fn handle_request(
|
|||||||
let _ = out.send(ok(id, serde_json::Value::Null)).await;
|
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 => {
|
Cmd::Shutdown => {
|
||||||
let _ = out.send(ok(id, serde_json::Value::Null)).await;
|
let _ = out.send(ok(id, serde_json::Value::Null)).await;
|
||||||
std::process::exit(0);
|
std::process::exit(0);
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -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
|
||||||
@@ -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
@@ -1098,7 +1098,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<h2 class="cta-title reveal">Готов гонять агентов пачками?</h2>
|
<h2 class="cta-title reveal">Готов гонять агентов пачками?</h2>
|
||||||
<div class="cta-buttons reveal">
|
<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">
|
<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"/>
|
<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>
|
</svg>
|
||||||
|
|||||||
Reference in New Issue
Block a user