Add update check functionality

Implement version checking and update notifications in the GUI
This commit is contained in:
2026-06-15 14:23:30 +07:00
parent 4c9eacccb7
commit 9db52595c7
8 changed files with 446 additions and 9 deletions
+12 -4
View File
@@ -42,8 +42,12 @@ deps: ## install frontend deps (npm ci)
targets: ## add rust targets for the universal build
rustup target add aarch64-apple-darwin x86_64-apple-darwin
.PHONY: bump
bump: ## increment the patch version in tauri.conf.json (single source of truth)
@node -e "const f='$(APP_DIR)/src-tauri/tauri.conf.json';const fs=require('fs');const j=JSON.parse(fs.readFileSync(f));const p=j.version.split('.').map(Number);p[2]=(p[2]||0)+1;j.version=p.join('.');fs.writeFileSync(f, JSON.stringify(j,null,2)+'\n');console.log('version → '+j.version)"
.PHONY: dmg
dmg: targets ## build the universal (Intel + Apple Silicon) .dmg — UNSIGNED
dmg: bump targets ## bump version + build the universal (Intel + Apple Silicon) .dmg — UNSIGNED
# Tauri's universal build needs BOTH the per-arch sidecars (resolved during each
# arch sub-build) AND a fat spaceshd-universal-apple-darwin (copied into the final
# bundle — Tauri does not lipo sidecars itself). spaceshd ships inside
@@ -60,7 +64,7 @@ dmg: targets ## build the universal (Intel + Apple Silicon) .dmg — UNSIGNED
@echo "$(DMG_DIR)" && ls -lh $(DMG_DIR)/*.dmg
.PHONY: dmg-native
dmg-native: ## build a .dmg for the current arch only (faster)
dmg-native: bump ## bump version + build a .dmg for the current arch only (faster)
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)
@@ -130,10 +134,14 @@ landing-push: landing-image ## tag & push the landing image to the registry
# ---- Prod deploy ----
.PHONY: deploy-dmg
deploy-dmg: dmg ## upload the universal .dmg to the prod download dir (stable spacesh.dmg)
deploy-dmg: dmg ## upload the .dmg + update manifest (latest.json) to the prod download dir
@VER=$$(node -p "require('./$(APP_DIR)/src-tauri/tauri.conf.json').version"); \
printf '{"version":"%s","url":"https://spaceshell.ru/download/spacesh.dmg"}\n' "$$VER" > /tmp/spacesh-latest.json; \
echo "manifest version → $$VER"
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"
scp $(SSH_OPTS) /tmp/spacesh-latest.json "$(SSH_USER)@$(SSH_HOST):$(SSH_REMOTE_DIR)/download/latest.json"
@echo "Uploaded → https://spaceshell.ru/download/spacesh.dmg + latest.json"
.PHONY: deploy-stack
deploy-stack: ## sync compose+proxy.conf to prod and pull/up (manual; CI does this on push)
+250 -1
View File
@@ -453,6 +453,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.45"
@@ -1246,8 +1252,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
@@ -1257,9 +1265,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
[[package]]
@@ -1537,6 +1547,22 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
@@ -1955,6 +1981,12 @@ version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "mac-notification-sys"
version = "0.6.13"
@@ -2649,6 +2681,61 @@ dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.59.0",
]
[[package]]
name = "quote"
version = "1.0.45"
@@ -2785,6 +2872,44 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots",
]
[[package]]
name = "reqwest"
version = "0.13.4"
@@ -2819,6 +2944,20 @@ dependencies = [
"web-sys",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rustc-hash"
version = "2.1.2"
@@ -2847,12 +2986,53 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [
"web-time",
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "same-file"
version = "1.0.6"
@@ -3043,6 +3223,18 @@ dependencies = [
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "serde_with"
version = "3.21.0"
@@ -3222,6 +3414,7 @@ dependencies = [
"anyhow",
"base64 0.22.1",
"dirs 5.0.1",
"reqwest 0.12.28",
"serde",
"serde_json",
"spacesh-proto",
@@ -3280,6 +3473,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "swift-rs"
version = "1.0.7"
@@ -3432,7 +3631,7 @@ dependencies = [
"percent-encoding",
"plist",
"raw-window-handle",
"reqwest",
"reqwest 0.13.4",
"serde",
"serde_json",
"serde_repr",
@@ -3824,6 +4023,16 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
@@ -4143,6 +4352,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"
@@ -4379,6 +4594,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "web_atoms"
version = "0.2.4"
@@ -4435,6 +4660,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-roots"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.2"
@@ -4674,6 +4908,15 @@ dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
@@ -5212,6 +5455,12 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]]
name = "zerotrie"
version = "0.2.4"
+2
View File
@@ -23,3 +23,5 @@ serde_json = "1"
base64 = "0.22"
anyhow = "1"
dirs = "5"
# rustls (no openssl) so the universal-apple-darwin cross-build stays self-contained.
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
+63
View File
@@ -422,6 +422,69 @@ pub async fn health(state: BridgeState<'_>) -> Result<Value, String> {
data_of(state.request(Cmd::Health).await.map_err(|e| e.to_string())?)
}
// ---- Update check ----
/// Where the GUI looks for the published app version. Overridable via
/// SPACESH_UPDATE_URL for local testing against a staging server.
const DEFAULT_UPDATE_URL: &str = "https://spaceshell.ru/download/latest.json";
#[derive(serde::Serialize)]
pub struct UpdateInfo {
current: String,
latest: String,
has_update: bool,
url: String,
}
/// Parse a `major.minor.patch` string (tolerating a leading `v` and a
/// `-prerelease` suffix) into a comparable tuple; missing parts are 0.
fn parse_ver(v: &str) -> (u64, u64, u64) {
let core = v.trim().trim_start_matches('v').split('-').next().unwrap_or("");
let mut it = core.split('.').map(|p| p.parse::<u64>().unwrap_or(0));
(it.next().unwrap_or(0), it.next().unwrap_or(0), it.next().unwrap_or(0))
}
/// Fetch the server manifest and compare against the bundled app version.
/// `current` is the Tauri package version (single source of truth: tauri.conf.json),
/// which `make dmg` bumps on every build.
#[tauri::command]
pub async fn check_update(app: AppHandle) -> Result<UpdateInfo, String> {
let current = app.package_info().version.to_string();
let manifest_url =
std::env::var("SPACESH_UPDATE_URL").ok().filter(|s| !s.is_empty()).unwrap_or_else(|| DEFAULT_UPDATE_URL.to_string());
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
.map_err(|e| e.to_string())?;
let manifest: Value = client
.get(&manifest_url)
.send()
.await
.map_err(|e| e.to_string())?
.json()
.await
.map_err(|e| e.to_string())?;
let latest = manifest.get("version").and_then(|v| v.as_str()).unwrap_or("").to_string();
let url = manifest
.get("url")
.and_then(|v| v.as_str())
.unwrap_or("https://spaceshell.ru/download/spacesh.dmg")
.to_string();
let has_update = !latest.is_empty() && parse_ver(&latest) > parse_ver(&current);
Ok(UpdateInfo { current, latest, has_update, url })
}
/// Open a URL in the default browser (macOS `open`). Used by the update popover's
/// download action — the GUI itself never streams the .dmg.
#[tauri::command]
pub fn open_external(url: String) -> Result<(), String> {
std::process::Command::new("open").arg(&url).spawn().map_err(|e| e.to_string())?;
Ok(())
}
// ---- Settings commands ----
#[tauri::command]
+2
View File
@@ -54,6 +54,8 @@ pub fn run() {
bridge::mark_read,
bridge::clear_events,
bridge::health,
bridge::check_update,
bridge::open_external,
bridge::get_config,
bridge::set_config,
bridge::shutdown_daemon,
+19 -3
View File
@@ -9,8 +9,8 @@ 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, clearEvents, getHealth, closeWorkspaceCmd, getConfig } from "./socketBridge";
import type { EventRecord, DaemonHealth, ConfigView } from "./socketBridge";
import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent, setWorkspaceMeta, focusSurface, getEventLog, markEventsRead, clearEvents, getHealth, closeWorkspaceCmd, getConfig, checkUpdate } from "./socketBridge";
import type { EventRecord, DaemonHealth, ConfigView, UpdateInfo } from "./socketBridge";
import { leafIds } from "./layoutTypes";
import type { Group, WorkspaceView, SurfaceState } from "./layoutTypes";
@@ -36,6 +36,8 @@ export function App() {
const [eventsOpen, setEventsOpen] = useState(() => loadFlag("spacesh.eventsOpen", true));
const [sidebarOpen, setSidebarOpen] = useState(() => loadFlag("spacesh.sidebarOpen", true));
const [health, setHealth] = useState<DaemonHealth | null>(null);
const [update, setUpdate] = useState<UpdateInfo | null>(null);
const [updateChecking, setUpdateChecking] = useState(false);
const [config, setConfigState] = useState<ConfigView | null>(null);
// Bumped when the daemon connection is re-established; used to remount the
// layout so terminals re-attach (snapshot + live stream) to the restarted daemon.
@@ -77,6 +79,13 @@ export function App() {
catch { setConnected(false); }
}, []);
const runUpdateCheck = useCallback(async () => {
setUpdateChecking(true);
try { setUpdate(await checkUpdate()); }
catch { /* offline / server unreachable — leave the last known result */ }
finally { setUpdateChecking(false); }
}, []);
const wsOf = (surfaceId: string): WorkspaceView | undefined =>
wsRef.current.find((w) => surfaceId in w.surfaces);
@@ -142,6 +151,13 @@ export function App() {
return () => window.removeEventListener("keydown", onKey);
}, []);
// Update check: once on launch, then every 6h.
useEffect(() => {
void runUpdateCheck();
const id = setInterval(() => { void runUpdateCheck(); }, 6 * 60 * 60 * 1000);
return () => clearInterval(id);
}, [runUpdateCheck]);
useEffect(() => { saveFlag("spacesh.eventsOpen", eventsOpen); }, [eventsOpen]);
useEffect(() => { saveFlag("spacesh.sidebarOpen", sidebarOpen); }, [sidebarOpen]);
@@ -162,7 +178,7 @@ export function App() {
return (
<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); }} update={update} updateChecking={updateChecking} onCheckUpdate={() => { void runUpdateCheck(); }} />
<div style={{ flex: 1, display: "flex", minHeight: 0 }}>
<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 }}>
+88 -1
View File
@@ -1,7 +1,10 @@
import { FolderGit2, PanelLeft, PanelRight, Search, Bell, Settings, ChevronDown } from "lucide-react";
import { useState } from "react";
import { FolderGit2, PanelLeft, PanelRight, Search, Bell, Settings, ChevronDown, RefreshCw, Download } from "lucide-react";
import { COLORS, FONT } from "./theme";
import type { WorkspaceView } from "./layoutTypes";
import { leafIds } from "./layoutTypes";
import { openExternal } from "./socketBridge";
import type { UpdateInfo } from "./socketBridge";
/** Human-readable descriptor of the active workspace layout (mock until a real preset id is tracked). */
function describeLayout(w: WorkspaceView | null): string {
@@ -28,8 +31,83 @@ function IconBtn({ icon, onClick, active, title }: { icon: React.ReactNode; onCl
);
}
/** Update-check button (lights up when the server has a newer version) plus its popover. */
function UpdateControl({ update, checking, onCheck }: { update: UpdateInfo | null; checking: boolean; onCheck: () => void }) {
const [open, setOpen] = useState(false);
const hasUpdate = !!update?.has_update;
return (
<div style={{ position: "relative", display: "flex" }}>
<button
title={hasUpdate ? `Доступна версия ${update?.latest}` : "Проверить обновления"}
onClick={() => setOpen((v) => !v)}
style={{
display: "flex", alignItems: "center", justifyContent: "center",
width: 26, height: 26, borderRadius: 6,
background: hasUpdate ? "rgba(52,211,194,0.15)" : open ? COLORS.bgElevated : "transparent",
border: `1px solid ${hasUpdate ? COLORS.accent : open ? COLORS.borderSubtle : "transparent"}`,
color: hasUpdate ? COLORS.accent : COLORS.textSecondary,
boxShadow: hasUpdate ? `0 0 10px rgba(52,211,194,0.5)` : "none",
animation: hasUpdate ? "spaceshPulse 2s ease-in-out infinite" : "none",
}}
>
<RefreshCw size={15} style={{ animation: checking ? "spaceshSpin 0.8s linear infinite" : "none" }} />
</button>
{open && (
<>
{/* click-away backdrop */}
<div onClick={() => setOpen(false)} style={{ position: "fixed", inset: 0, zIndex: 200 }} />
<div style={{
position: "absolute", top: 32, right: 0, zIndex: 201, width: 240,
background: COLORS.bgPanel, border: `1px solid ${COLORS.borderSubtle}`, borderRadius: 8,
padding: 12, boxShadow: "0 8px 24px rgba(0,0,0,0.4)",
fontFamily: FONT.ui, color: COLORS.textPrimary,
}}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>Обновление</div>
<div style={{ fontSize: 12, color: COLORS.textSecondary, display: "flex", justifyContent: "space-between" }}>
<span>Установлено</span><span style={{ fontFamily: FONT.mono }}>{update?.current ?? "—"}</span>
</div>
<div style={{ fontSize: 12, color: COLORS.textSecondary, display: "flex", justifyContent: "space-between", marginTop: 2 }}>
<span>На сервере</span><span style={{ fontFamily: FONT.mono }}>{update?.latest || "—"}</span>
</div>
{hasUpdate ? (
<button
onClick={() => { void openExternal(update!.url); setOpen(false); }}
style={{
marginTop: 10, width: "100%", height: 30, borderRadius: 6, border: "none", cursor: "pointer",
background: COLORS.accent, color: COLORS.bgApp, fontFamily: FONT.ui, fontSize: 13, fontWeight: 600,
display: "flex", alignItems: "center", justifyContent: "center", gap: 6,
}}
>
<Download size={14} /> Скачать {update?.latest}
</button>
) : (
<div style={{ marginTop: 10, fontSize: 12, color: COLORS.stDone }}>Установлена последняя версия</div>
)}
<button
onClick={onCheck}
disabled={checking}
style={{
marginTop: 8, width: "100%", height: 28, borderRadius: 6, cursor: checking ? "default" : "pointer",
background: "transparent", border: `1px solid ${COLORS.borderStrong}`, color: COLORS.textSecondary,
fontFamily: FONT.ui, fontSize: 12,
}}
>
{checking ? "Проверяю…" : "Проверить снова"}
</button>
</div>
</>
)}
</div>
);
}
export function TopBar({
active, eventsOpen, onToggleEvents, onShowEvents, sidebarOpen, onToggleSidebar, unread, onOpenSettings,
update, updateChecking, onCheckUpdate,
}: {
active: WorkspaceView | null;
eventsOpen: boolean;
@@ -39,6 +117,9 @@ export function TopBar({
onToggleSidebar: () => void;
unread: number;
onOpenSettings: () => void;
update: UpdateInfo | null;
updateChecking: boolean;
onCheckUpdate: () => void;
}) {
return (
<div
@@ -69,9 +150,15 @@ export function TopBar({
<div style={{ flex: 1 }} />
<style>{`
@keyframes spaceshSpin { to { transform: rotate(360deg); } }
@keyframes spaceshPulse { 0%,100% { box-shadow: 0 0 6px rgba(52,211,194,0.35); } 50% { box-shadow: 0 0 14px rgba(52,211,194,0.7); } }
`}</style>
{/* Right cluster */}
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<IconBtn icon={<Search size={16} />} title="Search (mock)" />
<UpdateControl update={update} checking={updateChecking} onCheck={onCheckUpdate} />
<div style={{ position: "relative", display: "flex" }}>
<IconBtn icon={<Bell size={16} />} onClick={onShowEvents} active={eventsOpen} title="Open activity log" />
{unread > 0 && (
+10
View File
@@ -189,6 +189,16 @@ export async function getHealth(): Promise<DaemonHealth> {
return await invoke<DaemonHealth>("health");
}
export interface UpdateInfo { current: string; latest: string; has_update: boolean; url: string }
export async function checkUpdate(): Promise<UpdateInfo> {
return await invoke<UpdateInfo>("check_update");
}
export async function openExternal(url: string): Promise<void> {
await invoke("open_external", { url });
}
export async function setZoom(workspaceId: string, surfaceId: string | null): Promise<void> {
await invoke("set_zoom", { workspaceId, surfaceId });
}