fix(core,cef): run core in-process and stop orphaning CEF helpers on Cmd+Q (#1061)

This commit is contained in:
Steven Enamakel
2026-04-30 23:22:18 -07:00
committed by GitHub
parent 9dd878cbfb
commit 016ad78086
17 changed files with 2811 additions and 2279 deletions
+2 -40
View File
@@ -71,46 +71,8 @@ jobs:
# vite build runs via tauri.conf.json's beforeBuildCommand during the
# "Build Tauri app" step below — no separate frontend build needed.
- name: Resolve core manifest and binary names
id: core-paths
shell: bash
run: |
if [ -f "openhuman_core/Cargo.toml" ]; then
CORE_DIR="openhuman_core"
elif [ -f "rust-core/Cargo.toml" ]; then
CORE_DIR="rust-core"
elif [ -f "Cargo.toml" ] && grep -q '^name = "openhuman"' Cargo.toml; then
CORE_DIR="."
else
echo "No core Cargo manifest found"
exit 1
fi
SIDE_CAR_BASE="$(node -e "const fs=require('fs');const c=JSON.parse(fs.readFileSync('app/src-tauri/tauri.conf.json','utf8'));const b=(c.bundle&&Array.isArray(c.bundle.externalBin)&&c.bundle.externalBin[0])||'binaries/openhuman-core';process.stdout.write(String(b).split('/').pop());")"
CORE_BIN_NAME="${SIDE_CAR_BASE}"
echo "core_dir=$CORE_DIR" >> "$GITHUB_OUTPUT"
echo "core_manifest=$CORE_DIR/Cargo.toml" >> "$GITHUB_OUTPUT"
echo "core_target_dir=target/x86_64-pc-windows-msvc/release" >> "$GITHUB_OUTPUT"
echo "core_bin_name=$CORE_BIN_NAME" >> "$GITHUB_OUTPUT"
echo "sidecar_base=$SIDE_CAR_BASE" >> "$GITHUB_OUTPUT"
- name: Build sidecar core binary
shell: bash
run: |
cargo build \
--manifest-path "$CORE_MANIFEST" \
--release \
--target x86_64-pc-windows-msvc \
--bin "$CORE_BIN_NAME"
env:
CORE_MANIFEST: ${{ steps.core-paths.outputs.core_manifest }}
CORE_BIN_NAME: ${{ steps.core-paths.outputs.core_bin_name }}
- name: Stage sidecar for Tauri bundler
shell: bash
run: |
bash scripts/release/stage-sidecar.sh \
x86_64-pc-windows-msvc \
"${{ steps.core-paths.outputs.core_target_dir }}" \
"${{ steps.core-paths.outputs.core_bin_name }}" \
"${{ steps.core-paths.outputs.sidecar_base }}"
# Core is linked into the Tauri binary as a path dep — no separate
# sidecar build / stage / path-resolution step needed.
- name: Define Tauri configuration overrides
id: config-overrides
# `prepareTauriConfig.js` only emits the Windows DigiCert sign
+3 -8
View File
@@ -61,19 +61,14 @@ jobs:
- name: Install cmake (for whisper-rs)
run: apt-get update && apt-get install -y --no-install-recommends cmake &&
rm -rf /var/lib/apt/lists/*
- name: Build sidecar core binary
run: cargo build --profile ci --target x86_64-unknown-linux-gnu --bin openhuman-core
- name: Stage sidecar for Tauri bundler
run: |
mkdir -p app/src-tauri/binaries
cp target/x86_64-unknown-linux-gnu/ci/openhuman-core app/src-tauri/binaries/openhuman-core-x86_64-unknown-linux-gnu
chmod +x app/src-tauri/binaries/openhuman-core-x86_64-unknown-linux-gnu
# Core is linked into the Tauri binary as a path dep — no separate
# sidecar build / stage step needed.
- name: Build Tauri app (CEF default)
working-directory: app
run: |
# Skip tsc in beforeBuildCommand — typechecking runs in the dedicated
# `typecheck` workflow, so doing it again here is duplicated CI time.
TAURI_CONFIG_OVERRIDE='{"build":{"beforeBuildCommand":"npx vite build && pnpm run core:stage"},"plugins":{"updater":{"active":false}}}'
TAURI_CONFIG_OVERRIDE='{"build":{"beforeBuildCommand":"npx vite build"},"plugins":{"updater":{"active":false}}}'
cargo tauri build -c "$TAURI_CONFIG_OVERRIDE" --bundles deb
env:
NODE_ENV: production
+1 -6
View File
@@ -89,12 +89,7 @@ jobs:
- name: Build E2E app
if: steps.gate.outputs.present == 'true'
run: pnpm --filter openhuman-app test:e2e:build
- name: Stage sidecar next to app binary
if: steps.gate.outputs.present == 'true'
run: |
cp app/src-tauri/binaries/openhuman-core-x86_64-unknown-linux-gnu \
app/src-tauri/target/debug/openhuman-core-x86_64-unknown-linux-gnu
chmod +x app/src-tauri/target/debug/openhuman-core-x86_64-unknown-linux-gnu
# Core is linked in-process — no sidecar staging needed.
- name: Run agent-review E2E spec under Xvfb
if: steps.gate.outputs.present == 'true'
run: |
+10 -12
View File
@@ -1,19 +1,17 @@
---
name: Release Packages
# Triggered after the main release.yml publishes a release.
# By the time this fires, macOS (aarch64 + x86_64) and Linux x86_64
# CLI tarballs are already attached to the release by release.yml.
# DISABLED while core distribution is Docker-only — see PR #1061.
#
# This workflow adds:
# 1. Linux arm64 CLI tarball
# 2. Homebrew tap formula update
# 3. Debian apt repository (gh-pages)
# 4. npm package publish
# 5. Smoke tests for each channel
# 6. One-time backlog issue for future package managers
# This workflow built standalone CLI tarballs / .deb / Homebrew / npm
# packages that wrapped the `openhuman-core` binary. Now that the core is
# linked into the Tauri shell as a path dep and shipped via the desktop
# bundle (with Docker as the only headless channel), there is no separate
# CLI binary to redistribute. Re-enable by switching the trigger back to
# `on: release: types: [published]` once a standalone CLI binary is
# re-introduced — every job below still references `package-cli-tarball.sh`
# and the `openhuman-core` cargo bin, so they will resume working then.
on:
release:
types: [published]
workflow_dispatch:
permissions:
contents: write
pages: write
+12 -183
View File
@@ -427,84 +427,11 @@ jobs:
# Note: vite build runs via tauri.conf.json's beforeBuildCommand during
# the "Build and package Tauri app" step below. A separate frontend build
# here would just double memory pressure (sentry-vite-plugin peaks hard).
- name: Resolve core manifest and binary names
id: core-paths
shell: bash
run: |
if [ -f "openhuman_core/Cargo.toml" ]; then
CORE_DIR="openhuman_core"
elif [ -f "rust-core/Cargo.toml" ]; then
CORE_DIR="rust-core"
elif [ -f "Cargo.toml" ] && grep -q '^name = "openhuman"' Cargo.toml; then
CORE_DIR="."
else
echo "No core Cargo manifest found (expected root Cargo.toml with openhuman, openhuman_core/Cargo.toml, or rust-core/Cargo.toml)"
exit 1
fi
SIDE_CAR_BASE="$(node -e "const fs=require('fs');const c=JSON.parse(fs.readFileSync('app/src-tauri/tauri.conf.json','utf8'));const b=(c.bundle&&Array.isArray(c.bundle.externalBin)&&c.bundle.externalBin[0])||'binaries/openhuman-core';process.stdout.write(String(b).split('/').pop());")"
CORE_BIN_NAME="${SIDE_CAR_BASE}"
BUILD_PROFILE="${BUILD_PROFILE:-release}"
echo "core_dir=$CORE_DIR" >> "$GITHUB_OUTPUT"
echo "core_manifest=$CORE_DIR/Cargo.toml" >> "$GITHUB_OUTPUT"
# Cargo workspace artifacts are under repo root target/, not <member>/target/
echo "core_target_dir=target/$MATRIX_TARGET/$BUILD_PROFILE" >> "$GITHUB_OUTPUT"
echo "build_profile=$BUILD_PROFILE" >> "$GITHUB_OUTPUT"
echo "core_bin_name=$CORE_BIN_NAME" >> "$GITHUB_OUTPUT"
echo "sidecar_base=$SIDE_CAR_BASE" >> "$GITHUB_OUTPUT"
env:
MATRIX_TARGET: ${{ matrix.settings.target }}
BUILD_PROFILE: ${{ inputs.build_target == 'staging' && 'debug' || 'release' }}
- name: Build sidecar core binary
shell: bash
run: |
if [ "$BUILD_PROFILE" = "release" ]; then
cargo build \
--manifest-path "$CORE_MANIFEST" \
--release \
--target "$MATRIX_TARGET" \
--bin "$CORE_BIN_NAME"
else
cargo build \
--manifest-path "$CORE_MANIFEST" \
--target "$MATRIX_TARGET" \
--bin "$CORE_BIN_NAME"
fi
env:
MATRIX_TARGET: ${{ matrix.settings.target }}
BUILD_PROFILE: ${{ steps.core-paths.outputs.build_profile }}
CORE_MANIFEST: ${{ steps.core-paths.outputs.core_manifest }}
CORE_BIN_NAME: ${{ steps.core-paths.outputs.core_bin_name }}
OPENHUMAN_APP_ENV: ${{ inputs.build_target == 'staging' && 'staging' || 'production' }}
# Core (Rust openhuman sidecar) Sentry DSN — separate Sentry project
# from the React frontend and the Tauri shell.
OPENHUMAN_SENTRY_DSN: ${{ vars.OPENHUMAN_CORE_SENTRY_DSN }}
# Sentry release tracking (#405): `option_env!("OPENHUMAN_BUILD_SHA")`
# in src/main.rs bakes the short SHA into the release tag
# (`openhuman@<version>+<sha>`) so core events match the frontend.
OPENHUMAN_BUILD_SHA: ${{ needs.prepare-build.outputs.sha }}
- name: Stage sidecar for Tauri bundler
shell: bash
run: |
bash scripts/release/stage-sidecar.sh \
"${{ matrix.settings.target }}" \
"${{ steps.core-paths.outputs.core_target_dir }}" \
"${{ steps.core-paths.outputs.core_bin_name }}" \
"${{ steps.core-paths.outputs.sidecar_base }}"
- name: Resolve standalone core CLI artifact path
id: cli-paths
shell: bash
run: |
BASE_DIR="$CORE_TARGET_DIR"
EXE_SUFFIX=""
if [[ "$MATRIX_TARGET" == *"windows"* ]]; then
EXE_SUFFIX=".exe"
fi
echo "base_dir=$BASE_DIR" >> "$GITHUB_OUTPUT"
echo "cli_path=$BASE_DIR/${CORE_BIN_NAME}${EXE_SUFFIX}" >> "$GITHUB_OUTPUT"
env:
MATRIX_TARGET: ${{ matrix.settings.target }}
CORE_TARGET_DIR: ${{ steps.core-paths.outputs.core_target_dir }}
CORE_BIN_NAME: ${{ steps.core-paths.outputs.core_bin_name }}
#
# The core no longer ships as a sidecar — it's linked into the Tauri
# binary as a path dep (`openhuman_core` in app/src-tauri/Cargo.toml)
# and the JSON-RPC server runs as an in-process tokio task. Docker is
# the only standalone distribution channel for the core CLI now.
# CEF is the default runtime — we build via the vendored tauri-cli so
# the bundler copies Chromium Embedded Framework files into the .app /
@@ -596,37 +523,12 @@ jobs:
MATRIX_TARGET: ${{ matrix.settings.target }}
run: |
set -euo pipefail
deps_dir="target/${MATRIX_TARGET}/release/deps"
if [ -d "$deps_dir" ]; then
echo "==> Uploading core symbols from $deps_dir to ${SENTRY_PROJECT}"
bash scripts/upload_sentry_symbols.sh "$VERSION" "$deps_dir"
else
echo "==> Skipping $deps_dir (not present)"
fi
- name: Upload Tauri shell debug symbols to Sentry
if:
needs.prepare-build.outputs.release_enabled == 'true' && env.SENTRY_AUTH_TOKEN
!= ''
shell: bash
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT_TAURI }}
# Must match the release tag the running shell binary reports
# (`openhuman@<version>+<sha>`, see `build_sentry_release_tag` in
# app/src-tauri/src/lib.rs). If this drifts, DIFs attach to a
# different release than events and stack traces stay
# un-symbolicated.
SENTRY_RELEASE:
openhuman@${{ needs.prepare-build.outputs.version }}+${{
needs.prepare-build.outputs.sha }}
VERSION: ${{ needs.prepare-build.outputs.version }}
MATRIX_TARGET: ${{ matrix.settings.target }}
run: |
set -euo pipefail
# Core is linked into the Tauri binary as a path dep, so all Rust
# debug info — core + shell — lives under the shell's target dir.
# upload-dif scans recursively.
deps_dir="app/src-tauri/target/${MATRIX_TARGET}/release/deps"
if [ -d "$deps_dir" ]; then
echo "==> Uploading Tauri shell symbols from $deps_dir to ${SENTRY_PROJECT}"
echo "==> Uploading symbols from $deps_dir to ${SENTRY_PROJECT}"
bash scripts/upload_sentry_symbols.sh "$VERSION" "$deps_dir"
else
echo "==> Skipping $deps_dir (not present)"
@@ -705,7 +607,7 @@ jobs:
echo "bundle_dir=$BUNDLE_DIR" >> "$GITHUB_OUTPUT"
echo "Found .app at: $APP_PATH"
echo "Bundle dir: $BUNDLE_DIR"
- name: Re-sign sidecar with hardened runtime and notarize
- name: Sign and notarize macOS .app
if: matrix.settings.platform == 'macos-latest'
shell: bash
env:
@@ -746,78 +648,13 @@ jobs:
"${{ steps.locate-app.outputs.bundle_dir }}" \
"${{ needs.prepare-build.outputs.version }}" \
"${{ matrix.settings.target }}"
- name: Verify macOS app bundle sidecar layout
- name: Verify macOS notarization staple
if: matrix.settings.platform == 'macos-latest'
shell: bash
run: |
APP_PATH="${{ steps.locate-app.outputs.app_path }}"
echo "Inspecting bundle at: $APP_PATH"
ls -la "$APP_PATH/Contents/MacOS"
ls -la "$APP_PATH/Contents/Resources" | grep openhuman || true
# Sidecar may be in MacOS/ or Resources/ depending on Tauri version
FOUND=false
if ls "$APP_PATH/Contents/MacOS"/openhuman* 2>/dev/null | grep -qv OpenHuman; then
echo "Sidecar found in Contents/MacOS/"
FOUND=true
fi
if ls "$APP_PATH/Contents/Resources"/openhuman-* 2>/dev/null; then
echo "Sidecar found in Contents/Resources/"
FOUND=true
fi
if [ "$FOUND" = "false" ]; then
echo "WARNING: Sidecar binary not found in expected locations"
fi
# Verify notarization staple
echo "Checking staple..."
echo "Checking staple at: $APP_PATH"
xcrun stapler validate "$APP_PATH" || echo "WARNING: Staple validation failed"
- name: Package CLI tarball and upload to release (unix)
if:
matrix.settings.platform != 'windows-latest' && needs.prepare-build.outputs.release_enabled
== 'true'
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# For macOS, prefer the notarized binary from inside the .app bundle
if [[ "${{ matrix.settings.platform }}" == "macos-latest" ]]; then
APP_PATH="${{ steps.locate-app.outputs.app_path }}"
BIN=$(find "$APP_PATH/Contents/MacOS" -maxdepth 1 -name "openhuman-core-*" \
! -name "*.sig" 2>/dev/null | head -1 || true)
[[ -z "$BIN" ]] && BIN=$(find "$APP_PATH/Contents/Resources" -maxdepth 1 \
-name "openhuman-core-*" ! -name "*.sig" 2>/dev/null | head -1 || true)
if [[ -z "$BIN" ]]; then
echo "[pkg] Falling back to target dir binary (no notarized sidecar found)"
BIN="${{ steps.cli-paths.outputs.cli_path }}"
fi
else
BIN="${{ steps.cli-paths.outputs.cli_path }}"
fi
bash scripts/release/package-cli-tarball.sh \
"$BIN" \
"${{ needs.prepare-build.outputs.version }}" \
"${{ matrix.settings.target }}"
- name: Package CLI zip and upload to release (windows)
if:
matrix.settings.platform == 'windows-latest' && needs.prepare-build.outputs.release_enabled
== 'true'
shell: pwsh
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
$Version = "${{ needs.prepare-build.outputs.version }}"
$Target = "${{ matrix.settings.target }}"
$BinPath = "${{ steps.cli-paths.outputs.cli_path }}"
$ZipName = "openhuman-core-${Version}-${Target}.zip"
$Work = New-Item -ItemType Directory -Path (Join-Path $env:TEMP "cli-pkg-$(Get-Random)")
Copy-Item $BinPath (Join-Path $Work.FullName "openhuman-core.exe")
Compress-Archive -Path (Join-Path $Work.FullName "openhuman-core.exe") -DestinationPath $ZipName
$Hash = (Get-FileHash -Path $ZipName -Algorithm SHA256).Hash.ToLowerInvariant()
Set-Content -Path "${ZipName}.sha256" -Value $Hash -NoNewline
Write-Host "[package-cli] Created $ZipName (sha256: $Hash)"
gh release upload "v${Version}" $ZipName "${ZipName}.sha256" --repo tinyhumansai/openhuman --clobber
Write-Host "[package-cli] Uploaded $ZipName to v${Version}"
- name: Upload staging desktop bundles
if: needs.prepare-build.outputs.release_enabled != 'true'
uses: actions/upload-artifact@v4
@@ -828,14 +665,6 @@ jobs:
path: |
app/src-tauri/target/${{ matrix.settings.target }}/${{ inputs.build_target == 'staging' && 'debug' || 'release' }}/bundle/**
target/${{ matrix.settings.target }}/${{ inputs.build_target == 'staging' && 'debug' || 'release' }}/bundle/**
- name: Upload standalone CLI artifacts
uses: actions/upload-artifact@v4
with:
name:
standalone-bins-${{ matrix.settings.platform }}-${{ matrix.settings.artifact_suffix
}}
path: |
${{ steps.cli-paths.outputs.cli_path }}
# =========================================================================
# Phase 3b: Build & push Docker image (runs parallel with build-desktop)
+2 -7
View File
@@ -130,13 +130,8 @@ jobs:
- name: Install sccache
uses: mozilla-actions/sccache-action@v0.0.9
- name: Build sidecar core binary
run: cargo build --profile ci --target x86_64-unknown-linux-gnu --bin openhuman-core
- name: Stage sidecar for Tauri shell tests
run: |
mkdir -p app/src-tauri/binaries
cp target/x86_64-unknown-linux-gnu/ci/openhuman-core app/src-tauri/binaries/openhuman-core-x86_64-unknown-linux-gnu
chmod +x app/src-tauri/binaries/openhuman-core-x86_64-unknown-linux-gnu
# Core is linked into the Tauri binary as a path dep, so the shell's
# cargo test pulls it in automatically — no separate sidecar build.
- name: Test Tauri shell (OpenHuman)
run: cargo test --manifest-path app/src-tauri/Cargo.toml
+3 -3
View File
@@ -8,10 +8,10 @@
"scripts": {
"dev": "vite",
"dev:web": "vite",
"dev:app": "pnpm tauri:ensure && export CEF_PATH=\"$HOME/Library/Caches/tauri-cef\" && pnpm core:stage && bash ../scripts/setup-chromium-safe-storage.sh && source ../scripts/load-dotenv.sh && APPLE_SIGNING_IDENTITY='OpenHuman Dev Signer' cargo tauri dev",
"dev:app": "pnpm tauri:ensure && export CEF_PATH=\"$HOME/Library/Caches/tauri-cef\" && bash ../scripts/setup-chromium-safe-storage.sh && source ../scripts/load-dotenv.sh && APPLE_SIGNING_IDENTITY='OpenHuman Dev Signer' cargo tauri dev",
"dev:cef": "pnpm dev:app",
"dev:wry": "pnpm tauri:ensure && export CEF_PATH=\"$HOME/Library/Caches/tauri-cef\" && pnpm core:stage && source ../scripts/load-dotenv.sh && cargo tauri dev --no-default-features --features wry",
"core:stage": "node ../scripts/stage-core-sidecar.mjs",
"dev:wry": "pnpm tauri:ensure && export CEF_PATH=\"$HOME/Library/Caches/tauri-cef\" && source ../scripts/load-dotenv.sh && cargo tauri dev --no-default-features --features wry",
"core:stage": "echo '[core:stage] no-op — core is linked in-process; sidecar removed (PR #1061)'",
"tauri:ensure": "bash ../scripts/ensure-tauri-cli.sh",
"build": "tsc && vite build",
"build:app": "tsc && vite build",
+2581 -166
View File
File diff suppressed because it is too large Load Diff
+8 -9
View File
@@ -80,15 +80,6 @@ hex = "0.4"
# "No provider set" the first time `tauri dev` proxies a request. We install
# the ring provider at startup in `lib.rs::run()`.
rustls = { version = "0.23", default-features = false, features = ["ring"] }
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"
@@ -114,6 +105,14 @@ async-trait = "0.1"
tauri-runtime-cef = { path = "vendor/tauri-cef/crates/tauri-runtime-cef" }
cef = { version = "=146.4.1", default-features = false }
# Core domain logic, embedded in-process so the core's HTTP/JSON-RPC server
# runs as a tokio task inside the Tauri host. Avoids the orphan-sidecar class
# of bugs (PR #1061: Cmd+Q leaving `openhuman-core` and CEF helpers behind)
# by tying the core's lifetime to the GUI process. The existing port-7788
# probe in `core_process::ensure_running` still attaches to a running
# `openhuman-core run` harness when one is already listening.
openhuman_core = { path = "../..", package = "openhuman", default-features = false }
[target.'cfg(unix)'.dependencies]
nix = { version = "0.29", default-features = false, features = ["signal"] }
+89 -539
View File
@@ -1,53 +1,20 @@
use std::io::IsTerminal;
use std::path::PathBuf;
//! In-process core lifecycle.
//!
//! The core's HTTP/JSON-RPC server runs as a tokio task inside the Tauri host
//! so its lifetime is tied to the GUI process — there is no sidecar to leak
//! on Cmd+Q. If something is already listening on the configured port (e.g.
//! a manual `openhuman-core run` harness for debugging), `ensure_running`
//! attaches to it instead of spawning a duplicate listener.
use std::sync::Arc;
use std::sync::LazyLock;
use parking_lot::RwLock;
use tokio::net::TcpStream;
use tokio::process::{Child, Command};
use tokio::sync::Mutex;
use tokio::task::JoinHandle;
use tokio::time::{timeout, Duration};
/// Propagate ANSI color hints to the spawned core child.
///
/// Core's tracing formatter auto-detects color via `stderr.is_terminal()`,
/// but when core runs as a grandchild under `yarn tauri dev` the inherited
/// stderr may not register as a TTY even though the ultimate terminal
/// supports ANSI. If the Tauri process itself is attached to a TTY we
/// forward `FORCE_COLOR=1` so core emits colored log lines; `NO_COLOR`
/// (user opt-out) always wins and short-circuits the propagation.
fn apply_core_color_env(cmd: &mut Command) {
if std::env::var_os("NO_COLOR").is_some() {
return;
}
if std::io::stderr().is_terminal() {
cmd.env("FORCE_COLOR", "1");
}
}
/// Hide the console window that Windows would otherwise allocate for the
/// core sidecar. The core binary is a console-subsystem executable so that
/// `openhuman core run` in a terminal behaves normally, but when the GUI
/// shell spawns it as a child a stray conhost window pops up on top of the
/// app. `CREATE_NO_WINDOW` suppresses that while leaving stdout/stderr
/// piping intact for our log forwarding.
#[cfg(windows)]
fn apply_core_no_window(cmd: &mut Command) {
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
cmd.creation_flags(CREATE_NO_WINDOW);
}
#[cfg(not(windows))]
fn apply_core_no_window(_cmd: &mut Command) {}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CoreRunMode {
InProcess,
ChildProcess,
}
/// Generate a 256-bit cryptographically-random bearer token as a hex string.
///
/// Uses the same encoding as `openhuman_core::core::auth::generate_token`
@@ -68,41 +35,35 @@ pub fn current_rpc_token() -> Option<String> {
#[derive(Clone)]
pub struct CoreProcessHandle {
child: Arc<Mutex<Option<Child>>>,
task: Arc<Mutex<Option<JoinHandle<()>>>>,
restart_lock: Arc<Mutex<()>>,
port: u16,
core_bin: Option<PathBuf>,
/// Override path set by the auto-updater after staging a new binary.
core_bin_override: Arc<Mutex<Option<PathBuf>>>,
run_mode: CoreRunMode,
/// Bearer token passed to the core via `OPENHUMAN_CORE_TOKEN` and returned
/// to the frontend so every RPC request can include `Authorization: Bearer`.
/// Bearer token the embedded server validates on every inbound request.
/// Passed to the embedded server through the `OPENHUMAN_CORE_TOKEN`
/// process env var (set in `ensure_running` before spawn) and exposed to
/// the frontend via the `core_rpc_token` Tauri command so every RPC call
/// can include `Authorization: Bearer`.
rpc_token: Arc<String>,
}
impl CoreProcessHandle {
pub fn new(port: u16, core_bin: Option<PathBuf>, run_mode: CoreRunMode) -> Self {
pub fn new(port: u16) -> Self {
// CURRENT_RPC_TOKEN is intentionally NOT set here. It is published by
// ensure_running() only after the embedded server has been spawned
// with OPENHUMAN_CORE_TOKEN in scope. Setting it here would advertise
// a token that an existing process listening on the port (the
// harness-attach fast-path) has never seen, causing 401s on every
// authenticated call.
let rpc_token = generate_rpc_token();
// CURRENT_RPC_TOKEN is intentionally NOT set here. It is published by
// ensure_running() only after the child process that received
// OPENHUMAN_CORE_TOKEN has been successfully spawned. Setting it here
// would advertise a token that the running core (which may be a stale
// process the handle did not spawn) has never seen, causing 401s on
// every subsequent authenticated call.
Self {
child: Arc::new(Mutex::new(None)),
task: Arc::new(Mutex::new(None)),
restart_lock: Arc::new(Mutex::new(())),
port,
core_bin,
core_bin_override: Arc::new(Mutex::new(None)),
run_mode,
rpc_token: Arc::new(rpc_token),
}
}
/// The bearer token the core process uses to authenticate inbound RPC requests.
/// The bearer token the embedded core validates on inbound RPC requests.
pub fn rpc_token(&self) -> &str {
&self.rpc_token
}
@@ -115,28 +76,6 @@ impl CoreProcessHandle {
self.port
}
/// Replace the core binary path so that the next `ensure_running()` launches
/// the new binary instead of the original one captured at construction time.
pub async fn set_core_bin(&self, new_bin: PathBuf) {
// We store it via a second field; but since core_bin is not behind a lock,
// we work around this by swapping the entire handle's notion of what to launch.
// For now, mutate through an interior-mutable wrapper.
log::info!(
"[core] set_core_bin: updating core binary path to {}",
new_bin.display()
);
*self.core_bin_override.lock().await = Some(new_bin);
}
/// Resolve which binary to launch: override (set by `set_core_bin`) > original.
async fn effective_core_bin(&self) -> Option<PathBuf> {
let override_guard = self.core_bin_override.lock().await;
if let Some(ref path) = *override_guard {
return Some(path.clone());
}
self.core_bin.clone()
}
/// Acquire the restart lock to serialize overlapping restart requests.
pub async fn restart_lock(&self) -> tokio::sync::MutexGuard<'_, ()> {
self.restart_lock.lock().await
@@ -160,95 +99,37 @@ impl CoreProcessHandle {
self.rpc_url()
);
log::warn!(
"[core] reusing port {} — if channel/Telegram behavior mismatches the app, another stale `openhuman` core may be attached; check [core-update] logs for version skew.",
"[core] reusing port {} — another `openhuman-core` instance is already listening; this Tauri host will not spawn an embedded server. Authenticated Tauri-side calls will 401 unless the listener was started with this process's OPENHUMAN_CORE_TOKEN.",
self.port
);
return Ok(());
}
let effective_bin = self.effective_core_bin().await;
match self.run_mode {
CoreRunMode::InProcess => {
log::warn!(
"[core] in-process core mode is unavailable in host-only build; falling back to child process"
);
let mut guard = self.child.lock().await;
if guard.is_none() {
let mut cmd = if let Some(core_bin) = &effective_bin {
let mut cmd = Command::new(core_bin);
if is_current_exe_path(core_bin) {
cmd.arg("core");
}
cmd.arg("run").arg("--port").arg(self.port.to_string());
cmd
{
let mut guard = self.task.lock().await;
if guard.is_none() {
let port = self.port;
// Set OPENHUMAN_CORE_TOKEN as a process-global env var before
// spawning the embedded server. Same-process tokio task reads
// the same env, matching what a child sidecar would have
// received via Command::env.
std::env::set_var("OPENHUMAN_CORE_TOKEN", self.rpc_token.as_str());
log::info!("[core] spawning embedded in-process core server on port {port}");
let task = tokio::spawn(async move {
if let Err(e) =
openhuman_core::core::jsonrpc::run_server_embedded(None, Some(port), true)
.await
{
log::error!("[core] embedded core server exited with error: {e}");
} else {
let exe = std::env::current_exe()
.map_err(|e| format!("failed to resolve current executable: {e}"))?;
let mut cmd = Command::new(exe);
cmd.arg("core")
.arg("run")
.arg("--port")
.arg(self.port.to_string());
cmd
};
apply_core_color_env(&mut cmd);
apply_core_no_window(&mut cmd);
cmd.env("OPENHUMAN_CORE_TOKEN", self.rpc_token.as_str());
let child = cmd
.spawn()
.map_err(|e| format!("failed to spawn core process: {e}"))?;
*guard = Some(child);
// Publish only after the child that holds OPENHUMAN_CORE_TOKEN
// has been spawned successfully.
*CURRENT_RPC_TOKEN.write() = Some(self.rpc_token.to_string());
log::debug!("[auth] CURRENT_RPC_TOKEN set after in-process spawn");
}
}
CoreRunMode::ChildProcess => {
let mut guard = self.child.lock().await;
if guard.is_none() {
let mut cmd = if let Some(core_bin) = &effective_bin {
let mut cmd = Command::new(core_bin);
if is_current_exe_path(core_bin) {
// Safety: if core_bin resolves to this GUI executable, force the
// explicit subcommand path so we don't accidentally relaunch clients.
cmd.arg("core");
}
cmd.arg("run").arg("--port").arg(self.port.to_string());
log::info!(
"[core] spawning dedicated core binary: {:?} run --port {}",
cmd.as_std().get_program(),
self.port
);
cmd
} else {
let exe = std::env::current_exe()
.map_err(|e| format!("failed to resolve current executable: {e}"))?;
let mut cmd = Command::new(exe);
cmd.arg("core")
.arg("run")
.arg("--port")
.arg(self.port.to_string());
log::warn!(
"[core] dedicated core binary not found; falling back to self subcommand"
);
cmd
};
apply_core_color_env(&mut cmd);
apply_core_no_window(&mut cmd);
cmd.env("OPENHUMAN_CORE_TOKEN", self.rpc_token.as_str());
let child = cmd
.spawn()
.map_err(|e| format!("failed to spawn core process: {e}"))?;
*guard = Some(child);
// Publish only after the child that holds OPENHUMAN_CORE_TOKEN
// has been spawned successfully.
*CURRENT_RPC_TOKEN.write() = Some(self.rpc_token.to_string());
log::debug!("[auth] CURRENT_RPC_TOKEN set after child process spawn");
}
log::info!("[core] embedded core server exited cleanly");
}
});
*guard = Some(task);
// Publish only after the embedded server has been spawned
// with OPENHUMAN_CORE_TOKEN in scope.
*CURRENT_RPC_TOKEN.write() = Some(self.rpc_token.to_string());
log::debug!("[auth] CURRENT_RPC_TOKEN set after embedded spawn");
}
}
@@ -258,272 +139,101 @@ impl CoreProcessHandle {
return Ok(());
}
match self.run_mode {
CoreRunMode::InProcess => {
let mut guard = self.task.lock().await;
if let Some(task) = guard.as_ref() {
if task.is_finished() {
let task = guard.take().expect("checked is_some");
drop(guard);
match task.await {
Ok(_) => {
return Err(
"in-process core server exited before becoming ready"
.to_string(),
)
}
Err(err) => {
return Err(format!(
"in-process core server task failed before ready: {err}"
))
}
}
let mut guard = self.task.lock().await;
if let Some(task) = guard.as_ref() {
if task.is_finished() {
let task = guard.take().expect("checked is_some");
drop(guard);
return match task.await {
Ok(_) => {
Err("in-process core server exited before becoming ready".to_string())
}
}
}
CoreRunMode::ChildProcess => {
let mut guard = self.child.lock().await;
if let Some(child) = guard.as_mut() {
match child.try_wait() {
Ok(Some(status)) => {
return Err(format!("core process exited before ready: {status}"));
}
Ok(None) => {}
Err(e) => {
return Err(format!("failed checking core process status: {e}"));
}
}
}
Err(err) => Err(format!(
"in-process core server task failed before ready: {err}"
)),
};
}
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
tokio::time::sleep(Duration::from_millis(100)).await;
}
Err("core process did not become ready".to_string())
}
/// Restart the core process to pick up updated macOS permission grants.
/// Restart the embedded core to pick up updated macOS permission grants.
///
/// macOS caches permission state per-process; the running sidecar never sees
/// a newly granted permission until it restarts. This method shuts down the
/// current child, waits until the RPC port is free (so `ensure_running` does not
/// fast-return while the old listener is still bound), then spawns a fresh instance.
///
/// If another process is listening on the core port (e.g. manual `openhuman core run`),
/// shutdown does not stop it — we time out and return an error instead of a false success.
/// macOS caches permission state per-process; restarting forces a fresh
/// read. If something else is bound to the port (e.g. a manual
/// `openhuman-core run` harness) we surface that instead of looping.
///
/// Issue: <https://github.com/tinyhumansai/openhuman/issues/133>
pub async fn restart(&self) -> Result<(), String> {
log::info!("[core] restarting core process for permission refresh");
log::info!("[core] restarting embedded core server for permission refresh");
let had_managed_child = {
let guard = self.child.lock().await;
let had_managed_task = {
let guard = self.task.lock().await;
guard.is_some()
};
log::debug!(
"[core] restart: had_managed_child={} before shutdown",
had_managed_child
);
self.shutdown().await;
log::debug!(
"[core] restart: shutdown complete, checking port {}",
self.port
);
// If we never spawned the sidecar (something else was already listening), we cannot free
// the port — fail fast with a clear message instead of polling for 8s.
if !had_managed_child && self.is_rpc_port_open().await {
if !had_managed_task && self.is_rpc_port_open().await {
log::error!(
"[core] restart: no child to stop but port {} is open — another process owns it",
"[core] restart: nothing to stop but port {} is in use — another process owns it",
self.port
);
return Err(format!(
"Core RPC port {} is already in use by another process (OpenHuman did not start it). Quit any `openhuman core run` in a terminal or free the port, then relaunch the app. You can also set OPENHUMAN_CORE_PORT to a different port.",
"Core RPC port {} is already in use by another process (OpenHuman did not start it). Quit any `openhuman-core run` in a terminal or set OPENHUMAN_CORE_PORT to a different port, then relaunch the app.",
self.port
));
}
// After kill+wait on our child, the port should close; poll briefly in case the OS is slow
// to release the socket.
const POLL_MS: u64 = 50;
const MAX_WAIT_MS: u64 = 10_000;
let mut waited_ms: u64 = 0;
while self.is_rpc_port_open().await {
if waited_ms >= MAX_WAIT_MS {
log::error!(
"[core] restart: port {} still in use after {}ms (had_managed_child={})",
self.port,
MAX_WAIT_MS,
had_managed_child
);
return Err(format!(
"Core RPC port {} did not become free after stopping the sidecar. Quit any other process using this port (e.g. `openhuman core run`) or change OPENHUMAN_CORE_PORT.",
"Core RPC port {} did not become free after stopping the embedded server.",
self.port
));
}
tokio::time::sleep(std::time::Duration::from_millis(POLL_MS)).await;
tokio::time::sleep(Duration::from_millis(POLL_MS)).await;
waited_ms += POLL_MS;
}
log::debug!("[core] restart: port free, calling ensure_running");
let result = self.ensure_running().await;
match &result {
Ok(()) => log::info!("[core] restart: core process ready after restart"),
Err(e) => log::error!("[core] restart: failed to restart core process: {e}"),
Ok(()) => log::info!("[core] restart: embedded core ready after restart"),
Err(e) => log::error!("[core] restart: failed to restart embedded core: {e}"),
}
result
}
/// Stop the core process this handle spawned (child or in-process task). Safe to call if
/// nothing was spawned or core was already external.
///
/// On Unix, sends SIGTERM first so the core process can run its graceful
/// shutdown hooks (e.g. stopping the autocomplete engine and its Swift
/// overlay helper). Falls back to SIGKILL after a timeout.
pub async fn shutdown(&self) {
let mut child_guard = self.child.lock().await;
if let Some(mut child) = child_guard.take() {
log::info!("[core] terminating child core process on app shutdown");
let exited = self.try_graceful_terminate(&child).await;
if !exited {
log::info!("[core] graceful shutdown timed out, sending SIGKILL");
if let Err(e) = child.kill().await {
log::warn!("[core] failed to kill child core process: {e}");
}
}
// Wait for the process to exit so the RPC listen socket is released before restart
// checks the port (otherwise we can spuriously hit "port still in use").
match timeout(Duration::from_secs(12), child.wait()).await {
Ok(Ok(status)) => {
log::debug!("[core] child core process reaped after shutdown: {status}");
}
Ok(Err(e)) => {
log::warn!("[core] wait on child core process after shutdown: {e}");
}
Err(_) => {
log::warn!("[core] timed out waiting for child core process to exit (12s)");
}
}
}
/// Lock the task slot, take its handle if any, and abort it. Shared by
/// `shutdown` (cleanup-on-drop semantics) and `send_terminate_signal`
/// (cooperative early teardown from `RunEvent::ExitRequested`).
async fn abort_task(&self, log_context: &str) {
let mut task_guard = self.task.lock().await;
if let Some(task) = task_guard.take() {
log::info!("[core] aborting embedded core server task{log_context}");
task.abort();
}
}
/// Send SIGTERM (Unix) / TerminateProcess (Windows) to the spawned
/// child without waiting for it to exit. Returns immediately.
/// Stop the embedded server task. Safe to call when nothing is running.
pub async fn shutdown(&self) {
self.abort_task("").await;
}
/// Synchronous-friendly shutdown for `RunEvent::ExitRequested`.
///
/// Used by the Tauri `RunEvent::ExitRequested` path so app shutdown
/// doesn't block on the core sidecar: the child receives the signal,
/// runs its own graceful shutdown hooks, and is reaped by the kernel
/// after the parent process exits. Blocking on `child.wait()` from the
/// main thread starves CEF's UI message loop (RunEvent is delivered on
/// the same thread) and on macOS that races the `browser_count == 0`
/// CHECK in `cef::shutdown`, panicking the app on Cmd+Q.
/// Aborts the embedded server task so any background tokio tasks the
/// server spawned stop driving I/O before CEF's teardown runs. Cheap
/// and non-blocking on the UI thread — `JoinHandle::abort` returns
/// immediately.
pub async fn send_terminate_signal(&self) {
let mut child_guard = self.child.lock().await;
let Some(child) = child_guard.as_mut() else {
return;
};
#[cfg(unix)]
{
use nix::sys::signal::{self, Signal};
use nix::unistd::Pid;
let _ = child;
if let Some(pid) = child_guard.as_ref().and_then(|c| c.id()) {
match signal::kill(Pid::from_raw(pid as i32), Signal::SIGTERM) {
Ok(()) => log::info!("[core] SIGTERM sent (non-blocking) pid={pid}"),
Err(e) => log::warn!("[core] SIGTERM (non-blocking) failed: {e}"),
}
}
}
#[cfg(windows)]
{
match child.start_kill() {
Ok(()) => log::info!("[core] start_kill issued (non-blocking)"),
Err(e) => log::warn!("[core] start_kill (non-blocking) failed: {e}"),
}
}
}
/// Send SIGTERM to the child and wait up to 5 seconds for it to exit.
/// Returns `true` if the process exited gracefully, `false` if it's still
/// alive (caller should escalate to SIGKILL).
async fn try_graceful_terminate(&self, child: &Child) -> bool {
#[cfg(unix)]
{
use nix::sys::signal::{self, Signal};
use nix::unistd::Pid;
let Some(pid) = child.id() else {
log::debug!("[core] child has no PID (already exited?)");
return true;
};
log::info!("[core] sending SIGTERM to core process (pid={pid})");
if let Err(e) = signal::kill(Pid::from_raw(pid as i32), Signal::SIGTERM) {
log::warn!("[core] failed to send SIGTERM: {e}");
return false;
}
// Poll for exit for up to 5 seconds.
const GRACE_PERIOD: Duration = Duration::from_secs(5);
const POLL_INTERVAL: Duration = Duration::from_millis(100);
let start = tokio::time::Instant::now();
while start.elapsed() < GRACE_PERIOD {
// Check if process is still alive (signal 0 = existence check).
match signal::kill(Pid::from_raw(pid as i32), None) {
Err(nix::errno::Errno::ESRCH) => {
log::info!(
"[core] core process exited gracefully after SIGTERM ({}ms)",
start.elapsed().as_millis()
);
return true;
}
_ => {}
}
tokio::time::sleep(POLL_INTERVAL).await;
}
log::warn!(
"[core] core process still alive after {}s grace period",
GRACE_PERIOD.as_secs()
);
false
}
#[cfg(not(unix))]
{
// On non-Unix platforms, there is no SIGTERM equivalent; the caller
// will use `child.kill()` directly.
let _ = child;
false
}
}
}
fn is_current_exe_path(candidate: &std::path::Path) -> bool {
let Ok(current) = std::env::current_exe() else {
return false;
};
same_executable_path(candidate, &current)
}
fn same_executable_path(a: &std::path::Path, b: &std::path::Path) -> bool {
if a == b {
return true;
}
match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
(Ok(a_real), Ok(b_real)) => a_real == b_real,
_ => false,
self.abort_task(" on app shutdown").await;
}
}
@@ -534,166 +244,6 @@ pub fn default_core_port() -> u16 {
.unwrap_or(7788)
}
pub fn default_core_run_mode(_daemon_mode: bool) -> CoreRunMode {
if let Ok(value) = std::env::var("OPENHUMAN_CORE_RUN_MODE") {
let normalized = value.trim().to_ascii_lowercase();
if matches!(normalized.as_str(), "inprocess" | "in-process" | "internal") {
return CoreRunMode::InProcess;
}
if matches!(
normalized.as_str(),
"child" | "process" | "external" | "sidecar"
) {
return CoreRunMode::ChildProcess;
}
}
// Default to a dedicated core process so app and core lifecycles are separated.
CoreRunMode::ChildProcess
}
pub fn default_core_bin() -> Option<PathBuf> {
if let Ok(path) = std::env::var("OPENHUMAN_CORE_BIN") {
let candidate = PathBuf::from(path);
if candidate.exists() {
log::info!(
"[core] default_core_bin: using OPENHUMAN_CORE_BIN override {}",
candidate.display()
);
return Some(candidate);
}
log::warn!(
"[core] default_core_bin: OPENHUMAN_CORE_BIN override does not exist: {}",
candidate.display()
);
}
#[cfg(target_os = "linux")]
{
let packaged_candidates = [
PathBuf::from("/usr/bin/openhuman-core"),
PathBuf::from("/usr/lib/OpenHuman/openhuman-core"),
];
for candidate in packaged_candidates {
if candidate.exists() {
log::info!(
"[core] default_core_bin: using packaged linux core binary {}",
candidate.display()
);
return Some(candidate);
}
}
}
// Dev: prefer a staged sidecar under src-tauri/binaries, then use the same search as
// release (next to the .app, Resources/, etc.). Previously we returned None here when the
// folder was empty, which forced `core run` on the GUI binary — a different TCC identity than
// `openhuman-core-*` and misleading "still denied" after granting the sidecar name.
#[cfg(debug_assertions)]
{
let binaries_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("binaries");
if let Ok(entries) = std::fs::read_dir(&binaries_dir) {
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
#[cfg(windows)]
let matches =
file_name.starts_with("openhuman-core-") && file_name.ends_with(".exe");
#[cfg(not(windows))]
let matches = file_name.starts_with("openhuman-core-");
if matches {
return Some(path);
}
}
}
}
let exe = std::env::current_exe().ok()?;
let exe_dir = exe.parent()?;
#[cfg(windows)]
let standalone = exe_dir.join("openhuman-core.exe");
#[cfg(not(windows))]
let standalone = exe_dir.join("openhuman-core");
if standalone.exists() && !same_executable_path(&standalone, &exe) {
log::info!(
"[core] default_core_bin: found standalone sibling binary {}",
standalone.display()
);
return Some(standalone);
}
#[cfg(windows)]
let legacy_standalone = exe_dir.join("openhuman-core.exe");
#[cfg(not(windows))]
let legacy_standalone = exe_dir.join("openhuman-core");
if legacy_standalone.exists() && !same_executable_path(&legacy_standalone, &exe) {
log::info!(
"[core] default_core_bin: found legacy standalone binary {}",
legacy_standalone.display()
);
return Some(legacy_standalone);
}
// Sidecar layout: bundle.externalBin("binaries/openhuman-core") is emitted as
// openhuman-core-<target-triple>(.exe) under app resources.
let search_dirs = {
let dirs = vec![exe_dir.to_path_buf()];
#[cfg(target_os = "macos")]
{
let mut dirs = dirs;
if let Some(resources_dir) = exe_dir.parent().map(|p| p.join("Resources")) {
dirs.push(resources_dir);
}
dirs
}
#[cfg(not(target_os = "macos"))]
{
dirs
}
};
for dir in search_dirs {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
#[cfg(windows)]
let matches = (file_name.starts_with("openhuman-core-") && file_name.ends_with(".exe"))
|| (file_name.starts_with("openhuman-core-") && file_name.ends_with(".exe"));
#[cfg(not(windows))]
let matches = file_name.starts_with("openhuman-core-")
|| file_name.starts_with("openhuman-core-");
if matches && !same_executable_path(&path, &exe) {
log::info!(
"[core] default_core_bin: found bundled sidecar {}",
path.display()
);
return Some(path);
}
}
}
log::warn!("[core] default_core_bin: no dedicated core binary found");
None
}
#[cfg(test)]
#[path = "core_process_tests.rs"]
mod tests;
+15 -208
View File
@@ -1,11 +1,4 @@
//! Sibling tests extracted from core_process.rs — see PR #835.
use super::{
current_rpc_token, default_core_bin, default_core_port, default_core_run_mode,
generate_rpc_token, same_executable_path, CoreProcessHandle, CoreRunMode,
};
use std::io::Write;
use std::path::PathBuf;
use super::{current_rpc_token, default_core_port, generate_rpc_token, CoreProcessHandle};
use std::sync::{Mutex, MutexGuard, OnceLock};
fn env_lock() -> MutexGuard<'static, ()> {
@@ -37,27 +30,13 @@ impl EnvGuard {
impl Drop for EnvGuard {
fn drop(&mut self) {
if let Some(old) = &self.old {
std::env::set_var(self.key, old);
} else {
std::env::remove_var(self.key);
match &self.old {
Some(v) => std::env::set_var(self.key, v),
None => std::env::remove_var(self.key),
}
}
}
#[test]
fn default_core_run_mode_env_parsing() {
let _env_lock = env_lock();
let _unset = EnvGuard::unset("OPENHUMAN_CORE_RUN_MODE");
assert_eq!(default_core_run_mode(false), CoreRunMode::ChildProcess);
let _guard = EnvGuard::set("OPENHUMAN_CORE_RUN_MODE", "in-process");
assert_eq!(default_core_run_mode(false), CoreRunMode::InProcess);
let _guard = EnvGuard::set("OPENHUMAN_CORE_RUN_MODE", "sidecar");
assert_eq!(default_core_run_mode(false), CoreRunMode::ChildProcess);
}
#[test]
fn default_core_port_env_and_fallback() {
let _env_lock = env_lock();
@@ -68,161 +47,13 @@ fn default_core_port_env_and_fallback() {
assert_eq!(default_core_port(), 8899);
}
#[test]
fn same_executable_path_handles_equal_and_non_equal_paths() {
let current = std::env::current_exe().expect("current exe");
assert!(same_executable_path(&current, &current));
let different = current.with_file_name("definitely-not-the-current-exe");
assert!(!same_executable_path(&current, &different));
}
#[test]
fn same_executable_path_handles_symlinks() {
// Create a temp directory with a file and a symlink
let temp_dir = std::env::temp_dir().join("openhuman-test-");
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(&temp_dir).expect("create temp dir");
let real_file = temp_dir.join("real-binary");
let mut file = std::fs::File::create(&real_file).expect("create file");
file.write_all(b"test").expect("write test content");
drop(file);
// Test canonical comparison works
let symlink = temp_dir.join("symlink-binary");
#[cfg(unix)]
std::os::unix::fs::symlink(&real_file, &symlink).expect("create symlink");
#[cfg(windows)]
std::os::windows::fs::symlink_file(&real_file, &symlink).expect("create symlink");
// Symlink and real file should be considered the same
assert!(
same_executable_path(&real_file, &symlink),
"symlink should resolve to same path"
);
// Different files should not match
let other_file = temp_dir.join("other-binary");
let mut file2 = std::fs::File::create(&other_file).expect("create other file");
file2.write_all(b"other").expect("write other content");
drop(file2);
assert!(
!same_executable_path(&real_file, &other_file),
"different files should not match"
);
// Cleanup
let _ = std::fs::remove_dir_all(&temp_dir);
}
// Tests for default_core_bin() - PR: make linux CEF deb package runnable
#[test]
fn default_core_bin_env_override_takes_precedence() {
let _env_lock = env_lock();
let temp_dir = std::env::temp_dir().join("openhuman-core-test-");
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(&temp_dir).expect("create temp dir");
// Create a fake core binary
let fake_core = temp_dir.join("openhuman-core");
let mut file = std::fs::File::create(&fake_core).expect("create fake core");
file.write_all(b"fake binary").expect("write content");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o755);
std::fs::set_permissions(&fake_core, perms).expect("set permissions");
}
drop(file);
// Set env override
let fake_core_str = fake_core.to_str().unwrap();
let _guard = EnvGuard::set("OPENHUMAN_CORE_BIN", fake_core_str);
let result = default_core_bin();
assert!(
result.is_some(),
"env override should return Some when file exists"
);
assert_eq!(result.unwrap(), fake_core, "should return the exact path");
// Cleanup
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[test]
fn default_core_bin_env_override_nonexistent_warns() {
let _env_lock = env_lock();
let _guard = EnvGuard::set("OPENHUMAN_CORE_BIN", "/nonexistent/path/openhuman-core");
let _result = default_core_bin();
// When env override is set but file doesn't exist, we log a warning and continue
// The function should continue to search other paths
// Result depends on whether a core binary exists elsewhere
// This test primarily verifies the function doesn't panic
}
#[test]
fn default_core_bin_returns_none_when_no_binary_found() {
let _env_lock = env_lock();
// Clear env override
let _guard = EnvGuard::unset("OPENHUMAN_CORE_BIN");
// Note: This test may pass or fail depending on whether there's actually
// a core binary in the expected locations. We verify the function
// returns a consistent type.
let _result = default_core_bin();
// Function should not panic regardless of result
}
#[test]
fn default_core_bin_prefers_staged_sidecar_in_dev() {
let _env_lock = env_lock();
// This test verifies the dev build behavior where we look for
// staged binaries in src-tauri/binaries
// In test mode (debug_assertions), this path is checked
let _guard = EnvGuard::unset("OPENHUMAN_CORE_BIN");
// We can't easily test this without modifying the CARGO_MANIFEST_DIR
// but we can verify the function runs without panic
let _result = default_core_bin();
}
// Test for same_executable_path edge cases
#[test]
fn same_executable_path_handles_nonexistent_files() {
let nonexistent = PathBuf::from("/definitely/does/not/exist");
let current = std::env::current_exe().expect("current exe");
// Should return false when one path doesn't exist
assert!(
!same_executable_path(&nonexistent, &current),
"nonexistent paths should not match existing"
);
// Both nonexistent should also return false (can't canonicalize)
let nonexistent2 = PathBuf::from("/also/does/not/exist");
assert!(
!same_executable_path(&nonexistent, &nonexistent2),
"both nonexistent should return false"
);
}
#[test]
fn core_process_handle_new_creates_instance() {
let handle = CoreProcessHandle::new(9999, None, CoreRunMode::ChildProcess);
let handle = CoreProcessHandle::new(9999);
assert_eq!(handle.port(), 9999);
assert_eq!(handle.rpc_url(), "http://127.0.0.1:9999/rpc");
}
#[test]
fn core_process_handle_rpc_url_format() {
let handle = CoreProcessHandle::new(12345, None, CoreRunMode::ChildProcess);
assert_eq!(handle.rpc_url(), "http://127.0.0.1:12345/rpc");
}
#[test]
fn ensure_running_returns_ok_when_rpc_port_already_open() {
let rt = tokio::runtime::Runtime::new().expect("runtime");
@@ -231,7 +62,7 @@ fn ensure_running_returns_ok_when_rpc_port_already_open() {
.await
.expect("bind test listener");
let port = listener.local_addr().expect("local addr").port();
let handle = CoreProcessHandle::new(port, None, CoreRunMode::ChildProcess);
let handle = CoreProcessHandle::new(port);
handle.ensure_running().await
});
assert!(
@@ -279,7 +110,7 @@ fn generate_rpc_token_is_not_constant() {
/// bearer token immediately — no file I/O or timing dependency.
#[test]
fn core_process_handle_new_token_is_valid() {
let handle = CoreProcessHandle::new(19001, None, CoreRunMode::ChildProcess);
let handle = CoreProcessHandle::new(19001);
let token = handle.rpc_token();
assert_eq!(token.len(), 64, "handle token must be 64 hex chars");
assert!(
@@ -289,36 +120,32 @@ fn core_process_handle_new_token_is_valid() {
}
/// `CoreProcessHandle::new()` must NOT publish the token to the global
/// `CURRENT_RPC_TOKEN`. The global is set only after `ensure_running()`
/// successfully spawns the child that received `OPENHUMAN_CORE_TOKEN`.
/// Advertising the token before spawn would cause 401s when the port is
/// already held by a stale process that never received this token.
/// `CURRENT_RPC_TOKEN`. The global is set only after `ensure_running()`
/// successfully spawns the embedded server with `OPENHUMAN_CORE_TOKEN` in
/// scope. Advertising the token before spawn would 401 against any process
/// already listening on the port that never received this token.
#[test]
fn new_does_not_publish_global_token() {
// Capture current global state before constructing the handle.
let before = current_rpc_token();
let handle = CoreProcessHandle::new(19002, None, CoreRunMode::ChildProcess);
let handle = CoreProcessHandle::new(19002);
let after = current_rpc_token();
// The global must not have changed to this handle's token.
assert_ne!(
after.as_deref(),
Some(handle.rpc_token()),
"new() must not publish its token to CURRENT_RPC_TOKEN before ensure_running() spawns"
);
// Whatever was in the global before must still be there (or still None).
assert_eq!(
before, after,
"new() must leave CURRENT_RPC_TOKEN unchanged"
);
}
/// Two handles constructed sequentially must each have a unique token,
/// but neither should update the global until ensure_running() spawns.
/// Two handles constructed sequentially must each have a unique token.
#[test]
fn each_handle_has_unique_token() {
let h1 = CoreProcessHandle::new(19003, None, CoreRunMode::ChildProcess);
let h2 = CoreProcessHandle::new(19004, None, CoreRunMode::ChildProcess);
let h1 = CoreProcessHandle::new(19003);
let h2 = CoreProcessHandle::new(19004);
assert_ne!(
h1.rpc_token(),
@@ -326,23 +153,3 @@ fn each_handle_has_unique_token() {
"each handle must have a unique token"
);
}
// Tests for logging/diagnostics (grep-friendly patterns)
#[test]
fn core_bin_resolution_logs_expected_patterns() {
// These patterns are documented in the PR as grep-friendly diagnostics.
// We verify they exist in the source code by checking the function compiles.
// The actual log output is verified at runtime.
// Expected log patterns from PR:
// "[core] default_core_bin: using OPENHUMAN_CORE_BIN override {path}"
// "[core] default_core_bin: OPENHUMAN_CORE_BIN override does not exist: {path}"
// "[core] default_core_bin: using packaged linux core binary {path}"
// "[core] default_core_bin: found standalone sibling binary {path}"
// "[core] default_core_bin: found legacy standalone binary {path}"
// "[core] default_core_bin: found bundled sidecar {path}"
// "[core] default_core_bin: no dedicated core binary found"
// This test ensures the function is callable and returns expected types
let _ = default_core_bin();
}
-776
View File
@@ -1,776 +0,0 @@
//! Core sidecar version checking and auto-update logic.
//!
//! After the Tauri shell starts the core sidecar, it queries `core.version` via
//! JSON-RPC. If the running core is older than the minimum expected version, the
//! shell downloads the latest release from GitHub, stages it, kills the old
//! process, and restarts with the new binary.
use std::io::Write;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::core_process::CoreProcessHandle;
/// The minimum core version this Tauri build expects.
/// Bump this when the app depends on new core RPC methods.
pub const MINIMUM_CORE_VERSION: &str = env!("CARGO_PKG_VERSION");
/// GitHub owner/repo for releases.
const GITHUB_OWNER: &str = "tinyhumansai";
const GITHUB_REPO: &str = "openhuman";
#[derive(Debug, Deserialize)]
struct RpcResponse {
result: Option<serde_json::Value>,
error: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
struct GitHubRelease {
tag_name: String,
assets: Vec<GitHubAsset>,
}
#[derive(Debug, Deserialize)]
struct GitHubAsset {
name: String,
browser_download_url: String,
}
/// Returned by `check_core_update` Tauri command.
#[derive(Debug, Clone, Serialize)]
pub struct CoreUpdateInfo {
pub running_version: String,
pub minimum_version: String,
/// True if running < minimum (compatibility issue).
pub outdated: bool,
/// Latest version on GitHub Releases (if fetch succeeded).
pub latest_version: Option<String>,
/// True if running < latest (newer release available).
pub update_available: bool,
}
/// Query the running core's version via JSON-RPC.
pub async fn query_core_version(rpc_url: &str, rpc_token: &str) -> Result<String, String> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.map_err(|e| format!("http client error: {e}"))?;
let body = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "core.version",
"params": {}
});
let resp = client
.post(rpc_url)
// `apply_auth()` reads from the process-global CURRENT_RPC_TOKEN, but
// this function accepts an explicit token so it can run immediately
// after the core process spawns — before publish_rpc_token() is called.
// If the Authorization header format ever changes, update both this
// site and `core_rpc::apply_auth()` together.
.header("Authorization", format!("Bearer {rpc_token}"))
.json(&body)
.send()
.await
.map_err(|e| format!("failed to query core.version: {e}"))?;
let rpc: RpcResponse = resp
.json()
.await
.map_err(|e| format!("failed to parse core.version response: {e}"))?;
if let Some(err) = rpc.error {
return Err(format!("core.version RPC error: {err}"));
}
let version = rpc
.result
.and_then(|v| v.get("version").and_then(|v| v.as_str()).map(String::from))
.ok_or_else(|| "core.version response missing 'version' field".to_string())?;
Ok(version)
}
/// Compare two version strings. Returns true if `running` is older than `target`.
pub fn is_outdated(running: &str, target: &str) -> bool {
let parse = |v: &str| -> Option<semver::Version> {
semver::Version::parse(v.trim_start_matches('v')).ok()
};
match (parse(running), parse(target)) {
(Some(r), Some(t)) => r < t,
_ => {
log::warn!("[core-update] could not parse versions running={running} target={target}");
false
}
}
}
/// Full check: query running version, compare against minimum AND latest GitHub release.
pub async fn check_full(rpc_url: &str, rpc_token: &str) -> Result<CoreUpdateInfo, String> {
let running = query_core_version(rpc_url, rpc_token).await?;
let minimum = MINIMUM_CORE_VERSION;
let outdated = is_outdated(&running, minimum);
// Best-effort fetch of latest release — don't fail the whole check if GitHub is unreachable.
let (latest_version, update_available) = match fetch_latest_release().await {
Ok(release) => {
let latest = release.tag_name.trim_start_matches('v').to_string();
let available = is_outdated(&running, &latest);
(Some(latest), available)
}
Err(e) => {
log::warn!("[core-update] could not fetch latest release: {e}");
(None, false)
}
};
Ok(CoreUpdateInfo {
running_version: running,
minimum_version: minimum.to_string(),
outdated,
latest_version,
update_available,
})
}
/// Build the platform triple for asset matching.
fn platform_triple() -> &'static str {
#[cfg(all(target_arch = "x86_64", target_os = "macos"))]
{
"x86_64-apple-darwin"
}
#[cfg(all(target_arch = "aarch64", target_os = "macos"))]
{
"aarch64-apple-darwin"
}
#[cfg(all(target_arch = "x86_64", target_os = "linux"))]
{
"x86_64-unknown-linux-gnu"
}
#[cfg(all(target_arch = "aarch64", target_os = "linux"))]
{
"aarch64-unknown-linux-gnu"
}
#[cfg(all(target_arch = "x86_64", target_os = "windows"))]
{
"x86_64-pc-windows-msvc"
}
#[cfg(all(target_arch = "aarch64", target_os = "windows"))]
{
"aarch64-pc-windows-msvc"
}
}
/// 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 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 == 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.
async fn fetch_latest_release() -> Result<GitHubRelease, String> {
let url = format!("https://api.github.com/repos/{GITHUB_OWNER}/{GITHUB_REPO}/releases/latest");
let client = reqwest::Client::builder()
.user_agent("openhuman-tauri-updater")
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|e| format!("http client error: {e}"))?;
let resp = client
.get(&url)
.header("Accept", "application/vnd.github+json")
.send()
.await
.map_err(|e| format!("failed to fetch latest release: {e}"))?;
if !resp.status().is_success() {
return Err(format!("GitHub API error: {}", resp.status()));
}
resp.json()
.await
.map_err(|e| format!("failed to parse release: {e}"))
}
/// 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`.
///
/// 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))
.build()
.map_err(|e| format!("http client error: {e}"))?;
let resp = client
.get(url)
.send()
.await
.map_err(|e| format!("download failed: {e}"))?;
if !resp.status().is_success() {
return Err(format!("download returned status {}", resp.status()));
}
let bytes = resp
.bytes()
.await
.map_err(|e| format!("failed to read download: {e}"))?;
log::info!(
"[core-update] downloaded {} bytes to {}",
bytes.len(),
dest.display()
);
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)
.map_err(|e| format!("write temp file: {e}"))?;
file.flush().map_err(|e| format!("flush temp file: {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| {
let _ = std::fs::remove_file(&tmp);
format!("rename downloaded file: {e}")
})?;
Ok(())
}
/// The main auto-update flow, called after the core process starts.
///
/// When `force` is false (startup auto-check), only updates if the running core
/// is older than `MINIMUM_CORE_VERSION`. When `force` is true (manual trigger),
/// updates whenever GitHub has a newer version than what's currently running.
///
/// Emits Tauri events so the frontend can show progress.
pub async fn check_and_update_core(
handle: CoreProcessHandle,
app: Option<tauri::AppHandle<crate::AppRuntime>>,
force: bool,
) -> Result<(), String> {
let rpc_url = handle.rpc_url();
let rpc_token = handle.rpc_token().to_string();
log::info!(
"[core-update] checking core version at {} (minimum: {}, force: {})",
rpc_url,
MINIMUM_CORE_VERSION,
force
);
// Step 1: Query running version.
let running_version = match query_core_version(&rpc_url, &rpc_token).await {
Ok(v) => v,
Err(e) => {
log::warn!("[core-update] could not query core version: {e}");
return Err(e);
}
};
log::info!(
"[core-update] running core version: {} (minimum: {})",
running_version,
MINIMUM_CORE_VERSION
);
let below_app_minimum = is_outdated(&running_version, MINIMUM_CORE_VERSION);
if below_app_minimum {
log::warn!(
"[core-update] sidecar is OLDER than this app build (running {running_version}, need >= {min}). \
UI features (e.g. channel connect) may not match RPC until the core is updated.",
min = MINIMUM_CORE_VERSION
);
}
// Step 2: Fetch latest release from GitHub (needed to download a replacement binary).
emit_event(&app, "core-update:status", "checking");
let release = match fetch_latest_release().await {
Ok(r) => r,
Err(e) => {
if force {
log::warn!("[core-update] could not fetch latest release: {e}");
return Err(e);
}
if below_app_minimum {
log::error!(
"[core-update] cannot auto-update core (GitHub unreachable): {e}\n\
→ Stop any other `openhuman` / OpenHuman using RPC port {}.\n\
→ From repo root: `cargo build --manifest-path Cargo.toml --bin openhuman` then `cd app && yarn core:stage`, restart the app.\n\
→ Or fix network access to https://api.github.com (VPN/DNS/firewall).",
handle.port()
);
emit_event(&app, "core-update:status", "error");
return Err(e);
}
log::warn!(
"[core-update] could not fetch latest release (non-fatal; core meets minimum): {e}"
);
emit_event(&app, "core-update:status", "up_to_date");
return Ok(());
}
};
let latest_version = release.tag_name.trim_start_matches('v').to_string();
log::info!("[core-update] latest release: {latest_version}");
// Decide whether to proceed with the update.
let needs_update = if force {
// Manual trigger: update if GitHub has anything newer than what's running.
is_outdated(&running_version, &latest_version)
} else {
// Auto-check: only update if running is below the minimum the app requires.
is_outdated(&running_version, MINIMUM_CORE_VERSION)
};
if !needs_update {
log::info!("[core-update] no update needed (running: {running_version}, latest: {latest_version}, force: {force})");
emit_event(&app, "core-update:status", "up_to_date");
return Ok(());
}
log::warn!(
"[core-update] updating core {} → {} (force: {})",
running_version,
latest_version,
force
);
let asset = find_platform_asset(&release.assets).ok_or_else(|| {
format!(
"no matching asset for platform '{}' in release {}",
platform_triple(),
latest_version
)
})?;
log::info!(
"[core-update] found asset: {} ({})",
asset.name,
asset.browser_download_url
);
emit_event(&app, "core-update:status", "downloading");
// Step 3: Determine staging directory.
let staging_dir = resolve_staging_dir();
if let Some(ref dir) = staging_dir {
if !dir.exists() {
std::fs::create_dir_all(dir).map_err(|e| format!("create staging dir: {e}"))?;
}
}
// 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.
{
let _guard = handle.restart_lock().await;
log::debug!("[core-update] acquired restart lock");
// Shutdown old process first so the binary isn't in use during staging.
handle.shutdown().await;
// Wait for port to free.
let mut waited = 0u64;
while waited < 10_000 {
if !port_open(handle.port()).await {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
waited += 50;
}
// 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(binary_dest).await;
emit_event(&app, "core-update:status", "restarting");
// Restart with the new binary.
handle.ensure_running().await?;
}
log::info!(
"[core-update] core updated from {} to {} and restarted",
running_version,
latest_version
);
emit_event(&app, "core-update:status", "updated");
Ok(())
}
/// Resolve the directory where staged sidecar binaries are placed.
/// Mirrors the discovery logic in `core_process::default_core_bin()`.
fn resolve_staging_dir() -> Option<PathBuf> {
// Dev: src-tauri/binaries/
#[cfg(debug_assertions)]
{
let binaries_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("binaries");
if binaries_dir.exists() {
return Some(binaries_dir);
}
}
// Production: next to the executable, or Resources/ on macOS.
let exe = std::env::current_exe().ok()?;
let exe_dir = exe.parent()?;
#[cfg(target_os = "macos")]
{
if let Some(resources) = exe_dir.parent().map(|p| p.join("Resources")) {
if resources.exists() {
return Some(resources);
}
}
}
Some(exe_dir.to_path_buf())
}
async fn port_open(port: u16) -> bool {
matches!(
tokio::time::timeout(
std::time::Duration::from_millis(150),
tokio::net::TcpStream::connect(("127.0.0.1", port)),
)
.await,
Ok(Ok(_))
)
}
fn emit_event(app: &Option<tauri::AppHandle<crate::AppRuntime>>, event: &str, payload: &str) {
if let Some(app) = app {
use tauri::Emitter;
if let Err(e) = app.emit(event, payload) {
log::warn!("[core-update] failed to emit {event}: {e}");
}
}
}
#[cfg(test)]
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"));
assert!(is_outdated("0.50.0", "0.51.0"));
assert!(!is_outdated("0.51.8", "0.51.8"));
assert!(!is_outdated("0.52.0", "0.51.8"));
assert!(!is_outdated("1.0.0", "0.51.8"));
}
}
+83 -142
View File
@@ -7,7 +7,6 @@ mod cef_preflight;
mod cef_profile;
mod core_process;
mod core_rpc;
mod core_update;
mod discord_scanner;
mod gmail;
mod gmessages_scanner;
@@ -211,113 +210,31 @@ fn configure_overlay_window_macos(window: &WebviewWindow<AppRuntime>) {
}
}
/// Resolve the core binary, preferring the staged sidecar.
fn resolve_core_bin() -> Result<std::path::PathBuf, String> {
if let Some(bin) = core_process::default_core_bin() {
return Ok(bin);
}
std::env::current_exe().map_err(|e| format!("cannot resolve executable: {e}"))
}
/// Run the core binary with the given CLI args and return its stdout.
async fn run_core_cli(args: Vec<String>) -> Result<String, String> {
tokio::task::spawn_blocking(move || {
let bin = resolve_core_bin()?;
let is_self = {
let current = std::env::current_exe().ok();
current
.as_ref()
.and_then(|c| std::fs::canonicalize(c).ok())
.zip(std::fs::canonicalize(&bin).ok())
.map_or(false, |(a, b)| a == b)
};
let mut cmd = std::process::Command::new(&bin);
if is_self {
cmd.arg("core");
}
cmd.args(&args);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
cmd.creation_flags(CREATE_NO_WINDOW);
}
log::info!(
"[service-direct] running {:?} {}{}",
bin,
if is_self { "core " } else { "" },
args.join(" ")
);
let output = cmd
.output()
.map_err(|e| format!("failed to execute core binary: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"core binary exited with {}: {}",
output.status,
stderr.trim()
));
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
})
.await
.map_err(|e| format!("task join error: {e}"))?
}
#[tauri::command]
async fn service_install_direct() -> Result<String, String> {
run_core_cli(vec!["service".into(), "install".into()]).await
}
#[tauri::command]
async fn service_start_direct() -> Result<String, String> {
run_core_cli(vec!["service".into(), "start".into()]).await
}
#[tauri::command]
async fn service_stop_direct() -> Result<String, String> {
run_core_cli(vec!["service".into(), "stop".into()]).await
}
#[tauri::command]
async fn service_status_direct() -> Result<String, String> {
run_core_cli(vec!["service".into(), "status".into()]).await
}
#[tauri::command]
async fn service_uninstall_direct() -> Result<String, String> {
run_core_cli(vec!["service".into(), "uninstall".into()]).await
}
/// Check if the core sidecar is outdated and whether a newer version is available on GitHub.
/// Returns version info, compatibility status, and update availability.
/// Core update is handled by the Tauri shell auto-updater (`tauri-plugin-updater`)
/// since the core ships in-process with the app. This command is kept as a
/// no-op stub so the frontend's `checkCoreUpdate` keeps working without errors;
/// it always reports the running version as up-to-date.
#[tauri::command]
async fn check_core_update(
state: tauri::State<'_, core_process::CoreProcessHandle>,
_state: tauri::State<'_, core_process::CoreProcessHandle>,
) -> Result<serde_json::Value, String> {
let rpc_url = state.inner().rpc_url();
let rpc_token = state.inner().rpc_token().to_string();
let info = core_update::check_full(&rpc_url, &rpc_token).await?;
serde_json::to_value(&info).map_err(|e| format!("serialize error: {e}"))
let version = env!("CARGO_PKG_VERSION");
Ok(serde_json::json!({
"running_version": version,
"minimum_version": version,
"outdated": false,
"latest_version": version,
"update_available": false,
}))
}
/// Trigger a full core update: download latest from GitHub, stage, kill old, restart.
/// Uses `force=true` so it updates to the latest release even if the running core
/// meets the minimum version requirement.
/// Stub kept for frontend compatibility — use `apply_app_update` instead.
#[tauri::command]
async fn apply_core_update(
state: tauri::State<'_, core_process::CoreProcessHandle>,
app: tauri::AppHandle<AppRuntime>,
_state: tauri::State<'_, core_process::CoreProcessHandle>,
_app: tauri::AppHandle<AppRuntime>,
) -> Result<(), String> {
log::info!("[core-update] manual apply_core_update invoked from frontend");
core_update::check_and_update_core(state.inner().clone(), Some(app), true).await
Err("core ships in-process; use the Tauri shell updater (apply_app_update) instead".into())
}
#[tauri::command]
@@ -1098,17 +1015,9 @@ pub fn run() {
return Err("webview_apis bridge failed to start — aborting setup".into());
}
let core_run_mode = core_process::default_core_run_mode(daemon_mode);
let core_bin = if matches!(core_run_mode, core_process::CoreRunMode::ChildProcess) {
core_process::default_core_bin()
} else {
None
};
let core_handle = core_process::CoreProcessHandle::new(
core_process::default_core_port(),
core_bin,
core_run_mode,
);
let _ = daemon_mode;
let core_handle =
core_process::CoreProcessHandle::new(core_process::default_core_port());
std::env::set_var("OPENHUMAN_CORE_RPC_URL", core_handle.rpc_url());
// Expose the shared CEF cookies SQLite path to the core sidecar
@@ -1131,25 +1040,12 @@ pub fn run() {
}
app.manage(core_handle.clone());
let app_handle_for_update = app.handle().clone();
tauri::async_runtime::spawn(async move {
if let Err(err) = core_handle.ensure_running().await {
log::error!("[core] failed to start core process: {err}");
log::error!("[core] failed to start embedded core: {err}");
return;
}
log::info!("[core] core process ready");
// Check if the running core is outdated and auto-update if needed.
let update_handle = core_handle.clone();
if let Err(err) = core_update::check_and_update_core(
update_handle,
Some(app_handle_for_update),
false,
)
.await
{
log::warn!("[core-update] auto-update check failed (non-fatal): {err}");
}
log::info!("[core] embedded core ready");
});
// Restore last-known window position+size before showing the
@@ -1510,11 +1406,6 @@ pub fn run() {
restart_app,
get_active_user_id,
schedule_cef_profile_purge,
service_install_direct,
service_start_direct,
service_stop_direct,
service_status_direct,
service_uninstall_direct,
register_dictation_hotkey,
unregister_dictation_hotkey,
webview_accounts::webview_account_open,
@@ -1613,31 +1504,81 @@ pub fn run() {
if let Some(core) = app_handle.try_state::<core_process::CoreProcessHandle>() {
let core = core.inner().clone();
// Aborts the embedded server task. Synchronous and safe on
// the UI thread — `JoinHandle::abort` returns immediately.
tauri::async_runtime::block_on(async move {
core.send_terminate_signal().await;
});
}
// Give CEF's UI message loop a brief window to process the
// queued browser close messages before the runtime calls
// `cef::shutdown()`. Without this, a webview that was mid-load
// when the user quit can race the shutdown and leave its
// renderer helper orphaned (re-parented to launchd on macOS).
std::thread::sleep(std::time::Duration::from_millis(50));
log::info!("[app] RunEvent::ExitRequested — early teardown complete");
}
RunEvent::Exit => {
log::info!("[app] RunEvent::Exit");
log::info!("[app] RunEvent::Exit — cef::shutdown follows");
}
_ => {}
});
// Belt-and-suspenders sweep: after Tauri's event loop returns the
// vendored runtime has already called `cef::shutdown()`. In normal
// operation every CEF helper (GPU / Network / Utility / Renderer) is
// gone by now. If anything is still alive — e.g. a renderer that was
// mid-spawn when the user quit — it would otherwise be re-parented to
// launchd on macOS / init on Linux and survive the GUI exit. SIGTERM
// its children before this process actually exits.
//
// We don't `wait()` on them: the kernel will reap them as our exit
// unwinds, and any helper that ignores SIGTERM is a CEF bug we'd
// rather see in Activity Monitor than silently SIGKILL.
sweep_orphan_children();
}
/// Send SIGTERM to every direct child of the current process. No-op on
/// non-Unix platforms (Windows job objects already kill CEF helpers when
/// the parent exits).
fn sweep_orphan_children() {
#[cfg(unix)]
{
let pid = std::process::id();
match std::process::Command::new("pkill")
.args(["-TERM", "-P", &pid.to_string()])
.status()
{
Ok(status) => {
// pkill exits 0 if it killed at least one process, 1 if no
// matches (the healthy case after cef::shutdown), 2/3 on
// error. Both 0 and 1 are expected; log 0 loudly so we
// notice when the safety net actually catches something.
match status.code() {
Some(0) => log::warn!(
"[app] sweep: SIGTERM'd leftover child processes after cef::shutdown"
),
Some(1) => log::info!("[app] sweep: no leftover children (clean exit)"),
other => log::warn!("[app] sweep: pkill exited with {:?}", other),
}
}
Err(e) => log::warn!("[app] sweep: failed to invoke pkill: {e}"),
}
}
#[cfg(not(unix))]
{
log::debug!("[app] sweep: skipped on non-unix platform");
}
}
pub fn run_core_from_args(args: &[String]) -> Result<(), String> {
let core_bin = crate::core_process::default_core_bin()
.ok_or_else(|| "openhuman-core binary not found".to_string())?;
let status = std::process::Command::new(core_bin)
.args(args)
.status()
.map_err(|e| format!("failed to execute core binary: {e}"))?;
if !status.success() {
return Err(format!("core binary exited with status {status}"));
}
Ok(())
// Core lives in-process: dispatch directly through the linked `openhuman_core`
// library instead of shelling out to a separate binary. The Tauri main()
// routes `OpenHuman core <args>` here so users can still drive the core CLI
// from the bundled app.
openhuman_core::run_core_from_args(args).map_err(|e| format!("{e:#}"))
}
// ---------------------------------------------------------------------------
+2 -5
View File
@@ -4,9 +4,9 @@
"version": "0.53.4",
"identifier": "com.openhuman.app",
"build": {
"beforeDevCommand": "pnpm run core:stage && pnpm run dev",
"beforeDevCommand": "pnpm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "pnpm run build:app && pnpm run core:stage",
"beforeBuildCommand": "pnpm run build:app",
"frontendDist": "../dist"
},
"app": {
@@ -48,9 +48,6 @@
"../../src/openhuman/agent/prompts",
"recipes/**/*"
],
"externalBin": [
"binaries/openhuman-core"
],
"linux": {
"deb": {
"depends": [
-1
View File
@@ -8,7 +8,6 @@
"scripts": {
"build": "pnpm --filter openhuman-app build",
"compile": "pnpm --filter openhuman-app compile",
"core:stage": "pnpm --filter openhuman-app core:stage",
"dev": "pnpm --filter openhuman-app dev",
"dev:app": "pnpm --filter openhuman-app dev:app",
"dev:cef": "pnpm --filter openhuman-app dev:cef",
-48
View File
@@ -1,48 +0,0 @@
#!/usr/bin/env bash
# Stage the core sidecar binary next to Tauri resources for bundling.
#
# Usage:
# stage-sidecar.sh <target> <core_target_dir> <core_bin_name> <sidecar_base>
#
# Example:
# stage-sidecar.sh aarch64-apple-darwin target/aarch64-apple-darwin/release openhuman-core openhuman-core
set -euo pipefail
TARGET="${1:?Usage: stage-sidecar.sh <target> <core_target_dir> <core_bin_name> <sidecar_base>}"
CORE_TARGET_DIR="${2:?}"
CORE_BIN_NAME="${3:?}"
SIDECAR_BASE="${4:?}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
EXE_SUFFIX=""
if [[ "$TARGET" == *"windows"* ]]; then
EXE_SUFFIX=".exe"
fi
SOURCE="${REPO_ROOT}/${CORE_TARGET_DIR}/${CORE_BIN_NAME}${EXE_SUFFIX}"
DEST_DIR="${REPO_ROOT}/app/src-tauri/binaries"
DEST="${DEST_DIR}/${SIDECAR_BASE}-${TARGET}${EXE_SUFFIX}"
mkdir -p "$DEST_DIR"
cp "$SOURCE" "$DEST"
if [[ "$TARGET" != *"windows"* ]]; then
chmod +x "$DEST"
fi
echo "[stage-sidecar] Staged: $DEST"
# ── Verify ───────────────────────────────────────────────────────────────────
if [ ! -f "$DEST" ]; then
echo "[stage-sidecar] ERROR: Missing staged sidecar binary: $DEST"
exit 1
fi
if [[ "$TARGET" != *"windows"* ]] && [ ! -x "$DEST" ]; then
echo "[stage-sidecar] ERROR: Staged sidecar is not executable: $DEST"
exit 1
fi
echo "[stage-sidecar] Verified OK"
-126
View File
@@ -1,126 +0,0 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { chmodSync, copyFileSync, existsSync, mkdirSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, "..");
function run(cmd, args, cwd = root) {
const res = spawnSync(cmd, args, { cwd, stdio: "inherit", shell: false });
if (res.status !== 0) {
process.exit(res.status ?? 1);
}
}
function rustHostTriple() {
const res = spawnSync("rustc", ["-vV"], { cwd: root, encoding: "utf8" });
if (res.status !== 0 || !res.stdout) {
console.error("[core:stage] failed to query rustc host triple");
process.exit(res.status ?? 1);
}
const line = res.stdout
.split("\n")
.map((s) => s.trim())
.find((s) => s.startsWith("host:"));
const triple = line?.replace(/^host:\s*/, "").trim();
if (!triple) {
console.error("[core:stage] rustc host triple missing");
process.exit(1);
}
return triple;
}
function cargoTargetDir() {
if (process.env.CARGO_TARGET_DIR) {
// Resolve against the repo root so this path stays consistent
// with the `cargo build` invocation below (which runs with
// `cwd: root`). If the script were invoked from a different
// working directory and `CARGO_TARGET_DIR` were relative, a
// bare `resolve()` would anchor it to the wrong cwd and the
// staged binary lookup would miss.
return resolve(root, process.env.CARGO_TARGET_DIR);
}
const res = spawnSync(
"cargo",
["metadata", "--format-version", "1", "--no-deps", "--manifest-path", "Cargo.toml"],
{ cwd: root, encoding: "utf8", shell: false, maxBuffer: 64 * 1024 * 1024 },
);
if (res.status === 0 && res.stdout) {
try {
const meta = JSON.parse(res.stdout);
if (meta.target_directory) return resolve(meta.target_directory);
} catch {
// fall through to default
}
}
return join(root, "target");
}
const triple = rustHostTriple();
const isWindows = process.platform === "win32";
const binName = isWindows ? "openhuman-core.exe" : "openhuman-core";
console.log(
`[core:stage] Building openhuman-core standalone binary for ${triple}...`,
);
run("cargo", [
"build",
"--manifest-path",
"Cargo.toml",
"--bin",
"openhuman-core",
"--features",
"whatsapp-web,channel-matrix",
]);
const targetDir = cargoTargetDir();
const source = join(targetDir, "debug", binName);
if (!existsSync(source)) {
console.error(`[core:stage] compiled binary not found: ${source}`);
process.exit(1);
}
const outputDir = join(root, "app", "src-tauri", "binaries");
mkdirSync(outputDir, { recursive: true });
const sidecarName = isWindows
? `openhuman-core-${triple}.exe`
: `openhuman-core-${triple}`;
const dest = join(outputDir, sidecarName);
copyFileSync(source, dest);
if (!isWindows) {
chmodSync(dest, 0o755);
}
console.log(`[core:stage] Staged sidecar: ${dest}`);
// macOS: sign with a stable local dev certificate so macOS TCC uses certificate
// identity (stable across rebuilds) instead of binary content hash (changes
// every compile). Without this, each recompile breaks existing TCC grants.
if (process.platform === "darwin") {
const DEV_IDENTITY = "OpenHuman Dev Signer";
const check = spawnSync(
"bash",
["-c", `security find-identity -v -p codesigning 2>/dev/null | grep "${DEV_IDENTITY}" || true`],
{ cwd: root, encoding: "utf8" },
);
if (check.stdout && check.stdout.includes(DEV_IDENTITY)) {
const signResult = spawnSync("codesign", ["--force", "--sign", DEV_IDENTITY, "--timestamp=none", dest], { cwd: root, stdio: "inherit", shell: false });
const isCI = process.env.CI === "true" || process.env.CI === "1";
if (signResult.status === 0) {
console.log(`[core:stage] Signed sidecar with "${DEV_IDENTITY}"`);
} else if (isCI) {
console.error(`[core:stage] Dev signing failed (status ${signResult.status}) in CI — aborting.`);
process.exit(signResult.status ?? 1);
} else {
console.warn(`[core:stage] Dev signing failed (status ${signResult.status}), continuing without stable signing.`);
}
} else {
console.warn(
`[core:stage] Dev signing identity "${DEV_IDENTITY}" not found.\n` +
`[core:stage] Run 'bash scripts/setup-dev-codesign.sh' once to enable stable TCC grants.\n` +
`[core:stage] Without signing, macOS accessibility grants break on every recompile.`,
);
}
}