Merge pull request #49 from vezuresdotxyz/develop

chore: merge develop into main (v0.31.0)
This commit is contained in:
Steven Enamakel
2026-02-05 22:05:42 +05:30
committed by GitHub
50 changed files with 3230 additions and 3868 deletions
+15 -256
View File
@@ -14,7 +14,7 @@ on:
branches:
- develop
workflow_run:
workflows: ['Version Bump']
workflows: ["Version Bump"]
types:
- completed
branches:
@@ -169,15 +169,15 @@ jobs:
fail-fast: false
matrix:
settings:
- platform: 'macos-latest'
args: '--target aarch64-apple-darwin'
target: 'aarch64-apple-darwin'
- platform: 'macos-latest'
args: '--target x86_64-apple-darwin'
target: 'x86_64-apple-darwin'
- platform: 'ubuntu-22.04'
args: ''
target: ''
- platform: "macos-latest"
args: "--target aarch64-apple-darwin"
target: "aarch64-apple-darwin"
- platform: "macos-latest"
args: "--target x86_64-apple-darwin"
target: "x86_64-apple-darwin"
- platform: "ubuntu-22.04"
args: ""
target: ""
runs-on: ${{ matrix.settings.platform }}
steps:
- name: Checkout
@@ -197,7 +197,7 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: 24.x
cache: 'yarn'
cache: "yarn"
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
@@ -318,135 +318,15 @@ jobs:
# macOS 10.15+ required for std::filesystem used by llama.cpp
MACOSX_DEPLOYMENT_TARGET: ${{ matrix.settings.platform == 'macos-latest' && '10.15' || '' }}
with:
args: '-c ${{ steps.config-overrides.outputs.json }} ${{ matrix.settings.args }}'
args: "-c ${{ steps.config-overrides.outputs.json }} ${{ matrix.settings.args }}"
includeDebug: ${{ needs.get-version.outputs.should-publish == '' && inputs.forceRelease != 'true' }}
includeRelease: ${{ needs.get-version.outputs.should-publish != '' || inputs.forceRelease == 'true' }}
# Don't let tauri-action upload for macOS - we need to bundle TDLib first and recreate the DMG
# For non-macOS platforms, upload directly via tauri-action
releaseId: ${{ matrix.settings.platform != 'macos-latest' && needs.create-release.outputs.releaseId || '' }}
# TDLib dylibs are now bundled natively via build.rs + tauri.conf.json macOS.frameworks
# No post-build scripts needed - Tauri bundler handles copying and signing
releaseId: ${{ needs.create-release.outputs.releaseId }}
owner: alphahumanxyz
repo: alphahuman
# Bundle TDLib dylib into macOS app (fixes "Library not loaded" crash)
- name: Bundle TDLib for macOS
if: matrix.settings.platform == 'macos-latest'
run: |
TARGET="${{ matrix.settings.target }}"
echo "Bundling TDLib for target: $TARGET"
chmod +x ./src-tauri/scripts/bundle-tdlib-macos.sh
./src-tauri/scripts/bundle-tdlib-macos.sh release "$TARGET"
# Re-sign the app after modifying the bundle (required for notarization)
- name: Re-sign macOS app after TDLib bundling
if: matrix.settings.platform == 'macos-latest'
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
run: |
TARGET="${{ matrix.settings.target }}"
echo "Re-signing for target: $TARGET"
if [ -z "$APPLE_SIGNING_IDENTITY" ] || [ -z "$APPLE_CERTIFICATE" ]; then
echo "No signing identity or certificate provided, skipping re-sign"
exit 0
fi
# Import certificate into a temporary keychain (same approach as tauri-action)
KEYCHAIN_PATH="$RUNNER_TEMP/tdlib-signing.keychain-db"
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
# Create and configure keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
# Import certificate
CERT_PATH="$RUNNER_TEMP/certificate.p12"
echo "$APPLE_CERTIFICATE" | base64 --decode > "$CERT_PATH"
security import "$CERT_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
rm "$CERT_PATH"
# Add keychain to search list
security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | tr -d '"')
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
# Find app bundle in target-specific or default location
if [ -n "$TARGET" ]; then
APP_BUNDLE=$(find "src-tauri/target/$TARGET/release/bundle/macos" -name "*.app" -type d 2>/dev/null | head -1)
fi
if [ -z "$APP_BUNDLE" ]; then
APP_BUNDLE=$(find "src-tauri/target/release/bundle/macos" -name "*.app" -type d 2>/dev/null | head -1)
fi
if [ -n "$APP_BUNDLE" ]; then
echo "Re-signing app bundle: $APP_BUNDLE"
# Sign the bundled dylibs first
for dylib in "$APP_BUNDLE/Contents/Frameworks/"*.dylib; do
if [ -f "$dylib" ]; then
echo "Signing: $dylib"
codesign --force --options runtime --sign "$APPLE_SIGNING_IDENTITY" "$dylib"
fi
done
# Re-sign the entire app bundle
codesign --force --deep --options runtime --sign "$APPLE_SIGNING_IDENTITY" "$APP_BUNDLE"
echo "App re-signed successfully"
# Verify signature
codesign --verify --verbose=2 "$APP_BUNDLE" || echo "Warning: Signature verification had issues"
else
echo "Warning: No app bundle found to re-sign"
fi
# Cleanup keychain
security delete-keychain "$KEYCHAIN_PATH" || true
# Re-create DMG after TDLib bundling (the original DMG was created before bundling)
- name: Recreate DMG with bundled TDLib
if: matrix.settings.platform == 'macos-latest'
run: |
TARGET="${{ matrix.settings.target }}"
echo "Recreating DMG for target: $TARGET"
# Tauri v2 puts .app in bundle/macos/ and .dmg in bundle/dmg/
if [ -n "$TARGET" ]; then
BASE_BUNDLE_DIR="src-tauri/target/$TARGET/release/bundle"
else
BASE_BUNDLE_DIR="src-tauri/target/release/bundle"
fi
MACOS_DIR="$BASE_BUNDLE_DIR/macos"
DMG_DIR="$BASE_BUNDLE_DIR/dmg"
APP_BUNDLE=$(find "$MACOS_DIR" -name "*.app" -type d 2>/dev/null | head -1)
if [ -z "$APP_BUNDLE" ]; then
echo "Error: No app bundle found in $MACOS_DIR"
exit 1
fi
APP_NAME=$(basename "$APP_BUNDLE" .app)
# Find the original DMG in bundle/dmg/ (NOT bundle/macos/)
ORIGINAL_DMG=$(find "$DMG_DIR" -name "*.dmg" -type f 2>/dev/null | head -1)
if [ -z "$ORIGINAL_DMG" ]; then
echo "Warning: No original DMG found in $DMG_DIR, skipping DMG recreation"
exit 0
fi
echo "Recreating DMG with bundled TDLib..."
echo "App bundle: $APP_BUNDLE"
echo "Original DMG: $ORIGINAL_DMG"
# Remove the old DMG
rm -f "$ORIGINAL_DMG"
# Create new DMG with the updated app bundle
DMG_NAME=$(basename "$ORIGINAL_DMG")
hdiutil create -volname "$APP_NAME" -srcfolder "$APP_BUNDLE" -ov -format UDZO "$DMG_DIR/$DMG_NAME"
echo "DMG recreated successfully: $DMG_DIR/$DMG_NAME"
ls -la "$DMG_DIR/$DMG_NAME"
- name: Get file info
id: file-info
shell: bash
@@ -522,127 +402,6 @@ jobs:
name: ${{ steps.file-info.outputs.filename }}
path: ${{ steps.file-info.outputs.path }}/${{ steps.file-info.outputs.filename }}
# package-android:
# name: Build and package Android APK
# needs: [get-version, check-version, create-release]
# if: ${{ !failure() && !cancelled() && needs.check-version.outputs.should-skip != 'true' }}
# environment: ${{ github.ref == 'refs/heads/main' && 'Production' || '' }}
# runs-on: ubuntu-22.04
# permissions:
# contents: write
# steps:
# - name: Checkout
# uses: actions/checkout@v4
# with:
# fetch-depth: 1
# ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref }}
# - name: Setup Node.js 24.x
# uses: actions/setup-node@v4
# with:
# node-version: 24.x
# cache: 'yarn'
# - name: Install Rust stable
# uses: dtolnay/rust-toolchain@stable
# with:
# targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android
# - name: Setup Java 17
# uses: actions/setup-java@v4
# with:
# distribution: 'temurin'
# java-version: '17'
# - name: Setup Android SDK
# uses: android-actions/setup-android@v3
# - name: Install Android NDK
# run: sdkmanager --install "ndk;27.0.12077973"
# - name: Set NDK path
# run: echo "NDK_HOME=$ANDROID_HOME/ndk/27.0.12077973" >> $GITHUB_ENV
# - name: Cache node modules
# id: yarn-cache
# uses: actions/cache@v4
# with:
# path: node_modules
# key: ${{ runner.os }}-build-${{ hashFiles('**/yarn.lock') }}
# restore-keys: |
# ${{ runner.os }}-build-
# - name: Install dependencies
# if: steps.yarn-cache.outputs.cache-hit != 'true'
# run: yarn install --frozen-lockfile
# - name: Build frontend
# run: yarn build
# env:
# NODE_ENV: production
# VITE_BACKEND_URL: ${{ vars.VITE_BACKEND_URL }}
# VITE_TELEGRAM_BOT_USERNAME: ${{ secrets.VITE_TELEGRAM_BOT_USERNAME }}
# VITE_TELEGRAM_BOT_ID: ${{ secrets.VITE_TELEGRAM_BOT_ID }}
# VITE_TELEGRAM_API_ID: ${{ secrets.VITE_TELEGRAM_API_ID }}
# VITE_TELEGRAM_API_HASH: ${{ secrets.VITE_TELEGRAM_API_HASH }}
# VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
# VITE_SKILLS_GITHUB_REPO: ${{ vars.VITE_SKILLS_GITHUB_REPO }}
# VITE_DEBUG: ${{ vars.VITE_DEBUG }}
# - name: Initialize Tauri Android
# run: npx tauri android init
# - name: Decode keystore
# if: needs.get-version.outputs.should-publish != ''
# env:
# ANDROID_KEYSTORE: ${{ secrets.ANDROID_KEYSTORE }}
# run: |
# echo "$ANDROID_KEYSTORE" | base64 --decode > ${{ github.workspace }}/release.jks
# - name: Build Android APK (signed)
# if: needs.get-version.outputs.should-publish != ''
# env:
# BASE_URL: ${{ vars.BASE_URL }}
# ANDROID_KEYSTORE_PATH: ${{ github.workspace }}/release.jks
# ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
# ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
# ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
# run: npx tauri android build --apk
# - name: Build Android APK (unsigned)
# if: needs.get-version.outputs.should-publish == ''
# env:
# BASE_URL: ${{ vars.BASE_URL }}
# run: npx tauri android build --apk
# - name: Find APK
# id: find-apk
# run: |
# APK_PATH=$(find gen/android -name '*.apk' -type f | head -1)
# if [ -z "$APK_PATH" ]; then
# echo "No APK found"
# exit 1
# fi
# APK_FILENAME=$(basename "$APK_PATH")
# echo "path=$APK_PATH" >> $GITHUB_OUTPUT
# echo "filename=$APK_FILENAME" >> $GITHUB_OUTPUT
# - name: Upload APK artifact
# uses: actions/upload-artifact@v4
# with:
# name: android-apk
# path: ${{ steps.find-apk.outputs.path }}
# - name: Upload APK to release
# if: needs.get-version.outputs.should-publish != ''
# env:
# RELEASE_ID: ${{ needs.create-release.outputs.releaseId }}
# run: |
# curl -X POST -H "Authorization: Bearer $XGH_TOKEN" \
# -H "Content-Type: application/octet-stream" \
# --data-binary "@${{ steps.find-apk.outputs.path }}" \
# "https://uploads.github.com/repos/$PUBLISH_REPO/releases/$RELEASE_ID/assets?name=${{ steps.find-apk.outputs.filename }}"
publish-release:
runs-on: ubuntu-latest
# needs: [get-version, check-version, create-release, package-tauri, package-android]
+13 -13
View File
@@ -10,19 +10,19 @@ on:
description: "Publish to release (requires main branch)"
type: boolean
default: false
push:
branches:
- develop
workflow_run:
workflows: ["Version Bump"]
types:
- completed
branches:
- main
pull_request:
branches:
- main
- develop
push:
branches:
- develop
workflow_run:
workflows: ["Version Bump"]
types:
- completed
branches:
- main
pull_request:
branches:
- main
- develop
env:
IS_PR: ${{ github.event_name == 'pull_request' }}
+8 -4
View File
@@ -75,11 +75,15 @@ jobs:
require('fs').writeFileSync('package.json', JSON.stringify(p, null, 2) + '\n');
"
# Update Cargo.toml to match
sed -i 's/^version = .*/version = "'"$NEW_VERSION"'"/' src-tauri/Cargo.toml
# Update tauri.conf.json to match
node -e "
const c = require('./src-tauri/tauri.conf.json');
c.version = process.env.NEW_VERSION;
require('fs').writeFileSync('src-tauri/tauri.conf.json', JSON.stringify(c, null, 2) + '\n');
"
# Single commit and tag for both npm and Cargo
git add package.json src-tauri/Cargo.toml
# Single commit and tag for package.json and tauri.conf.json
git add package.json src-tauri/tauri.conf.json
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
git tag "v$NEW_VERSION"
+4
View File
@@ -20,6 +20,10 @@ dist-ssr
.env.local
.env.*.local
# CI secrets for local testing (contains real tokens)
scripts/ci-secrets.json
scripts/ci-secrets.local.json
# Editor directories and files
.idea
.DS_Store
+5 -4
View File
@@ -6,18 +6,19 @@
"scripts": {
"dev": "vite",
"dev:web": "vite",
"dev:app": "source scripts/load-dotenv.sh && tauri dev",
"build": "tsc && vite build",
"build:app": "yarn skills:build && tsc && vite build",
"compile": "tsc --noEmit",
"preview": "vite preview",
"tauri": "tauri",
"macos:build": "tauri build --debug --bundles app && ./src-tauri/scripts/bundle-tdlib-macos.sh debug",
"macos:build:release": "tauri build --bundles app dmg && ./src-tauri/scripts/bundle-tdlib-macos.sh release",
"macos:build:debug": "yarn macos:build:release --debug",
"macos:build:release": "source scripts/load-dotenv.sh && tauri build --bundles app dmg",
"macos:build:sign:release": "source scripts/load-env.sh && tauri build --bundles app dmg",
"macos:run": "open 'src-tauri/target/debug/bundle/macos/AlphaHuman.app'",
"macos:dev": "tauri build --debug --bundles app && ./src-tauri/scripts/bundle-tdlib-macos.sh debug && open 'src-tauri/target/debug/bundle/macos/AlphaHuman.app'",
"macos:dev": "yarn macos:build:debug && open 'src-tauri/target/debug/bundle/macos/AlphaHuman.app'",
"android:dev": "tauri android dev",
"android:build": "tauri android build",
"dev:app": "RUST_BACKTRACE=1 RUST_LOG=info tauri dev",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
+16
View File
@@ -0,0 +1,16 @@
{
"ref": "refs/heads/develop",
"before": "0000000000000000000000000000000000000000",
"after": "19281e16457e7c8ff8e6bda6ceda77f0880d10d2",
"repository": {
"full_name": "vezuresdotxyz/alphahuman-frontend-runner",
"default_branch": "main",
"name": "alphahuman-frontend-runner",
"owner": { "login": "vezuresdotxyz" }
},
"head_commit": {
"id": "19281e16457e7c8ff8e6bda6ceda77f0880d10d2",
"message": "local test build"
},
"sender": { "login": "local-dev" }
}
+28
View File
@@ -0,0 +1,28 @@
{
"secrets": {
"XGH_TOKEN": "",
"GITHUB_TOKEN": "",
"UPDATER_GIST_URL": "",
"UPDATER_GIST_ID": "",
"UPDATER_PUBLIC_KEY": "",
"UPDATER_PRIVATE_KEY": "",
"UPDATER_PRIVATE_KEY_PASSWORD": "",
"APPLE_CERTIFICATE_BASE64": "",
"APPLE_CERTIFICATE_PASSWORD": "",
"APPLE_SIGNING_IDENTITY": "",
"APPLE_ID": "",
"APPLE_APP_SPECIFIC_PASSWORD": "",
"APPLE_TEAM_ID": "",
"VITE_TELEGRAM_BOT_USERNAME": "",
"VITE_TELEGRAM_BOT_ID": "",
"VITE_TELEGRAM_API_ID": "",
"VITE_TELEGRAM_API_HASH": "",
"VITE_SENTRY_DSN": ""
},
"vars": {
"BASE_URL": "https://localhost",
"VITE_BACKEND_URL": "https://localhost:5005",
"VITE_SKILLS_GITHUB_REPO": "",
"VITE_DEBUG": "true"
}
}
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Load .env file into environment variables.
# Usage:
# source scripts/load-dotenv.sh [path/to/.env]
# eval "$(scripts/load-dotenv.sh [path/to/.env])"
# Default path: .env (project root when run from repo root)
set -e
FILE="${1:-.env}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
RESOLVED="${1:+$1}"
RESOLVED="${RESOLVED:-$ROOT_DIR/.env}"
if [[ ! -f "$RESOLVED" ]]; then
echo "File not found: $RESOLVED" >&2
exit 1
fi
exports=()
while IFS= read -r line || [[ -n "$line" ]]; do
line="${line%%#*}"
line="${line#"${line%%[![:space:]]*}"}"
line="${line%"${line##*[![:space:]]}"}"
[[ -z "$line" ]] && continue
if [[ "$line" == export\ * ]]; then
line="${line#export }"
fi
if [[ "$line" == *"="* ]]; then
key="${line%%=*}"
key="${key%"${key##*[![:space:]]}"}"
value="${line#*=}"
value="${value#\"}"
value="${value%\"}"
value="${value#\'}"
value="${value%\'}"
[[ -n "$key" ]] && exports+=("$(printf 'export %s=%q' "$key" "$value")")
fi
done < "$RESOLVED"
joined=$(printf '%s\n' "${exports[@]}")
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
echo "$joined"
else
eval "$joined"
fi
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Load key-value JSON into environment variables.
# Usage:
# source scripts/load-env-json.sh path/to/file.json
# eval "$(scripts/load-env-json.sh path/to/file.json)"
# Optional jq filter to select object (default: .):
# source scripts/load-env-json.sh ci-secrets.json '.secrets + .vars'
set -e
FILE="${1:?Usage: $0 <file.json> [jq-filter]}"
FILTER="${2:-.}"
if [[ ! -f "$FILE" ]]; then
echo "File not found: $FILE" >&2
exit 1
fi
if ! command -v jq &>/dev/null; then
echo "jq is required" >&2
exit 1
fi
exports=$(jq -r "${FILTER} | to_entries | .[] | \"export \(.key)=\(.value | @sh)\"" "$FILE")
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
echo "$exports"
else
eval "$exports"
fi
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Load .env file into environment variables and optional ci-secrets for signing/notarization.
# Usage:
# source scripts/load-env.sh
# eval "$(scripts/load-env.sh)"
set -e
source scripts/load-dotenv.sh
if [[ -f scripts/ci-secrets.local.json ]]; then
source scripts/load-env-json.sh scripts/ci-secrets.local.json
# Tauri notarization expects APPLE_PASSWORD; secrets file uses APPLE_APP_SPECIFIC_PASSWORD
if [[ -z "${APPLE_PASSWORD:-}" && -n "${APPLE_APP_SPECIFIC_PASSWORD:-}" ]]; then
export APPLE_PASSWORD="$APPLE_APP_SPECIFIC_PASSWORD"
fi
fi
+152
View File
@@ -0,0 +1,152 @@
#!/usr/bin/env bash
# Test the package-and-publish workflow locally using `act`.
#
# Prerequisites:
# brew install act jq
#
# Setup:
# cp scripts/ci-secrets.example.json scripts/ci-secrets.json
# # Edit scripts/ci-secrets.json with your real values
#
# Usage:
# ./scripts/test-ci-local.sh # Run full workflow via act
# ./scripts/test-ci-local.sh --manual # Run build steps natively on macOS (recommended)
# ./scripts/test-ci-local.sh --list # List available jobs
# ./scripts/test-ci-local.sh --dryrun # Dry-run (show what would execute)
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
# ── Configuration ─────────────────────────────────────────────────────────────
WORKFLOW=".github/workflows/package-and-publish.yml"
SECRETS_JSON="scripts/ci-secrets.json"
EVENT_JSON="scripts/ci-event.json"
if [[ ! -f "$SECRETS_JSON" ]]; then
echo "ERROR: $SECRETS_JSON not found."
echo ""
echo "Create it from the example:"
echo " cp scripts/ci-secrets.example.json scripts/ci-secrets.json"
echo " # then fill in your values"
exit 1
fi
# ── Generate event payload with current HEAD ──────────────────────────────────
CURRENT_REF=$(git rev-parse HEAD)
cat > "$EVENT_JSON" <<EOF
{
"ref": "refs/heads/develop",
"before": "0000000000000000000000000000000000000000",
"after": "$CURRENT_REF",
"repository": {
"full_name": "vezuresdotxyz/alphahuman-frontend-runner",
"default_branch": "main",
"name": "alphahuman-frontend-runner",
"owner": { "login": "vezuresdotxyz" }
},
"head_commit": {
"id": "$CURRENT_REF",
"message": "local test build"
},
"sender": { "login": "local-dev" }
}
EOF
# ── Convert JSON to act-compatible KEY=VALUE files ────────────────────────────
SECRETS_FILE=$(mktemp)
VARS_FILE=$(mktemp)
trap 'rm -f "$SECRETS_FILE" "$VARS_FILE"' EXIT
# Extract "secrets" object → KEY=VALUE
jq -r '.secrets // {} | to_entries[] | "\(.key)=\(.value)"' "$SECRETS_JSON" > "$SECRETS_FILE"
# Extract "vars" object → KEY=VALUE
jq -r '.vars // {} | to_entries[] | "\(.key)=\(.value)"' "$SECRETS_JSON" > "$VARS_FILE"
echo "Loaded $(wc -l < "$SECRETS_FILE" | tr -d ' ') secrets and $(wc -l < "$VARS_FILE" | tr -d ' ') vars from $SECRETS_JSON"
# ── Common act arguments ──────────────────────────────────────────────────────
ACT_ARGS=(
-W "$WORKFLOW"
--secret-file "$SECRETS_FILE"
--var-file "$VARS_FILE"
--eventpath "$EVENT_JSON"
-P ubuntu-latest=catthehacker/ubuntu:act-latest
-P macos-latest=-self-hosted
)
# ── Handle CLI flags ──────────────────────────────────────────────────────────
if [[ "${1:-}" == "--list" ]]; then
echo "Available jobs in $WORKFLOW:"
act -W "$WORKFLOW" --list
exit 0
fi
if [[ "${1:-}" == "--dryrun" ]]; then
echo "Dry-run of workflow:"
act push "${ACT_ARGS[@]}" -n
exit 0
fi
# ── Manual macOS-native build (recommended) ───────────────────────────────────
if [[ "${1:-}" == "--manual" ]]; then
echo "=== Running build steps manually on macOS host ==="
echo ""
# Export VITE_* vars from the JSON so the frontend build picks them up
eval "$(jq -r '
(.secrets // {}) + (.vars // {})
| to_entries[]
| select(.key | startswith("VITE_"))
| "export \(.key)=\(.value | @sh)"
' "$SECRETS_JSON")"
# Step 1: Ensure OpenSSL is installed
echo ">>> Step 1: Ensure OpenSSL is installed"
brew install openssl@3 2>/dev/null || true
# Step 2: Install Node dependencies
echo ">>> Step 2: Install Node dependencies"
yarn install --frozen-lockfile
# Step 3: Install skills dependencies and build
echo ">>> Step 3: Build skills"
(cd skills && yarn install --frozen-lockfile && yarn build)
# Step 4: Build frontend
echo ">>> Step 4: Build frontend"
NODE_ENV=production yarn build
# Step 5: Build Tauri (aarch64)
echo ">>> Step 5: Build Tauri app (aarch64-apple-darwin)"
yarn tauri build --target aarch64-apple-darwin
echo ""
echo "=== Build complete ==="
echo "Check src-tauri/target/aarch64-apple-darwin/release/bundle/ for output"
exit 0
fi
# ── Default: run full workflow with act ────────────────────────────────────────
#
# We run the full workflow (not -j single-job) so act executes the dependency
# chain: get-version → check-version → create-release (skipped) → package-tauri.
# Using -j package-tauri alone fails because act can't resolve outputs from
# skipped `needs` jobs.
echo "=== Testing package-and-publish workflow locally ==="
echo ""
echo "Workflow: $WORKFLOW"
echo "Event: push to develop (from $EVENT_JSON)"
echo ""
echo "NOTE: act uses Docker containers — macOS-specific steps won't work."
echo "For a native macOS build, use: $0 --manual"
echo ""
act push "${ACT_ARGS[@]}" --verbose
+1 -1
Submodule skills updated: d9dfb8d974...4905daf9e8
+7
View File
@@ -5,3 +5,10 @@
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas
# TDLib and dependencies copied by build.rs for macOS bundling
/libraries/
# TDLib built from source (see scripts/build-tdlib-from-source.sh)
/tdlib-local/
/tdlib-build/
+107 -631
View File
File diff suppressed because it is too large Load Diff
+11 -11
View File
@@ -30,7 +30,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
# HTTP client (rustls for cross-platform TLS support including Android)
reqwest = { version = "0.12", default-features = false, features = ["json", "blocking", "rustls-tls"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "blocking", "rustls-tls", "stream"] }
# Async runtime
tokio = { version = "1", features = ["full", "sync"] }
@@ -74,14 +74,9 @@ chrono = { version = "0.4", features = ["serde"] }
# Cron expression parsing for scheduled skill tasks
cron = "0.12"
# Persistent Socket.io client (Rust-native, survives app backgrounding)
# Note: Using default native-tls on desktop, but we'll handle Android separately
# Stream/Sink utilities for WebSocket and HTTP streaming
futures-util = "0.3"
# Desktop/iOS use native-tls for Socket.io
[target.'cfg(not(target_os = "android"))'.dependencies]
rust_socketio = { version = "0.6", features = ["async"] }
# Android uses a simpler approach - no persistent Socket.io (use web socket from frontend)
[target.'cfg(target_os = "android")'.dependencies]
# Android logging (routes Rust `log` crate to logcat)
@@ -91,11 +86,16 @@ android_logger = "0.14"
keyring = "3"
tauri-plugin-autostart = "2"
tauri-plugin-notification = "2"
# V8 JavaScript runtime (via deno_core - powers Deno)
# QuickJS JavaScript runtime (via quickjs-rs)
# Only available on desktop - prebuilt binaries don't exist for Android/iOS
deno_core = "0.314"
# TDLib Rust bindings (desktop only - downloads prebuilt TDLib library)
rquickjs = { version = "0.11", features = ["futures", "macro", "loader", "parallel"] }
# TDLib Rust bindings (desktop only - downloads prebuilt TDLib for linking)
# On macOS, the bundled dylib is replaced with a source-built version (see build.rs)
# to ensure macOS 10.15 deployment target compatibility.
tdlib-rs = { version = "1.2", features = ["download-tdlib"] }
# Local LLM inference - desktop only (llama.cpp requires native C++ compilation)
# Disable default features (including openmp) to avoid cross-compilation issues
# on macOS where Homebrew installs ARM64-only OpenMP libraries
@@ -103,7 +103,7 @@ llama-cpp-2 = { version = "0.1", default-features = false }
encoding_rs = "0.8"
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.build-dependencies]
# TDLib build configuration
# TDLib build configuration (download-tdlib for all desktop platforms)
tdlib-rs = { version = "1.2", features = ["download-tdlib"] }
[features]
+119 -14
View File
@@ -1,18 +1,123 @@
fn main() {
// Get the target OS from environment variable (set by Cargo during cross-compilation)
let target = std::env::var("TARGET").unwrap_or_default();
let is_mobile_target = target.contains("android") || target.contains("ios");
use std::env;
// TDLib build configuration (desktop only)
// The tdlib-rs crate with download-tdlib feature handles downloading and linking
// the prebuilt TDLib library automatically.
// Note: We check the TARGET env var because cfg() checks the HOST platform for build scripts.
#[cfg(not(any(target_os = "android", target_os = "ios")))]
if !is_mobile_target {
// Download and link TDLib library
// Pass None to use default download location
tdlib_rs::build::build(None);
fn main() {
setup_tdlib();
tauri_build::build();
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
fn setup_tdlib() {
// download-tdlib: downloads prebuilt TDLib and configures linker flags
tdlib_rs::build::build(None);
// On macOS, replace the bundled dylib with our source-built version
// that targets macOS 10.15 (the prebuilt one targets macOS 14.0)
#[cfg(target_os = "macos")]
copy_local_tdlib_for_bundle();
}
#[cfg(any(target_os = "android", target_os = "ios"))]
fn setup_tdlib() {
// No TDLib on mobile
}
/// On macOS, copy the source-built TDLib dylib (with 10.15 deployment target)
/// to libraries/ for Tauri bundler. Lookup order:
/// 1. tdlib-prebuilt/macos-<arch>/ (committed to git, no build needed)
/// 2. tdlib-local/lib/ (local build via build-tdlib-from-source.sh)
/// 3. download-tdlib output (fallback, targets macOS 14.0+)
#[cfg(target_os = "macos")]
fn copy_local_tdlib_for_bundle() {
use std::path::PathBuf;
let manifest_dir =
PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"));
let libraries_dir = manifest_dir.join("libraries");
// Use CARGO_CFG_TARGET_ARCH to handle cross-compilation (e.g. arm64 host → x86_64 target)
let target_arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_else(|_| "aarch64".into());
let arch_name = match target_arch.as_str() {
"aarch64" => "arm64",
other => other,
};
let tdlib_version = "1.8.29";
let dylib_name = format!("libtdjson.{tdlib_version}.dylib");
// 1. Check tdlib-prebuilt/ (committed to git)
let prebuilt = manifest_dir
.join("tdlib-prebuilt")
.join(format!("macos-{arch_name}"))
.join(&dylib_name);
// 2. Check tdlib-local/<arch>/ (local source build)
let local = manifest_dir
.join("tdlib-local")
.join(arch_name)
.join("lib")
.join(&dylib_name);
let src_dylib = if prebuilt.exists() {
println!("cargo:warning=Using prebuilt TDLib from {}", prebuilt.display());
prebuilt
} else if local.exists() {
println!("cargo:warning=Using locally-built TDLib from {}", local.display());
local
} else {
// 3. Fall back to the download-tdlib output
println!(
"cargo:warning=No source-built TDLib found for macos-{arch_name}. \
The prebuilt TDLib will be bundled instead (targets macOS 14.0+). \
Run: cd src-tauri && ./scripts/build-tdlib-from-source.sh"
);
let out_dir = env::var("OUT_DIR").unwrap();
PathBuf::from(&out_dir).join("tdlib").join("lib").join(&dylib_name)
};
if !src_dylib.exists() {
println!("cargo:warning=TDLib dylib not found at {}", src_dylib.display());
return;
}
tauri_build::build()
let dst_dylib = libraries_dir.join(&dylib_name);
std::fs::create_dir_all(&libraries_dir).expect("Failed to create libraries/");
std::fs::copy(&src_dylib, &dst_dylib).expect("Failed to copy TDLib dylib to libraries/");
set_permissions_rw(&dst_dylib);
fix_install_name(&dst_dylib, &dylib_name);
// Add rpath so the binary finds the dylib in Contents/Frameworks/
println!("cargo:rustc-link-arg=-Wl,-rpath,@executable_path/../Frameworks");
}
#[cfg(target_os = "macos")]
fn fix_install_name(dylib_path: &std::path::Path, dylib_name: &str) {
run_cmd(
"install_name_tool",
&[
"-id",
&format!("@rpath/{dylib_name}"),
dylib_path.to_str().unwrap(),
],
);
}
#[cfg(target_os = "macos")]
fn set_permissions_rw(path: &std::path::Path) {
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(path)
.expect("Failed to read metadata")
.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(path, perms).expect("Failed to set permissions");
}
#[cfg(target_os = "macos")]
fn run_cmd(cmd: &str, args: &[&str]) {
let status = std::process::Command::new(cmd)
.args(args)
.status()
.unwrap_or_else(|e| panic!("Failed to run {cmd}: {e}"));
if !status.success() {
panic!("{cmd} failed with exit code {:?}", status.code());
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1.8 MiB

+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env bash
#
# Build TDLib v1.8.29 from source with MACOSX_DEPLOYMENT_TARGET=10.15
# OpenSSL 3.x is statically linked so there are NO external OpenSSL deps.
#
# Usage:
# cd src-tauri
# ./scripts/build-tdlib-from-source.sh [arm64|x86_64]
#
# Output: src-tauri/tdlib-local/ (lib/ + include/)
# Time: 5-15 minutes on first run
set -euo pipefail
TDLIB_VERSION="1.8.29"
TDLIB_COMMIT="af69dd4397b6dc1bf23ba0fd0bf429fcba6454f6"
OPENSSL_VERSION="3.4.1"
export MACOSX_DEPLOYMENT_TARGET="10.15"
# --- Resolve paths relative to src-tauri/ ---
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SRC_TAURI_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
# --- Detect or override architecture ---
ARCH="${1:-$(uname -m)}"
case "$ARCH" in
arm64|aarch64) ARCH="arm64" ;;
x86_64) ARCH="x86_64" ;;
*)
echo "Error: unsupported architecture '$ARCH'. Use arm64 or x86_64."
exit 1
;;
esac
echo "==> Building for architecture: $ARCH"
echo "==> Deployment target: macOS $MACOSX_DEPLOYMENT_TARGET"
# Arch-specific build and install dirs (so arm64 and x86_64 don't clobber each other)
BUILD_DIR="${SRC_TAURI_DIR}/tdlib-build/${ARCH}"
INSTALL_DIR="${SRC_TAURI_DIR}/tdlib-local/${ARCH}"
COMMON_FLAGS="-arch $ARCH -mmacosx-version-min=$MACOSX_DEPLOYMENT_TARGET"
# --- Check prerequisites ---
for cmd in cmake gperf make perl; do
if ! command -v $cmd &>/dev/null; then
echo "Error: '$cmd' not found. Install via: brew install $cmd"
exit 1
fi
done
mkdir -p "$BUILD_DIR"
# ============================================================
# 1. Build OpenSSL 3.x (static only)
# ============================================================
OPENSSL_SRC="${BUILD_DIR}/openssl-${OPENSSL_VERSION}"
OPENSSL_INSTALL="${BUILD_DIR}/openssl-install"
if [ -f "${OPENSSL_INSTALL}/lib/libssl.a" ]; then
echo "==> OpenSSL already built, skipping (delete ${OPENSSL_INSTALL} to rebuild)"
else
echo "==> Downloading OpenSSL ${OPENSSL_VERSION}..."
cd "$BUILD_DIR"
if [ ! -d "$OPENSSL_SRC" ]; then
curl -sSL "https://github.com/openssl/openssl/releases/download/openssl-${OPENSSL_VERSION}/openssl-${OPENSSL_VERSION}.tar.gz" -o openssl.tar.gz
tar xzf openssl.tar.gz
rm openssl.tar.gz
fi
echo "==> Building OpenSSL (static, no-shared)..."
cd "$OPENSSL_SRC"
# Map arch to OpenSSL target
if [ "$ARCH" = "arm64" ]; then
OPENSSL_TARGET="darwin64-arm64-cc"
else
OPENSSL_TARGET="darwin64-x86_64-cc"
fi
./Configure "$OPENSSL_TARGET" \
no-shared \
no-tests \
--prefix="$OPENSSL_INSTALL" \
-mmacosx-version-min=$MACOSX_DEPLOYMENT_TARGET
make -j"$(sysctl -n hw.ncpu)"
make install_sw
echo "==> OpenSSL installed to ${OPENSSL_INSTALL}"
fi
# ============================================================
# 2. Build TDLib from source
# ============================================================
TDLIB_SRC="${BUILD_DIR}/td"
TDLIB_BUILD="${BUILD_DIR}/td-build"
if [ -f "${INSTALL_DIR}/lib/libtdjson.${TDLIB_VERSION}.dylib" ]; then
echo "==> TDLib already built, skipping (delete ${INSTALL_DIR} to rebuild)"
else
echo "==> Downloading TDLib ${TDLIB_VERSION} (commit ${TDLIB_COMMIT})..."
cd "$BUILD_DIR"
if [ ! -d "$TDLIB_SRC" ]; then
git clone https://github.com/tdlib/td.git
cd td && git checkout "$TDLIB_COMMIT" && cd ..
fi
echo "==> Building TDLib..."
rm -rf "$TDLIB_BUILD"
mkdir -p "$TDLIB_BUILD"
cd "$TDLIB_BUILD"
# CMAKE_OSX_ARCHITECTURES ensures all C/C++ code targets the right arch
cmake "$TDLIB_SRC" \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX="$INSTALL_DIR" \
-DCMAKE_OSX_DEPLOYMENT_TARGET="$MACOSX_DEPLOYMENT_TARGET" \
-DCMAKE_OSX_ARCHITECTURES="$ARCH" \
-DCMAKE_C_FLAGS="$COMMON_FLAGS" \
-DCMAKE_CXX_FLAGS="$COMMON_FLAGS" \
-DOPENSSL_ROOT_DIR="$OPENSSL_INSTALL" \
-DOPENSSL_USE_STATIC_LIBS=ON \
-DOPENSSL_CRYPTO_LIBRARY="$OPENSSL_INSTALL/lib/libcrypto.a" \
-DOPENSSL_SSL_LIBRARY="$OPENSSL_INSTALL/lib/libssl.a" \
-DOPENSSL_INCLUDE_DIR="$OPENSSL_INSTALL/include" \
-DTD_ENABLE_JNI=OFF
cmake --build . --target tdjson -j"$(sysctl -n hw.ncpu)"
# cmake --install may fail on missing static lib targets we don't need.
# Just install tdjson manually.
mkdir -p "$INSTALL_DIR/lib"
cp -a libtdjson*.dylib "$INSTALL_DIR/lib/"
echo "==> TDLib installed to ${INSTALL_DIR}"
fi
# ============================================================
# 3. Copy to tdlib-prebuilt/ for git commit
# ============================================================
PREBUILT_DIR="${SRC_TAURI_DIR}/tdlib-prebuilt/macos-${ARCH}"
mkdir -p "$PREBUILT_DIR"
cp "${INSTALL_DIR}/lib/libtdjson.${TDLIB_VERSION}.dylib" "$PREBUILT_DIR/"
echo "==> Copied dylib to ${PREBUILT_DIR}/ (commit this to git)"
# ============================================================
# 4. Verify the build
# ============================================================
DYLIB="${INSTALL_DIR}/lib/libtdjson.${TDLIB_VERSION}.dylib"
if [ ! -f "$DYLIB" ]; then
echo "Error: expected dylib not found at ${DYLIB}"
exit 1
fi
echo ""
echo "==> Verification:"
# Check deployment target
MINOS=$(otool -l "$DYLIB" | grep -A2 "minos" | head -3)
echo " Min OS version info:"
echo "$MINOS" | sed 's/^/ /'
# Check for OpenSSL references (should be NONE)
OPENSSL_REFS=$(otool -L "$DYLIB" | grep -i "ssl\|crypto" || true)
if [ -n "$OPENSSL_REFS" ]; then
echo " WARNING: Found external OpenSSL references (should be none):"
echo "$OPENSSL_REFS" | sed 's/^/ /'
else
echo " No external OpenSSL references (statically linked)"
fi
# Show all dylib deps
echo " Dependencies:"
otool -L "$DYLIB" | tail -n +2 | sed 's/^/ /'
echo ""
echo "==> Done! TDLib ${TDLIB_VERSION} built successfully for ${ARCH}."
echo " Install dir: ${INSTALL_DIR}"
echo ""
echo " Next: run 'yarn tauri build --debug --bundles app' to build the app."
-234
View File
@@ -1,234 +0,0 @@
#!/bin/bash
# Script to bundle TDLib dylib and its dependencies into macOS app bundle
# This fixes the "Library not loaded: @rpath/libtdjson.1.8.29.dylib" error
set -e
TDLIB_VERSION="1.8.29"
DYLIB_NAME="libtdjson.${TDLIB_VERSION}.dylib"
# Determine build type (debug or release)
BUILD_TYPE="${1:-release}"
# Optional: specific target (e.g., aarch64-apple-darwin, x86_64-apple-darwin)
TARGET="${2:-}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
TAURI_DIR="$(dirname "$SCRIPT_DIR")"
# Determine target directory (handles cross-compilation targets)
if [ -n "$TARGET" ]; then
TARGET_DIR="${TAURI_DIR}/target/${TARGET}/${BUILD_TYPE}"
else
TARGET_DIR="${TAURI_DIR}/target/${BUILD_TYPE}"
fi
# Fallback: if target-specific dir doesn't exist, try without target
if [ ! -d "$TARGET_DIR" ]; then
TARGET_DIR="${TAURI_DIR}/target/${BUILD_TYPE}"
fi
echo "=== TDLib macOS Bundler ==="
echo "Build type: ${BUILD_TYPE}"
echo "Target: ${TARGET:-default}"
echo "Target dir: ${TARGET_DIR}"
# Find the app bundle - check multiple possible locations
APP_BUNDLE=""
for search_dir in \
"${TAURI_DIR}/target/${TARGET}/${BUILD_TYPE}/bundle/macos" \
"${TAURI_DIR}/target/${BUILD_TYPE}/bundle/macos" \
"${TAURI_DIR}/target/release/bundle/macos" \
"${TAURI_DIR}/target/debug/bundle/macos"; do
if [ -d "$search_dir" ]; then
found=$(find "$search_dir" -name "*.app" -type d 2>/dev/null | head -1)
if [ -n "$found" ]; then
APP_BUNDLE="$found"
break
fi
fi
done
if [ -z "$APP_BUNDLE" ]; then
echo "Warning: No .app bundle found"
echo "Searched in:"
echo " - ${TAURI_DIR}/target/${TARGET}/${BUILD_TYPE}/bundle/macos"
echo " - ${TAURI_DIR}/target/${BUILD_TYPE}/bundle/macos"
echo "This script should be run after 'tauri build'"
exit 0
fi
echo "Found app bundle: ${APP_BUNDLE}"
# Create Frameworks directory
FRAMEWORKS_DIR="${APP_BUNDLE}/Contents/Frameworks"
mkdir -p "$FRAMEWORKS_DIR"
echo "Created Frameworks directory: ${FRAMEWORKS_DIR}"
# Find the TDLib dylib in build artifacts - search in multiple locations
DYLIB_PATH=""
for search_dir in \
"${TAURI_DIR}/target/${TARGET}/${BUILD_TYPE}/build" \
"${TAURI_DIR}/target/${BUILD_TYPE}/build" \
"${TAURI_DIR}/target/release/build" \
"${TAURI_DIR}/target/debug/build"; do
if [ -d "$search_dir" ]; then
found=$(find "$search_dir" -name "${DYLIB_NAME}" -type f 2>/dev/null | head -1)
if [ -n "$found" ]; then
DYLIB_PATH="$found"
break
fi
fi
done
if [ -z "$DYLIB_PATH" ]; then
echo "Error: Could not find ${DYLIB_NAME} in build artifacts"
echo "Searched in target/*/build directories"
echo "Make sure TDLib was built correctly."
exit 1
fi
echo "Found TDLib dylib: ${DYLIB_PATH}"
# Copy the dylib to Frameworks
cp "$DYLIB_PATH" "${FRAMEWORKS_DIR}/"
echo "Copied ${DYLIB_NAME} to Frameworks"
# Also copy the symlink version if it exists
DYLIB_SYMLINK="${DYLIB_PATH%/*}/libtdjson.dylib"
if [ -f "$DYLIB_SYMLINK" ] || [ -L "$DYLIB_SYMLINK" ]; then
cp "$DYLIB_SYMLINK" "${FRAMEWORKS_DIR}/" 2>/dev/null || true
fi
# Find the main binary
BINARY_NAME=$(basename "${APP_BUNDLE}" .app)
BINARY_PATH="${APP_BUNDLE}/Contents/MacOS/${BINARY_NAME}"
if [ ! -f "$BINARY_PATH" ]; then
echo "Error: Binary not found at ${BINARY_PATH}"
exit 1
fi
echo "Found binary: ${BINARY_PATH}"
# Bundled dylib path
BUNDLED_DYLIB="${FRAMEWORKS_DIR}/${DYLIB_NAME}"
# Function to bundle a dependency and fix references
bundle_dependency() {
local dep_path="$1"
local dep_name=$(basename "$dep_path")
if [ ! -f "$dep_path" ]; then
echo "Warning: Dependency not found: $dep_path"
return 1
fi
# Copy to Frameworks if not already there
if [ ! -f "${FRAMEWORKS_DIR}/${dep_name}" ]; then
echo "Bundling dependency: ${dep_name}"
cp "$dep_path" "${FRAMEWORKS_DIR}/"
chmod 755 "${FRAMEWORKS_DIR}/${dep_name}"
# Change the dependency's install name to use @rpath
install_name_tool -id "@rpath/${dep_name}" "${FRAMEWORKS_DIR}/${dep_name}"
fi
return 0
}
# Function to fix references in a library
fix_references() {
local lib_path="$1"
local lib_name=$(basename "$lib_path")
echo "Fixing references in: ${lib_name}"
# Get all dependencies
local deps=$(otool -L "$lib_path" | tail -n +2 | awk '{print $1}')
for dep in $deps; do
# Skip system libraries (they're fine as-is)
if [[ "$dep" == /usr/lib/* ]] || [[ "$dep" == /System/* ]] || [[ "$dep" == "@rpath/"* ]] || [[ "$dep" == "@executable_path/"* ]]; then
continue
fi
local dep_name=$(basename "$dep")
# Bundle the dependency if it's from Homebrew or a non-system path
if [[ "$dep" == /opt/homebrew/* ]] || [[ "$dep" == /usr/local/* ]]; then
if bundle_dependency "$dep"; then
# Update the reference to use @rpath
install_name_tool -change "$dep" "@rpath/${dep_name}" "$lib_path"
echo " Fixed reference: ${dep_name}"
fi
fi
done
}
# Fix the TDLib dylib's install name
echo ""
echo "Fixing dylib install name..."
install_name_tool -id "@rpath/${DYLIB_NAME}" "$BUNDLED_DYLIB"
# Bundle OpenSSL and other dependencies from TDLib
echo ""
echo "Bundling TDLib dependencies..."
fix_references "$BUNDLED_DYLIB"
# Fix any cross-references between bundled dependencies
echo ""
echo "Fixing cross-references between dependencies..."
for dylib in "${FRAMEWORKS_DIR}"/*.dylib; do
if [ -f "$dylib" ]; then
fix_references "$dylib"
fi
done
# Fix rpaths in the binary
echo ""
echo "Fixing binary rpaths..."
# Get all current rpaths
CURRENT_RPATHS=$(otool -l "$BINARY_PATH" | grep -A2 "cmd LC_RPATH" | grep "path " | awk '{print $2}')
echo "Current rpaths:"
echo "$CURRENT_RPATHS"
# Remove all rpaths that point to build directories (they won't exist at runtime)
for rpath in $CURRENT_RPATHS; do
# Remove rpaths containing /build/, /target/, or absolute paths that aren't @executable_path
if [[ "$rpath" == *"/build/"* ]] || [[ "$rpath" == *"/target/"* ]] || [[ "$rpath" != @* ]]; then
echo "Removing build-time rpath: $rpath"
install_name_tool -delete_rpath "$rpath" "$BINARY_PATH" 2>/dev/null || true
fi
done
# Delete our target rpath first if it exists (to avoid "already exists" error)
install_name_tool -delete_rpath "@executable_path/../Frameworks" "$BINARY_PATH" 2>/dev/null || true
# Add the correct rpath for the bundled Frameworks
echo "Adding rpath: @executable_path/../Frameworks"
install_name_tool -add_rpath "@executable_path/../Frameworks" "$BINARY_PATH"
# Verify the changes
echo ""
echo "=== Verification ==="
echo ""
echo "Bundled libraries:"
ls -la "${FRAMEWORKS_DIR}/"
echo ""
echo "TDLib dylib dependencies (should all be @rpath or system libs):"
otool -L "$BUNDLED_DYLIB"
echo ""
echo "Binary library dependencies:"
otool -L "$BINARY_PATH" | grep -i tdjson || echo "No tdjson reference found"
echo ""
echo "Binary rpaths:"
otool -l "$BINARY_PATH" | grep -A3 "cmd LC_RPATH" | head -8 || true
echo ""
echo "=== TDLib bundling complete ==="
echo "The app bundle at ${APP_BUNDLE} now includes TDLib and its dependencies."
+2 -1
View File
@@ -14,7 +14,7 @@ use tauri::State;
// Desktop-only imports
#[cfg(not(any(target_os = "android", target_os = "ios")))]
use crate::runtime::v8_engine::RuntimeEngine;
use crate::runtime::qjs_engine::RuntimeEngine;
#[cfg(not(any(target_os = "android", target_os = "ios")))]
use crate::runtime::types::{SkillSnapshot, ToolResult};
@@ -386,6 +386,7 @@ pub async fn runtime_socket_connect(
url: Option<String>,
) -> Result<(), String> {
let backend_url = url.unwrap_or_else(get_backend_url);
log::info!("[socket-cmd] runtime_socket_connect to {}", backend_url);
socket_mgr.connect(&backend_url, &token).await
}
+43 -33
View File
@@ -84,7 +84,7 @@ macro_rules! common_handlers {
ai_read_memory_file,
ai_write_memory_file,
ai_list_memory_files,
// V8 runtime commands
// Runtime commands
runtime_discover_skills,
runtime_list_skills,
runtime_start_skill,
@@ -93,14 +93,14 @@ macro_rules! common_handlers {
runtime_call_tool,
runtime_all_tools,
runtime_broadcast_event,
// V8 runtime enable/disable + KV commands
// Runtime enable/disable + KV commands
runtime_enable_skill,
runtime_disable_skill,
runtime_is_skill_enabled,
runtime_get_skill_preferences,
runtime_skill_kv_get,
runtime_skill_kv_set,
// V8 runtime JSON-RPC + data commands
// Runtime JSON-RPC + data commands
runtime_rpc,
runtime_skill_data_read,
runtime_skill_data_write,
@@ -207,15 +207,6 @@ fn setup_tray(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> {
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// Load .env file (silently ignore if missing — production won't have one)
// Try current directory first, then parent (for when running from src-tauri)
if dotenvy::dotenv().is_err() {
if let Ok(cwd) = std::env::current_dir() {
if let Some(parent) = cwd.parent() {
let _ = dotenvy::from_path(parent.join(".env"));
}
}
}
// Initialize platform-appropriate logger
#[cfg(target_os = "android")]
@@ -289,7 +280,7 @@ pub fn run() {
);
socket_mgr.set_app_handle(app.handle().clone());
// Initialize V8 Runtime Engine (desktop only - V8 not available on Android/iOS)
// Initialize QuickJS Runtime Engine (desktop only - not available on Android/iOS)
#[cfg(not(any(target_os = "android", target_os = "ios")))]
{
let data_dir = app
@@ -308,7 +299,7 @@ pub fn run() {
services::llama::LLAMA_MANAGER.set_data_dir(model_dir);
log::info!("[runtime] Local model service initialized");
match runtime::v8_engine::RuntimeEngine::new(skills_data_dir) {
match runtime::qjs_engine::RuntimeEngine::new(skills_data_dir) {
Ok(engine) => {
engine.set_app_handle(app.handle().clone());
@@ -330,28 +321,29 @@ pub fn run() {
cron.start();
});
// Auto-start skills in background
// Auto-start skills in background (no delay needed for QuickJS -
// lightweight contexts don't have V8's memory reservation issue)
let engine_clone = engine.clone();
tauri::async_runtime::spawn(async move {
engine_clone.auto_start_skills().await;
});
log::info!("[runtime] V8 runtime engine initialized");
log::info!("[runtime] QuickJS runtime engine initialized");
}
Err(e) => {
log::error!("[runtime] Failed to initialize V8 runtime: {e}");
log::error!("[runtime] Failed to initialize QuickJS runtime: {e}");
}
}
}
#[cfg(target_os = "android")]
{
log::info!("[runtime] V8 runtime and local model disabled on Android");
log::info!("[runtime] QuickJS runtime and local model disabled on Android");
}
#[cfg(target_os = "ios")]
{
log::info!("[runtime] V8 runtime and local model disabled on iOS");
log::info!("[runtime] QuickJS runtime and local model disabled on iOS");
}
// Store SocketManager as Tauri state
@@ -433,7 +425,7 @@ pub fn run() {
ai_read_memory_file,
ai_write_memory_file,
ai_list_memory_files,
// V8 runtime commands
// Runtime commands
runtime_discover_skills,
runtime_list_skills,
runtime_start_skill,
@@ -442,14 +434,14 @@ pub fn run() {
runtime_call_tool,
runtime_all_tools,
runtime_broadcast_event,
// V8 runtime enable/disable + KV commands
// Runtime enable/disable + KV commands
runtime_enable_skill,
runtime_disable_skill,
runtime_is_skill_enabled,
runtime_get_skill_preferences,
runtime_skill_kv_get,
runtime_skill_kv_set,
// V8 runtime JSON-RPC + data commands
// Runtime JSON-RPC + data commands
runtime_rpc,
runtime_skill_data_read,
runtime_skill_data_write,
@@ -525,7 +517,7 @@ pub fn run() {
ai_read_memory_file,
ai_write_memory_file,
ai_list_memory_files,
// V8 runtime commands
// Runtime commands
runtime_discover_skills,
runtime_list_skills,
runtime_start_skill,
@@ -534,14 +526,14 @@ pub fn run() {
runtime_call_tool,
runtime_all_tools,
runtime_broadcast_event,
// V8 runtime enable/disable + KV commands
// Runtime enable/disable + KV commands
runtime_enable_skill,
runtime_disable_skill,
runtime_is_skill_enabled,
runtime_get_skill_preferences,
runtime_skill_kv_get,
runtime_skill_kv_set,
// V8 runtime JSON-RPC + data commands
// Runtime JSON-RPC + data commands
runtime_rpc,
runtime_skill_data_read,
runtime_skill_data_write,
@@ -574,14 +566,32 @@ pub fn run() {
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app_handle, event| {
// Handle macOS Dock icon click (reopen event)
#[cfg(target_os = "macos")]
if let RunEvent::Reopen { .. } = event {
show_main_window(app_handle);
}
match event {
// Handle macOS Dock icon click (reopen event)
#[cfg(target_os = "macos")]
RunEvent::Reopen { .. } => {
show_main_window(app_handle);
}
// Suppress unused variable warnings on other platforms
#[cfg(not(target_os = "macos"))]
let _ = (app_handle, event);
// Gracefully shut down TDLib before process exit to prevent
// use-after-free crash in the blocking receive loop.
#[cfg(not(any(target_os = "android", target_os = "ios")))]
RunEvent::Exit => {
log::info!("[app] Exit event received, shutting down TDLib");
use crate::services::tdlib::TDLIB_MANAGER;
// Signal the TDLib worker to stop. The blocking receive() call
// has a 2-second internal timeout, so we must wait long enough
// for any in-flight call to finish before process teardown runs
// C++ destructors on TDLib's internal state.
TDLIB_MANAGER.signal_shutdown();
std::thread::sleep(std::time::Duration::from_millis(2500));
log::info!("[app] TDLib shutdown wait complete");
let _ = app_handle;
}
_ => {
let _ = app_handle;
}
}
});
}
+3 -4
View File
@@ -1,10 +1,9 @@
//! Custom V8 module resolver and loader for `@alphahuman/*` imports.
//! Custom module resolver and loader for `@alphahuman/*` imports.
//!
//! NOTE: Currently unused. Skills access bridge APIs via globals (db, store, console)
//! injected by v8_skill_instance.rs. This module is reserved for future ES module
//! injected by qjs_skill_instance.rs. This module is reserved for future ES module
//! import support (e.g., `import { db } from '@alphahuman/db'`).
//!
//! The globals-based approach was chosen because:
//! - V8/deno_core shares the module loader across all contexts in the same runtime
//! - Per-skill module loaders would require separate runtime instances
//! - Globals are simpler and sufficient for the initial implementation
//! - Per-skill module loaders can be added later if needed
+3 -3
View File
@@ -86,10 +86,10 @@ impl SkillManifest {
Self::from_json(&content)
}
/// Whether this manifest declares a JavaScript runtime (V8).
/// Accepts "v8", "javascript" for compatibility.
/// Whether this manifest declares a JavaScript runtime (QuickJS).
/// Accepts "v8", "javascript", "quickjs" for compatibility.
pub fn is_javascript(&self) -> bool {
self.runtime == "v8" || self.runtime == "javascript"
matches!(self.runtime.as_str(), "v8" | "javascript" | "quickjs")
}
/// Whether the skill is available on the current platform.
+6 -6
View File
@@ -1,9 +1,9 @@
//! V8 skill runtime module.
//! QuickJS skill runtime module.
//!
//! Provides a persistent JavaScript execution environment for skills
//! using the V8 engine via `deno_core`.
//! using the QuickJS engine via `rquickjs`.
//!
//! Note: V8/deno_core is only available on desktop platforms.
//! Note: The skill runtime is only available on desktop platforms.
//! On mobile (Android/iOS), the skill runtime is disabled.
// Platform-agnostic modules
@@ -13,7 +13,7 @@ pub mod preferences;
pub mod socket_manager;
pub mod types;
// V8/deno_core modules - desktop only (no prebuilt binaries for Android/iOS)
// QuickJS modules - desktop only (not available on Android/iOS)
#[cfg(not(any(target_os = "android", target_os = "ios")))]
pub mod bridge;
#[cfg(not(any(target_os = "android", target_os = "ios")))]
@@ -21,6 +21,6 @@ pub mod cron_scheduler;
#[cfg(not(any(target_os = "android", target_os = "ios")))]
pub mod skill_registry;
#[cfg(not(any(target_os = "android", target_os = "ios")))]
pub mod v8_engine;
pub mod qjs_engine;
#[cfg(not(any(target_os = "android", target_os = "ios")))]
pub mod v8_skill_instance;
pub mod qjs_skill_instance;
@@ -1,7 +1,7 @@
//! RuntimeEngine — top-level orchestrator for the V8 skill runtime.
//! RuntimeEngine — top-level orchestrator for the QuickJS skill runtime.
//!
//! Manages skill lifecycle and provides the public API consumed by Tauri commands.
//! Uses V8 (via deno_core) for JavaScript execution.
//! Uses QuickJS (via rquickjs) for JavaScript execution.
use std::path::PathBuf;
use std::sync::Arc;
@@ -14,10 +14,10 @@ use crate::runtime::manifest::SkillManifest;
use crate::runtime::preferences::PreferencesStore;
use crate::runtime::skill_registry::SkillRegistry;
use crate::runtime::types::{events, SkillSnapshot, SkillStatus, ToolResult};
use crate::runtime::v8_skill_instance::{BridgeDeps, V8SkillInstance};
use crate::runtime::qjs_skill_instance::{BridgeDeps, QjsSkillInstance};
use crate::services::tdlib_v8::storage::IdbStorage;
/// The central runtime engine using V8.
/// The central runtime engine using QuickJS.
pub struct RuntimeEngine {
/// Registry of all skills.
registry: Arc<SkillRegistry>,
@@ -43,7 +43,7 @@ impl RuntimeEngine {
cron_scheduler.set_registry(Arc::clone(&registry));
let preferences = Arc::new(PreferencesStore::new(&skills_data_dir));
log::info!("[runtime] V8 RuntimeEngine created");
log::info!("[runtime] QuickJS RuntimeEngine created");
Ok(Self {
registry,
@@ -111,9 +111,7 @@ impl RuntimeEngine {
}
// 4. Production: bundled resources
// Tauri converts "../" to "_up_/" when bundling resources with array notation
if let Some(resource_dir) = self.resource_dir.read().as_ref() {
// Try: $RESOURCE/_up_/skills/skills/ (array notation with ../)
let bundled_skills = resource_dir.join("_up_").join("skills").join("skills");
if bundled_skills.exists() {
log::info!(
@@ -123,7 +121,6 @@ impl RuntimeEngine {
return Ok(bundled_skills);
}
// Try: $RESOURCE/skills/ (map notation)
let bundled_skills_alt = resource_dir.join("skills");
if bundled_skills_alt.exists() {
log::info!(
@@ -140,7 +137,7 @@ impl RuntimeEngine {
);
}
// 5. Final fallback: app data dir (for user-installed skills)
// 5. Final fallback: app data dir
let prod_dir = self.skills_data_dir.clone();
log::info!(
"[runtime] Skills source dir (data dir fallback): {:?}",
@@ -184,7 +181,6 @@ impl RuntimeEngine {
);
}
Ok(_) => {
// Not a JavaScript skill, skip
log::info!(
"[runtime] Skipping skill '{}': not a JavaScript skill",
manifest_path.display()
@@ -227,7 +223,7 @@ impl RuntimeEngine {
let manifest = SkillManifest::from_path(&manifest_path).await?;
if !manifest.is_javascript() {
return Err(format!(
"Skill '{}' uses runtime '{}', not 'v8' or 'javascript'",
"Skill '{}' uses runtime '{}', not a supported JavaScript runtime",
skill_id, manifest.runtime
));
}
@@ -235,15 +231,15 @@ impl RuntimeEngine {
let config = manifest.to_config();
let data_dir = self.skills_data_dir.join(skill_id);
// Create the V8 skill instance
log::info!("[runtime] Creating V8 skill instance for '{}'", skill_id);
// Create the QuickJS skill instance
log::info!("[runtime] Creating QuickJS skill instance for '{}'", skill_id);
log::info!("[runtime] Config: {:?}", config);
log::info!("[runtime] Skill dir: {:?}", skill_dir);
log::info!("[runtime] Data dir: {:?}", data_dir);
let (instance, rx) = V8SkillInstance::new(config.clone(), skill_dir, data_dir.clone());
log::info!("[runtime] V8 skill instance created for '{}'", skill_id);
let (instance, rx) = QjsSkillInstance::new(config.clone(), skill_dir, data_dir.clone());
log::info!("[runtime] QuickJS skill instance created for '{}'", skill_id);
// Bundle bridge dependencies
// Bundle bridge dependencies (no creation lock needed for QuickJS)
let deps = BridgeDeps {
cron_scheduler: self.cron_scheduler.clone(),
skill_registry: self.registry.clone(),
@@ -265,8 +261,7 @@ impl RuntimeEngine {
self.emit_status_change(skill_id);
// Wait for initialization to complete (Running or Error status)
// with a reasonable timeout
// Wait for initialization to complete
let state = instance.state.clone();
let skill_id_owned = skill_id.to_string();
let max_wait = std::time::Duration::from_secs(10);
@@ -278,12 +273,10 @@ impl RuntimeEngine {
match current_status {
SkillStatus::Running => {
// Successfully started
self.emit_status_change(&skill_id_owned);
return Ok(instance.snapshot());
}
SkillStatus::Error => {
// Initialization failed - unregister and return error
let error_msg = state
.read()
.error
@@ -297,7 +290,6 @@ impl RuntimeEngine {
));
}
SkillStatus::Stopped => {
// Skill stopped unexpectedly during init
self.registry.unregister(&skill_id_owned);
return Err(format!(
"Skill '{}' stopped unexpectedly during initialization",
@@ -305,9 +297,7 @@ impl RuntimeEngine {
));
}
SkillStatus::Initializing | SkillStatus::Pending => {
// Still initializing, continue waiting
if start.elapsed() > max_wait {
// Timeout - skill is taking too long
log::warn!(
"[runtime] Skill '{}' initialization timeout, returning current state",
skill_id_owned
@@ -317,7 +307,6 @@ impl RuntimeEngine {
tokio::time::sleep(poll_interval).await;
}
SkillStatus::Stopping => {
// Unexpected state during startup
return Err(format!(
"Skill '{}' is in unexpected Stopping state during startup",
skill_id_owned
@@ -375,6 +364,7 @@ impl RuntimeEngine {
}
/// Auto-start skills based on user preferences.
/// No stagger delay needed for QuickJS (lightweight contexts).
pub async fn auto_start_skills(&self) {
match self.discover_skills().await {
Ok(manifests) => {
+649
View File
@@ -0,0 +1,649 @@
//! QjsSkillInstance — manages one QuickJS context per skill.
//!
//! Key differences from V8 version:
//! - QuickJS contexts are Send+Sync with `parallel` feature, so we use regular tokio::spawn (not spawn_blocking)
//! - No V8 creation lock needed (QuickJS contexts are lightweight ~1-2MB)
//! - No stagger delay needed between skill starts
//! - Direct memory limits via `rt.set_memory_limit()`
//! - Uses `ctx.eval::<T, _>(code)` instead of `runtime.execute_script()`
//! - Simplified error handling with rquickjs::Error
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use parking_lot::RwLock;
use tokio::sync::mpsc;
use crate::runtime::cron_scheduler::CronScheduler;
use crate::runtime::skill_registry::SkillRegistry;
use crate::runtime::types::{
SkillConfig, SkillMessage, SkillSnapshot, SkillStatus, ToolContent, ToolDefinition, ToolResult,
};
use crate::services::tdlib_v8::{qjs_ops, IdbStorage};
/// Dependencies passed to a skill instance for bridge installation.
#[allow(dead_code)]
pub struct BridgeDeps {
pub cron_scheduler: Arc<CronScheduler>,
pub skill_registry: Arc<SkillRegistry>,
pub app_handle: Option<tauri::AppHandle>,
pub data_dir: PathBuf,
// NOTE: No v8_creation_lock - QuickJS doesn't need it
}
/// Shared mutable state for a skill instance.
pub struct SkillState {
pub status: SkillStatus,
pub tools: Vec<ToolDefinition>,
pub error: Option<String>,
pub published_state: HashMap<String, serde_json::Value>,
}
impl Default for SkillState {
fn default() -> Self {
Self {
status: SkillStatus::Pending,
tools: Vec::new(),
error: None,
published_state: HashMap::new(),
}
}
}
/// A running skill instance using QuickJS.
pub struct QjsSkillInstance {
pub config: SkillConfig,
pub state: Arc<RwLock<SkillState>>,
pub sender: mpsc::Sender<SkillMessage>,
pub skill_dir: PathBuf,
pub data_dir: PathBuf,
}
impl QjsSkillInstance {
/// Create a new QuickJS skill instance.
pub fn new(
config: SkillConfig,
skill_dir: PathBuf,
data_dir: PathBuf,
) -> (Self, mpsc::Receiver<SkillMessage>) {
let (tx, rx) = mpsc::channel(64);
let instance = Self {
config,
state: Arc::new(RwLock::new(SkillState::default())),
sender: tx,
skill_dir,
data_dir,
};
(instance, rx)
}
/// Take a snapshot of the current skill state.
pub fn snapshot(&self) -> SkillSnapshot {
let state = self.state.read();
SkillSnapshot {
skill_id: self.config.skill_id.clone(),
name: self.config.name.clone(),
status: state.status,
tools: state.tools.clone(),
error: state.error.clone(),
state: state.published_state.clone(),
}
}
/// Spawn the skill's execution loop as a tokio task.
/// Unlike V8 (which needed spawn_blocking), QuickJS contexts are Send.
pub fn spawn(
&self,
mut rx: mpsc::Receiver<SkillMessage>,
_deps: BridgeDeps,
) -> tokio::task::JoinHandle<()> {
let config = self.config.clone();
let state = self.state.clone();
let skill_dir = self.skill_dir.clone();
let data_dir = self.data_dir.clone();
tokio::spawn(async move {
// Update status
state.write().status = SkillStatus::Initializing;
// Create storage
let storage = match IdbStorage::new(&data_dir) {
Ok(s) => s,
Err(e) => {
let mut s = state.write();
s.status = SkillStatus::Error;
s.error = Some(format!("Failed to create storage: {e}"));
log::error!("[skill:{}] Storage creation failed: {e}", config.skill_id);
return;
}
};
// Read the entry point JS file
let entry_path = skill_dir.join(&config.entry_point);
let js_source = match tokio::fs::read_to_string(&entry_path).await {
Ok(src) => src,
Err(e) => {
let mut s = state.write();
s.status = SkillStatus::Error;
s.error = Some(format!("Failed to read {}: {e}", config.entry_point));
log::error!("[skill:{}] Failed to read entry point: {e}", config.skill_id);
return;
}
};
// Create QuickJS runtime with memory limits
let rt = match rquickjs::AsyncRuntime::new() {
Ok(rt) => rt,
Err(e) => {
let mut s = state.write();
s.status = SkillStatus::Error;
s.error = Some(format!("Failed to create QuickJS runtime: {e}"));
log::error!("[skill:{}] Failed to create QuickJS runtime: {e}", config.skill_id);
return;
}
};
// Set memory limit (config.memory_limit is in bytes)
rt.set_memory_limit(config.memory_limit).await;
rt.set_max_stack_size(512 * 1024).await; // 512KB stack
// Create context with full standard library
let ctx = match rquickjs::AsyncContext::full(&rt).await {
Ok(ctx) => ctx,
Err(e) => {
let mut s = state.write();
s.status = SkillStatus::Error;
s.error = Some(format!("Failed to create QuickJS context: {e}"));
log::error!("[skill:{}] Failed to create context: {e}", config.skill_id);
return;
}
};
// Create shared timer state
let timer_state = Arc::new(RwLock::new(qjs_ops::TimerState::default()));
// Create WebSocket state
let ws_state = Arc::new(RwLock::new(qjs_ops::WebSocketState::default()));
// Create published skill state (different from SkillState above)
let published_state = Arc::new(RwLock::new(qjs_ops::SkillState::default()));
// Register ops and run bootstrap + skill code
let skill_id = config.skill_id.clone();
let init_result = ctx.with(|js_ctx| {
// Register native ops as __ops global
let skill_context = qjs_ops::SkillContext {
skill_id: skill_id.clone(),
data_dir: data_dir.clone(),
};
if let Err(e) = qjs_ops::register_ops(
&js_ctx,
storage.clone(),
skill_context,
published_state.clone(),
timer_state.clone(),
ws_state.clone(),
) {
return Err(format!("Failed to register ops: {e}"));
}
// Load bootstrap
let bootstrap_code = include_str!("../services/tdlib_v8/bootstrap.js");
if let Err(e) = js_ctx.eval::<rquickjs::Value, _>(bootstrap_code) {
let err_str = format!("Bootstrap failed: {e}");
return Err(err_str);
}
// Set skill ID
let bridge_code = format!(
r#"globalThis.__skillId = "{}";"#,
skill_id.replace('"', r#"\""#)
);
if let Err(e) = js_ctx.eval::<rquickjs::Value, _>(bridge_code.as_bytes()) {
return Err(format!("Skill init failed: {e}"));
}
// Execute the skill's entry point
if let Err(e) = js_ctx.eval::<rquickjs::Value, _>(js_source.as_bytes()) {
return Err(format!("Skill load failed: {e}"));
}
// Extract tool definitions
extract_tools(&js_ctx, &state);
Ok(())
}).await;
if let Err(e) = init_result {
let mut s = state.write();
s.status = SkillStatus::Error;
s.error = Some(e.clone());
log::error!("[skill:{}] {e}", config.skill_id);
return;
}
// Call init() lifecycle
if let Err(e) = call_lifecycle(&ctx, "init").await {
let mut s = state.write();
s.status = SkillStatus::Error;
s.error = Some(format!("init() failed: {e}"));
log::error!("[skill:{}] init() failed: {e}", config.skill_id);
return;
}
// Execute pending jobs after init (process promises)
drive_jobs(&rt).await;
// Call start() lifecycle
if let Err(e) = call_lifecycle(&ctx, "start").await {
let mut s = state.write();
s.status = SkillStatus::Error;
s.error = Some(format!("start() failed: {e}"));
log::error!("[skill:{}] start() failed: {e}", config.skill_id);
return;
}
// Execute pending jobs after start
drive_jobs(&rt).await;
// Mark as running
state.write().status = SkillStatus::Running;
log::info!("[skill:{}] Running (QuickJS)", config.skill_id);
// Run the event loop
run_event_loop(&rt, &ctx, &mut rx, &state, &config.skill_id, &timer_state).await;
})
}
}
// ============================================================================
// Event Loop
// ============================================================================
/// The main event loop that drives the QuickJS runtime.
/// This continuously:
/// 1. Polls for ready timers and fires their callbacks
/// 2. Checks for incoming messages (non-blocking)
/// 3. Runs the QuickJS job queue for promises/async ops
/// 4. Sleeps efficiently when idle
async fn run_event_loop(
rt: &rquickjs::AsyncRuntime,
ctx: &rquickjs::AsyncContext,
rx: &mut mpsc::Receiver<SkillMessage>,
state: &Arc<RwLock<SkillState>>,
skill_id: &str,
timer_state: &Arc<RwLock<qjs_ops::TimerState>>,
) {
// Maximum sleep duration when no timers are pending
const MAX_IDLE_SLEEP: Duration = Duration::from_millis(100);
// Minimum sleep to prevent busy-spinning
const MIN_SLEEP: Duration = Duration::from_millis(1);
loop {
// 1. Poll and fire ready timers
let ready_timers = {
let (ready, _next) = qjs_ops::poll_timers(timer_state);
ready
};
// Fire timer callbacks in JavaScript
for timer_id in ready_timers {
fire_timer_callback(ctx, timer_id).await;
}
// 2. Check for incoming messages (non-blocking)
match rx.try_recv() {
Ok(msg) => {
let should_stop = handle_message(ctx, msg, state, skill_id).await;
if should_stop {
break;
}
}
Err(mpsc::error::TryRecvError::Empty) => {
// No message - that's fine
}
Err(mpsc::error::TryRecvError::Disconnected) => {
// Channel closed, exit
log::info!("[skill:{}] Message channel disconnected, stopping", skill_id);
break;
}
}
// 3. Drive QuickJS job queue (process pending promises)
drive_jobs(rt).await;
// 4. Calculate sleep duration based on next timer
let sleep_duration = {
let (_, next_timer) = qjs_ops::poll_timers(timer_state);
match next_timer {
Some(d) if d < MIN_SLEEP => MIN_SLEEP,
Some(d) if d > MAX_IDLE_SLEEP => MAX_IDLE_SLEEP,
Some(d) => d,
None => MAX_IDLE_SLEEP,
}
};
// Sleep efficiently - this yields the thread when no work is needed
tokio::time::sleep(sleep_duration).await;
}
}
/// Drive the QuickJS job queue until no more jobs are pending.
async fn drive_jobs(rt: &rquickjs::AsyncRuntime) {
// idle() runs all pending futures and jobs
rt.idle().await;
}
/// Fire a timer callback in JavaScript.
async fn fire_timer_callback(ctx: &rquickjs::AsyncContext, timer_id: u32) {
let code = format!("globalThis.__handleTimer({});", timer_id);
ctx.with(|js_ctx| {
if let Err(e) = js_ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
log::error!("[timer] Callback for timer {} failed: {}", timer_id, e);
}
}).await;
}
/// Handle a single message from the channel.
/// Returns true if the skill should stop.
async fn handle_message(
ctx: &rquickjs::AsyncContext,
msg: SkillMessage,
state: &Arc<RwLock<SkillState>>,
skill_id: &str,
) -> bool {
match msg {
SkillMessage::CallTool { tool_name, arguments, reply } => {
let result = handle_tool_call(ctx, &tool_name, arguments).await;
let _ = reply.send(result);
}
SkillMessage::ServerEvent { event, data } => {
let _ = handle_server_event(ctx, &event, data).await;
}
SkillMessage::CronTrigger { schedule_id } => {
let _ = handle_cron_trigger(ctx, &schedule_id).await;
}
SkillMessage::Stop { reply } => {
let _ = call_lifecycle(ctx, "stop").await;
state.write().status = SkillStatus::Stopped;
log::info!("[skill:{}] Stopped", skill_id);
let _ = reply.send(());
return true; // Signal to stop
}
SkillMessage::SetupStart { reply } => {
let result = handle_js_call(ctx, "onSetupStart", "{}").await;
let _ = reply.send(result);
}
SkillMessage::SetupSubmit { step_id, values, reply } => {
let args = serde_json::json!({ "stepId": step_id, "values": values });
let result = handle_js_call(ctx, "onSetupSubmit", &args.to_string()).await;
let _ = reply.send(result);
}
SkillMessage::SetupCancel { reply } => {
let result = handle_js_void_call(ctx, "onSetupCancel", "{}").await;
let _ = reply.send(result);
}
SkillMessage::ListOptions { reply } => {
let result = handle_js_call(ctx, "onListOptions", "{}").await;
let _ = reply.send(result);
}
SkillMessage::SetOption { name, value, reply } => {
let args = serde_json::json!({ "name": name, "value": value });
let result = handle_js_void_call(ctx, "onSetOption", &args.to_string()).await;
let _ = reply.send(result);
}
SkillMessage::SessionStart { session_id, reply } => {
let args = serde_json::json!({ "sessionId": session_id });
let result = handle_js_void_call(ctx, "onSessionStart", &args.to_string()).await;
let _ = reply.send(result);
}
SkillMessage::SessionEnd { session_id, reply } => {
let args = serde_json::json!({ "sessionId": session_id });
let result = handle_js_void_call(ctx, "onSessionEnd", &args.to_string()).await;
let _ = reply.send(result);
}
SkillMessage::Tick { reply } => {
let result = handle_js_void_call(ctx, "onTick", "{}").await;
let _ = reply.send(result);
}
SkillMessage::Rpc { method, params, reply } => {
let args = serde_json::json!({ "method": method, "params": params });
let result = handle_js_call(ctx, "onRpc", &args.to_string()).await;
let _ = reply.send(result);
}
}
false // Don't stop
}
// ============================================================================
// Helper Functions
// ============================================================================
/// Call a lifecycle function on the skill object.
/// Looks for the skill at globalThis.__skill.default first, then falls back to globalThis.
async fn call_lifecycle(ctx: &rquickjs::AsyncContext, name: &str) -> Result<(), String> {
let name = name.to_string();
ctx.with(|js_ctx| {
let code = format!(
r#"(function() {{
var skill = globalThis.__skill && globalThis.__skill.default
? globalThis.__skill.default
: (globalThis.__skill || globalThis);
if (typeof skill.{name} === 'function') {{
skill.{name}();
}} else if (typeof globalThis.{name} === 'function') {{
globalThis.{name}();
}}
}})()"#
);
js_ctx.eval::<rquickjs::Value, _>(code.as_bytes())
.map_err(|e| format!("{name}() failed: {e}"))?;
Ok(())
}).await
}
/// Extract tool definitions from the skill.
fn extract_tools(js_ctx: &rquickjs::Ctx<'_>, state: &Arc<RwLock<SkillState>>) {
let code = r#"
(function() {
var skill = globalThis.__skill && globalThis.__skill.default
? globalThis.__skill.default
: (globalThis.__skill || null);
var tools = (skill && skill.tools) || globalThis.tools || [];
return JSON.stringify(tools.map(function(t) {
return {
name: t.name || "",
description: t.description || "",
input_schema: t.inputSchema || t.input_schema || {}
};
}));
})()
"#;
// eval with String type hint tells rquickjs to convert the result to a Rust String
match js_ctx.eval::<String, _>(code) {
Ok(json_str) => {
match serde_json::from_str::<Vec<ToolDefinition>>(&json_str) {
Ok(tools) => {
state.write().tools = tools;
}
Err(e) => {
log::warn!("[tools] Failed to parse tools JSON: {e}");
}
}
}
Err(e) => {
log::warn!("[tools] Failed to extract tools: {e}");
}
}
}
/// Handle a tool call.
async fn handle_tool_call(
ctx: &rquickjs::AsyncContext,
tool_name: &str,
arguments: serde_json::Value,
) -> Result<ToolResult, String> {
let args_str = serde_json::to_string(&arguments)
.map_err(|e| format!("Failed to serialize args: {e}"))?;
let tool_name = tool_name.to_string();
let result_text = ctx.with(|js_ctx| {
let code = format!(
r#"(function() {{
var skill = globalThis.__skill && globalThis.__skill.default
? globalThis.__skill.default
: (globalThis.__skill || null);
var tools = (skill && skill.tools) || globalThis.tools || [];
for (var i = 0; i < tools.length; i++) {{
if (tools[i].name === "{}") {{
var args = {};
var result = tools[i].execute(args);
if (result && typeof result === 'object') {{
return JSON.stringify(result);
}}
return String(result);
}}
}}
throw new Error("Tool '{}' not found");
}})()"#,
tool_name.replace('"', r#"\""#),
args_str,
tool_name.replace('"', r#"\""#),
);
match js_ctx.eval::<String, _>(code.as_bytes()) {
Ok(s) => Ok(s),
Err(e) => Err(format!("Tool execution failed: {e}"))
}
}).await?;
Ok(ToolResult {
content: vec![ToolContent::Text { text: result_text }],
is_error: false,
})
}
/// Handle a server event.
async fn handle_server_event(
ctx: &rquickjs::AsyncContext,
event: &str,
data: serde_json::Value,
) -> Result<(), String> {
let data_str = serde_json::to_string(&data).unwrap_or_else(|_| "null".to_string());
let event = event.to_string();
ctx.with(|js_ctx| {
let code = format!(
r#"(function() {{
var skill = globalThis.__skill && globalThis.__skill.default
? globalThis.__skill.default
: (globalThis.__skill || globalThis);
if (typeof skill.onServerEvent === 'function') {{
skill.onServerEvent("{}", {});
}} else if (typeof globalThis.onServerEvent === 'function') {{
globalThis.onServerEvent("{}", {});
}}
}})()"#,
event.replace('"', r#"\""#),
data_str,
event.replace('"', r#"\""#),
data_str,
);
js_ctx.eval::<rquickjs::Value, _>(code.as_bytes())
.map_err(|e| format!("Event handler failed: {e}"))?;
Ok(())
}).await
}
/// Handle a cron trigger.
async fn handle_cron_trigger(ctx: &rquickjs::AsyncContext, schedule_id: &str) -> Result<(), String> {
let schedule_id = schedule_id.to_string();
ctx.with(|js_ctx| {
let code = format!(
r#"(function() {{
var skill = globalThis.__skill && globalThis.__skill.default
? globalThis.__skill.default
: (globalThis.__skill || globalThis);
if (typeof skill.onCronTrigger === 'function') {{
skill.onCronTrigger("{}");
}} else if (typeof globalThis.onCronTrigger === 'function') {{
globalThis.onCronTrigger("{}");
}}
}})()"#,
schedule_id.replace('"', r#"\""#),
schedule_id.replace('"', r#"\""#),
);
js_ctx.eval::<rquickjs::Value, _>(code.as_bytes())
.map_err(|e| format!("Cron trigger failed: {e}"))
.map(|_| ())
}).await
}
/// Call a JS function on the skill object that returns a JSON value.
async fn handle_js_call(
ctx: &rquickjs::AsyncContext,
fn_name: &str,
args_json: &str,
) -> Result<serde_json::Value, String> {
let fn_name = fn_name.to_string();
let args_json = args_json.to_string();
let result_text = ctx.with(|js_ctx| {
let code = format!(
r#"(function() {{
var skill = globalThis.__skill && globalThis.__skill.default
? globalThis.__skill.default
: (globalThis.__skill || globalThis);
var fn = skill.{fn_name} || globalThis.{fn_name};
if (typeof fn === 'function') {{
var args = {args_json};
var result = fn.call(skill, args);
return JSON.stringify(result);
}}
return "null";
}})()"#
);
match js_ctx.eval::<String, _>(code.as_bytes()) {
Ok(s) => Ok(s),
Err(e) => Err(format!("{fn_name}() failed: {e}"))
}
}).await?;
serde_json::from_str(&result_text)
.map_err(|e| format!("{fn_name}() returned invalid JSON: {e}"))
}
/// Call a JS function on the skill object that returns void.
async fn handle_js_void_call(
ctx: &rquickjs::AsyncContext,
fn_name: &str,
args_json: &str,
) -> Result<(), String> {
let fn_name = fn_name.to_string();
let args_json = args_json.to_string();
ctx.with(|js_ctx| {
let code = format!(
r#"(function() {{
var skill = globalThis.__skill && globalThis.__skill.default
? globalThis.__skill.default
: (globalThis.__skill || globalThis);
var fn = skill.{fn_name} || globalThis.{fn_name};
if (typeof fn === 'function') {{
var args = {args_json};
fn.call(skill, args);
}}
}})()"#
);
js_ctx.eval::<rquickjs::Value, _>(code.as_bytes())
.map_err(|e| format!("{fn_name}() failed: {e}"))
.map(|_| ())
}).await
}
+1 -1
View File
@@ -6,7 +6,7 @@ use std::sync::Arc;
use parking_lot::RwLock;
use tokio::sync::{mpsc, oneshot};
use crate::runtime::v8_skill_instance::SkillState;
use crate::runtime::qjs_skill_instance::SkillState;
use crate::runtime::types::{
SkillConfig, SkillMessage, SkillSnapshot, SkillStatus, ToolDefinition, ToolResult,
};
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -144,7 +144,8 @@ pub struct SkillConfig {
pub skill_id: String,
pub name: String,
pub entry_point: String,
/// Memory limit in bytes. Default: 64 MB.
/// Memory limit in bytes. Default: 256 MB.
/// Skills can override this in their manifest.json.
#[serde(default = "default_memory_limit")]
pub memory_limit: usize,
/// Whether this skill should auto-start on app launch.
@@ -153,7 +154,7 @@ pub struct SkillConfig {
}
fn default_memory_limit() -> usize {
64 * 1024 * 1024 // 64 MB
256 * 1024 * 1024 // 256 MB
}
/// Events emitted from the runtime to the frontend via Tauri.
-694
View File
@@ -1,694 +0,0 @@
//! V8SkillInstance — manages one V8 context per skill.
//!
//! Each skill runs on its own dedicated thread (V8's JsRuntime is not Send)
//! with:
//! - A scoped SQLite database
//! - Bridge globals (db, store, net, platform, console)
//! - An async event loop that drives timers, promises, and handles messages
//! - Lifecycle hooks: init() -> start() -> [event loop] -> stop()
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use deno_core::{v8, JsRuntime, PollEventLoopOptions, RuntimeOptions};
use parking_lot::RwLock;
use tokio::sync::mpsc;
use crate::runtime::cron_scheduler::CronScheduler;
use crate::runtime::skill_registry::SkillRegistry;
use crate::runtime::types::{
SkillConfig, SkillMessage, SkillSnapshot, SkillStatus, ToolContent, ToolDefinition, ToolResult,
};
use crate::services::tdlib_v8::{ops, IdbStorage};
/// Dependencies passed to a skill instance for bridge installation.
/// Currently not all fields are used, but they're kept for future feature parity.
#[allow(dead_code)]
pub struct BridgeDeps {
pub cron_scheduler: Arc<CronScheduler>,
pub skill_registry: Arc<SkillRegistry>,
pub app_handle: Option<tauri::AppHandle>,
pub data_dir: PathBuf,
}
/// Shared mutable state for a skill instance.
pub struct SkillState {
pub status: SkillStatus,
pub tools: Vec<ToolDefinition>,
pub error: Option<String>,
pub published_state: HashMap<String, serde_json::Value>,
}
impl Default for SkillState {
fn default() -> Self {
Self {
status: SkillStatus::Pending,
tools: Vec::new(),
error: None,
published_state: HashMap::new(),
}
}
}
/// A running skill instance using V8.
pub struct V8SkillInstance {
pub config: SkillConfig,
pub state: Arc<RwLock<SkillState>>,
pub sender: mpsc::Sender<SkillMessage>,
pub skill_dir: PathBuf,
pub data_dir: PathBuf,
}
impl V8SkillInstance {
/// Create a new V8 skill instance.
pub fn new(
config: SkillConfig,
skill_dir: PathBuf,
data_dir: PathBuf,
) -> (Self, mpsc::Receiver<SkillMessage>) {
let (tx, rx) = mpsc::channel(64);
let instance = Self {
config,
state: Arc::new(RwLock::new(SkillState::default())),
sender: tx,
skill_dir,
data_dir,
};
(instance, rx)
}
/// Take a snapshot of the current skill state.
pub fn snapshot(&self) -> SkillSnapshot {
let state = self.state.read();
SkillSnapshot {
skill_id: self.config.skill_id.clone(),
name: self.config.name.clone(),
status: state.status,
tools: state.tools.clone(),
error: state.error.clone(),
state: state.published_state.clone(),
}
}
/// Spawn the skill's execution loop in a dedicated thread.
/// Returns a JoinHandle wrapped in a tokio task for compatibility.
pub fn spawn(
&self,
mut rx: mpsc::Receiver<SkillMessage>,
_deps: BridgeDeps,
) -> tokio::task::JoinHandle<()> {
let config = self.config.clone();
let state = self.state.clone();
let skill_dir = self.skill_dir.clone();
let data_dir = self.data_dir.clone();
// Use std::thread::spawn since JsRuntime is not Send
// Wrap in tokio task for API compatibility
tokio::task::spawn_blocking(move || {
// Update status
state.write().status = SkillStatus::Initializing;
// Create storage
let storage = match IdbStorage::new(&data_dir) {
Ok(s) => s,
Err(e) => {
let mut s = state.write();
s.status = SkillStatus::Error;
s.error = Some(format!("Failed to create storage: {e}"));
log::error!("[skill:{}] Storage creation failed: {e}", config.skill_id);
return;
}
};
// Read the entry point JS file synchronously
let entry_path = skill_dir.join(&config.entry_point);
let js_source = match std::fs::read_to_string(&entry_path) {
Ok(src) => src,
Err(e) => {
let mut s = state.write();
s.status = SkillStatus::Error;
s.error = Some(format!("Failed to read {}: {e}", config.entry_point));
log::error!("[skill:{}] Failed to read entry point: {e}", config.skill_id);
return;
}
};
// Create V8 runtime
let extension = ops::build_extension(storage.clone());
let mut runtime = JsRuntime::new(RuntimeOptions {
extensions: vec![extension],
..Default::default()
});
// Set skill context in op state
{
let op_state = runtime.op_state();
let mut state_ref = op_state.borrow_mut();
ops::init_state_with_data_dir(
&mut state_ref,
storage,
config.skill_id.clone(),
data_dir.clone(),
state.clone(),
);
}
// Load bootstrap
let bootstrap_code = include_str!("../services/tdlib_v8/bootstrap.js");
if let Err(e) = runtime.execute_script("<bootstrap>", bootstrap_code.to_string()) {
let mut s = state.write();
s.status = SkillStatus::Error;
s.error = Some(format!("Bootstrap failed: {e}"));
log::error!("[skill:{}] Bootstrap failed: {e}", config.skill_id);
return;
}
// Install skill-specific bridges
let skill_id = config.skill_id.clone();
let bridge_code = format!(
r#"globalThis.__skillId = "{}";"#,
skill_id.replace('"', r#"\""#)
);
if let Err(e) = runtime.execute_script("<skill-init>", bridge_code) {
let mut s = state.write();
s.status = SkillStatus::Error;
s.error = Some(format!("Skill init failed: {e}"));
return;
}
// Execute the skill's entry point
// Use a static string for the filename
let filename: &'static str = Box::leak(format!("<skill:{}>", config.skill_id).into_boxed_str());
if let Err(e) = runtime.execute_script(filename, js_source) {
let mut s = state.write();
s.status = SkillStatus::Error;
s.error = Some(format!("Skill load failed: {e}"));
log::error!("[skill:{}] Load failed: {e}", config.skill_id);
return;
}
// Extract tool definitions
extract_tools(&mut runtime, &state);
// Create a tokio runtime for this thread FIRST - all async ops must run inside it
// This is critical because deno_unsync requires CurrentThread runtime for async ops
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("Failed to create tokio runtime");
// Run the entire lifecycle inside the CurrentThread runtime
rt.block_on(async {
// Call init()
if let Err(e) = call_lifecycle_fn_async(&mut runtime, "init").await {
let mut s = state.write();
s.status = SkillStatus::Error;
s.error = Some(format!("init() failed: {e}"));
log::error!("[skill:{}] init() failed: {e}", config.skill_id);
return;
}
// Call start()
if let Err(e) = call_lifecycle_fn_async(&mut runtime, "start").await {
let mut s = state.write();
s.status = SkillStatus::Error;
s.error = Some(format!("start() failed: {e}"));
log::error!("[skill:{}] start() failed: {e}", config.skill_id);
return;
}
// Mark as running
state.write().status = SkillStatus::Running;
log::info!("[skill:{}] Running (V8)", config.skill_id);
// Run the event loop
run_event_loop(&mut runtime, &mut rx, &state, &config.skill_id).await;
});
})
}
}
// ============================================================================
// Event Loop
// ============================================================================
/// The main event loop that drives the V8 runtime.
/// This continuously:
/// 1. Polls for ready timers and fires their callbacks
/// 2. Checks for incoming messages (non-blocking)
/// 3. Runs the V8 event loop for promises/async ops
/// 4. Sleeps efficiently when idle
async fn run_event_loop(
runtime: &mut JsRuntime,
rx: &mut mpsc::Receiver<SkillMessage>,
state: &Arc<RwLock<SkillState>>,
skill_id: &str,
) {
// Maximum sleep duration when no timers are pending
const MAX_IDLE_SLEEP: Duration = Duration::from_millis(100);
// Minimum sleep to prevent busy-spinning
const MIN_SLEEP: Duration = Duration::from_millis(1);
loop {
// 1. Poll and fire ready timers
let ready_timers = {
let op_state = runtime.op_state();
let mut state_ref = op_state.borrow_mut();
let (ready, _next) = ops::poll_timers(&mut state_ref);
ready
};
// Fire timer callbacks in JavaScript
for timer_id in ready_timers {
fire_timer_callback(runtime, timer_id);
}
// 2. Check for incoming messages (non-blocking)
match rx.try_recv() {
Ok(msg) => {
let should_stop = handle_message(runtime, msg, state, skill_id).await;
if should_stop {
break;
}
}
Err(mpsc::error::TryRecvError::Empty) => {
// No message - that's fine
}
Err(mpsc::error::TryRecvError::Disconnected) => {
// Channel closed, exit
log::info!("[skill:{}] Message channel disconnected, stopping", skill_id);
break;
}
}
// 3. Run the V8 event loop (processes promises, async ops, etc.)
// Use poll mode - returns immediately if nothing to do
let poll_result = runtime
.run_event_loop(PollEventLoopOptions {
wait_for_inspector: false,
pump_v8_message_loop: true,
})
.await;
if let Err(e) = poll_result {
log::error!("[skill:{}] Event loop error: {}", skill_id, e);
// Don't break - try to continue
}
// 4. Calculate sleep duration based on next timer
let sleep_duration = {
let op_state = runtime.op_state();
let mut state_ref = op_state.borrow_mut();
let (_, next_timer) = ops::poll_timers(&mut state_ref);
match next_timer {
Some(d) if d < MIN_SLEEP => MIN_SLEEP,
Some(d) if d > MAX_IDLE_SLEEP => MAX_IDLE_SLEEP,
Some(d) => d,
None => MAX_IDLE_SLEEP,
}
};
// Sleep efficiently - this yields the thread when no work is needed
tokio::time::sleep(sleep_duration).await;
}
}
/// Fire a timer callback in JavaScript.
fn fire_timer_callback(runtime: &mut JsRuntime, timer_id: u32) {
let code = format!("globalThis.__handleTimer({});", timer_id);
if let Err(e) = runtime.execute_script("<timer-callback>", code) {
log::error!("[timer] Callback for timer {} failed: {}", timer_id, e);
}
}
/// Handle a single message from the channel.
/// Returns true if the skill should stop.
async fn handle_message(
runtime: &mut JsRuntime,
msg: SkillMessage,
state: &Arc<RwLock<SkillState>>,
skill_id: &str,
) -> bool {
match msg {
SkillMessage::CallTool {
tool_name,
arguments,
reply,
} => {
let result = handle_tool_call_sync(runtime, &tool_name, arguments);
let _ = reply.send(result);
}
SkillMessage::ServerEvent { event, data } => {
let _ = handle_server_event_sync(runtime, &event, data);
}
SkillMessage::CronTrigger { schedule_id } => {
let _ = handle_cron_trigger_sync(runtime, &schedule_id);
}
SkillMessage::Stop { reply } => {
let _ = call_lifecycle_fn_sync(runtime, "stop");
state.write().status = SkillStatus::Stopped;
log::info!("[skill:{}] Stopped", skill_id);
let _ = reply.send(());
return true; // Signal to stop
}
SkillMessage::SetupStart { reply } => {
let result = handle_js_call_sync(runtime, "onSetupStart", "{}");
let _ = reply.send(result);
}
SkillMessage::SetupSubmit {
step_id,
values,
reply,
} => {
let args = serde_json::json!({
"stepId": step_id,
"values": values,
});
let result = handle_js_call_sync(runtime, "onSetupSubmit", &args.to_string());
let _ = reply.send(result);
}
SkillMessage::SetupCancel { reply } => {
let result = handle_js_void_call_sync(runtime, "onSetupCancel", "{}");
let _ = reply.send(result);
}
SkillMessage::ListOptions { reply } => {
let result = handle_js_call_sync(runtime, "onListOptions", "{}");
let _ = reply.send(result);
}
SkillMessage::SetOption { name, value, reply } => {
let args = serde_json::json!({
"name": name,
"value": value,
});
let result = handle_js_void_call_sync(runtime, "onSetOption", &args.to_string());
let _ = reply.send(result);
}
SkillMessage::SessionStart { session_id, reply } => {
let args = serde_json::json!({ "sessionId": session_id });
let result = handle_js_void_call_sync(runtime, "onSessionStart", &args.to_string());
let _ = reply.send(result);
}
SkillMessage::SessionEnd { session_id, reply } => {
let args = serde_json::json!({ "sessionId": session_id });
let result = handle_js_void_call_sync(runtime, "onSessionEnd", &args.to_string());
let _ = reply.send(result);
}
SkillMessage::Tick { reply } => {
let result = handle_js_void_call_sync(runtime, "onTick", "{}");
let _ = reply.send(result);
}
SkillMessage::Rpc {
method,
params,
reply,
} => {
let args = serde_json::json!({
"method": method,
"params": params,
});
let result = handle_js_call_sync(runtime, "onRpc", &args.to_string());
let _ = reply.send(result);
}
}
false // Don't stop
}
/// Extract tool definitions from skill.tools (supports both globalThis.__skill.default and globalThis.tools).
fn extract_tools(runtime: &mut JsRuntime, state: &Arc<RwLock<SkillState>>) {
let code = r#"
(function() {
// Try to get skill from bundled export first, then fall back to globalThis.tools
var skill = globalThis.__skill && globalThis.__skill.default
? globalThis.__skill.default
: (globalThis.__skill || null);
var tools = (skill && skill.tools) || globalThis.tools || [];
return JSON.stringify(tools.map(function(t) {
return {
name: t.name || "",
description: t.description || "",
input_schema: t.inputSchema || t.input_schema || {}
};
}));
})()
"#;
if let Ok(result) = runtime.execute_script("<extract-tools>", code.to_string()) {
let scope = &mut runtime.handle_scope();
let local = v8::Local::new(scope, result);
if let Some(s) = local.to_string(scope) {
let json_str = s.to_rust_string_lossy(scope);
if let Ok(tools) = serde_json::from_str::<Vec<ToolDefinition>>(&json_str) {
state.write().tools = tools;
}
}
}
}
/// Call a lifecycle function on the skill object asynchronously.
/// This version runs inside the skill's CurrentThread runtime - no nested runtime creation.
/// Looks for the skill at globalThis.__skill.default first, then falls back to globalThis.
///
/// Note: This does NOT wait for the event loop to complete, because lifecycle functions
/// may start async operations (like update loops) that run indefinitely. The main event
/// loop will process pending async work after init/start complete.
async fn call_lifecycle_fn_async(runtime: &mut JsRuntime, name: &str) -> Result<(), String> {
let code = format!(
r#"(function() {{
var skill = globalThis.__skill && globalThis.__skill.default
? globalThis.__skill.default
: (globalThis.__skill || globalThis);
if (typeof skill.{name} === 'function') {{
skill.{name}();
}} else if (typeof globalThis.{name} === 'function') {{
globalThis.{name}();
}}
}})()"#
);
runtime
.execute_script("<lifecycle>", code)
.map_err(|e| format!("{name}() failed: {e}"))?;
// Don't wait for event loop here - the main event loop will handle pending async work.
// This is important because lifecycle functions may start long-running async operations
// (like TDLib update loops) that would block init/start from completing.
Ok(())
}
/// Call a lifecycle function on the skill object synchronously.
/// WARNING: This creates its own tokio runtime - only use when NOT inside an async context.
/// Looks for the skill at globalThis.__skill.default first, then falls back to globalThis.
#[allow(dead_code)]
fn call_lifecycle_fn_sync(runtime: &mut JsRuntime, name: &str) -> Result<(), String> {
let code = format!(
r#"(function() {{
var skill = globalThis.__skill && globalThis.__skill.default
? globalThis.__skill.default
: (globalThis.__skill || globalThis);
if (typeof skill.{name} === 'function') {{
skill.{name}();
}} else if (typeof globalThis.{name} === 'function') {{
globalThis.{name}();
}}
}})()"#
);
runtime
.execute_script("<lifecycle>", code)
.map_err(|e| format!("{name}() failed: {e}"))?;
// Run event loop synchronously to handle any pending ops
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| format!("Failed to create runtime: {e}"))?;
rt.block_on(async {
runtime
.run_event_loop(PollEventLoopOptions::default())
.await
.map_err(|e| format!("Event loop error: {e}"))
})?;
Ok(())
}
/// Handle a tool call synchronously (no async ops waited).
/// This is used when we just need to invoke the tool and get immediate result.
fn handle_tool_call_sync(
runtime: &mut JsRuntime,
tool_name: &str,
arguments: serde_json::Value,
) -> Result<ToolResult, String> {
let args_str =
serde_json::to_string(&arguments).map_err(|e| format!("Failed to serialize args: {e}"))?;
let code = format!(
r#"(function() {{
var skill = globalThis.__skill && globalThis.__skill.default
? globalThis.__skill.default
: (globalThis.__skill || null);
var tools = (skill && skill.tools) || globalThis.tools || [];
for (var i = 0; i < tools.length; i++) {{
if (tools[i].name === "{}") {{
var args = {};
var result = tools[i].execute(args);
if (result && typeof result === 'object') {{
return JSON.stringify(result);
}}
return String(result);
}}
}}
throw new Error("Tool '{}' not found");
}})()"#,
tool_name.replace('"', r#"\""#),
args_str,
tool_name.replace('"', r#"\""#),
);
let result = runtime
.execute_script("<tool-call>", code)
.map_err(|e| format!("Tool execution failed: {e}"))?;
// Note: We don't run the event loop here because we're already inside
// the skill's event loop. Pending async ops will be handled by the main loop.
let scope = &mut runtime.handle_scope();
let local = v8::Local::new(scope, result);
let result_text = if let Some(s) = local.to_string(scope) {
s.to_rust_string_lossy(scope)
} else {
"null".to_string()
};
Ok(ToolResult {
content: vec![ToolContent::Text { text: result_text }],
is_error: false,
})
}
/// Handle a server event synchronously.
fn handle_server_event_sync(
runtime: &mut JsRuntime,
event: &str,
data: serde_json::Value,
) -> Result<(), String> {
let data_str = serde_json::to_string(&data).unwrap_or_else(|_| "null".to_string());
let code = format!(
r#"(function() {{
var skill = globalThis.__skill && globalThis.__skill.default
? globalThis.__skill.default
: (globalThis.__skill || globalThis);
if (typeof skill.onServerEvent === 'function') {{
skill.onServerEvent("{}", {});
}} else if (typeof globalThis.onServerEvent === 'function') {{
globalThis.onServerEvent("{}", {});
}}
}})()"#,
event.replace('"', r#"\""#),
data_str,
event.replace('"', r#"\""#),
data_str,
);
runtime
.execute_script("<server-event>", code)
.map_err(|e| format!("Event handler failed: {e}"))?;
Ok(())
}
/// Handle a cron trigger synchronously.
fn handle_cron_trigger_sync(runtime: &mut JsRuntime, schedule_id: &str) -> Result<(), String> {
let code = format!(
r#"(function() {{
var skill = globalThis.__skill && globalThis.__skill.default
? globalThis.__skill.default
: (globalThis.__skill || globalThis);
if (typeof skill.onCronTrigger === 'function') {{
skill.onCronTrigger("{}");
}} else if (typeof globalThis.onCronTrigger === 'function') {{
globalThis.onCronTrigger("{}");
}}
}})()"#,
schedule_id.replace('"', r#"\""#),
schedule_id.replace('"', r#"\""#),
);
runtime
.execute_script("<cron-trigger>", code)
.map_err(|e| format!("Cron trigger failed: {e}"))?;
Ok(())
}
/// Call a JS function on the skill object that returns a JSON value synchronously.
fn handle_js_call_sync(
runtime: &mut JsRuntime,
fn_name: &str,
args_json: &str,
) -> Result<serde_json::Value, String> {
let code = format!(
r#"(function() {{
var skill = globalThis.__skill && globalThis.__skill.default
? globalThis.__skill.default
: (globalThis.__skill || globalThis);
var fn = skill.{fn_name} || globalThis.{fn_name};
if (typeof fn === 'function') {{
var args = {args_json};
var result = fn.call(skill, args);
return JSON.stringify(result);
}}
return "null";
}})()"#
);
let result = runtime
.execute_script("<js-call>", code)
.map_err(|e| format!("{fn_name}() failed: {e}"))?;
let scope = &mut runtime.handle_scope();
let local = v8::Local::new(scope, result);
let result_text = if let Some(s) = local.to_string(scope) {
s.to_rust_string_lossy(scope)
} else {
"null".to_string()
};
serde_json::from_str(&result_text)
.map_err(|e| format!("{fn_name}() returned invalid JSON: {e}"))
}
/// Call a JS function on the skill object that returns void synchronously.
fn handle_js_void_call_sync(
runtime: &mut JsRuntime,
fn_name: &str,
args_json: &str,
) -> Result<(), String> {
let code = format!(
r#"(function() {{
var skill = globalThis.__skill && globalThis.__skill.default
? globalThis.__skill.default
: (globalThis.__skill || globalThis);
var fn = skill.{fn_name} || globalThis.{fn_name};
if (typeof fn === 'function') {{
var args = {args_json};
fn.call(skill, args);
}}
}})()"#
);
runtime
.execute_script("<js-void-call>", code)
.map_err(|e| format!("{fn_name}() failed: {e}"))?;
Ok(())
}
+18 -5
View File
@@ -179,14 +179,20 @@ impl TdLibManager {
let poll_handle = tokio::spawn(async move {
loop {
if !state_clone.is_active.load(Ordering::SeqCst) {
log::info!("[tdlib] Receive loop exiting (shutdown signalled)");
break;
}
// Use spawn_blocking for the blocking TDLib receive call
let receive_result = tokio::task::spawn_blocking(|| {
// Use a short timeout to be more responsive
// Note: tdlib_rs::receive() uses 2.0s internally, but that's OK
// because we're in spawn_blocking and won't block the async runtime
// Clone the Arc so we can check is_active inside
// spawn_blocking *before* entering the 2-second blocking
// td_receive() FFI call. This minimises the window where
// a process-exit could tear down TDLib state while we're
// inside the C++ code.
let state_for_recv = state_clone.clone();
let receive_result = tokio::task::spawn_blocking(move || {
if !state_for_recv.is_active.load(Ordering::SeqCst) {
return None;
}
tdlib_rs::receive()
})
.await;
@@ -504,6 +510,13 @@ impl TdLibManager {
Ok(())
}
/// Signal the TDLib worker to stop (non-async, safe to call from any context).
/// This is used during app exit to prevent the receive loop from crashing
/// when TDLib's C++ static destructors run during process shutdown.
pub fn signal_shutdown(&self) {
self.state.is_active.store(false, Ordering::SeqCst);
}
/// Check if the client is active.
pub fn is_active(&self) -> bool {
self.state.is_active.load(Ordering::SeqCst) && self.client_id.read().is_some()
+67 -67
View File
@@ -1,5 +1,5 @@
/**
* Bootstrap JavaScript for V8 Runtime
* Bootstrap JavaScript for QuickJS Runtime
*
* Provides browser-like API shims that tdweb expects.
* These shims call Rust "ops" for actual I/O.
@@ -14,19 +14,19 @@ globalThis.window = globalThis;
// ============================================================================
globalThis.console = {
log: function (...args) {
Deno.core.ops.op_console_log(args.map(String).join(' '));
__ops.console_log(args.map(String).join(' '));
},
info: function (...args) {
Deno.core.ops.op_console_log(args.map(String).join(' '));
__ops.console_log(args.map(String).join(' '));
},
warn: function (...args) {
Deno.core.ops.op_console_warn(args.map(String).join(' '));
__ops.console_warn(args.map(String).join(' '));
},
error: function (...args) {
Deno.core.ops.op_console_error(args.map(String).join(' '));
__ops.console_error(args.map(String).join(' '));
},
debug: function (...args) {
Deno.core.ops.op_console_log('[DEBUG] ' + args.map(String).join(' '));
__ops.console_log('[DEBUG] ' + args.map(String).join(' '));
},
};
@@ -39,25 +39,25 @@ let nextTimerId = 1;
globalThis.setTimeout = function (callback, delay, ...args) {
const id = nextTimerId++;
timerCallbacks.set(id, { callback, args, type: 'timeout' });
Deno.core.ops.op_ah_timer_start(id, delay || 0, false);
__ops.timer_start(id, delay || 0, false);
return id;
};
globalThis.setInterval = function (callback, delay, ...args) {
const id = nextTimerId++;
timerCallbacks.set(id, { callback, args, type: 'interval' });
Deno.core.ops.op_ah_timer_start(id, delay || 0, true);
__ops.timer_start(id, delay || 0, true);
return id;
};
globalThis.clearTimeout = function (id) {
timerCallbacks.delete(id);
Deno.core.ops.op_ah_timer_cancel(id);
__ops.timer_cancel(id);
};
globalThis.clearInterval = function (id) {
timerCallbacks.delete(id);
Deno.core.ops.op_ah_timer_cancel(id);
__ops.timer_cancel(id);
};
// Timer callback handler (called from Rust)
@@ -146,7 +146,7 @@ globalThis.fetch = async function (url, options = {}) {
headersObj = headers;
}
const result = await Deno.core.ops.op_fetch(url.toString(), {
const result = await __ops.fetch(url.toString(), {
method,
headers: headersObj,
body: typeof body === 'string' ? body : body ? JSON.stringify(body) : null,
@@ -259,7 +259,7 @@ class WebSocket {
async _connect() {
try {
this._id = await Deno.core.ops.op_ws_connect(this.url);
this._id = await __ops.ws_connect(this.url);
this.readyState = WebSocket.OPEN;
// Register for message callbacks
@@ -282,7 +282,7 @@ class WebSocket {
async _startReceiving() {
while (this.readyState === WebSocket.OPEN) {
try {
const message = await Deno.core.ops.op_ws_recv(this._id);
const message = await __ops.ws_recv(this._id);
if (message === null) {
// Connection closed
this._handleClose(1000, '');
@@ -322,7 +322,7 @@ class WebSocket {
}
const dataStr = typeof data === 'string' ? data : JSON.stringify(data);
Deno.core.ops.op_ws_send(this._id, dataStr);
__ops.ws_send(this._id, dataStr);
}
close(code = 1000, reason = '') {
@@ -331,7 +331,7 @@ class WebSocket {
}
this.readyState = WebSocket.CLOSING;
Deno.core.ops.op_ws_close(this._id, code, reason);
__ops.ws_close(this._id, code, reason);
}
}
@@ -350,7 +350,7 @@ class IDBFactory {
const request = new IDBRequest();
(async () => {
try {
await Deno.core.ops.op_idb_delete_database(name);
await __ops.idb_delete_database(name);
request._success(undefined);
} catch (e) {
request._error(e);
@@ -398,7 +398,7 @@ class IDBOpenDBRequest extends IDBRequest {
async _open() {
try {
const info = await Deno.core.ops.op_idb_open(this._name, this._version);
const info = await __ops.idb_open(this._name, this._version);
const db = new IDBDatabase(this._name, this._version, info.objectStores || []);
if (info.needsUpgrade) {
@@ -433,13 +433,13 @@ class IDBDatabase {
}
createObjectStore(name, options = {}) {
Deno.core.ops.op_idb_create_object_store(this.name, name, options);
__ops.idb_create_object_store(this.name, name, options);
this.objectStoreNames.push(name);
return new IDBObjectStore(this, name);
}
deleteObjectStore(name) {
Deno.core.ops.op_idb_delete_object_store(this.name, name);
__ops.idb_delete_object_store(this.name, name);
const idx = this.objectStoreNames.indexOf(name);
if (idx >= 0) this.objectStoreNames.splice(idx, 1);
}
@@ -449,7 +449,7 @@ class IDBDatabase {
}
close() {
Deno.core.ops.op_idb_close(this.name);
__ops.idb_close(this.name);
}
}
@@ -495,7 +495,7 @@ class IDBObjectStore {
const request = new IDBRequest();
(async () => {
try {
const value = await Deno.core.ops.op_idb_get(this._db.name, this.name, key);
const value = await __ops.idb_get(this._db.name, this.name, key);
request._success(value);
} catch (e) {
request._error(e);
@@ -508,7 +508,7 @@ class IDBObjectStore {
const request = new IDBRequest();
(async () => {
try {
await Deno.core.ops.op_idb_put(this._db.name, this.name, key, value);
await __ops.idb_put(this._db.name, this.name, key, value);
request._success(key);
} catch (e) {
request._error(e);
@@ -525,7 +525,7 @@ class IDBObjectStore {
const request = new IDBRequest();
(async () => {
try {
await Deno.core.ops.op_idb_delete(this._db.name, this.name, key);
await __ops.idb_delete(this._db.name, this.name, key);
request._success(undefined);
} catch (e) {
request._error(e);
@@ -538,7 +538,7 @@ class IDBObjectStore {
const request = new IDBRequest();
(async () => {
try {
await Deno.core.ops.op_idb_clear(this._db.name, this.name);
await __ops.idb_clear(this._db.name, this.name);
request._success(undefined);
} catch (e) {
request._error(e);
@@ -551,7 +551,7 @@ class IDBObjectStore {
const request = new IDBRequest();
(async () => {
try {
const values = await Deno.core.ops.op_idb_get_all(this._db.name, this.name, count);
const values = await __ops.idb_get_all(this._db.name, this.name, count);
request._success(values);
} catch (e) {
request._error(e);
@@ -564,7 +564,7 @@ class IDBObjectStore {
const request = new IDBRequest();
(async () => {
try {
const keys = await Deno.core.ops.op_idb_get_all_keys(this._db.name, this.name, count);
const keys = await __ops.idb_get_all_keys(this._db.name, this.name, count);
request._success(keys);
} catch (e) {
request._error(e);
@@ -577,7 +577,7 @@ class IDBObjectStore {
const request = new IDBRequest();
(async () => {
try {
const count = await Deno.core.ops.op_idb_count(this._db.name, this.name);
const count = await __ops.idb_count(this._db.name, this.name);
request._success(count);
} catch (e) {
request._error(e);
@@ -636,13 +636,13 @@ if (typeof globalThis.TextDecoder === 'undefined') {
// ============================================================================
if (typeof globalThis.atob === 'undefined') {
globalThis.atob = function (str) {
return Deno.core.ops.op_atob(str);
return __ops.atob(str);
};
}
if (typeof globalThis.btoa === 'undefined') {
globalThis.btoa = function (str) {
return Deno.core.ops.op_btoa(str);
return __ops.btoa(str);
};
}
@@ -651,7 +651,7 @@ if (typeof globalThis.btoa === 'undefined') {
// ============================================================================
globalThis.crypto = {
getRandomValues: function (array) {
const bytes = Deno.core.ops.op_crypto_random(array.length);
const bytes = __ops.crypto_random(array.length);
array.set(bytes);
return array;
},
@@ -662,7 +662,7 @@ globalThis.crypto = {
// ============================================================================
globalThis.performance = {
now: function () {
return Deno.core.ops.op_performance_now();
return __ops.performance_now();
},
};
@@ -673,53 +673,53 @@ globalThis.performance = {
globalThis.__db = {
exec: function (sql, paramsJson) {
return Deno.core.ops.op_db_exec(sql, paramsJson);
return __ops.db_exec(sql, paramsJson);
},
get: function (sql, paramsJson) {
return Deno.core.ops.op_db_get(sql, paramsJson);
return __ops.db_get(sql, paramsJson);
},
all: function (sql, paramsJson) {
return Deno.core.ops.op_db_all(sql, paramsJson);
return __ops.db_all(sql, paramsJson);
},
kvGet: function (key) {
return Deno.core.ops.op_db_kv_get(key);
return __ops.db_kv_get(key);
},
kvSet: function (key, valueJson) {
return Deno.core.ops.op_db_kv_set(key, valueJson);
return __ops.db_kv_set(key, valueJson);
},
};
globalThis.__store = {
get: function (key) {
return Deno.core.ops.op_store_get(key);
return __ops.store_get(key);
},
set: function (key, valueJson) {
return Deno.core.ops.op_store_set(key, valueJson);
return __ops.store_set(key, valueJson);
},
delete: function (key) {
return Deno.core.ops.op_store_delete(key);
return __ops.store_delete(key);
},
keys: function () {
return Deno.core.ops.op_store_keys();
return __ops.store_keys();
},
};
globalThis.__net = {
fetch: function (url, optionsJson) {
return Deno.core.ops.op_net_fetch(url, optionsJson);
return __ops.net_fetch(url, optionsJson);
},
};
globalThis.__platform = {
os: function () {
return Deno.core.ops.op_platform_os();
return __ops.platform_os();
},
env: function (key) {
return Deno.core.ops.op_platform_env(key);
return __ops.platform_env(key);
},
};
// High-level wrappers for skills (V8 bridge)
// High-level wrappers for skills (QuickJS bridge)
globalThis.db = {
exec: function (sql, params) {
return __db.exec(sql, params ? JSON.stringify(params) : undefined);
@@ -779,13 +779,13 @@ globalThis.platform = {
// ============================================================================
globalThis.__state = {
get: function (key) {
return Deno.core.ops.op_state_get(key);
return __ops.state_get(key);
},
set: function (key, valueJson) {
return Deno.core.ops.op_state_set(key, valueJson);
return __ops.state_set(key, valueJson);
},
setPartial: function (partialJson) {
return Deno.core.ops.op_state_set_partial(partialJson);
return __ops.state_set_partial(partialJson);
},
};
@@ -807,10 +807,10 @@ globalThis.state = {
// ============================================================================
globalThis.__data = {
read: function (filename) {
return Deno.core.ops.op_data_read(filename);
return __ops.data_read(filename);
},
write: function (filename, content) {
return Deno.core.ops.op_data_write(filename, content);
return __ops.data_write(filename, content);
},
};
@@ -828,15 +828,15 @@ globalThis.data = {
// ============================================================================
globalThis.cron = {
register: function (scheduleId, cronExpr) {
console.warn('[cron] register not implemented in V8 runtime yet');
console.warn('[cron] register not implemented in QuickJS runtime yet');
return false;
},
unregister: function (scheduleId) {
console.warn('[cron] unregister not implemented in V8 runtime yet');
console.warn('[cron] unregister not implemented in QuickJS runtime yet');
return false;
},
list: function () {
console.warn('[cron] list not implemented in V8 runtime yet');
console.warn('[cron] list not implemented in QuickJS runtime yet');
return [];
},
};
@@ -846,11 +846,11 @@ globalThis.cron = {
// ============================================================================
globalThis.skills = {
list: function () {
console.warn('[skills] list not implemented in V8 runtime yet');
console.warn('[skills] list not implemented in QuickJS runtime yet');
return [];
},
callTool: function (skillId, toolName, args) {
console.warn('[skills] callTool not implemented in V8 runtime yet');
console.warn('[skills] callTool not implemented in QuickJS runtime yet');
return { error: 'Not implemented' };
},
};
@@ -868,8 +868,8 @@ globalThis.tdlib = {
*/
isAvailable: function () {
try {
return typeof Deno?.core?.ops?.op_tdlib_is_available === 'function'
? Deno.core.ops.op_tdlib_is_available()
return typeof __ops?.tdlib_is_available === 'function'
? __ops.tdlib_is_available()
: false;
} catch (e) {
return false;
@@ -881,8 +881,8 @@ globalThis.tdlib = {
* @param {string} dataDir - Path to store TDLib data files.
* @returns {Promise<number>} Client ID (always 1 for singleton).
*/
createClient: async function (dataDir) {
return await Deno.core.ops.op_tdlib_create_client(dataDir);
createClient: function (dataDir) {
return __ops.tdlib_create_client(dataDir);
},
/**
@@ -891,7 +891,7 @@ globalThis.tdlib = {
* @returns {Promise<object>} TDLib response object.
*/
send: async function (request) {
return await Deno.core.ops.op_tdlib_send(request);
return await __ops.tdlib_send(request);
},
/**
@@ -900,7 +900,7 @@ globalThis.tdlib = {
* @returns {Promise<object|null>} Update object or null if timeout.
*/
receive: async function (timeoutMs = 1000) {
return await Deno.core.ops.op_tdlib_receive(timeoutMs);
return await __ops.tdlib_receive(timeoutMs);
},
/**
@@ -908,7 +908,7 @@ globalThis.tdlib = {
* @returns {Promise<void>}
*/
destroy: async function () {
return await Deno.core.ops.op_tdlib_destroy();
return await __ops.tdlib_destroy();
},
};
@@ -919,21 +919,21 @@ globalThis.tdlib = {
globalThis.__model = {
isAvailable: function () {
try {
return typeof Deno?.core?.ops?.op_model_is_available === 'function'
? Deno.core.ops.op_model_is_available()
return typeof __ops?.model_is_available === 'function'
? __ops.model_is_available()
: false;
} catch (e) {
return false;
}
},
getStatus: function () {
return Deno.core.ops.op_model_get_status();
return __ops.model_get_status();
},
generate: async function (prompt, configJson) {
return await Deno.core.ops.op_model_generate(prompt, configJson);
return await __ops.model_generate(prompt, configJson);
},
summarize: async function (text, maxTokens) {
return await Deno.core.ops.op_model_summarize(text, maxTokens);
return await __ops.model_summarize(text, maxTokens);
},
};
@@ -986,4 +986,4 @@ globalThis.model = {
};
console.log('[bootstrap] Model API initialized');
console.log('[bootstrap] V8 browser APIs initialized');
console.log('[bootstrap] QuickJS browser APIs initialized');
+5 -5
View File
@@ -1,10 +1,10 @@
//! TDLib V8 Runtime Module
//! TDLib Runtime Module
//!
//! Provides a V8 JavaScript runtime (via deno_core) for running
//! skill JavaScript code and TDLib via tdweb. Provides a browser-like
//! environment that supports WASM natively.
//! Provides a QuickJS JavaScript runtime (via rquickjs) for running
//! skill JavaScript code and TDLib integration. Provides a browser-like
//! environment for skill execution.
pub mod ops;
pub mod qjs_ops;
pub mod service;
pub mod storage;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,51 @@
//! QuickJS native ops registered on `globalThis.__ops`.
//!
//! Split by category for readability:
//! - `types` — shared state structs, constants, helpers
//! - `ops_core` — console, crypto, performance, platform, timers
//! - `ops_net` — fetch, WebSocket, net bridge
//! - `ops_storage` — IndexedDB, DB bridge, Store bridge
//! - `ops_state` — published state, filesystem data
//! - `ops_tdlib` — TDLib (Telegram) integration
//! - `ops_model` — local LLM inference
mod ops_core;
mod ops_model;
mod ops_net;
mod ops_state;
mod ops_storage;
mod ops_tdlib;
pub mod types;
// Re-export public API used by qjs_skill_instance.rs
pub use types::{poll_timers, SkillContext, SkillState, TimerState, WebSocketState};
use parking_lot::RwLock;
use rquickjs::{Ctx, Object, Result as JsResult};
use std::sync::Arc;
use crate::services::tdlib_v8::storage::IdbStorage;
use types::SkillContext as SC;
/// Register all ops on `globalThis.__ops`.
pub fn register_ops(
ctx: &Ctx<'_>,
storage: IdbStorage,
skill_context: SC,
skill_state: Arc<RwLock<SkillState>>,
timer_state: Arc<RwLock<TimerState>>,
ws_state: Arc<RwLock<WebSocketState>>,
) -> JsResult<()> {
let globals = ctx.globals();
let ops = Object::new(ctx.clone())?;
ops_core::register(ctx, &ops, timer_state)?;
ops_net::register(ctx, &ops, ws_state)?;
ops_storage::register(ctx, &ops, storage, skill_context.clone())?;
ops_state::register(ctx, &ops, skill_state, skill_context.clone())?;
ops_tdlib::register(ctx, &ops, skill_context.clone())?;
ops_model::register(ctx, &ops)?;
globals.set("__ops", ops)?;
Ok(())
}
@@ -0,0 +1,111 @@
//! Core ops: console, crypto, performance, platform, timers.
use parking_lot::RwLock;
use rquickjs::{Ctx, Function, Object};
use std::sync::Arc;
use std::time::{Duration, Instant};
use super::types::{TimerEntry, TimerState, ALLOWED_ENV_VARS};
pub fn register<'js>(ctx: &Ctx<'js>, ops: &Object<'js>, timer_state: Arc<RwLock<TimerState>>) -> rquickjs::Result<()> {
// ========================================================================
// Console (3)
// ========================================================================
ops.set("console_log", Function::new(ctx.clone(), |msg: String| {
log::info!("[js] {}", msg);
}))?;
ops.set("console_warn", Function::new(ctx.clone(), |msg: String| {
log::warn!("[js] {}", msg);
}))?;
ops.set("console_error", Function::new(ctx.clone(), |msg: String| {
log::error!("[js] {}", msg);
}))?;
// ========================================================================
// Crypto (3)
// ========================================================================
ops.set("crypto_random", Function::new(ctx.clone(), |len: usize| -> Vec<u8> {
use rand::RngCore;
let mut buf = vec![0u8; len];
rand::thread_rng().fill_bytes(&mut buf);
buf
}))?;
ops.set("atob", Function::new(ctx.clone(), |input: String| -> rquickjs::Result<String> {
use base64::Engine;
let bytes = base64::engine::general_purpose::STANDARD
.decode(&input)
.map_err(|e| super::types::js_err(e.to_string()))?;
String::from_utf8(bytes).map_err(|e| super::types::js_err(e.to_string()))
}))?;
ops.set("btoa", Function::new(ctx.clone(), |input: String| -> String {
use base64::Engine;
base64::engine::general_purpose::STANDARD.encode(input.as_bytes())
}))?;
// ========================================================================
// Performance (1)
// ========================================================================
ops.set("performance_now", Function::new(ctx.clone(), || -> f64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs_f64()
* 1000.0
}))?;
// ========================================================================
// Platform (2)
// ========================================================================
ops.set("platform_os", Function::new(ctx.clone(), || -> &'static str {
if cfg!(target_os = "windows") { "windows" }
else if cfg!(target_os = "macos") { "macos" }
else if cfg!(target_os = "linux") { "linux" }
else if cfg!(target_os = "android") { "android" }
else if cfg!(target_os = "ios") { "ios" }
else { "unknown" }
}))?;
ops.set("platform_env", Function::new(ctx.clone(), |key: String| -> Option<String> {
if ALLOWED_ENV_VARS.contains(&key.as_str()) {
std::env::var(&key).ok()
} else {
None
}
}))?;
// ========================================================================
// Timers (2)
// ========================================================================
{
let ts = timer_state.clone();
ops.set("timer_start", Function::new(ctx.clone(),
move |id: u32, delay_ms: u32, is_interval: bool| {
let mut state = ts.write();
state.timers.insert(id, TimerEntry {
deadline: Instant::now() + Duration::from_millis(delay_ms as u64),
delay_ms,
is_interval,
});
},
))?;
}
{
let ts = timer_state;
ops.set("timer_cancel", Function::new(ctx.clone(), move |id: u32| {
let mut state = ts.write();
state.timers.remove(&id);
}))?;
}
Ok(())
}
@@ -0,0 +1,36 @@
//! Model ops: local LLM inference via llama-cpp-2.
use rquickjs::{function::Async, Ctx, Function, Object};
use super::types::js_err;
pub fn register<'js>(ctx: &Ctx<'js>, ops: &Object<'js>) -> rquickjs::Result<()> {
ops.set("model_is_available", Function::new(ctx.clone(), || -> bool { false }))?;
ops.set("model_get_status", Function::new(ctx.clone(), || -> rquickjs::Result<String> {
let status = crate::services::llama::LLAMA_MANAGER.get_status();
serde_json::to_string(&status).map_err(|e| js_err(e.to_string()))
}))?;
ops.set("model_generate", Function::new(ctx.clone(),
Async(move |prompt: String, config_json: String| async move {
let config: crate::services::llama::GenerateConfig =
serde_json::from_str(&config_json).map_err(|e| js_err(e.to_string()))?;
crate::services::llama::LLAMA_MANAGER
.generate(&prompt, config)
.await
.map_err(|e| js_err(e))
}),
))?;
ops.set("model_summarize", Function::new(ctx.clone(),
Async(move |text: String, max_tokens: u32| async move {
crate::services::llama::LLAMA_MANAGER
.summarize(&text, max_tokens)
.await
.map_err(|e| js_err(e))
}),
))?;
Ok(())
}
@@ -0,0 +1,123 @@
//! Network ops: fetch, WebSocket, net bridge.
use parking_lot::RwLock;
use rquickjs::{function::Async, Ctx, Function, Object};
use std::collections::HashMap;
use std::sync::Arc;
use super::types::{js_err, WebSocketConnection, WebSocketState};
pub fn register<'js>(ctx: &Ctx<'js>, ops: &Object<'js>, ws_state: Arc<RwLock<WebSocketState>>) -> rquickjs::Result<()> {
// ========================================================================
// Fetch (1) - ASYNC
// ========================================================================
ops.set("fetch", Function::new(ctx.clone(),
Async(move |url: String, options: String| async move {
let opts: serde_json::Value =
serde_json::from_str(&options).map_err(|e| js_err(e.to_string()))?;
let method = opts["method"].as_str().unwrap_or("GET");
let headers_obj = opts["headers"].as_object();
let body = opts["body"].as_str();
let client = reqwest::Client::new();
let mut req = match method {
"GET" => client.get(&url),
"POST" => client.post(&url),
"PUT" => client.put(&url),
"DELETE" => client.delete(&url),
_ => client.get(&url),
};
if let Some(h) = headers_obj {
for (k, v) in h {
if let Some(val_str) = v.as_str() {
req = req.header(k, val_str);
}
}
}
if let Some(b) = body {
req = req.body(b.to_string());
}
let response = req.send().await.map_err(|e| js_err(e.to_string()))?;
let status = response.status().as_u16();
let status_text = response.status().canonical_reason().unwrap_or("").to_string();
let headers: HashMap<String, String> = response
.headers()
.iter()
.map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
.collect();
let body_text = response.text().await.map_err(|e| js_err(e.to_string()))?;
let result = serde_json::json!({
"status": status,
"statusText": status_text,
"headers": headers,
"body": body_text,
});
Ok::<String, rquickjs::Error>(result.to_string())
}),
))?;
// ========================================================================
// WebSocket (4) - placeholders
// ========================================================================
{
let ws = ws_state.clone();
ops.set("ws_connect", Function::new(ctx.clone(),
Async(move |url: String| {
let ws = ws.clone();
async move {
let mut state = ws.write();
let id = state.next_id;
state.next_id += 1;
state.connections.insert(id, WebSocketConnection { url });
Ok::<u32, rquickjs::Error>(id)
}
}),
))?;
}
{
let ws = ws_state.clone();
ops.set("ws_send", Function::new(ctx.clone(), move |_id: u32, _data: String| {
let _state = ws.read();
}))?;
}
{
let ws = ws_state.clone();
ops.set("ws_recv", Function::new(ctx.clone(),
Async(move |_id: u32| {
let _ws = ws.clone();
async move { Ok::<Option<String>, rquickjs::Error>(None) }
}),
))?;
}
{
let ws = ws_state;
ops.set("ws_close", Function::new(ctx.clone(), move |id: u32, _code: u16, _reason: String| {
let mut state = ws.write();
state.connections.remove(&id);
}))?;
}
// ========================================================================
// Net Bridge (1)
// ========================================================================
ops.set("net_fetch", Function::new(ctx.clone(),
|url: String, options_json: String| -> rquickjs::Result<String> {
crate::runtime::bridge::net::http_fetch(&url, &options_json).map_err(|e| js_err(e))
},
))?;
Ok(())
}
@@ -0,0 +1,83 @@
//! State and data ops: published state get/set, filesystem data read/write.
use parking_lot::RwLock;
use rquickjs::{Ctx, Function, Object};
use std::sync::Arc;
use super::types::{js_err, SkillContext, SkillState};
pub fn register<'js>(
ctx: &Ctx<'js>,
ops: &Object<'js>,
skill_state: Arc<RwLock<SkillState>>,
skill_context: SkillContext,
) -> rquickjs::Result<()> {
// ========================================================================
// State Bridge (3)
// ========================================================================
{
let ss = skill_state.clone();
ops.set("state_get", Function::new(ctx.clone(),
move |key: String| -> rquickjs::Result<String> {
let state = ss.read();
let value = state.data.get(&key).cloned().unwrap_or(serde_json::Value::Null);
serde_json::to_string(&value).map_err(|e| js_err(e.to_string()))
},
))?;
}
{
let ss = skill_state.clone();
ops.set("state_set", Function::new(ctx.clone(),
move |key: String, value_json: String| -> rquickjs::Result<()> {
let value: serde_json::Value =
serde_json::from_str(&value_json).map_err(|e| js_err(e.to_string()))?;
let mut state = ss.write();
state.data.insert(key, value);
Ok(())
},
))?;
}
{
let ss = skill_state;
ops.set("state_set_partial", Function::new(ctx.clone(),
move |partial_json: String| -> rquickjs::Result<()> {
let partial: serde_json::Map<String, serde_json::Value> =
serde_json::from_str(&partial_json).map_err(|e| js_err(e.to_string()))?;
let mut state = ss.write();
for (k, v) in partial {
state.data.insert(k, v);
}
Ok(())
},
))?;
}
// ========================================================================
// Data Bridge (2)
// ========================================================================
{
let sc = skill_context.clone();
ops.set("data_read", Function::new(ctx.clone(),
move |filename: String| -> rquickjs::Result<String> {
let path = sc.data_dir.join(&filename);
std::fs::read_to_string(&path).map_err(|e| js_err(e.to_string()))
},
))?;
}
{
let sc = skill_context;
ops.set("data_write", Function::new(ctx.clone(),
move |filename: String, content: String| -> rquickjs::Result<()> {
let path = sc.data_dir.join(&filename);
std::fs::write(&path, content).map_err(|e| js_err(e.to_string()))
},
))?;
}
Ok(())
}
@@ -0,0 +1,260 @@
//! Storage ops: IndexedDB, DB bridge, Store bridge.
use rquickjs::{Ctx, Function, Object};
use super::types::{js_err, SkillContext};
use crate::services::tdlib_v8::storage::IdbStorage;
pub fn register<'js>(ctx: &Ctx<'js>, ops: &Object<'js>, storage: IdbStorage, skill_context: SkillContext) -> rquickjs::Result<()> {
// ========================================================================
// IndexedDB (11) - all sync
// ========================================================================
{
let s = storage.clone();
ops.set("idb_open", Function::new(ctx.clone(),
move |name: String, version: u32| -> rquickjs::Result<String> {
let result = s.open_database(&name, version).map_err(|e| js_err(e))?;
serde_json::to_string(&result).map_err(|e| js_err(e.to_string()))
},
))?;
}
{
let s = storage.clone();
ops.set("idb_close", Function::new(ctx.clone(), move |name: String| {
s.close_database(&name);
}))?;
}
{
let s = storage.clone();
ops.set("idb_delete_database", Function::new(ctx.clone(),
move |name: String| -> rquickjs::Result<()> {
s.delete_database(&name).map_err(|e| js_err(e))
},
))?;
}
{
let s = storage.clone();
ops.set("idb_create_object_store", Function::new(ctx.clone(),
move |db_name: String, store_name: String, options: String| -> rquickjs::Result<()> {
let opts: serde_json::Value =
serde_json::from_str(&options).map_err(|e| js_err(e.to_string()))?;
let key_path = opts["keyPath"].as_str();
let auto_increment = opts["autoIncrement"].as_bool().unwrap_or(false);
s.create_object_store(&db_name, &store_name, key_path, auto_increment)
.map_err(|e| js_err(e))
},
))?;
}
{
let s = storage.clone();
ops.set("idb_delete_object_store", Function::new(ctx.clone(),
move |db_name: String, store_name: String| -> rquickjs::Result<()> {
s.delete_object_store(&db_name, &store_name).map_err(|e| js_err(e))
},
))?;
}
{
let s = storage.clone();
ops.set("idb_get", Function::new(ctx.clone(),
move |db_name: String, store_name: String, key: String| -> rquickjs::Result<String> {
let key_val: serde_json::Value =
serde_json::from_str(&key).map_err(|e| js_err(e.to_string()))?;
let result = s.get(&db_name, &store_name, &key_val).map_err(|e| js_err(e))?;
serde_json::to_string(&result).map_err(|e| js_err(e.to_string()))
},
))?;
}
{
let s = storage.clone();
ops.set("idb_put", Function::new(ctx.clone(),
move |db_name: String, store_name: String, key: String, value: String| -> rquickjs::Result<()> {
let key_val: serde_json::Value =
serde_json::from_str(&key).map_err(|e| js_err(e.to_string()))?;
let value_val: serde_json::Value =
serde_json::from_str(&value).map_err(|e| js_err(e.to_string()))?;
s.put(&db_name, &store_name, &key_val, &value_val).map_err(|e| js_err(e))
},
))?;
}
{
let s = storage.clone();
ops.set("idb_delete", Function::new(ctx.clone(),
move |db_name: String, store_name: String, key: String| -> rquickjs::Result<()> {
let key_val: serde_json::Value =
serde_json::from_str(&key).map_err(|e| js_err(e.to_string()))?;
s.delete(&db_name, &store_name, &key_val).map_err(|e| js_err(e))
},
))?;
}
{
let s = storage.clone();
ops.set("idb_clear", Function::new(ctx.clone(),
move |db_name: String, store_name: String| -> rquickjs::Result<()> {
s.clear(&db_name, &store_name).map_err(|e| js_err(e))
},
))?;
}
{
let s = storage.clone();
ops.set("idb_get_all", Function::new(ctx.clone(),
move |db_name: String, store_name: String, count: Option<u32>| -> rquickjs::Result<String> {
let result = s.get_all(&db_name, &store_name, count).map_err(|e| js_err(e))?;
serde_json::to_string(&result).map_err(|e| js_err(e.to_string()))
},
))?;
}
{
let s = storage.clone();
ops.set("idb_get_all_keys", Function::new(ctx.clone(),
move |db_name: String, store_name: String, count: Option<u32>| -> rquickjs::Result<String> {
let result = s.get_all_keys(&db_name, &store_name, count).map_err(|e| js_err(e))?;
serde_json::to_string(&result).map_err(|e| js_err(e.to_string()))
},
))?;
}
{
let s = storage.clone();
ops.set("idb_count", Function::new(ctx.clone(),
move |db_name: String, store_name: String| -> rquickjs::Result<u32> {
s.count(&db_name, &store_name).map_err(|e| js_err(e))
},
))?;
}
// ========================================================================
// DB Bridge (5)
// ========================================================================
{
let s = storage.clone();
let sc = skill_context.clone();
ops.set("db_exec", Function::new(ctx.clone(),
move |sql: String, params_json: Option<String>| -> rquickjs::Result<i64> {
let params: Vec<serde_json::Value> = if let Some(p) = params_json {
serde_json::from_str(&p).map_err(|e| js_err(e.to_string()))?
} else {
Vec::new()
};
let rows = s.skill_db_exec(&sc.skill_id, &sql, &params).map_err(|e| js_err(e))?;
Ok(rows as i64)
},
))?;
}
{
let s = storage.clone();
let sc = skill_context.clone();
ops.set("db_get", Function::new(ctx.clone(),
move |sql: String, params_json: Option<String>| -> rquickjs::Result<String> {
let params: Vec<serde_json::Value> = if let Some(p) = params_json {
serde_json::from_str(&p).map_err(|e| js_err(e.to_string()))?
} else {
Vec::new()
};
let result = s.skill_db_get(&sc.skill_id, &sql, &params).map_err(|e| js_err(e))?;
serde_json::to_string(&result).map_err(|e| js_err(e.to_string()))
},
))?;
}
{
let s = storage.clone();
let sc = skill_context.clone();
ops.set("db_all", Function::new(ctx.clone(),
move |sql: String, params_json: Option<String>| -> rquickjs::Result<String> {
let params: Vec<serde_json::Value> = if let Some(p) = params_json {
serde_json::from_str(&p).map_err(|e| js_err(e.to_string()))?
} else {
Vec::new()
};
let result = s.skill_db_all(&sc.skill_id, &sql, &params).map_err(|e| js_err(e))?;
serde_json::to_string(&result).map_err(|e| js_err(e.to_string()))
},
))?;
}
{
let s = storage.clone();
let sc = skill_context.clone();
ops.set("db_kv_get", Function::new(ctx.clone(),
move |key: String| -> rquickjs::Result<String> {
let result = s.skill_kv_get(&sc.skill_id, &key).map_err(|e| js_err(e))?;
serde_json::to_string(&result).map_err(|e| js_err(e.to_string()))
},
))?;
}
{
let s = storage.clone();
let sc = skill_context.clone();
ops.set("db_kv_set", Function::new(ctx.clone(),
move |key: String, value_json: String| -> rquickjs::Result<()> {
let value: serde_json::Value =
serde_json::from_str(&value_json).map_err(|e| js_err(e.to_string()))?;
s.skill_kv_set(&sc.skill_id, &key, &value).map_err(|e| js_err(e))
},
))?;
}
// ========================================================================
// Store Bridge (4)
// ========================================================================
{
let s = storage.clone();
let sc = skill_context.clone();
ops.set("store_get", Function::new(ctx.clone(),
move |key: String| -> rquickjs::Result<String> {
let result = s.skill_store_get(&sc.skill_id, &key).map_err(|e| js_err(e))?;
serde_json::to_string(&result).map_err(|e| js_err(e.to_string()))
},
))?;
}
{
let s = storage.clone();
let sc = skill_context.clone();
ops.set("store_set", Function::new(ctx.clone(),
move |key: String, value_json: String| -> rquickjs::Result<()> {
let value: serde_json::Value =
serde_json::from_str(&value_json).map_err(|e| js_err(e.to_string()))?;
s.skill_store_set(&sc.skill_id, &key, &value).map_err(|e| js_err(e))
},
))?;
}
{
let s = storage.clone();
let sc = skill_context.clone();
ops.set("store_delete", Function::new(ctx.clone(),
move |key: String| -> rquickjs::Result<()> {
s.skill_store_delete(&sc.skill_id, &key).map_err(|e| js_err(e))
},
))?;
}
{
let s = storage;
let sc = skill_context;
ops.set("store_keys", Function::new(ctx.clone(),
move || -> rquickjs::Result<String> {
let keys = s.skill_store_keys(&sc.skill_id).map_err(|e| js_err(e))?;
serde_json::to_string(&keys).map_err(|e| js_err(e.to_string()))
},
))?;
}
Ok(())
}
@@ -0,0 +1,80 @@
//! TDLib ops: Telegram database library integration (gated on skill_id == "telegram").
use rquickjs::{function::Async, Ctx, Function, Object};
use std::path::PathBuf;
use super::types::{check_telegram_skill, js_err, SkillContext};
pub fn register<'js>(ctx: &Ctx<'js>, ops: &Object<'js>, skill_context: SkillContext) -> rquickjs::Result<()> {
{
let sc = skill_context.clone();
ops.set("tdlib_is_available", Function::new(ctx.clone(),
move || -> bool { sc.skill_id == "telegram" },
))?;
}
{
let sc = skill_context.clone();
ops.set("tdlib_create_client", Function::new(ctx.clone(),
move |data_dir: String| -> rquickjs::Result<i32> {
check_telegram_skill(&sc.skill_id).map_err(|e| js_err(e))?;
crate::services::tdlib::TDLIB_MANAGER
.create_client(PathBuf::from(data_dir))
.map_err(|e| js_err(e))
},
))?;
}
{
let sc = skill_context.clone();
ops.set("tdlib_send", Function::new(ctx.clone(),
Async(move |request_json: String| {
let skill_id = sc.skill_id.clone();
async move {
check_telegram_skill(&skill_id).map_err(|e| js_err(e))?;
let request: serde_json::Value =
serde_json::from_str(&request_json).map_err(|e| js_err(e.to_string()))?;
let result = crate::services::tdlib::TDLIB_MANAGER
.send(request)
.await
.map_err(|e| js_err(e))?;
serde_json::to_string(&result).map_err(|e| js_err(e.to_string()))
}
}),
))?;
}
{
let sc = skill_context.clone();
ops.set("tdlib_receive", Function::new(ctx.clone(),
Async(move |timeout_ms: u32| {
let skill_id = sc.skill_id.clone();
async move {
check_telegram_skill(&skill_id).map_err(|e| js_err(e))?;
let result = crate::services::tdlib::TDLIB_MANAGER.receive(timeout_ms).await;
if let Some(val) = result {
let json = serde_json::to_string(&val).map_err(|e| js_err(e.to_string()))?;
Ok::<Option<String>, rquickjs::Error>(Some(json))
} else {
Ok(None)
}
}
}),
))?;
}
{
let sc = skill_context;
ops.set("tdlib_destroy", Function::new(ctx.clone(),
Async(move || {
let skill_id = sc.skill_id.clone();
async move {
check_telegram_skill(&skill_id).map_err(|e| js_err(e))?;
crate::services::tdlib::TDLIB_MANAGER.destroy().await.map_err(|e| js_err(e))
}
}),
))?;
}
Ok(())
}
@@ -0,0 +1,137 @@
//! Shared types, state structs, and helpers for QuickJS ops.
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::{Duration, Instant};
// ============================================================================
// Timer State
// ============================================================================
#[derive(Debug)]
pub struct TimerEntry {
pub deadline: Instant,
pub delay_ms: u32,
pub is_interval: bool,
}
#[derive(Debug, Default)]
pub struct TimerState {
pub timers: HashMap<u32, TimerEntry>,
}
impl TimerState {
pub fn poll_ready(&mut self) -> Vec<u32> {
let now = Instant::now();
let mut ready = Vec::new();
let mut to_remove = Vec::new();
for (&id, entry) in &self.timers {
if now >= entry.deadline {
ready.push(id);
if !entry.is_interval {
to_remove.push(id);
}
}
}
for id in to_remove {
self.timers.remove(&id);
}
for &id in &ready {
if let Some(entry) = self.timers.get_mut(&id) {
if entry.is_interval {
entry.deadline = now + Duration::from_millis(entry.delay_ms as u64);
}
}
}
ready
}
pub fn time_until_next(&self) -> Option<Duration> {
let now = Instant::now();
self.timers
.values()
.map(|e| e.deadline.saturating_duration_since(now))
.min()
}
}
pub fn poll_timers(timer_state: &RwLock<TimerState>) -> (Vec<u32>, Option<Duration>) {
let mut ts = timer_state.write();
let ready = ts.poll_ready();
let next = ts.time_until_next();
(ready, next)
}
// ============================================================================
// Skill Context
// ============================================================================
#[derive(Clone)]
pub struct SkillContext {
pub skill_id: String,
pub data_dir: PathBuf,
}
// ============================================================================
// Skill State (shared published state)
// ============================================================================
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillState {
#[serde(flatten)]
pub data: serde_json::Map<String, serde_json::Value>,
}
impl Default for SkillState {
fn default() -> Self {
Self {
data: serde_json::Map::new(),
}
}
}
// ============================================================================
// WebSocket State (placeholder)
// ============================================================================
#[derive(Debug)]
pub struct WebSocketConnection {
pub url: String,
}
#[derive(Debug, Default)]
pub struct WebSocketState {
pub connections: HashMap<u32, WebSocketConnection>,
pub next_id: u32,
}
// ============================================================================
// Constants & Helpers
// ============================================================================
pub const ALLOWED_ENV_VARS: &[&str] = &[
"VITE_BACKEND_URL",
"VITE_TELEGRAM_API_ID",
"VITE_TELEGRAM_API_HASH",
"VITE_TELEGRAM_BOT_USERNAME",
"VITE_TELEGRAM_BOT_ID",
"NODE_ENV",
];
pub fn check_telegram_skill(skill_id: &str) -> Result<(), String> {
if skill_id != "telegram" {
Err("TDLib operations only available in telegram skill".to_string())
} else {
Ok(())
}
}
pub fn js_err(msg: String) -> rquickjs::Error {
rquickjs::Error::new_from_js_message("ops", "Error", msg)
}
+30 -30
View File
@@ -13,7 +13,7 @@ use std::sync::Arc;
/// Result of opening an IndexedDB database.
/// Used by the IndexedDB emulation layer for tdweb.
#[derive(Debug, Clone)]
#[derive(Debug, Clone, serde::Serialize)]
pub struct IdbOpenResult {
/// Whether a version upgrade is needed.
pub needs_upgrade: bool,
@@ -29,7 +29,7 @@ pub struct IdbStorage {
/// Base directory for all databases.
data_dir: PathBuf,
/// Open database connections (db_name -> connection).
connections: Arc<RwLock<HashMap<String, Arc<RwLock<Connection>>>>>,
connections: Arc<RwLock<HashMap<String, Arc<parking_lot::Mutex<Connection>>>>>,
/// Database version info (used by IndexedDB emulation).
#[allow(dead_code)]
versions: Arc<RwLock<HashMap<String, u32>>>,
@@ -49,7 +49,7 @@ impl IdbStorage {
}
/// Get or create a database connection.
fn get_connection(&self, db_name: &str) -> Result<Arc<RwLock<Connection>>, String> {
fn get_connection(&self, db_name: &str) -> Result<Arc<parking_lot::Mutex<Connection>>, String> {
// Check if already open
if let Some(conn) = self.connections.read().get(db_name) {
return Ok(conn.clone());
@@ -81,7 +81,7 @@ impl IdbStorage {
)
.map_err(|e| format!("Failed to initialize database schema: {e}"))?;
let conn = Arc::new(RwLock::new(conn));
let conn = Arc::new(parking_lot::Mutex::new(conn));
self.connections
.write()
.insert(db_name.to_string(), conn.clone());
@@ -90,9 +90,9 @@ impl IdbStorage {
}
/// Open or create an IndexedDB database.
pub async fn open_database(&self, name: &str, version: u32) -> Result<IdbOpenResult, String> {
pub fn open_database(&self, name: &str, version: u32) -> Result<IdbOpenResult, String> {
let conn = self.get_connection(name)?;
let conn_guard = conn.read();
let conn_guard = conn.lock();
// Get current version
let current_version: u32 = conn_guard
@@ -145,7 +145,7 @@ impl IdbStorage {
}
/// Delete a database.
pub async fn delete_database(&self, name: &str) -> Result<(), String> {
pub fn delete_database(&self, name: &str) -> Result<(), String> {
self.close_database(name);
let db_path = self.data_dir.join(format!("{}.sqlite", name));
if db_path.exists() {
@@ -164,7 +164,7 @@ impl IdbStorage {
auto_increment: bool,
) -> Result<(), String> {
let conn = self.get_connection(db_name)?;
let conn_guard = conn.read();
let conn_guard = conn.lock();
// Register the store
conn_guard
@@ -197,7 +197,7 @@ impl IdbStorage {
/// Delete an object store.
pub fn delete_object_store(&self, db_name: &str, store_name: &str) -> Result<(), String> {
let conn = self.get_connection(db_name)?;
let conn_guard = conn.read();
let conn_guard = conn.lock();
// Remove from registry
conn_guard
@@ -214,14 +214,14 @@ impl IdbStorage {
}
/// Get a value from an object store.
pub async fn get(
pub fn get(
&self,
db_name: &str,
store_name: &str,
key: &serde_json::Value,
) -> Result<Option<serde_json::Value>, String> {
let conn = self.get_connection(db_name)?;
let conn_guard = conn.read();
let conn_guard = conn.lock();
let table_name = format!("store_{}", sanitize_name(store_name));
let key_str = serde_json::to_string(key).unwrap_or_else(|_| "null".to_string());
@@ -245,7 +245,7 @@ impl IdbStorage {
}
/// Put a value into an object store.
pub async fn put(
pub fn put(
&self,
db_name: &str,
store_name: &str,
@@ -253,7 +253,7 @@ impl IdbStorage {
value: &serde_json::Value,
) -> Result<(), String> {
let conn = self.get_connection(db_name)?;
let conn_guard = conn.read();
let conn_guard = conn.lock();
let table_name = format!("store_{}", sanitize_name(store_name));
let key_str = serde_json::to_string(key).unwrap_or_else(|_| "null".to_string());
@@ -273,14 +273,14 @@ impl IdbStorage {
}
/// Delete a value from an object store.
pub async fn delete(
pub fn delete(
&self,
db_name: &str,
store_name: &str,
key: &serde_json::Value,
) -> Result<(), String> {
let conn = self.get_connection(db_name)?;
let conn_guard = conn.read();
let conn_guard = conn.lock();
let table_name = format!("store_{}", sanitize_name(store_name));
let key_str = serde_json::to_string(key).unwrap_or_else(|_| "null".to_string());
@@ -296,9 +296,9 @@ impl IdbStorage {
}
/// Clear all values from an object store.
pub async fn clear(&self, db_name: &str, store_name: &str) -> Result<(), String> {
pub fn clear(&self, db_name: &str, store_name: &str) -> Result<(), String> {
let conn = self.get_connection(db_name)?;
let conn_guard = conn.read();
let conn_guard = conn.lock();
let table_name = format!("store_{}", sanitize_name(store_name));
@@ -310,14 +310,14 @@ impl IdbStorage {
}
/// Get all values from an object store.
pub async fn get_all(
pub fn get_all(
&self,
db_name: &str,
store_name: &str,
count: Option<u32>,
) -> Result<Vec<serde_json::Value>, String> {
let conn = self.get_connection(db_name)?;
let conn_guard = conn.read();
let conn_guard = conn.lock();
let table_name = format!("store_{}", sanitize_name(store_name));
let limit = count.map(|c| format!(" LIMIT {}", c)).unwrap_or_default();
@@ -337,14 +337,14 @@ impl IdbStorage {
}
/// Get all keys from an object store.
pub async fn get_all_keys(
pub fn get_all_keys(
&self,
db_name: &str,
store_name: &str,
count: Option<u32>,
) -> Result<Vec<serde_json::Value>, String> {
let conn = self.get_connection(db_name)?;
let conn_guard = conn.read();
let conn_guard = conn.lock();
let table_name = format!("store_{}", sanitize_name(store_name));
let limit = count.map(|c| format!(" LIMIT {}", c)).unwrap_or_default();
@@ -364,9 +364,9 @@ impl IdbStorage {
}
/// Count values in an object store.
pub async fn count(&self, db_name: &str, store_name: &str) -> Result<u32, String> {
pub fn count(&self, db_name: &str, store_name: &str) -> Result<u32, String> {
let conn = self.get_connection(db_name)?;
let conn_guard = conn.read();
let conn_guard = conn.lock();
let table_name = format!("store_{}", sanitize_name(store_name));
@@ -394,7 +394,7 @@ impl IdbStorage {
) -> Result<usize, String> {
let db_name = format!("skill_{}", skill_id);
let conn = self.get_connection(&db_name)?;
let conn_guard = conn.read();
let conn_guard = conn.lock();
let params: Vec<Box<dyn rusqlite::ToSql>> = params
.iter()
@@ -418,7 +418,7 @@ impl IdbStorage {
) -> Result<serde_json::Value, String> {
let db_name = format!("skill_{}", skill_id);
let conn = self.get_connection(&db_name)?;
let conn_guard = conn.read();
let conn_guard = conn.lock();
let params: Vec<Box<dyn rusqlite::ToSql>> = params
.iter()
@@ -463,7 +463,7 @@ impl IdbStorage {
) -> Result<serde_json::Value, String> {
let db_name = format!("skill_{}", skill_id);
let conn = self.get_connection(&db_name)?;
let conn_guard = conn.read();
let conn_guard = conn.lock();
let params: Vec<Box<dyn rusqlite::ToSql>> = params
.iter()
@@ -502,7 +502,7 @@ impl IdbStorage {
pub fn skill_kv_get(&self, skill_id: &str, key: &str) -> Result<serde_json::Value, String> {
let db_name = format!("skill_{}", skill_id);
let conn = self.get_connection(&db_name)?;
let conn_guard = conn.read();
let conn_guard = conn.lock();
// Ensure KV table exists
conn_guard
@@ -535,7 +535,7 @@ impl IdbStorage {
) -> Result<(), String> {
let db_name = format!("skill_{}", skill_id);
let conn = self.get_connection(&db_name)?;
let conn_guard = conn.read();
let conn_guard = conn.lock();
// Ensure KV table exists
conn_guard
@@ -580,7 +580,7 @@ impl IdbStorage {
pub fn skill_store_delete(&self, skill_id: &str, key: &str) -> Result<(), String> {
let db_name = format!("skill_{}", skill_id);
let conn = self.get_connection(&db_name)?;
let conn_guard = conn.read();
let conn_guard = conn.lock();
conn_guard
.execute(
@@ -596,7 +596,7 @@ impl IdbStorage {
pub fn skill_store_keys(&self, skill_id: &str) -> Result<Vec<String>, String> {
let db_name = format!("skill_{}", skill_id);
let conn = self.get_connection(&db_name)?;
let conn_guard = conn.read();
let conn_guard = conn.lock();
// Ensure KV table exists
conn_guard
-48
View File
@@ -3,8 +3,6 @@
/// This module provides configuration values that can be
/// overridden via environment variables at runtime.
use std::env;
use std::path::PathBuf;
use std::sync::Once;
/// Default backend URL (can be overridden via BACKEND_URL env var)
pub const DEFAULT_BACKEND_URL: &str = "https://api.alphahuman.xyz";
@@ -15,55 +13,9 @@ pub const APP_IDENTIFIER: &str = "com.alphahuman.app";
/// Service name for keychain
pub const KEYCHAIN_SERVICE: &str = "AlphaHuman";
/// Ensure .env is loaded once
static DOTENV_INIT: Once = Once::new();
/// Try to find and load .env file from various locations
fn ensure_dotenv_loaded() {
DOTENV_INIT.call_once(|| {
// Try current directory first (project root when running `tauri dev`)
if dotenvy::dotenv().is_ok() {
log::debug!("[config] Loaded .env from current directory");
return;
}
// Try parent directory (when cwd is src-tauri)
if let Ok(cwd) = env::current_dir() {
let parent_env = cwd.parent().map(|p| p.join(".env"));
if let Some(path) = parent_env {
if path.exists() {
if dotenvy::from_path(&path).is_ok() {
log::debug!("[config] Loaded .env from parent directory: {:?}", path);
return;
}
}
}
}
// Try CARGO_MANIFEST_DIR (available during development builds)
if let Ok(manifest_dir) = env::var("CARGO_MANIFEST_DIR") {
let manifest_path = PathBuf::from(&manifest_dir);
// .env is one level up from src-tauri
let project_root_env = manifest_path.parent().map(|p| p.join(".env"));
if let Some(path) = project_root_env {
if path.exists() {
if dotenvy::from_path(&path).is_ok() {
log::debug!("[config] Loaded .env from project root: {:?}", path);
return;
}
}
}
}
log::debug!("[config] No .env file found, using defaults/environment");
});
}
/// Get the backend URL from environment or use default
/// Checks VITE_BACKEND_URL first, then BACKEND_URL, then defaults
pub fn get_backend_url() -> String {
ensure_dotenv_loaded();
let url = env::var("VITE_BACKEND_URL")
.or_else(|_| env::var("BACKEND_URL"))
.unwrap_or_else(|_| DEFAULT_BACKEND_URL.to_string());
+8 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "AlphaHuman",
"version": "0.1.0",
"version": "0.31.0",
"identifier": "com.alphahuman.app",
"build": {
"beforeDevCommand": "npm run dev",
@@ -39,7 +39,13 @@
],
"resources": ["../skills/skills"],
"macOS": {
"minimumSystemVersion": "10.15"
"minimumSystemVersion": "10.15",
"dmg": {
"background": "./images/background-dmg.png"
},
"frameworks": [
"./libraries/libtdjson.1.8.29.dylib"
]
}
},
"plugins": {
Binary file not shown.
Binary file not shown.
+20 -4
View File
@@ -2,18 +2,34 @@ import type { RootState } from './index';
const PENDING_USER = '__pending__';
function selectCurrentUserId(state: RootState): string {
return state.user.user?._id ?? PENDING_USER;
/**
* Derive the socket user ID from the JWT token — must match the key used
* by tauriSocket.ts and socketService.ts when writing to byUser[].
*/
function selectSocketUserId(state: RootState): string {
const token = state.auth.token;
if (!token) return PENDING_USER;
try {
const parts = token.split('.');
if (parts.length !== 3) return PENDING_USER;
const payloadBase64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
const payloadJson = atob(payloadBase64);
const payload = JSON.parse(payloadJson);
return payload.tgUserId || payload.userId || payload.sub || PENDING_USER;
} catch {
return PENDING_USER;
}
}
export const selectSocketStatus = (state: RootState) => {
const userId = selectCurrentUserId(state);
const userId = selectSocketUserId(state);
const userState = state.socket.byUser[userId];
return userState?.status ?? 'disconnected';
};
export const selectSocketId = (state: RootState): string | null => {
const userId = selectCurrentUserId(state);
const userId = selectSocketUserId(state);
const userState = state.socket.byUser[userId];
return userState?.socketId ?? null;
};
+5 -1
View File
@@ -37,10 +37,14 @@ export async function connectRustSocket(token: string): Promise<void> {
if (!isTauri()) return;
try {
console.log('[TauriSocket] Connecting Rust socket to', BACKEND_URL);
await invoke('runtime_socket_connect', { token, url: BACKEND_URL });
console.log('[TauriSocket] Rust socket connecting');
console.log('[TauriSocket] Rust socket connect call succeeded');
} catch (error) {
console.error('[TauriSocket] Failed to connect Rust socket:', error);
// Ensure Redux status reflects the failure
const uid = getSocketUserId();
store.dispatch(setStatusForUser({ userId: uid, status: 'disconnected' }));
}
}