mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(core,cef): run core in-process and stop orphaning CEF helpers on Cmd+Q (#1061)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Generated
+2581
-166
File diff suppressed because it is too large
Load Diff
@@ -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"] }
|
||||
|
||||
|
||||
@@ -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, ¤t)
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -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(¤t, ¤t));
|
||||
|
||||
let different = current.with_file_name("definitely-not-the-current-exe");
|
||||
assert!(!same_executable_path(¤t, &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, ¤t),
|
||||
"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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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:#}"))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
@@ -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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user