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: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,3 +3,6 @@
|
||||
node_modules/
|
||||
app/dist/
|
||||
.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
|
||||
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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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())?)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 { 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
@@ -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 }}>
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/// 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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user