Add update check functionality
Implement version checking and update notifications in the GUI
This commit is contained in:
@@ -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)
|
||||
|
||||
Generated
+250
-1
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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(¤t);
|
||||
|
||||
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]
|
||||
|
||||
@@ -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
@@ -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
@@ -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 && (
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user