fix(core-update): support versioned tarball/zip release assets (#908)

This commit is contained in:
Steven Enamakel
2026-04-24 20:52:43 -07:00
committed by GitHub
parent 98b74b5a3d
commit c191a1f0aa
6 changed files with 385 additions and 53 deletions
+16 -16
View File
@@ -15,7 +15,7 @@ Narrative architecture: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md). Frontend
| **`Cargo.toml`** (root) | Core crate; `cargo build --bin openhuman` produces the sidecar staged by `app`'s `core:stage` |
| **`docs/`** | Architecture and module guides |
Commands assume the **repo root**; `yarn dev` delegates to the `app` workspace.
Commands assume the **repo root**; `pnpm dev` delegates to the `app` workspace. (Repo migrated from yarn to pnpm — `package.json` enforces pnpm via the `packageManager` field.)
---
@@ -34,14 +34,14 @@ Commands assume the **repo root**; `yarn dev` delegates to the `app` workspace.
## Commands (from repo root)
```bash
yarn dev # Frontend + Tauri dev
yarn tauri dev # Desktop with Tauri (loads env via scripts/load-dotenv.sh)
yarn build # Production UI build
yarn typecheck # Typecheck (app workspace)
yarn lint # ESLint
yarn format # Prettier write
yarn format:check # Prettier check
cd app && yarn core:stage # Stage openhuman binary next to Tauri resources
pnpm dev # Frontend + Tauri dev
pnpm tauri dev # Desktop with Tauri (loads env via scripts/load-dotenv.sh)
pnpm build # Production UI build
pnpm typecheck # Typecheck (app workspace)
pnpm lint # ESLint
pnpm format # Prettier write
pnpm format:check # Prettier check
cd app && pnpm core:stage # Stage openhuman binary next to Tauri resources
# Rust — core library + CLI
cargo check --manifest-path Cargo.toml
@@ -51,7 +51,7 @@ cargo build --manifest-path Cargo.toml --bin openhuman
cargo check --manifest-path app/src-tauri/Cargo.toml
```
**Tests**: Vitest in `app/` (`yarn test:unit`, `yarn test:coverage`); Rust via `cargo test`.
**Tests**: Vitest in `app/` (`pnpm test:unit`, `pnpm test:coverage`); Rust via `cargo test`.
**Quality**: ESLint + Prettier + Husky in `app`.
---
@@ -73,7 +73,7 @@ cargo check --manifest-path app/src-tauri/Cargo.toml
- Co-locate as `*.test.ts` / `*.test.tsx` under `app/src/**`.
- Config: `app/test/vitest.config.ts`; setup: `app/src/test/setup.ts`.
- Run: `yarn test:unit`, `yarn test:coverage`.
- Run: `pnpm test:unit`, `pnpm test:coverage`.
- Prefer behavior over implementation. Use helpers in `app/src/test/`. No real network, no time flakes.
### Shared mock backend
@@ -81,7 +81,7 @@ cargo check --manifest-path app/src-tauri/Cargo.toml
Used by both unit and Rust tests.
- Core: `scripts/mock-api-core.mjs` · server: `scripts/mock-api-server.mjs` · E2E wrapper: `app/test/e2e/mock-server.ts`.
- Admin: `GET /__admin/health`, `POST /__admin/reset`, `POST /__admin/behavior`, `GET /__admin/requests`.
- Run manually: `yarn mock:api`.
- Run manually: `pnpm mock:api`.
### E2E (WDIO — dual platform)
@@ -91,9 +91,9 @@ Full guide: [`docs/E2E-TESTING.md`](docs/E2E-TESTING.md).
- Specs: `app/test/e2e/specs/*.spec.ts`. Helpers in `app/test/e2e/helpers/`. Config: `app/test/wdio.conf.ts`.
```bash
yarn test:e2e:build
pnpm test:e2e:build
bash app/scripts/e2e-run-spec.sh test/e2e/specs/smoke.spec.ts smoke
yarn test:e2e:all:flows
pnpm test:e2e:all:flows
docker compose -f e2e/docker-compose.yml run --rm e2e # Linux E2E on macOS
```
@@ -106,7 +106,7 @@ Use `element-helpers.ts` (`clickNativeButton`, `waitForWebView`, `clickToggle`)
### Rust tests with mock
```bash
yarn test:rust
pnpm test:rust
bash scripts/test-rust-with-mock.sh --test json_rpc_e2e
```
@@ -262,7 +262,7 @@ Specify → prove in Rust → prove over RPC → surface in the UI → test.
## Platform notes
- **Vendored CEF-aware `tauri-cli`**: runtime is CEF; only the vendored CLI at `app/src-tauri/vendor/tauri-cef/crates/tauri-cli` bundles Chromium into `Contents/Frameworks/`. Stock `@tauri-apps/cli` produces a broken bundle (panic in `cef::library_loader::LibraryLoader::new`). `yarn dev:app` and all `cargo tauri` scripts call `yarn tauri:ensure` which runs [`scripts/ensure-tauri-cli.sh`](scripts/ensure-tauri-cli.sh). If overwritten, reinstall with `cargo install --locked --path app/src-tauri/vendor/tauri-cef/crates/tauri-cli`.
- **Vendored CEF-aware `tauri-cli`**: runtime is CEF; only the vendored CLI at `app/src-tauri/vendor/tauri-cef/crates/tauri-cli` bundles Chromium into `Contents/Frameworks/`. Stock `@tauri-apps/cli` produces a broken bundle (panic in `cef::library_loader::LibraryLoader::new`). `pnpm dev:app` and all `cargo tauri` scripts call `pnpm tauri:ensure` which runs [`scripts/ensure-tauri-cli.sh`](scripts/ensure-tauri-cli.sh). If overwritten, reinstall with `cargo install --locked --path app/src-tauri/vendor/tauri-cef/crates/tauri-cli`.
- **macOS deep links**: often require a built `.app` bundle, not just `tauri dev`.
- **Tauri environment guard**: use `isTauri()` (from `app/src/services/webviewAccountService.ts`) or wrap `invoke(...)` in `try/catch`; do not check `window.__TAURI__` directly — it is not present at module load and bypasses the established wrapper contract.
- **Core sidecar**: must be staged so `core_rpc` can reach the `openhuman` binary (see `scripts/stage-core-sidecar.mjs`).
Generated
+1 -1
View File
@@ -4420,7 +4420,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "openhuman"
version = "0.52.28"
version = "0.52.20"
dependencies = [
"aes-gcm",
"anyhow",
+52
View File
@@ -12,6 +12,7 @@ dependencies = [
"cef",
"chrono",
"env_logger",
"flate2",
"futures-util",
"log",
"mac-notification-sys",
@@ -25,6 +26,7 @@ dependencies = [
"semver",
"serde",
"serde_json",
"tar",
"tauri",
"tauri-build",
"tauri-plugin-deep-link",
@@ -34,6 +36,7 @@ dependencies = [
"tauri-runtime-cef",
"tokio",
"tokio-tungstenite",
"zip",
]
[[package]]
@@ -116,6 +119,15 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -892,6 +904,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "derive_more"
version = "0.99.20"
@@ -6574,12 +6597,41 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "zip"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
dependencies = [
"arbitrary",
"crc32fast",
"crossbeam-utils",
"displaydoc",
"flate2",
"indexmap 2.13.0",
"memchr",
"thiserror 2.0.18",
"zopfli",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]]
name = "zvariant"
version = "5.10.0"
+7
View File
@@ -66,6 +66,13 @@ futures-util = { version = "0.3", default-features = false, features = ["sink",
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
semver = "1"
# Auto-update archive extraction. GitHub release assets ship as
# `openhuman-core-<version>-<triple>.tar.gz` on Unix and `…<triple>.zip`
# on Windows; the inner binary is `openhuman-core` (or `.exe`) with no
# wrapping directory. See `core_update.rs::extract_archive`.
flate2 = "1"
tar = "0.4"
zip = { version = "2", default-features = false, features = ["deflate"] }
log = "0.4"
env_logger = "0.11"
+308 -35
View File
@@ -6,7 +6,7 @@
//! process, and restarts with the new binary.
use std::io::Write;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
@@ -160,13 +160,53 @@ fn platform_triple() -> &'static str {
}
/// Find the right asset for this platform.
///
/// Current release format (since 0.52.x): `openhuman-core-<version>-<triple>.tar.gz`
/// on Unix, `.zip` on Windows. The archive contains a single `openhuman-core`
/// (or `openhuman-core.exe`) file with no wrapping directory.
///
/// Legacy format (kept as fallback for older releases): a raw binary named
/// `openhuman-core-<triple>` (or `.exe`).
fn find_platform_asset(assets: &[GitHubAsset]) -> Option<&GitHubAsset> {
let triple = platform_triple();
let expected = format!("openhuman-core-{triple}");
let archive_ext = if cfg!(windows) { ".zip" } else { ".tar.gz" };
// New versioned-archive format: `openhuman-core-0.52.26-aarch64-apple-darwin.tar.gz`
let archive_match = assets.iter().find(|a| {
a.name.starts_with("openhuman-core-")
&& a.name.contains(triple)
&& a.name.ends_with(archive_ext)
// Defensive: avoid matching detached signatures or checksums that
// happen to share the prefix (e.g. `…tar.gz.sha256`, `…tar.gz.sig`).
&& !a.name.ends_with(".sha256")
&& !a.name.ends_with(".sig")
});
if archive_match.is_some() {
return archive_match;
}
// Legacy raw-binary format.
let legacy = format!("openhuman-core-{triple}");
let legacy_exe = format!("{legacy}.exe");
assets
.iter()
.find(|a| a.name == expected || a.name == format!("{expected}.exe"))
.or_else(|| assets.iter().find(|a| a.name.starts_with(&expected)))
.find(|a| a.name == legacy || a.name == legacy_exe)
}
/// Filename the staged core binary must be saved as for `core_process::default_core_bin`
/// to discover it on subsequent runs.
fn staged_binary_name() -> String {
let triple = platform_triple();
if cfg!(windows) {
format!("openhuman-core-{triple}.exe")
} else {
format!("openhuman-core-{triple}")
}
}
/// True if the asset name looks like an archive we need to extract (vs. a raw binary).
fn is_archive_asset(name: &str) -> bool {
name.ends_with(".tar.gz") || name.ends_with(".tgz") || name.ends_with(".zip")
}
/// Fetch the latest release from GitHub.
@@ -195,11 +235,106 @@ async fn fetch_latest_release() -> Result<GitHubRelease, String> {
.map_err(|e| format!("failed to parse release: {e}"))
}
/// Download a binary from `url` and stage it at `dest`.
/// Build a unique sibling temp path next to `dest` to stage writes before an atomic rename.
fn unique_tmp_path(dest: &Path) -> PathBuf {
let tmp_name = format!(
".openhuman-update-{}.tmp",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0)
);
dest.parent()
.unwrap_or_else(|| std::path::Path::new("."))
.join(tmp_name)
}
/// Make a file executable (Unix) and rename it atomically to `dest`. On rename
/// failure the temp file is best-effort cleaned up.
fn finalize_executable(tmp: &Path, dest: &Path) -> Result<(), String> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(tmp, std::fs::Permissions::from_mode(0o755))
.map_err(|e| format!("set permissions: {e}"))?;
}
std::fs::rename(tmp, dest).map_err(|e| {
let _ = std::fs::remove_file(tmp);
format!("rename staged binary: {e}")
})
}
/// Extract the inner core binary from a downloaded archive into `dest`.
///
/// Uses a unique temp file (UUID-based) to avoid conflicts from concurrent downloads.
/// Sets executable permissions on Unix before the atomic rename.
async fn download_binary(url: &str, dest: &PathBuf) -> Result<(), String> {
/// The archive is expected to contain a single file named `openhuman-core`
/// (or `openhuman-core.exe`) at the root — matching the layout produced by
/// the release workflow. `dest` must be the final binary path (with the
/// platform-triple-suffixed name `default_core_bin` looks for).
fn extract_archive(archive_path: &Path, dest: &Path) -> Result<(), String> {
let inner_name = if cfg!(windows) {
"openhuman-core.exe"
} else {
"openhuman-core"
};
let archive_name = archive_path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("");
let file = std::fs::File::open(archive_path).map_err(|e| format!("open archive: {e}"))?;
if archive_name.ends_with(".zip") {
let mut zip = zip::ZipArchive::new(file).map_err(|e| format!("read zip: {e}"))?;
let mut entry = zip
.by_name(inner_name)
.map_err(|e| format!("zip entry '{inner_name}' missing: {e}"))?;
let tmp = unique_tmp_path(dest);
{
let mut out = std::fs::File::create(&tmp).map_err(|e| format!("create temp: {e}"))?;
std::io::copy(&mut entry, &mut out).map_err(|e| format!("extract zip: {e}"))?;
out.flush().map_err(|e| format!("flush extracted: {e}"))?;
}
finalize_executable(&tmp, dest)?;
return Ok(());
}
// Default: tar.gz / tgz
let gz = flate2::read::GzDecoder::new(file);
let mut tar = tar::Archive::new(gz);
let entries = tar
.entries()
.map_err(|e| format!("read tar entries: {e}"))?;
for entry in entries {
let mut entry = entry.map_err(|e| format!("tar entry: {e}"))?;
let entry_path = entry.path().map_err(|e| format!("entry path: {e}"))?;
let matches = entry_path
.file_name()
.and_then(|s| s.to_str())
.map(|n| n == inner_name)
.unwrap_or(false);
if !matches {
continue;
}
let tmp = unique_tmp_path(dest);
{
let mut out = std::fs::File::create(&tmp).map_err(|e| format!("create temp: {e}"))?;
std::io::copy(&mut entry, &mut out).map_err(|e| format!("extract tar: {e}"))?;
out.flush().map_err(|e| format!("flush extracted: {e}"))?;
}
finalize_executable(&tmp, dest)?;
return Ok(());
}
Err(format!(
"archive {} contained no entry named '{inner_name}'",
archive_path.display()
))
}
/// Download `url` to `dest` atomically. Used for both raw binaries (legacy
/// release format) and archive files (current format — caller then extracts).
async fn download_to_file(url: &str, dest: &Path) -> Result<(), String> {
let client = reqwest::Client::builder()
.user_agent("openhuman-tauri-updater")
.timeout(std::time::Duration::from_secs(300))
@@ -227,19 +362,7 @@ async fn download_binary(url: &str, dest: &PathBuf) -> Result<(), String> {
dest.display()
);
// Use a unique temp filename to avoid collisions from concurrent writes.
let tmp_name = format!(
".openhuman-update-{}.tmp",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0)
);
let tmp = dest
.parent()
.unwrap_or_else(|| std::path::Path::new("."))
.join(tmp_name);
let tmp = unique_tmp_path(dest);
{
let mut file = std::fs::File::create(&tmp).map_err(|e| format!("create temp file: {e}"))?;
file.write_all(&bytes)
@@ -247,17 +370,11 @@ async fn download_binary(url: &str, dest: &PathBuf) -> Result<(), String> {
file.flush().map_err(|e| format!("flush temp file: {e}"))?;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755))
.map_err(|e| format!("set permissions: {e}"))?;
}
// Move into place. Caller is responsible for marking executable when the
// payload is a raw binary; archives stay non-executable on disk.
std::fs::rename(&tmp, dest).map_err(|e| {
// Best-effort cleanup of temp file on rename failure.
let _ = std::fs::remove_file(&tmp);
format!("rename staged binary: {e}")
format!("rename downloaded file: {e}")
})?;
Ok(())
@@ -385,10 +502,17 @@ UI features (e.g. channel connect) may not match RPC until the core is updated."
}
}
let dest = staging_dir
// Where the downloaded asset lands (named after the release asset).
let download_dest = staging_dir
.as_ref()
.map(|d| d.join(&asset.name))
.unwrap_or_else(|| PathBuf::from(&asset.name));
// Final binary path that `default_core_bin` will pick up next launch.
let binary_dest = staging_dir
.as_ref()
.map(|d| d.join(staged_binary_name()))
.unwrap_or_else(|| PathBuf::from(staged_binary_name()));
let asset_is_archive = is_archive_asset(&asset.name);
// Step 4: Acquire restart lock, shutdown old process, download, stage, restart.
// Hold the lock across download + staging + restart to prevent concurrent updates.
@@ -409,12 +533,48 @@ UI features (e.g. channel connect) may not match RPC until the core is updated."
waited += 50;
}
// Download and stage the new binary.
download_binary(&asset.browser_download_url, &dest).await?;
log::info!("[core-update] staged new binary at {}", dest.display());
// Download the asset.
download_to_file(&asset.browser_download_url, &download_dest).await?;
log::info!(
"[core-update] downloaded asset to {}",
download_dest.display()
);
if asset_is_archive {
// Extract the inner `openhuman-core` binary into the staged location.
extract_archive(&download_dest, &binary_dest)?;
log::info!(
"[core-update] extracted core binary to {}",
binary_dest.display()
);
// Best-effort cleanup of the archive (don't fail the update if this fails).
if let Err(e) = std::fs::remove_file(&download_dest) {
log::warn!(
"[core-update] could not remove archive {}: {e}",
download_dest.display()
);
}
} else {
// Legacy raw-binary asset: rename into the canonical staged path
// and mark executable.
if download_dest != binary_dest {
std::fs::rename(&download_dest, &binary_dest)
.map_err(|e| format!("rename legacy binary: {e}"))?;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&binary_dest, std::fs::Permissions::from_mode(0o755))
.map_err(|e| format!("set permissions on staged binary: {e}"))?;
}
log::info!(
"[core-update] staged legacy raw binary at {}",
binary_dest.display()
);
}
// Point the handle at the new binary so ensure_running launches it.
handle.set_core_bin(dest).await;
handle.set_core_bin(binary_dest).await;
emit_event(&app, "core-update:status", "restarting");
@@ -485,6 +645,119 @@ fn emit_event(app: &Option<tauri::AppHandle<crate::AppRuntime>>, event: &str, pa
mod tests {
use super::*;
fn asset(name: &str) -> GitHubAsset {
GitHubAsset {
name: name.to_string(),
browser_download_url: format!("https://example.test/{name}"),
}
}
#[test]
fn find_platform_asset_matches_versioned_archive() {
let triple = platform_triple();
let archive_ext = if cfg!(windows) { "zip" } else { "tar.gz" };
let archive_name = format!("openhuman-core-0.52.26-{triple}.{archive_ext}");
let assets = vec![
asset("latest.json"),
asset(&format!("{archive_name}.sha256")),
asset(&archive_name),
asset(&format!("{archive_name}.sig")),
asset(&format!("OpenHuman_0.52.26_{triple}.app.tar.gz")),
];
let m = find_platform_asset(&assets).expect("should match versioned archive");
assert_eq!(m.name, archive_name);
}
#[test]
fn find_platform_asset_falls_back_to_legacy_raw_binary() {
let triple = platform_triple();
let legacy_name = if cfg!(windows) {
format!("openhuman-core-{triple}.exe")
} else {
format!("openhuman-core-{triple}")
};
let assets = vec![asset("latest.json"), asset(&legacy_name)];
let m = find_platform_asset(&assets).expect("legacy raw binary should match");
assert_eq!(m.name, legacy_name);
}
#[test]
fn find_platform_asset_skips_signatures_and_checksums() {
let triple = platform_triple();
let archive_ext = if cfg!(windows) { "zip" } else { "tar.gz" };
let assets = vec![
asset(&format!(
"openhuman-core-0.52.26-{triple}.{archive_ext}.sha256"
)),
asset(&format!(
"openhuman-core-0.52.26-{triple}.{archive_ext}.sig"
)),
];
assert!(find_platform_asset(&assets).is_none());
}
#[test]
fn is_archive_asset_recognises_known_extensions() {
assert!(is_archive_asset(
"openhuman-core-0.52.26-aarch64-apple-darwin.tar.gz"
));
assert!(is_archive_asset(
"openhuman-core-0.52.26-x86_64-pc-windows-msvc.zip"
));
assert!(is_archive_asset("foo.tgz"));
assert!(!is_archive_asset("openhuman-core-aarch64-apple-darwin"));
assert!(!is_archive_asset("openhuman-core.exe"));
}
#[test]
fn extract_archive_pulls_inner_binary_from_targz() {
use std::io::Read;
let dir = std::env::temp_dir().join(format!(
"oh-extract-test-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
// Build an in-memory tar.gz with a single `openhuman-core` (or .exe) entry.
let inner = if cfg!(windows) {
"openhuman-core.exe"
} else {
"openhuman-core"
};
let payload = b"#!fake-binary-bytes";
let archive_path = dir.join("test.tar.gz");
{
let tar_gz = std::fs::File::create(&archive_path).unwrap();
let enc = flate2::write::GzEncoder::new(tar_gz, flate2::Compression::default());
let mut builder = tar::Builder::new(enc);
let mut header = tar::Header::new_gnu();
header.set_size(payload.len() as u64);
header.set_mode(0o755);
header.set_cksum();
builder
.append_data(&mut header, inner, &payload[..])
.unwrap();
builder.into_inner().unwrap().finish().unwrap();
}
let dest = dir.join("openhuman-core-staged");
extract_archive(&archive_path, &dest).expect("extract should succeed");
let mut got = Vec::new();
std::fs::File::open(&dest)
.unwrap()
.read_to_end(&mut got)
.unwrap();
assert_eq!(got, payload);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn outdated_detection() {
assert!(is_outdated("0.49.17", "0.51.8"));
+1 -1
View File
@@ -75,7 +75,7 @@
"plugins": {
"updater": {
"active": false,
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDc0OTREMjkxREFCNUIzRTEKUldUaHM3WGFrZEtVZEJzZWtMTlc5dGxnT0R2Q3hUTWVaclJWSm9JUFpPcVFUV2RBSG5oNFN6UjQK",
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDdDNDNENzk3QzVCN0MwNDcKUldSSHdMZkZsOWREZkpicit5c2ZWTDJtcDVhT25WMXkzbUJqcW5ucVBDdU1kN29GbUwwRDFkTkoK",
"endpoints": [
"https://github.com/tinyhumansai/openhuman/releases/latest/download/latest.json"
]