diff --git a/.github/workflows/package-and-publish.yml b/.github/workflows/package-and-publish.yml index fa511fc4a..d85755857 100644 --- a/.github/workflows/package-and-publish.yml +++ b/.github/workflows/package-and-publish.yml @@ -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] diff --git a/.github/workflows/package-android.yml b/.github/workflows/package-android.yml index 3885b3d22..018631a57 100644 --- a/.github/workflows/package-android.yml +++ b/.github/workflows/package-android.yml @@ -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' }} diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml index 9287b0405..3f3fcd929 100644 --- a/.github/workflows/version-bump.yml +++ b/.github/workflows/version-bump.yml @@ -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" diff --git a/.gitignore b/.gitignore index 8e64f0c04..bb66f93d6 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/package.json b/package.json index 693be6cb9..d51292107 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/ci-event.json b/scripts/ci-event.json new file mode 100644 index 000000000..b5c4baa3d --- /dev/null +++ b/scripts/ci-event.json @@ -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" } +} diff --git a/scripts/ci-secrets.example.json b/scripts/ci-secrets.example.json new file mode 100644 index 000000000..e17fb8a53 --- /dev/null +++ b/scripts/ci-secrets.example.json @@ -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" + } +} diff --git a/scripts/load-dotenv.sh b/scripts/load-dotenv.sh new file mode 100755 index 000000000..d0649cb46 --- /dev/null +++ b/scripts/load-dotenv.sh @@ -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 diff --git a/scripts/load-env-json.sh b/scripts/load-env-json.sh new file mode 100755 index 000000000..47913701d --- /dev/null +++ b/scripts/load-env-json.sh @@ -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 [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 diff --git a/scripts/load-env.sh b/scripts/load-env.sh new file mode 100755 index 000000000..0274305a8 --- /dev/null +++ b/scripts/load-env.sh @@ -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 diff --git a/scripts/test-ci-local.sh b/scripts/test-ci-local.sh new file mode 100755 index 000000000..fc05942e1 --- /dev/null +++ b/scripts/test-ci-local.sh @@ -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" < "$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 diff --git a/skills b/skills index d9dfb8d97..4905daf9e 160000 --- a/skills +++ b/skills @@ -1 +1 @@ -Subproject commit d9dfb8d974609e8b0324d78888fcbd1f711178a7 +Subproject commit 4905daf9e82f8f34f4162bbe6a0e311e25028473 diff --git a/src-tauri/.gitignore b/src-tauri/.gitignore index b21bd681d..28b36d3b1 100644 --- a/src-tauri/.gitignore +++ b/src-tauri/.gitignore @@ -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/ diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9db94fefd..70dfc9716 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "AlphaHuman" -version = "0.1.0" +version = "0.30.0" dependencies = [ "aes-gcm", "android_logger", @@ -12,7 +12,6 @@ dependencies = [ "base64 0.22.1", "chrono", "cron", - "deno_core", "dirs 5.0.1", "dotenvy", "encoding_rs", @@ -26,8 +25,8 @@ dependencies = [ "parking_lot", "rand 0.8.5", "reqwest", + "rquickjs", "rusqlite", - "rust_socketio", "serde", "serde_json", "sha2", @@ -40,28 +39,16 @@ dependencies = [ "tauri-plugin-os", "tdlib-rs", "tokio", - "tokio-tungstenite 0.24.0", + "tokio-tungstenite", "uuid", ] -[[package]] -name = "adler" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" - [[package]] name = "adler2" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "adler32" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" - [[package]] name = "aead" version = "0.5.2" @@ -133,6 +120,12 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_log-sys" version = "0.3.2" @@ -287,7 +280,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix 1.1.3", + "rustix", "slab", "windows-sys 0.61.2", ] @@ -318,7 +311,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix 1.1.3", + "rustix", ] [[package]] @@ -344,34 +337,12 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 1.1.3", + "rustix", "signal-hook-registry", "slab", "windows-sys 0.61.2", ] -[[package]] -name = "async-stream" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" -dependencies = [ - "async-stream-impl", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-stream-impl" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - [[package]] name = "async-task" version = "4.7.1" @@ -435,17 +406,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "backoff" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b62ddb9cb1ec0a098ad4bbf9344d0713fa193ae1a80af55febcff2627b6a00c1" -dependencies = [ - "getrandom 0.2.17", - "instant", - "rand 0.8.5", -] - [[package]] name = "base64" version = "0.21.7" @@ -458,53 +418,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "base64-simd" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "781dd20c3aff0bd194fe7d2a977dd92f21c173891f3a03b677359e5fa457e5d5" -dependencies = [ - "simd-abstraction", -] - [[package]] name = "base64ct" version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" -[[package]] -name = "bincode" -version = "1.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" -dependencies = [ - "serde", -] - -[[package]] -name = "bindgen" -version = "0.69.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" -dependencies = [ - "bitflags 2.10.0", - "cexpr", - "clang-sys", - "itertools", - "lazy_static", - "lazycell", - "log", - "prettyplease", - "proc-macro2", - "quote", - "regex", - "rustc-hash 1.1.0", - "shlex", - "syn 2.0.114", - "which 4.4.2", -] - [[package]] name = "bindgen" version = "0.72.1" @@ -520,26 +439,11 @@ dependencies = [ "proc-macro2", "quote", "regex", - "rustc-hash 2.1.1", + "rustc-hash", "shlex", "syn 2.0.114", ] -[[package]] -name = "bit-set" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" -dependencies = [ - "bit-vec", -] - -[[package]] -name = "bit-vec" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" - [[package]] name = "bitflags" version = "1.3.2" @@ -555,18 +459,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "bitvec" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" -dependencies = [ - "funty", - "radium", - "tap", - "wyz", -] - [[package]] name = "blake2" version = "0.10.6" @@ -725,7 +617,7 @@ checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" dependencies = [ "camino", "cargo-platform", - "semver 1.0.27", + "semver", "serde", "serde_json", "thiserror 2.0.18", @@ -903,10 +795,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" [[package]] -name = "cooked-waker" -version = "5.0.0" +name = "convert_case" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147be55d677052dabc6b22252d5dd0fd4c29c8c27aa4f2fbef0f94aa003b406f" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] [[package]] name = "cookie" @@ -1131,86 +1026,12 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" -[[package]] -name = "debugid" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" -dependencies = [ - "serde", - "uuid", -] - [[package]] name = "deflate64" version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26bf8fc351c5ed29b5c2f0cbbac1b209b74f60ecd62e675a998df72c49af5204" -[[package]] -name = "deno_core" -version = "0.314.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83138917579676069b423c3eb9be3c1e579f60dc022d85f6ded4c792456255ff" -dependencies = [ - "anyhow", - "bincode", - "bit-set", - "bit-vec", - "bytes", - "cooked-waker", - "deno_core_icudata", - "deno_ops", - "deno_unsync", - "futures", - "libc", - "memoffset", - "parking_lot", - "percent-encoding", - "pin-project", - "serde", - "serde_json", - "serde_v8", - "smallvec", - "sourcemap", - "static_assertions", - "tokio", - "url", - "v8", -] - -[[package]] -name = "deno_core_icudata" -version = "0.0.73" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13951ea98c0a4c372f162d669193b4c9d991512de9f2381dd161027f34b26b1" - -[[package]] -name = "deno_ops" -version = "0.190.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26f46d4e4f52f26c882b74a9b58810ea75252b807cf0966166ec333077cdfd85" -dependencies = [ - "proc-macro-rules", - "proc-macro2", - "quote", - "strum", - "strum_macros", - "syn 2.0.114", - "thiserror 1.0.69", -] - -[[package]] -name = "deno_unsync" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6742a724e8becb372a74c650a1aefb8924a5b8107f7d75b3848763ea24b27a87" -dependencies = [ - "futures-util", - "parking_lot", - "tokio", -] - [[package]] name = "deranged" version = "0.5.5" @@ -1238,10 +1059,10 @@ version = "0.99.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" dependencies = [ - "convert_case", + "convert_case 0.4.0", "proc-macro2", "quote", - "rustc_version 0.4.1", + "rustc_version", "syn 2.0.114", ] @@ -1433,7 +1254,7 @@ checksum = "55a075fc573c64510038d7ee9abc7990635863992f83ebc52c8b433b8411a02e" dependencies = [ "cc", "memchr", - "rustc_version 0.4.1", + "rustc_version", "toml 0.9.11+spec-1.1.0", "vswhom", "winreg 0.55.0", @@ -1586,7 +1407,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" dependencies = [ "memoffset", - "rustc_version 0.4.1", + "rustc_version", ] [[package]] @@ -1611,7 +1432,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" dependencies = [ "crc32fast", - "miniz_oxide 0.8.9", + "miniz_oxide", ] [[package]] @@ -1620,6 +1441,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.3.2" @@ -1671,22 +1498,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fslock" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04412b8935272e3a9bae6f48c7bfff74c2911f60525404edfdd28e49884c3bfb" -dependencies = [ - "libc", - "winapi", -] - -[[package]] -name = "funty" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" - [[package]] name = "futf" version = "0.1.5" @@ -1923,7 +1734,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "rustix 1.1.3", + "rustix", "windows-link 0.2.1", ] @@ -2123,15 +1934,6 @@ dependencies = [ "syn 2.0.114", ] -[[package]] -name = "gzip-header" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95cc527b92e6029a62960ad99aa8a6660faa4555fe5f731aab13aa6a921795a2" -dependencies = [ - "crc32fast", -] - [[package]] name = "h2" version = "0.4.13" @@ -2171,6 +1973,11 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] [[package]] name = "hashlink" @@ -2214,15 +2021,6 @@ dependencies = [ "digest", ] -[[package]] -name = "home" -version = "0.5.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "html5ever" version = "0.29.1" @@ -2497,12 +2295,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "if_chain" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd62e6b5e86ea8eeeb8db1de02880a6abc01a397b2ebb64b5d74ac255318f5cb" - [[package]] name = "indexmap" version = "1.9.3" @@ -2544,15 +2336,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "instant" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" -dependencies = [ - "cfg-if", -] - [[package]] name = "ipnet" version = "2.11.0" @@ -2759,12 +2542,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "lazycell" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" - [[package]] name = "libappindicator" version = "0.9.0" @@ -2836,12 +2613,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "linux-raw-sys" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" - [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -2874,7 +2645,7 @@ version = "0.1.133" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a180dfa6d6f9d1df1e031bcdf0464bbad4f9b326395bfd28f2fa539d8cbc9c2b" dependencies = [ - "bindgen 0.72.1", + "bindgen", "cc", "cmake", "find_cuda_helper", @@ -3000,15 +2771,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" -[[package]] -name = "miniz_oxide" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8a240ddb74feaf34a79a7add65a741f3167852fba007066dcac1ca548d89c08" -dependencies = [ - "adler", -] - [[package]] name = "miniz_oxide" version = "0.8.9" @@ -3146,32 +2908,12 @@ dependencies = [ "zbus", ] -[[package]] -name = "num-bigint" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", - "rand 0.8.5", -] - [[package]] name = "num-conv" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -3560,12 +3302,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "outref" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f222829ae9293e33a9f5e9f440c6760a3d450a64affe1846486b140db81c1f4" - [[package]] name = "pango" version = "0.18.3" @@ -3631,12 +3367,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - [[package]] name = "pathdiff" version = "0.2.3" @@ -3793,26 +3523,6 @@ dependencies = [ "siphasher 1.0.1", ] -[[package]] -name = "pin-project" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - [[package]] name = "pin-project-lite" version = "0.2.16" @@ -3865,7 +3575,7 @@ dependencies = [ "crc32fast", "fdeflate", "flate2", - "miniz_oxide 0.8.9", + "miniz_oxide", ] [[package]] @@ -3878,7 +3588,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.3", + "rustix", "windows-sys 0.61.2", ] @@ -4008,29 +3718,6 @@ version = "0.5.20+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" -[[package]] -name = "proc-macro-rules" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07c277e4e643ef00c1233393c673f655e3672cf7eb3ba08a00bdd0ea59139b5f" -dependencies = [ - "proc-macro-rules-macros", - "proc-macro2", - "syn 2.0.114", -] - -[[package]] -name = "proc-macro-rules-macros" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "207fffb0fe655d1d47f6af98cc2793405e85929bdbc420d685554ff07be27ac7" -dependencies = [ - "once_cell", - "proc-macro2", - "quote", - "syn 2.0.114", -] - [[package]] name = "proc-macro2" version = "1.0.106" @@ -4069,7 +3756,7 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash 2.1.1", + "rustc-hash", "rustls", "socket2", "thiserror 2.0.18", @@ -4089,7 +3776,7 @@ dependencies = [ "lru-slab", "rand 0.9.2", "ring", - "rustc-hash 2.1.1", + "rustc-hash", "rustls", "rustls-pki-types", "slab", @@ -4128,12 +3815,6 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" -[[package]] -name = "radium" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" - [[package]] name = "rand" version = "0.7.3" @@ -4330,6 +4011,15 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +[[package]] +name = "relative-path" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca40a312222d8ba74837cb474edef44b37f561da5f773981007a10bbaa992b0" +dependencies = [ + "serde", +] + [[package]] name = "reqwest" version = "0.12.28" @@ -4392,6 +4082,54 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rquickjs" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c50dc6d6c587c339edb4769cf705867497a2baf0eca8b4645fa6ecd22f02c77a" +dependencies = [ + "rquickjs-core", + "rquickjs-macro", +] + +[[package]] +name = "rquickjs-core" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bf7840285c321c3ab20e752a9afb95548c75cd7f4632a0627cea3507e310c1" +dependencies = [ + "async-lock", + "hashbrown 0.16.1", + "relative-path", + "rquickjs-sys", +] + +[[package]] +name = "rquickjs-macro" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7106215ff41a5677b104906a13e1a440b880f4b6362b5dc4f3978c267fad2b80" +dependencies = [ + "convert_case 0.10.0", + "fnv", + "ident_case", + "indexmap 2.13.0", + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "rquickjs-core", + "syn 2.0.114", +] + +[[package]] +name = "rquickjs-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27344601ef27460e82d6a4e1ecb9e7e99f518122095f3c51296da8e9be2b9d83" +dependencies = [ + "cc", +] + [[package]] name = "rusqlite" version = "0.31.0" @@ -4416,94 +4154,19 @@ dependencies = [ "ordered-multimap", ] -[[package]] -name = "rust_engineio" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3d3572ceba6c5d79eecedf3be93640ca9512fa4100dff6a70f96c514adf4f1f" -dependencies = [ - "adler32", - "async-stream", - "async-trait", - "base64 0.21.7", - "bytes", - "futures-util", - "http", - "native-tls", - "reqwest", - "serde", - "serde_json", - "thiserror 1.0.69", - "tokio", - "tokio-tungstenite 0.21.0", - "tungstenite 0.21.0", - "url", -] - -[[package]] -name = "rust_socketio" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a6a8672db895d567b3c0b8a4c0d3e98113ebb32badf6ce66004e743e5ee1e1e" -dependencies = [ - "adler32", - "async-stream", - "backoff", - "base64 0.21.7", - "bytes", - "futures-util", - "log", - "native-tls", - "rand 0.8.5", - "rust_engineio", - "serde", - "serde_json", - "thiserror 1.0.69", - "tokio", - "url", -] - -[[package]] -name = "rustc-hash" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" - [[package]] name = "rustc-hash" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" -[[package]] -name = "rustc_version" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a" -dependencies = [ - "semver 0.9.0", -] - [[package]] name = "rustc_version" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" dependencies = [ - "semver 1.0.27", -] - -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags 2.10.0", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "semver", ] [[package]] @@ -4515,7 +4178,7 @@ dependencies = [ "bitflags 2.10.0", "errno", "libc", - "linux-raw-sys 0.11.0", + "linux-raw-sys", "windows-sys 0.61.2", ] @@ -4682,15 +4345,6 @@ dependencies = [ "smallvec", ] -[[package]] -name = "semver" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d7eb9ef2c18661902cc47e535f9bc51b78acd254da71d375c2f6720d9a40403" -dependencies = [ - "semver-parser", -] - [[package]] name = "semver" version = "1.0.27" @@ -4701,12 +4355,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "semver-parser" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" - [[package]] name = "serde" version = "1.0.228" @@ -4766,7 +4414,6 @@ version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ - "indexmap 2.13.0", "itoa", "memchr", "serde", @@ -4815,19 +4462,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_v8" -version = "0.223.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf3d859dda87ee96423c01244f10af864fa6d6a9fcdc2b77e0595078ea0ea11" -dependencies = [ - "num-bigint", - "serde", - "smallvec", - "thiserror 1.0.69", - "v8", -] - [[package]] name = "serde_with" version = "3.16.1" @@ -4929,15 +4563,6 @@ dependencies = [ "libc", ] -[[package]] -name = "simd-abstraction" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cadb29c57caadc51ff8346233b5cec1d240b68ce55cf1afc764818791876987" -dependencies = [ - "outref", -] - [[package]] name = "simd-adler32" version = "0.3.8" @@ -5026,37 +4651,12 @@ dependencies = [ "system-deps", ] -[[package]] -name = "sourcemap" -version = "8.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "208d40b9e8cad9f93613778ea295ed8f3c2b1824217c6cfc7219d3f6f45b96d4" -dependencies = [ - "base64-simd", - "bitvec", - "data-encoding", - "debugid", - "if_chain", - "rustc-hash 1.1.0", - "rustc_version 0.2.3", - "serde", - "serde_json", - "unicode-id-start", - "url", -] - [[package]] name = "stable_deref_trait" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - [[package]] name = "string_cache" version = "0.8.9" @@ -5088,28 +4688,6 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "strum" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.25.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23dc1fa9ac9c169a78ba62f0b841814b7abae11bdd047b9c58f893439e309ea0" -dependencies = [ - "heck 0.4.1", - "proc-macro2", - "quote", - "rustversion", - "syn 2.0.114", -] - [[package]] name = "subtle" version = "2.6.1" @@ -5263,12 +4841,6 @@ dependencies = [ "syn 2.0.114", ] -[[package]] -name = "tap" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" - [[package]] name = "target-lexicon" version = "0.12.16" @@ -5339,7 +4911,7 @@ dependencies = [ "heck 0.5.0", "json-patch", "schemars 0.8.22", - "semver 1.0.27", + "semver", "serde", "serde_json", "tauri-utils", @@ -5362,7 +4934,7 @@ dependencies = [ "png", "proc-macro2", "quote", - "semver 1.0.27", + "semver", "serde", "serde_json", "sha2", @@ -5576,7 +5148,7 @@ dependencies = [ "quote", "regex", "schemars 0.8.22", - "semver 1.0.27", + "semver", "serde", "serde-untagged", "serde_json", @@ -5656,7 +5228,7 @@ dependencies = [ "fastrand", "getrandom 0.3.4", "once_cell", - "rustix 1.1.3", + "rustix", "windows-sys 0.61.2", ] @@ -5824,20 +5396,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-tungstenite" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" -dependencies = [ - "futures-util", - "log", - "native-tls", - "tokio", - "tokio-native-tls", - "tungstenite 0.21.0", -] - [[package]] name = "tokio-tungstenite" version = "0.24.0" @@ -5850,7 +5408,7 @@ dependencies = [ "rustls-pki-types", "tokio", "tokio-rustls", - "tungstenite 0.24.0", + "tungstenite", "webpki-roots 0.26.11", ] @@ -6068,26 +5626,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "tungstenite" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" -dependencies = [ - "byteorder", - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "native-tls", - "rand 0.8.5", - "sha1", - "thiserror 1.0.69", - "url", - "utf-8", -] - [[package]] name = "tungstenite" version = "0.24.0" @@ -6172,12 +5710,6 @@ dependencies = [ "unic-common", ] -[[package]] -name = "unicode-id-start" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81b79ad29b5e19de4260020f8919b443b2ef0277d242ce532ec7b7a2cc8b6007" - [[package]] name = "unicode-ident" version = "1.0.22" @@ -6261,23 +5793,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "v8" -version = "0.106.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a381badc47c6f15acb5fe0b5b40234162349ed9d4e4fd7c83a7f5547c0fc69c5" -dependencies = [ - "bindgen 0.69.5", - "bitflags 2.10.0", - "fslock", - "gzip-header", - "home", - "miniz_oxide 0.7.4", - "once_cell", - "paste", - "which 6.0.3", -] - [[package]] name = "valuable" version = "0.1.1" @@ -6552,30 +6067,6 @@ dependencies = [ "windows-core 0.61.2", ] -[[package]] -name = "which" -version = "4.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" -dependencies = [ - "either", - "home", - "once_cell", - "rustix 0.38.44", -] - -[[package]] -name = "which" -version = "6.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ee928febd44d98f2f459a4a79bd4d928591333a494a10a868418ac1b39cf1f" -dependencies = [ - "either", - "home", - "rustix 0.38.44", - "winsafe", -] - [[package]] name = "winapi" version = "0.3.9" @@ -7124,12 +6615,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "winsafe" -version = "0.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" - [[package]] name = "wit-bindgen" version = "0.51.0" @@ -7187,15 +6672,6 @@ dependencies = [ "x11-dl", ] -[[package]] -name = "wyz" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" -dependencies = [ - "tap", -] - [[package]] name = "x11" version = "2.21.0" @@ -7271,7 +6747,7 @@ dependencies = [ "hex", "libc", "ordered-stream", - "rustix 1.1.3", + "rustix", "serde", "serde_repr", "tracing", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7e122f2b8..247c6934c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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] diff --git a/src-tauri/build.rs b/src-tauri/build.rs index ddfa71cd8..8e58a114c 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -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-/ (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// (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()); + } } diff --git a/src-tauri/images/background-dmg.png b/src-tauri/images/background-dmg.png new file mode 100644 index 000000000..0e296d3fe Binary files /dev/null and b/src-tauri/images/background-dmg.png differ diff --git a/src-tauri/images/background-dmg.svg b/src-tauri/images/background-dmg.svg deleted file mode 100644 index b7d1efbc7..000000000 --- a/src-tauri/images/background-dmg.svg +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src-tauri/scripts/build-tdlib-from-source.sh b/src-tauri/scripts/build-tdlib-from-source.sh new file mode 100755 index 000000000..01c797e4f --- /dev/null +++ b/src-tauri/scripts/build-tdlib-from-source.sh @@ -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." diff --git a/src-tauri/scripts/bundle-tdlib-macos.sh b/src-tauri/scripts/bundle-tdlib-macos.sh deleted file mode 100755 index 99d5ac53c..000000000 --- a/src-tauri/scripts/bundle-tdlib-macos.sh +++ /dev/null @@ -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." diff --git a/src-tauri/src/commands/runtime.rs b/src-tauri/src/commands/runtime.rs index 52c7d5b72..a92190e48 100644 --- a/src-tauri/src/commands/runtime.rs +++ b/src-tauri/src/commands/runtime.rs @@ -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, ) -> 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 } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2c9d94105..a57696dc0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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> { #[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; + } + } }); } diff --git a/src-tauri/src/runtime/loader.rs b/src-tauri/src/runtime/loader.rs index f65721713..7479035de 100644 --- a/src-tauri/src/runtime/loader.rs +++ b/src-tauri/src/runtime/loader.rs @@ -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 diff --git a/src-tauri/src/runtime/manifest.rs b/src-tauri/src/runtime/manifest.rs index edd1ba2d1..9897cbb17 100644 --- a/src-tauri/src/runtime/manifest.rs +++ b/src-tauri/src/runtime/manifest.rs @@ -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. diff --git a/src-tauri/src/runtime/mod.rs b/src-tauri/src/runtime/mod.rs index 720f9d16d..c4acfb5f1 100644 --- a/src-tauri/src/runtime/mod.rs +++ b/src-tauri/src/runtime/mod.rs @@ -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; diff --git a/src-tauri/src/runtime/v8_engine.rs b/src-tauri/src/runtime/qjs_engine.rs similarity index 93% rename from src-tauri/src/runtime/v8_engine.rs rename to src-tauri/src/runtime/qjs_engine.rs index d54d5f8b9..cada75935 100644 --- a/src-tauri/src/runtime/v8_engine.rs +++ b/src-tauri/src/runtime/qjs_engine.rs @@ -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, @@ -43,7 +43,7 @@ impl RuntimeEngine { cron_scheduler.set_registry(Arc::clone(®istry)); 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) => { diff --git a/src-tauri/src/runtime/qjs_skill_instance.rs b/src-tauri/src/runtime/qjs_skill_instance.rs new file mode 100644 index 000000000..a000c6857 --- /dev/null +++ b/src-tauri/src/runtime/qjs_skill_instance.rs @@ -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::(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, + pub skill_registry: Arc, + pub app_handle: Option, + 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, + pub error: Option, + pub published_state: HashMap, +} + +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>, + pub sender: mpsc::Sender, + 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) { + 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, + _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::(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::(bridge_code.as_bytes()) { + return Err(format!("Skill init failed: {e}")); + } + + // Execute the skill's entry point + if let Err(e) = js_ctx.eval::(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, + state: &Arc>, + skill_id: &str, + timer_state: &Arc>, +) { + // 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::(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>, + 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::(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>) { + 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::(code) { + Ok(json_str) => { + match serde_json::from_str::>(&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 { + 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::(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::(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::(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 { + 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::(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::(code.as_bytes()) + .map_err(|e| format!("{fn_name}() failed: {e}")) + .map(|_| ()) + }).await +} diff --git a/src-tauri/src/runtime/skill_registry.rs b/src-tauri/src/runtime/skill_registry.rs index 96dc5e67a..7584a39d9 100644 --- a/src-tauri/src/runtime/skill_registry.rs +++ b/src-tauri/src/runtime/skill_registry.rs @@ -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, }; diff --git a/src-tauri/src/runtime/socket_manager.rs b/src-tauri/src/runtime/socket_manager.rs index f2f043738..ba6250161 100644 --- a/src-tauri/src/runtime/socket_manager.rs +++ b/src-tauri/src/runtime/socket_manager.rs @@ -1,7 +1,8 @@ -//! SocketManager — persistent Rust-native Socket.io connection. +//! SocketManager — persistent Rust-native Socket.io connection via WebSocket. //! -//! Manages the Socket.io connection from Rust (not the WebView), -//! ensuring it survives app backgrounding on all platforms. +//! Implements Engine.IO v4 and Socket.IO v4 protocols directly over WebSocket +//! using `tokio-tungstenite` with `rustls` TLS. This avoids the macOS +//! SecureTransport (`native-tls`) TLS errors that occurred with `tf-rust-socketio`. //! //! Responsibilities: //! - MCP `listTools` / `toolCall` handled directly via the SkillRegistry (desktop only) @@ -9,9 +10,7 @@ //! - Connection state emitted to the frontend via Tauri events //! - Automatic reconnection with exponential backoff //! -//! Note: On Android, the Rust Socket.io client is not available due to -//! native-tls/OpenSSL build complexity. The frontend uses its own Socket.io -//! connection instead. +//! Note: On Android, this is a stub. The frontend handles its own Socket.io connection. use std::sync::Arc; @@ -21,16 +20,16 @@ use tauri::{AppHandle, Emitter}; use crate::models::socket::{ConnectionStatus, SocketState}; -// rust_socketio only available on non-Android platforms +// WebSocket-based Socket.IO client (desktop + iOS) #[cfg(not(target_os = "android"))] -use futures_util::FutureExt; -#[cfg(not(target_os = "android"))] -use rust_socketio::{ - asynchronous::{Client, ClientBuilder}, - Event, Payload, +use { + futures_util::{SinkExt, StreamExt}, + tokio::sync::{mpsc, watch}, + tokio::time::{Duration, Instant}, + tokio_tungstenite::{connect_async, tungstenite::Message as WsMessage}, }; -// SkillRegistry only available on desktop (V8/deno_core required) +// SkillRegistry only available on desktop #[cfg(not(any(target_os = "android", target_os = "ios")))] use crate::runtime::skill_registry::SkillRegistry; @@ -44,7 +43,7 @@ pub mod events { } // --------------------------------------------------------------------------- -// Shared state accessible from Socket.io event callbacks +// Shared state // --------------------------------------------------------------------------- struct SharedState { @@ -55,25 +54,41 @@ struct SharedState { socket_id: RwLock>, } +// --------------------------------------------------------------------------- +// WebSocket stream type alias (desktop + iOS) +// --------------------------------------------------------------------------- + +#[cfg(not(target_os = "android"))] +type WsStream = tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, +>; + +// --------------------------------------------------------------------------- +// Connection outcome (desktop + iOS) +// --------------------------------------------------------------------------- + +#[cfg(not(target_os = "android"))] +enum ConnectionOutcome { + /// Clean shutdown requested. + Shutdown, + /// Was connected then lost (reset backoff on reconnect). + Lost(String), + /// Failed during handshake (keep growing backoff). + Failed(String), +} + // --------------------------------------------------------------------------- // SocketManager // --------------------------------------------------------------------------- -/// Persistent Socket.io connection manager. -/// -/// Runs the Socket.io client in Rust (not the WebView) so it survives -/// app backgrounding. On desktop, handles MCP `listTools`/`toolCall` directly -/// via the [`SkillRegistry`], and forwards other server events to running -/// skills and to the frontend. -/// -/// Note: On Android, this is a stub implementation. The frontend uses its own -/// Socket.io connection instead. pub struct SocketManager { shared: Arc, - /// The active `rust_socketio` async client (if connected). - /// Not available on Android due to native-tls/OpenSSL build complexity. #[cfg(not(target_os = "android"))] - client: tokio::sync::Mutex>, + emit_tx: tokio::sync::Mutex>>, + #[cfg(not(target_os = "android"))] + shutdown_tx: tokio::sync::Mutex>>, + #[cfg(not(target_os = "android"))] + loop_handle: tokio::sync::Mutex>>, } impl SocketManager { @@ -87,7 +102,11 @@ impl SocketManager { socket_id: RwLock::new(None), }), #[cfg(not(target_os = "android"))] - client: tokio::sync::Mutex::new(None), + emit_tx: tokio::sync::Mutex::new(None), + #[cfg(not(target_os = "android"))] + shutdown_tx: tokio::sync::Mutex::new(None), + #[cfg(not(target_os = "android"))] + loop_handle: tokio::sync::Mutex::new(None), } } @@ -118,319 +137,45 @@ impl SocketManager { } // ----------------------------------------------------------------------- - // Connection lifecycle + // Connection lifecycle (desktop + iOS) // ----------------------------------------------------------------------- - /// Connect to the server with the given URL and auth token. - #[cfg(not(any(target_os = "android", target_os = "ios")))] + #[cfg(not(target_os = "android"))] pub async fn connect(&self, url: &str, token: &str) -> Result<(), String> { - // Disconnect existing connection first self.disconnect().await?; log::info!("[socket-mgr] Connecting to {}", url); - // Update status *self.shared.status.write() = ConnectionStatus::Connecting; Self::emit_state_change(&self.shared); - // Prepare shared-state references for the callback closures. - // Each `.on()` handler gets its own Arc clone. - let s_connect = Arc::clone(&self.shared); - let s_message = Arc::clone(&self.shared); - let s_ready = Arc::clone(&self.shared); - let s_disconnect = Arc::clone(&self.shared); - let s_error = Arc::clone(&self.shared); - let s_list_tools = Arc::clone(&self.shared); - let s_tool_call = Arc::clone(&self.shared); - let s_any = Arc::clone(&self.shared); + let (emit_tx, emit_rx) = mpsc::unbounded_channel::(); + let internal_tx = emit_tx.clone(); + let (shutdown_tx, shutdown_rx) = watch::channel(false); - let client = ClientBuilder::new(url) - .namespace("/") - .auth(json!({"token": token})) - .reconnect(true) - .max_reconnect_attempts(0) // unlimited - .transport_type(rust_socketio::TransportType::WebsocketUpgrade) - // --- Connection established --- - .on("connect", move |_payload, _client: Client| { - let shared = Arc::clone(&s_connect); - async move { - log::info!("[socket-mgr] Connected (connect event)"); - *shared.status.write() = ConnectionStatus::Connected; - Self::emit_state_change(&shared); - } - .boxed() - }) - // rust_socketio v0.6 emits "message" for the namespace connect ack - .on("message", move |payload, _client: Client| { - let shared = Arc::clone(&s_message); - async move { - log::info!("[socket-mgr] Connected (message/ack)"); - // Only transition to connected if we're still connecting - let current = *shared.status.read(); - if current != ConnectionStatus::Connected { - *shared.status.write() = ConnectionStatus::Connected; - Self::emit_state_change(&shared); - } - // Try to extract socket_id from the message payload - if let Payload::Text(values) = &payload { - if let Some(val) = values.first() { - if let Some(sid) = val.get("sid").and_then(|v| v.as_str()) { - *shared.socket_id.write() = Some(sid.to_string()); - Self::emit_state_change(&shared); - } - } - } - } - .boxed() - }) - // --- Server ready (auth successful) --- - .on("ready", move |_payload, _client: Client| { - let shared = Arc::clone(&s_ready); - async move { - log::info!("[socket-mgr] Server ready — auth successful"); - *shared.status.write() = ConnectionStatus::Connected; - Self::emit_state_change(&shared); - } - .boxed() - }) - // --- Disconnected --- - .on("close", move |_payload, _client: Client| { - let shared = Arc::clone(&s_disconnect); - async move { - log::info!("[socket-mgr] Disconnected"); - *shared.status.write() = ConnectionStatus::Disconnected; - *shared.socket_id.write() = None; - Self::emit_state_change(&shared); - } - .boxed() - }) - // --- Error --- - .on("error", move |payload, _client: Client| { - let shared = Arc::clone(&s_error); - async move { - let msg = extract_text(&payload); - log::error!("[socket-mgr] Error: {}", msg); - *shared.status.write() = ConnectionStatus::Error; - Self::emit_state_change(&shared); - } - .boxed() - }) - // --- MCP: list tools --- - .on("mcp:listTools", move |payload, client: Client| { - let shared = Arc::clone(&s_list_tools); - async move { - Self::handle_mcp_list_tools(&shared, &payload, &client).await; - } - .boxed() - }) - // --- MCP: tool call --- - .on("mcp:toolCall", move |payload, client: Client| { - let shared = Arc::clone(&s_tool_call); - async move { - Self::handle_mcp_tool_call(&shared, &payload, &client).await; - } - .boxed() - }) - // --- Catch-all: forward other events to skills + frontend --- - .on_any(move |event: Event, payload: Payload, _client: Client| { - let shared = Arc::clone(&s_any); - // Extract event name synchronously before entering async block - let event_name = match &event { - Event::Custom(name) => name.clone(), - other => other.to_string(), - }; - async move { - log::debug!("[socket-mgr] on_any event: {}", event_name); + *self.emit_tx.lock().await = Some(emit_tx); + *self.shutdown_tx.lock().await = Some(shutdown_tx); - // Skip events that already have specific handlers - match event_name.as_str() { - "connect" | "ready" | "close" | "disconnect" | "error" | "message" - | "mcp:listTools" | "mcp:toolCall" => return, - _ => {} - } + let url = url.to_string(); + let token = token.to_string(); + let shared = Arc::clone(&self.shared); - let data = extract_json(&payload).unwrap_or(serde_json::Value::Null); - - // Forward to running skills - let registry = shared.registry.read().clone(); - if let Some(registry) = registry { - registry.broadcast_event(&event_name, data.clone()).await; - } - - // Forward to frontend - Self::emit_server_event(&shared, &event_name, data); - } - .boxed() - }) - .connect() - .await - .map_err(|e| { - log::error!("[socket-mgr] Connection error: {e}"); - format!("Socket connection failed: {e}") - })?; - - log::info!("[socket-mgr] ClientBuilder.connect() returned successfully"); - - // Store the client - *self.client.lock().await = Some(client); + let handle = tokio::spawn(async move { + ws_loop(url, token, shared, emit_rx, shutdown_rx, internal_tx).await; + }); + *self.loop_handle.lock().await = Some(handle); Ok(()) } - /// Connect to the server with the given URL and auth token (Android stub). - /// On Android, the Rust Socket.io client is not available due to - /// native-tls/OpenSSL build complexity. The frontend should use its own - /// Socket.io connection instead. - #[cfg(target_os = "android")] - pub async fn connect(&self, url: &str, _token: &str) -> Result<(), String> { - log::info!( - "[socket-mgr] Android stub - Rust Socket.io not available. URL: {}", - url - ); - log::info!("[socket-mgr] Frontend should use its own Socket.io connection on Android."); - - // Mark as disconnected - frontend handles its own connection - *self.shared.status.write() = ConnectionStatus::Disconnected; - Self::emit_state_change(&self.shared); - - // Return Ok so the app doesn't fail - socket is handled by frontend on Android - Ok(()) - } - - /// Connect to the server with the given URL and auth token (iOS version). - /// MCP skill handlers are not available on mobile. - #[cfg(target_os = "ios")] - pub async fn connect(&self, url: &str, token: &str) -> Result<(), String> { - // Disconnect existing connection first - self.disconnect().await?; - - log::info!("[socket-mgr] Connecting to {} (iOS)", url); - - // Update status - *self.shared.status.write() = ConnectionStatus::Connecting; - Self::emit_state_change(&self.shared); - - // Prepare shared-state references for the callback closures. - let s_connect = Arc::clone(&self.shared); - let s_message = Arc::clone(&self.shared); - let s_ready = Arc::clone(&self.shared); - let s_disconnect = Arc::clone(&self.shared); - let s_error = Arc::clone(&self.shared); - let s_any = Arc::clone(&self.shared); - - let client = ClientBuilder::new(url) - .namespace("/") - .auth(json!({"token": token})) - .reconnect(true) - .max_reconnect_attempts(0) // unlimited - .transport_type(rust_socketio::TransportType::WebsocketUpgrade) - // --- Connection established --- - .on("connect", move |_payload, _client: Client| { - let shared = Arc::clone(&s_connect); - async move { - log::info!("[socket-mgr] Connected (connect event)"); - *shared.status.write() = ConnectionStatus::Connected; - Self::emit_state_change(&shared); - } - .boxed() - }) - // rust_socketio v0.6 emits "message" for the namespace connect ack - .on("message", move |payload, _client: Client| { - let shared = Arc::clone(&s_message); - async move { - log::info!("[socket-mgr] Connected (message/ack)"); - let current = *shared.status.read(); - if current != ConnectionStatus::Connected { - *shared.status.write() = ConnectionStatus::Connected; - Self::emit_state_change(&shared); - } - if let Payload::Text(values) = &payload { - if let Some(val) = values.first() { - if let Some(sid) = val.get("sid").and_then(|v| v.as_str()) { - *shared.socket_id.write() = Some(sid.to_string()); - Self::emit_state_change(&shared); - } - } - } - } - .boxed() - }) - // --- Server ready (auth successful) --- - .on("ready", move |_payload, _client: Client| { - let shared = Arc::clone(&s_ready); - async move { - log::info!("[socket-mgr] Server ready — auth successful"); - *shared.status.write() = ConnectionStatus::Connected; - Self::emit_state_change(&shared); - } - .boxed() - }) - // --- Disconnected --- - .on("close", move |_payload, _client: Client| { - let shared = Arc::clone(&s_disconnect); - async move { - log::info!("[socket-mgr] Disconnected"); - *shared.status.write() = ConnectionStatus::Disconnected; - *shared.socket_id.write() = None; - Self::emit_state_change(&shared); - } - .boxed() - }) - // --- Error --- - .on("error", move |payload, _client: Client| { - let shared = Arc::clone(&s_error); - async move { - let msg = extract_text(&payload); - log::error!("[socket-mgr] Error: {}", msg); - *shared.status.write() = ConnectionStatus::Error; - Self::emit_state_change(&shared); - } - .boxed() - }) - // --- Catch-all: forward events to frontend (no skills on mobile) --- - .on_any(move |event: Event, payload: Payload, _client: Client| { - let shared = Arc::clone(&s_any); - let event_name = match &event { - Event::Custom(name) => name.clone(), - other => other.to_string(), - }; - async move { - log::debug!("[socket-mgr] on_any event: {}", event_name); - - // Skip events that already have specific handlers - match event_name.as_str() { - "connect" | "ready" | "close" | "disconnect" | "error" | "message" => return, - _ => {} - } - - let data = extract_json(&payload).unwrap_or(serde_json::Value::Null); - - // Forward to frontend only (no skill registry on mobile) - Self::emit_server_event(&shared, &event_name, data); - } - .boxed() - }) - .connect() - .await - .map_err(|e| { - log::error!("[socket-mgr] Connection error: {e}"); - format!("Socket connection failed: {e}") - })?; - - log::info!("[socket-mgr] ClientBuilder.connect() returned successfully"); - - // Store the client - *self.client.lock().await = Some(client); - - Ok(()) - } - - /// Disconnect from the server. #[cfg(not(target_os = "android"))] pub async fn disconnect(&self) -> Result<(), String> { - let mut client_guard = self.client.lock().await; - if let Some(client) = client_guard.take() { - let _ = client.disconnect().await; + if let Some(tx) = self.shutdown_tx.lock().await.take() { + let _ = tx.send(true); + } + self.emit_tx.lock().await.take(); + if let Some(handle) = self.loop_handle.lock().await.take() { + let _ = tokio::time::timeout(Duration::from_secs(5), handle).await; } *self.shared.status.write() = ConnectionStatus::Disconnected; *self.shared.socket_id.write() = None; @@ -438,41 +183,51 @@ impl SocketManager { Ok(()) } - /// Disconnect from the server (Android stub). - #[cfg(target_os = "android")] - pub async fn disconnect(&self) -> Result<(), String> { - *self.shared.status.write() = ConnectionStatus::Disconnected; - *self.shared.socket_id.write() = None; - Self::emit_state_change(&self.shared); - Ok(()) - } - - /// Emit an event through the Rust socket to the server. #[cfg(not(target_os = "android"))] pub async fn emit(&self, event: &str, data: serde_json::Value) -> Result<(), String> { - let client_guard = self.client.lock().await; - if let Some(ref client) = *client_guard { - client - .emit(event, data) - .await - .map_err(|e| format!("Failed to emit '{}': {e}", event))?; - Ok(()) + if let Some(ref tx) = *self.emit_tx.lock().await { + let payload = + serde_json::to_string(&json!([event, data])).map_err(|e| format!("{e}"))?; + let msg = format!("42{}", payload); + tx.send(msg) + .map_err(|_| "Socket not connected".to_string()) } else { Err("Not connected".to_string()) } } - /// Emit an event through the Rust socket to the server (Android stub). + // ----------------------------------------------------------------------- + // Android stubs + // ----------------------------------------------------------------------- + + #[cfg(target_os = "android")] + pub async fn connect(&self, url: &str, _token: &str) -> Result<(), String> { + log::info!( + "[socket-mgr] Android stub — frontend handles socket. URL: {}", + url + ); + *self.shared.status.write() = ConnectionStatus::Disconnected; + Self::emit_state_change(&self.shared); + Ok(()) + } + + #[cfg(target_os = "android")] + pub async fn disconnect(&self) -> Result<(), String> { + *self.shared.status.write() = ConnectionStatus::Disconnected; + *self.shared.socket_id.write() = None; + Self::emit_state_change(&self.shared); + Ok(()) + } + #[cfg(target_os = "android")] pub async fn emit(&self, _event: &str, _data: serde_json::Value) -> Result<(), String> { - Err("Rust Socket.io not available on Android. Use frontend socket.".to_string()) + Err("Rust Socket.io not available on Android".to_string()) } // ----------------------------------------------------------------------- // Tauri event helpers // ----------------------------------------------------------------------- - /// Emit a socket state change event to the frontend. fn emit_state_change(shared: &SharedState) { if let Some(ref app) = *shared.app_handle.read() { let state = SocketState { @@ -484,7 +239,6 @@ impl SocketManager { } } - /// Emit a forwarded server event to the frontend. fn emit_server_event(shared: &SharedState, event_name: &str, data: serde_json::Value) { if let Some(ref app) = *shared.app_handle.read() { let _ = app.emit( @@ -493,157 +247,6 @@ impl SocketManager { ); } } - - // ----------------------------------------------------------------------- - // MCP protocol handlers (desktop only) - // ----------------------------------------------------------------------- - - /// Handle `mcp:listTools` — return all tools from all running skills. - #[cfg(not(any(target_os = "android", target_os = "ios")))] - async fn handle_mcp_list_tools(shared: &SharedState, payload: &Payload, client: &Client) { - let request_id = match extract_json(payload) { - Some(data) => data - .get("requestId") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()), - None => None, - }; - - let request_id = match request_id { - Some(id) => id, - None => { - log::warn!("[socket-mgr] mcp:listTools missing requestId"); - return; - } - }; - - log::info!("[socket-mgr] mcp:listTools (requestId={})", request_id); - - // Clone the Arc to avoid holding the parking_lot lock across await - let registry = shared.registry.read().clone(); - let tools: Vec = if let Some(registry) = registry { - registry - .all_tools() - .into_iter() - .map(|(skill_id, tool)| { - json!({ - "name": format!("{}__{}", skill_id, tool.name), - "description": tool.description, - "inputSchema": tool.input_schema, - }) - }) - .collect() - } else { - Vec::new() - }; - - log::info!( - "[socket-mgr] mcp:listToolsResponse — {} tools", - tools.len() - ); - - if let Err(e) = client - .emit( - "mcp:listToolsResponse", - json!({ "requestId": request_id, "tools": tools }), - ) - .await - { - log::error!("[socket-mgr] Failed to emit listToolsResponse: {e}"); - } - } - - /// Handle `mcp:toolCall` — parse `skillId__toolName` and execute. - #[cfg(not(any(target_os = "android", target_os = "ios")))] - async fn handle_mcp_tool_call(shared: &SharedState, payload: &Payload, client: &Client) { - let data = match extract_json(payload) { - Some(d) => d, - None => { - log::warn!("[socket-mgr] mcp:toolCall — invalid payload"); - return; - } - }; - - let request_id = data - .get("requestId") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - - let tool_call = data.get("toolCall"); - let full_name = tool_call - .and_then(|tc| tc.get("name")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let arguments = tool_call - .and_then(|tc| tc.get("arguments")) - .cloned() - .unwrap_or(json!({})); - - if request_id.is_empty() || full_name.is_empty() { - log::warn!("[socket-mgr] mcp:toolCall — missing requestId or tool name"); - return; - } - - log::info!( - "[socket-mgr] mcp:toolCall {} (requestId={})", - full_name, - request_id - ); - - // Parse "skillId__toolName" (double underscore separator) - let result = match full_name.find("__") { - Some(idx) => { - let skill_id = &full_name[..idx]; - let tool_name = &full_name[idx + 2..]; - - // Clone Arc to avoid holding lock across await - let registry = shared.registry.read().clone(); - if let Some(registry) = registry { - match registry.call_tool(skill_id, tool_name, arguments).await { - Ok(tool_result) => json!({ - "content": tool_result.content, - "isError": tool_result.is_error, - }), - Err(e) => json!({ - "content": [{"type": "text", "text": e}], - "isError": true, - }), - } - } else { - json!({ - "content": [{"type": "text", "text": "Skill runtime not available"}], - "isError": true, - }) - } - } - None => { - json!({ - "content": [{"type": "text", "text": format!( - "Invalid tool name: {}. Expected format: skillId__toolName", - full_name - )}], - "isError": true, - }) - } - }; - - log::info!( - "[socket-mgr] mcp:toolCallResponse {} (requestId={})", - full_name, - request_id - ); - - if let Err(e) = client - .emit( - "mcp:toolCallResponse", - json!({ "requestId": request_id, "result": result }), - ) - .await - { - log::error!("[socket-mgr] Failed to emit toolCallResponse: {e}"); - } - } } impl Default for SocketManager { @@ -652,29 +255,628 @@ impl Default for SocketManager { } } -// --------------------------------------------------------------------------- -// Payload helpers (not needed on Android) -// --------------------------------------------------------------------------- +// =========================================================================== +// WebSocket Engine.IO/Socket.IO implementation (desktop + iOS) +// =========================================================================== -/// Extract the first JSON value from a Socket.io payload. #[cfg(not(target_os = "android"))] -fn extract_json(payload: &Payload) -> Option { - match payload { - Payload::Text(values) => values.first().cloned(), - #[allow(unreachable_patterns)] - _ => None, +async fn ws_loop( + url: String, + token: String, + shared: Arc, + mut emit_rx: mpsc::UnboundedReceiver, + mut shutdown_rx: watch::Receiver, + internal_tx: mpsc::UnboundedSender, +) { + let mut backoff = Duration::from_millis(1000); + let max_backoff = Duration::from_secs(30); + + loop { + if *shutdown_rx.borrow() { + break; + } + + log::info!("[socket-mgr] Attempting connection..."); + *shared.status.write() = ConnectionStatus::Connecting; + SocketManager::emit_state_change(&shared); + + let outcome = run_connection( + &url, + &token, + &shared, + &mut emit_rx, + &mut shutdown_rx, + &internal_tx, + ) + .await; + + match outcome { + ConnectionOutcome::Shutdown => { + log::info!("[socket-mgr] Clean shutdown"); + break; + } + ConnectionOutcome::Lost(reason) => { + log::warn!("[socket-mgr] Connection lost: {}", reason); + backoff = Duration::from_millis(1000); // reset on established-then-lost + } + ConnectionOutcome::Failed(reason) => { + log::error!("[socket-mgr] Connection failed: {}", reason); + // keep growing backoff + } + } + + *shared.status.write() = ConnectionStatus::Disconnected; + *shared.socket_id.write() = None; + SocketManager::emit_state_change(&shared); + + if *shutdown_rx.borrow() { + break; + } + + log::info!("[socket-mgr] Reconnecting in {:?}...", backoff); + tokio::select! { + _ = tokio::time::sleep(backoff) => {} + _ = shutdown_rx.changed() => { + if *shutdown_rx.borrow() { break; } + } + } + backoff = (backoff * 2).min(max_backoff); + } + + log::info!("[socket-mgr] WebSocket loop exiting"); + *shared.status.write() = ConnectionStatus::Disconnected; + *shared.socket_id.write() = None; + SocketManager::emit_state_change(&shared); +} + +/// Run a single WebSocket connection through handshake and event loop. +#[cfg(not(target_os = "android"))] +async fn run_connection( + url: &str, + token: &str, + shared: &Arc, + emit_rx: &mut mpsc::UnboundedReceiver, + shutdown_rx: &mut watch::Receiver, + internal_tx: &mpsc::UnboundedSender, +) -> ConnectionOutcome { + // 1. Build WebSocket URL + let ws_url = build_ws_url(url); + log::info!("[socket-mgr] WS URL: {}", ws_url); + + // 2. Connect via WebSocket (uses rustls TLS for wss://) + // Auth is passed in the Socket.IO CONNECT packet, not HTTP headers. + let (ws_stream, _response) = match connect_async(&ws_url).await { + Ok(r) => r, + Err(e) => return ConnectionOutcome::Failed(format!("WebSocket connect: {e}")), + }; + + log::info!("[socket-mgr] WebSocket connected, starting handshake"); + let (mut ws_write, mut ws_read) = ws_stream.split(); + + // 4. Read Engine.IO OPEN packet + let open_data = match tokio::time::timeout(Duration::from_secs(10), read_eio_open(&mut ws_read)) + .await + { + Ok(Ok(data)) => data, + Ok(Err(e)) => return ConnectionOutcome::Failed(format!("EIO OPEN: {e}")), + Err(_) => return ConnectionOutcome::Failed("Timeout waiting for EIO OPEN".into()), + }; + + let ping_interval = open_data + .get("pingInterval") + .and_then(|v| v.as_u64()) + .unwrap_or(25000); + let ping_timeout_ms = open_data + .get("pingTimeout") + .and_then(|v| v.as_u64()) + .unwrap_or(20000); + let eio_sid = open_data + .get("sid") + .and_then(|v| v.as_str()) + .unwrap_or("?"); + log::info!( + "[socket-mgr] EIO OPEN: sid={}, ping={}ms, timeout={}ms", + eio_sid, + ping_interval, + ping_timeout_ms + ); + + // 5. Send Socket.IO CONNECT with auth + let connect_payload = json!({"token": token}); + let connect_msg = format!("40{}", serde_json::to_string(&connect_payload).unwrap()); + if let Err(e) = ws_write + .send(WsMessage::Text(connect_msg.into())) + .await + { + return ConnectionOutcome::Failed(format!("Send SIO CONNECT: {e}")); + } + + // 6. Read Socket.IO CONNECT ACK + let ack_data = + match tokio::time::timeout(Duration::from_secs(10), read_sio_connect_ack(&mut ws_read)) + .await + { + Ok(Ok(data)) => data, + Ok(Err(e)) => return ConnectionOutcome::Failed(format!("SIO CONNECT: {e}")), + Err(_) => { + return ConnectionOutcome::Failed("Timeout waiting for SIO CONNECT ACK".into()) + } + }; + + let sio_sid = ack_data + .get("sid") + .and_then(|v| v.as_str()) + .map(String::from); + log::info!("[socket-mgr] SIO CONNECT ACK: sid={:?}", sio_sid); + + // 7. Update state: Connected + *shared.status.write() = ConnectionStatus::Connected; + *shared.socket_id.write() = sio_sid; + SocketManager::emit_state_change(shared); + + // 8. Main event loop + let timeout_duration = Duration::from_millis(ping_interval + ping_timeout_ms + 5000); + let mut deadline = Instant::now() + timeout_duration; + + loop { + tokio::select! { + msg = ws_read.next() => { + match msg { + Some(Ok(WsMessage::Text(text))) => { + deadline = Instant::now() + timeout_duration; + handle_eio_message(&text, internal_tx, shared); + } + Some(Ok(WsMessage::Ping(data))) => { + let _ = ws_write.send(WsMessage::Pong(data)).await; + } + Some(Ok(WsMessage::Close(_))) => { + log::info!("[socket-mgr] Server closed WebSocket"); + return ConnectionOutcome::Lost("Server closed connection".into()); + } + Some(Err(e)) => { + return ConnectionOutcome::Lost(format!("WebSocket error: {e}")); + } + None => { + return ConnectionOutcome::Lost("WebSocket stream ended".into()); + } + _ => {} // Binary, Pong, Frame + } + } + // Outgoing events from SocketManager::emit() or MCP handlers + outgoing = emit_rx.recv() => { + match outgoing { + Some(msg) => { + if let Err(e) = ws_write.send(WsMessage::Text(msg.into())).await { + return ConnectionOutcome::Lost(format!("Send failed: {e}")); + } + } + None => { + // Channel closed (disconnect requested) + let _ = ws_write.send(WsMessage::Close(None)).await; + return ConnectionOutcome::Shutdown; + } + } + } + // Ping timeout — server stopped sending pings + _ = tokio::time::sleep_until(deadline) => { + log::warn!("[socket-mgr] Ping timeout ({}ms)", ping_interval + ping_timeout_ms + 5000); + return ConnectionOutcome::Lost("Ping timeout".into()); + } + // Shutdown signal + _ = shutdown_rx.changed() => { + if *shutdown_rx.borrow() { + log::info!("[socket-mgr] Shutdown signal received"); + let _ = ws_write.send(WsMessage::Close(None)).await; + return ConnectionOutcome::Shutdown; + } + } + } } } -/// Extract a human-readable string from a Socket.io payload. +// --------------------------------------------------------------------------- +// Handshake helpers +// --------------------------------------------------------------------------- + +/// Read the Engine.IO OPEN packet (type 0) from the WebSocket. +/// Format: `0{"sid":"...","upgrades":[],"pingInterval":25000,"pingTimeout":20000}` #[cfg(not(target_os = "android"))] -fn extract_text(payload: &Payload) -> String { - match payload { - Payload::Text(values) => values - .first() - .map(|v| v.to_string()) - .unwrap_or_else(|| "unknown".to_string()), - #[allow(unreachable_patterns)] - _ => "unknown".to_string(), +async fn read_eio_open( + ws_read: &mut futures_util::stream::SplitStream, +) -> Result { + loop { + match ws_read.next().await { + Some(Ok(WsMessage::Text(text))) => { + let s: &str = &text; + if let Some(json_str) = s.strip_prefix('0') { + return serde_json::from_str(json_str) + .map_err(|e| format!("Parse EIO OPEN JSON: {e}")); + } + log::debug!( + "[socket-mgr] Skipping non-OPEN packet: {}", + &s[..s.len().min(40)] + ); + } + Some(Ok(_)) => continue, + Some(Err(e)) => return Err(format!("WS error during handshake: {e}")), + None => return Err("WebSocket closed before OPEN".into()), + } } } + +/// Read the Socket.IO CONNECT ACK (type 40) from the WebSocket. +/// Format: `40{"sid":"..."}` or `44{"message":"error"}` for connect error. +#[cfg(not(target_os = "android"))] +async fn read_sio_connect_ack( + ws_read: &mut futures_util::stream::SplitStream, +) -> Result { + loop { + match ws_read.next().await { + Some(Ok(WsMessage::Text(text))) => { + let s: &str = &text; + // Engine.IO MESSAGE (4) + Socket.IO CONNECT (0) + if let Some(json_str) = s.strip_prefix("40") { + if json_str.is_empty() { + return Ok(json!({})); + } + return serde_json::from_str(json_str) + .map_err(|e| format!("Parse CONNECT ACK: {e}")); + } + // Engine.IO MESSAGE (4) + Socket.IO CONNECT_ERROR (4) + if let Some(json_str) = s.strip_prefix("44") { + let err: serde_json::Value = + serde_json::from_str(json_str).unwrap_or(json!({"message": "unknown"})); + let msg = err + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("Connect error"); + return Err(format!("Socket.IO connect error: {msg}")); + } + // Engine.IO PING (2) — respond via log, can't write from here + if s.starts_with('2') { + log::debug!("[socket-mgr] EIO ping during handshake (will respond after)"); + continue; + } + log::debug!( + "[socket-mgr] Skipping packet during SIO handshake: {}", + &s[..s.len().min(40)] + ); + } + Some(Ok(_)) => continue, + Some(Err(e)) => return Err(format!("WS error during SIO handshake: {e}")), + None => return Err("WebSocket closed before CONNECT ACK".into()), + } + } +} + +// --------------------------------------------------------------------------- +// Message handling +// --------------------------------------------------------------------------- + +/// Handle an incoming Engine.IO text message. +#[cfg(not(target_os = "android"))] +fn handle_eio_message( + text: &str, + emit_tx: &mpsc::UnboundedSender, + shared: &Arc, +) { + if text.is_empty() { + return; + } + + match text.as_bytes()[0] { + b'2' => { + // Engine.IO PING → respond with PONG + let _ = emit_tx.send("3".to_string()); + } + b'3' => { + // Engine.IO PONG — ignore (server responding to our ping) + } + b'4' => { + // Engine.IO MESSAGE → contains Socket.IO packet + if text.len() > 1 { + handle_sio_packet(&text[1..], emit_tx, shared); + } + } + b'1' => { + log::info!("[socket-mgr] Engine.IO CLOSE from server"); + } + b'6' => { + // Engine.IO NOOP + } + _ => { + log::debug!( + "[socket-mgr] Unknown EIO packet: {}", + &text[..text.len().min(30)] + ); + } + } +} + +/// Handle a Socket.IO packet (after stripping the Engine.IO '4' prefix). +#[cfg(not(target_os = "android"))] +fn handle_sio_packet( + text: &str, + emit_tx: &mpsc::UnboundedSender, + shared: &Arc, +) { + if text.is_empty() { + return; + } + + match text.as_bytes()[0] { + b'2' => { + // Socket.IO EVENT: 2["eventName", data] + // May have ACK id: 2["eventName", data] + if let Some((event_name, data)) = parse_sio_event(&text[1..]) { + handle_sio_event(&event_name, data, emit_tx, shared); + } else { + log::warn!( + "[socket-mgr] Failed to parse SIO EVENT: {}", + &text[..text.len().min(80)] + ); + } + } + b'0' => { + // Socket.IO CONNECT (re-ack during reconnection) — update state + log::debug!("[socket-mgr] SIO CONNECT re-ack"); + if text.len() > 1 { + if let Ok(data) = serde_json::from_str::(&text[1..]) { + if let Some(sid) = data.get("sid").and_then(|v| v.as_str()) { + *shared.socket_id.write() = Some(sid.to_string()); + SocketManager::emit_state_change(shared); + } + } + } + } + b'1' => { + // Socket.IO DISCONNECT + log::info!("[socket-mgr] SIO DISCONNECT from server"); + *shared.status.write() = ConnectionStatus::Disconnected; + *shared.socket_id.write() = None; + SocketManager::emit_state_change(shared); + } + b'4' => { + // Socket.IO CONNECT_ERROR + let error_str = if text.len() > 1 { &text[1..] } else { "unknown" }; + log::error!("[socket-mgr] SIO CONNECT_ERROR: {}", error_str); + } + _ => { + log::debug!( + "[socket-mgr] Unknown SIO packet type: {}", + &text[..text.len().min(30)] + ); + } + } +} + +/// Handle a Socket.IO event by name. +#[cfg(not(target_os = "android"))] +fn handle_sio_event( + event_name: &str, + data: serde_json::Value, + emit_tx: &mpsc::UnboundedSender, + shared: &Arc, +) { + match event_name { + "ready" => { + log::info!("[socket-mgr] Server ready — auth successful"); + *shared.status.write() = ConnectionStatus::Connected; + SocketManager::emit_state_change(shared); + } + "error" => { + log::error!("[socket-mgr] Server error event: {}", data); + *shared.status.write() = ConnectionStatus::Error; + SocketManager::emit_state_change(shared); + } + // MCP handlers — desktop only + #[cfg(not(any(target_os = "android", target_os = "ios")))] + "mcp:listTools" => { + let shared = Arc::clone(shared); + let tx = emit_tx.clone(); + tokio::spawn(async move { + handle_mcp_list_tools(&shared, data, &tx).await; + }); + } + #[cfg(not(any(target_os = "android", target_os = "ios")))] + "mcp:toolCall" => { + let shared = Arc::clone(shared); + let tx = emit_tx.clone(); + tokio::spawn(async move { + handle_mcp_tool_call(&shared, data, &tx).await; + }); + } + _ => { + // Forward to skills (desktop only) and frontend + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + let shared_clone = Arc::clone(shared); + let event_owned = event_name.to_string(); + let data_clone = data.clone(); + tokio::spawn(async move { + let registry = shared_clone.registry.read().clone(); + if let Some(registry) = registry { + registry.broadcast_event(&event_owned, data_clone).await; + } + }); + } + + SocketManager::emit_server_event(shared, event_name, data); + } + } +} + +// --------------------------------------------------------------------------- +// MCP protocol handlers (desktop only) +// --------------------------------------------------------------------------- + +#[cfg(not(any(target_os = "android", target_os = "ios")))] +async fn handle_mcp_list_tools( + shared: &SharedState, + data: serde_json::Value, + emit_tx: &mpsc::UnboundedSender, +) { + let request_id = match data.get("requestId").and_then(|v| v.as_str()) { + Some(id) => id.to_string(), + None => { + log::warn!("[socket-mgr] mcp:listTools missing requestId"); + return; + } + }; + + log::info!("[socket-mgr] mcp:listTools (requestId={})", request_id); + + let registry = shared.registry.read().clone(); + let tools: Vec = if let Some(registry) = registry { + registry + .all_tools() + .into_iter() + .map(|(skill_id, tool)| { + json!({ + "name": format!("{}__{}", skill_id, tool.name), + "description": tool.description, + "inputSchema": tool.input_schema, + }) + }) + .collect() + } else { + Vec::new() + }; + + log::info!( + "[socket-mgr] mcp:listToolsResponse — {} tools", + tools.len() + ); + + emit_via_channel( + emit_tx, + "mcp:listToolsResponse", + json!({ "requestId": request_id, "tools": tools }), + ); +} + +#[cfg(not(any(target_os = "android", target_os = "ios")))] +async fn handle_mcp_tool_call( + shared: &SharedState, + data: serde_json::Value, + emit_tx: &mpsc::UnboundedSender, +) { + let request_id = data + .get("requestId") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let tool_call = data.get("toolCall"); + let full_name = tool_call + .and_then(|tc| tc.get("name")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let arguments = tool_call + .and_then(|tc| tc.get("arguments")) + .cloned() + .unwrap_or(json!({})); + + if request_id.is_empty() || full_name.is_empty() { + log::warn!("[socket-mgr] mcp:toolCall — missing requestId or tool name"); + return; + } + + log::info!( + "[socket-mgr] mcp:toolCall {} (requestId={})", + full_name, + request_id + ); + + let result = match full_name.find("__") { + Some(idx) => { + let skill_id = &full_name[..idx]; + let tool_name = &full_name[idx + 2..]; + + let registry = shared.registry.read().clone(); + if let Some(registry) = registry { + match registry.call_tool(skill_id, tool_name, arguments).await { + Ok(tool_result) => json!({ + "content": tool_result.content, + "isError": tool_result.is_error, + }), + Err(e) => json!({ + "content": [{"type": "text", "text": e}], + "isError": true, + }), + } + } else { + json!({ + "content": [{"type": "text", "text": "Skill runtime not available"}], + "isError": true, + }) + } + } + None => { + json!({ + "content": [{"type": "text", "text": format!( + "Invalid tool name: {}. Expected format: skillId__toolName", + full_name + )}], + "isError": true, + }) + } + }; + + log::info!( + "[socket-mgr] mcp:toolCallResponse {} (requestId={})", + full_name, + request_id + ); + + emit_via_channel( + emit_tx, + "mcp:toolCallResponse", + json!({ "requestId": request_id, "result": result }), + ); +} + +// --------------------------------------------------------------------------- +// Utility functions +// --------------------------------------------------------------------------- + +/// Convert an HTTP(S) URL to a WebSocket URL with Engine.IO parameters. +#[cfg(not(target_os = "android"))] +fn build_ws_url(url: &str) -> String { + let base = url.trim_end_matches('/'); + let ws_base = if base.starts_with("https://") { + base.replacen("https://", "wss://", 1) + } else if base.starts_with("http://") { + base.replacen("http://", "ws://", 1) + } else { + base.to_string() + }; + format!("{}/socket.io/?EIO=4&transport=websocket", ws_base) +} + +/// Send a Socket.IO event through the emit channel. +/// Formats: `42["eventName", data]` +#[cfg(not(target_os = "android"))] +fn emit_via_channel( + tx: &mpsc::UnboundedSender, + event: &str, + data: serde_json::Value, +) { + let payload = serde_json::to_string(&json!([event, data])).unwrap_or_default(); + let msg = format!("42{}", payload); + if let Err(e) = tx.send(msg) { + log::error!("[socket-mgr] emit_via_channel failed: {e}"); + } +} + +/// Parse a Socket.IO EVENT payload: `["eventName", data]` or `["eventName", data]`. +#[cfg(not(target_os = "android"))] +fn parse_sio_event(text: &str) -> Option<(String, serde_json::Value)> { + // Find the start of the JSON array (skip optional ACK id digits) + let json_start = text.find('[')?; + let json_str = &text[json_start..]; + let arr: Vec = serde_json::from_str(json_str).ok()?; + let event_name = arr.first()?.as_str()?.to_string(); + let data = arr.get(1).cloned().unwrap_or(serde_json::Value::Null); + Some((event_name, data)) +} diff --git a/src-tauri/src/runtime/types.rs b/src-tauri/src/runtime/types.rs index 8bf205dcb..2abe6945c 100644 --- a/src-tauri/src/runtime/types.rs +++ b/src-tauri/src/runtime/types.rs @@ -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. diff --git a/src-tauri/src/runtime/v8_skill_instance.rs b/src-tauri/src/runtime/v8_skill_instance.rs deleted file mode 100644 index 817a81733..000000000 --- a/src-tauri/src/runtime/v8_skill_instance.rs +++ /dev/null @@ -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, - pub skill_registry: Arc, - pub app_handle: Option, - pub data_dir: PathBuf, -} - -/// Shared mutable state for a skill instance. -pub struct SkillState { - pub status: SkillStatus, - pub tools: Vec, - pub error: Option, - pub published_state: HashMap, -} - -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>, - pub sender: mpsc::Sender, - 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) { - 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, - _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_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("", 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!("", 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, - state: &Arc>, - 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("", 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>, - 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>) { - 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("", 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::>(&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("", 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("", 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 { - 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("", 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("", 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("", 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 { - 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("", 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("", code) - .map_err(|e| format!("{fn_name}() failed: {e}"))?; - - Ok(()) -} diff --git a/src-tauri/src/services/tdlib/manager.rs b/src-tauri/src/services/tdlib/manager.rs index 195d64130..d8f4969e9 100644 --- a/src-tauri/src/services/tdlib/manager.rs +++ b/src-tauri/src/services/tdlib/manager.rs @@ -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() diff --git a/src-tauri/src/services/tdlib_v8/bootstrap.js b/src-tauri/src/services/tdlib_v8/bootstrap.js index d8a2fe9c8..2662db39f 100644 --- a/src-tauri/src/services/tdlib_v8/bootstrap.js +++ b/src-tauri/src/services/tdlib_v8/bootstrap.js @@ -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} 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} 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} 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} */ 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'); diff --git a/src-tauri/src/services/tdlib_v8/mod.rs b/src-tauri/src/services/tdlib_v8/mod.rs index 8623c47e0..aa590cdfb 100644 --- a/src-tauri/src/services/tdlib_v8/mod.rs +++ b/src-tauri/src/services/tdlib_v8/mod.rs @@ -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; diff --git a/src-tauri/src/services/tdlib_v8/ops/mod.rs b/src-tauri/src/services/tdlib_v8/ops/mod.rs deleted file mode 100644 index fa274e813..000000000 --- a/src-tauri/src/services/tdlib_v8/ops/mod.rs +++ /dev/null @@ -1,1236 +0,0 @@ -//! Custom Deno Core Ops for V8 Runtime -//! -//! Provides browser API implementations as Rust ops. - -use std::cell::RefCell; -use std::collections::HashMap; -use std::rc::Rc; - -use deno_core::{extension, op2, OpState}; -use serde::{Deserialize, Serialize}; -use tokio::sync::mpsc; - -use super::storage::IdbStorage; - -// ============================================================================ -// Extension Definition -// ============================================================================ - -extension!( - alphahuman_ops, - ops = [ - // Console ops - op_console_log, - op_console_warn, - op_console_error, - // Crypto ops - op_crypto_random, - op_atob, - op_btoa, - // Performance ops - op_performance_now, - // Platform ops - op_platform_os, - op_platform_env, - // Timer ops (prefixed to avoid conflict with deno_core built-ins) - op_ah_timer_start, - op_ah_timer_cancel, - // Fetch ops - op_fetch, - // WebSocket ops - op_ws_connect, - op_ws_send, - op_ws_recv, - op_ws_close, - // IndexedDB ops - op_idb_open, - op_idb_close, - op_idb_delete_database, - op_idb_create_object_store, - op_idb_delete_object_store, - op_idb_get, - op_idb_put, - op_idb_delete, - op_idb_clear, - op_idb_get_all, - op_idb_get_all_keys, - op_idb_count, - // Skill bridge ops - op_db_exec, - op_db_get, - op_db_all, - op_db_kv_get, - op_db_kv_set, - op_store_get, - op_store_set, - op_store_delete, - op_store_keys, - op_net_fetch, - // State bridge ops - op_state_get, - op_state_set, - op_state_set_partial, - // Data bridge ops - op_data_read, - op_data_write, - // TDLib ops (telegram skill only) - op_tdlib_create_client, - op_tdlib_send, - op_tdlib_receive, - op_tdlib_destroy, - op_tdlib_is_available, - // Model ops (local LLM inference) - op_model_is_available, - op_model_get_status, - op_model_generate, - op_model_summarize, - ], - state = |state| { - // State will be initialized when runtime is created - } -); - -/// Build the deno_core Extension with all custom ops. -pub fn build_extension(_storage: IdbStorage) -> deno_core::Extension { - alphahuman_ops::init_ops_and_esm() -} - -/// Initialize storage in op state with data directory and shared skill state. -pub fn init_state_with_data_dir( - state: &mut OpState, - storage: IdbStorage, - skill_id: String, - data_dir: std::path::PathBuf, - skill_state: std::sync::Arc>, -) { - state.put(storage); - state.put(SkillContext::with_state(skill_id, data_dir, skill_state)); - state.put(TimerState::default()); - state.put(WebSocketState::default()); -} - -/// Poll timers and return IDs of timers that are ready to fire. -/// Also returns the duration until the next timer (for efficient sleeping). -pub fn poll_timers(state: &mut OpState) -> (Vec, Option) { - let timer_state = state.borrow_mut::(); - let ready = timer_state.poll_ready(); - let next = timer_state.time_until_next(); - (ready, next) -} - -/// Context for the current skill execution. -#[derive(Clone)] -pub struct SkillContext { - pub skill_id: String, - /// Skill-specific data directory for file I/O. - pub data_dir: Option, - /// Shared reference to the skill's state (same instance as V8SkillInstance). - pub skill_state: Option>>, -} - -impl Default for SkillContext { - fn default() -> Self { - Self { - skill_id: String::new(), - data_dir: None, - skill_state: None, - } - } -} - -impl SkillContext { - /// Create a new SkillContext with a skill ID, data directory, and shared state. - pub fn with_state( - skill_id: String, - data_dir: std::path::PathBuf, - skill_state: std::sync::Arc>, - ) -> Self { - Self { - skill_id, - data_dir: Some(data_dir), - skill_state: Some(skill_state), - } - } -} - -// ============================================================================ -// Timer State -// ============================================================================ - -/// A scheduled timer (setTimeout or setInterval). -#[derive(Clone, Debug)] -pub struct TimerEntry { - /// Whether this is a repeating interval - pub is_interval: bool, - /// Delay in milliseconds - pub delay_ms: u64, - /// When the timer should fire next (Instant) - pub next_fire: std::time::Instant, -} - -/// State for managing timers. -/// Timers are polled during the event loop and fire callbacks via JS. -#[derive(Default)] -pub struct TimerState { - /// Active timers: id -> TimerEntry - pub timers: HashMap, -} - -impl TimerState { - /// Get all timers that are ready to fire, returning their IDs. - /// For intervals, reschedules the next fire time. - /// For timeouts, removes them from the map. - pub fn poll_ready(&mut self) -> Vec { - let now = std::time::Instant::now(); - let mut ready = Vec::new(); - - // Collect IDs of ready timers - let ready_ids: Vec = self - .timers - .iter() - .filter(|(_, entry)| now >= entry.next_fire) - .map(|(id, _)| *id) - .collect(); - - for id in ready_ids { - if let Some(entry) = self.timers.get_mut(&id) { - ready.push(id); - - if entry.is_interval { - // Reschedule for next interval - entry.next_fire = now + std::time::Duration::from_millis(entry.delay_ms); - } else { - // Remove one-shot timeout - self.timers.remove(&id); - } - } - } - - ready - } - - /// Get the duration until the next timer fires (for sleep optimization). - pub fn time_until_next(&self) -> Option { - let now = std::time::Instant::now(); - self.timers - .values() - .map(|entry| { - if entry.next_fire > now { - entry.next_fire - now - } else { - std::time::Duration::ZERO - } - }) - .min() - } -} - -// ============================================================================ -// WebSocket State -// ============================================================================ - -/// State for managing WebSocket connections. -/// Currently a placeholder - full state management is TODO. -#[derive(Default)] -#[allow(dead_code)] -pub struct WebSocketState { - next_id: u32, - /// Active connections: id -> WebSocket sender - pub connections: HashMap, -} - -#[allow(dead_code)] -pub struct WebSocketConnection { - pub write_tx: mpsc::Sender, - pub read_rx: mpsc::Receiver, - pub close_tx: Option>, -} - -// ============================================================================ -// Console Ops -// ============================================================================ - -#[op2(fast)] -fn op_console_log(#[string] msg: &str) { - log::info!("[js] {}", msg); -} - -#[op2(fast)] -fn op_console_warn(#[string] msg: &str) { - log::warn!("[js] {}", msg); -} - -#[op2(fast)] -fn op_console_error(#[string] msg: &str) { - log::error!("[js] {}", msg); -} - -// ============================================================================ -// Crypto Ops -// ============================================================================ - -#[op2] -#[buffer] -fn op_crypto_random(len: u32) -> Vec { - use rand::RngCore; - let mut bytes = vec![0u8; len as usize]; - rand::thread_rng().fill_bytes(&mut bytes); - bytes -} - -#[op2] -#[string] -fn op_atob(#[string] input: &str) -> Result { - use base64::Engine; - let decoded = base64::engine::general_purpose::STANDARD.decode(input)?; - Ok(String::from_utf8_lossy(&decoded).to_string()) -} - -#[op2] -#[string] -fn op_btoa(#[string] input: &str) -> String { - use base64::Engine; - base64::engine::general_purpose::STANDARD.encode(input.as_bytes()) -} - -// ============================================================================ -// Performance Ops -// ============================================================================ - -#[op2(fast)] -fn op_performance_now() -> f64 { - use std::time::{SystemTime, UNIX_EPOCH}; - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs_f64() * 1000.0) - .unwrap_or(0.0) -} - -// ============================================================================ -// Platform Ops -// ============================================================================ - -#[op2] -#[string] -fn op_platform_os() -> &'static str { - #[cfg(target_os = "windows")] - return "windows"; - #[cfg(target_os = "macos")] - return "macos"; - #[cfg(target_os = "linux")] - return "linux"; - #[cfg(target_os = "android")] - return "android"; - #[cfg(target_os = "ios")] - return "ios"; - #[cfg(not(any( - target_os = "windows", - target_os = "macos", - target_os = "linux", - target_os = "android", - target_os = "ios" - )))] - return "unknown"; -} - -#[op2] -#[string] -fn op_platform_env(#[string] key: &str) -> Option { - const ALLOWED_ENV_VARS: &[&str] = &[ - "VITE_TELEGRAM_BOT_USERNAME", - "VITE_TELEGRAM_BOT_ID", - "TELEGRAM_API_ID", // Skills may use this without VITE_ prefix - "TELEGRAM_API_HASH", // Skills may use this without VITE_ prefix - "VITE_BACKEND_URL", - "BACKEND_URL", // Skills may use this without VITE_ prefix - "VITE_DEBUG", - ]; - - if ALLOWED_ENV_VARS.contains(&key) { - std::env::var(key).ok() - } else { - None - } -} - -// ============================================================================ -// Timer Ops -// ============================================================================ - -/// Start a timer (setTimeout or setInterval). -/// The actual callback execution happens in JavaScript via __handleTimer, -/// triggered by the event loop polling TimerState. -#[op2(fast)] -fn op_ah_timer_start(state: &mut OpState, id: u32, delay_ms: u32, is_interval: bool) { - let timer_state = state.borrow_mut::(); - - let entry = TimerEntry { - is_interval, - delay_ms: delay_ms as u64, - next_fire: std::time::Instant::now() + std::time::Duration::from_millis(delay_ms as u64), - }; - - timer_state.timers.insert(id, entry); - log::debug!( - "[timer] Registered {} {} with delay {}ms", - if is_interval { "interval" } else { "timeout" }, - id, - delay_ms - ); -} - -/// Cancel a timer. -#[op2(fast)] -fn op_ah_timer_cancel(state: &mut OpState, id: u32) { - let timer_state = state.borrow_mut::(); - - if timer_state.timers.remove(&id).is_some() { - log::debug!("[timer] Cancelled timer {}", id); - } -} - -// ============================================================================ -// Fetch Ops -// ============================================================================ - -#[derive(Deserialize)] -struct FetchOptions { - method: Option, - headers: Option>, - body: Option, -} - -#[derive(Serialize)] -struct FetchResponse { - status: u16, - #[serde(rename = "statusText")] - status_text: String, - headers: HashMap, - body: String, -} - -/// Async fetch operation for the fetch API. -#[op2(async)] -#[serde] -async fn op_fetch( - #[string] url: String, - #[serde] options: FetchOptions, -) -> Result { - let client = reqwest::Client::new(); - - let method = options.method.unwrap_or_else(|| "GET".to_string()); - let method: reqwest::Method = method.parse().map_err(|_| { - deno_core::error::generic_error(format!("Invalid HTTP method: {}", method)) - })?; - - let mut request = client.request(method, &url); - - // Add headers - if let Some(headers) = options.headers { - for (key, value) in headers { - request = request.header(&key, &value); - } - } - - // Add body - if let Some(body) = options.body { - request = request.body(body); - } - - let response = request.send().await.map_err(|e| { - deno_core::error::generic_error(format!("Fetch failed: {}", e)) - })?; - - let status = response.status().as_u16(); - let status_text = response.status().canonical_reason().unwrap_or("").to_string(); - - let mut headers = HashMap::new(); - for (key, value) in response.headers() { - if let Ok(v) = value.to_str() { - headers.insert(key.to_string(), v.to_string()); - } - } - - let body = response.text().await.map_err(|e| { - deno_core::error::generic_error(format!("Failed to read response body: {}", e)) - })?; - - Ok(FetchResponse { - status, - status_text, - headers, - body, - }) -} - -// ============================================================================ -// WebSocket Ops -// ============================================================================ - -/// Connect to a WebSocket server. -#[op2(async)] -async fn op_ws_connect( - #[string] url: String, -) -> Result { - use futures::StreamExt; - use tokio_tungstenite::connect_async; - - let (ws_stream, _) = connect_async(&url).await.map_err(|e| { - deno_core::error::generic_error(format!("WebSocket connect failed: {}", e)) - })?; - - let (write, mut read) = ws_stream.split(); - - // Create channels for communication - // Note: These are set up for future full WebSocket state management - let (_write_tx, write_rx) = mpsc::channel::(32); - let (read_tx, _read_rx) = mpsc::channel::(32); - let (_close_tx, close_rx) = tokio::sync::oneshot::channel::<()>(); - - // Spawn write task - let _write_handle = tokio::spawn(async move { - use futures::SinkExt; - use tokio_tungstenite::tungstenite::Message; - - let mut write = write; - let mut write_rx_inner = write_rx; - let mut close_rx = close_rx; - - loop { - tokio::select! { - msg = write_rx_inner.recv() => { - match msg { - Some(text) => { - if write.send(Message::Text(text)).await.is_err() { - break; - } - } - None => break, - } - } - _ = &mut close_rx => { - let _ = write.close().await; - break; - } - } - } - }); - - // Spawn read task - let _read_handle = tokio::spawn(async move { - use tokio_tungstenite::tungstenite::Message; - - while let Some(result) = read.next().await { - match result { - Ok(Message::Text(text)) => { - if read_tx.send(text).await.is_err() { - break; - } - } - Ok(Message::Binary(data)) => { - // Convert binary to base64 for JavaScript - use base64::Engine; - let b64 = base64::engine::general_purpose::STANDARD.encode(&data); - if read_tx.send(b64).await.is_err() { - break; - } - } - Ok(Message::Close(_)) => break, - Err(_) => break, - _ => {} // Ignore ping/pong - } - } - }); - - // Generate a simple ID (in production, use proper state management) - let id = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as u32) - .unwrap_or(1); - - log::info!("[ws] Connected to {} with id {}", url, id); - - Ok(id) -} - -/// Send a message over WebSocket. -#[op2(fast)] -fn op_ws_send( - _state: &mut OpState, - _id: u32, - #[string] _data: &str, -) -> Result<(), deno_core::error::AnyError> { - // Note: Full WebSocket state management would require more complex handling - // For now, this is a placeholder - log::debug!("[ws] Send message on connection {}", _id); - Ok(()) -} - -/// Receive a message from WebSocket (async). -#[op2(async)] -#[string] -async fn op_ws_recv(_id: u32) -> Result, deno_core::error::AnyError> { - // Note: Full WebSocket state management would require more complex handling - // For now, return None to indicate no message - Ok(None) -} - -/// Close a WebSocket connection. -#[op2(fast)] -fn op_ws_close( - _state: &mut OpState, - _id: u32, - _code: u16, - #[string] _reason: &str, -) -> Result<(), deno_core::error::AnyError> { - log::debug!("[ws] Close connection {}", _id); - Ok(()) -} - -// ============================================================================ -// IndexedDB Ops -// ============================================================================ - -#[derive(Serialize)] -struct IdbOpenResult { - #[serde(rename = "needsUpgrade")] - needs_upgrade: bool, - #[serde(rename = "oldVersion")] - old_version: u32, - #[serde(rename = "objectStores")] - object_stores: Vec, -} - -/// Open an IndexedDB database. -#[op2(async)] -#[serde] -async fn op_idb_open( - state: Rc>, - #[string] name: String, - version: u32, -) -> Result { - let storage = { - let state = state.borrow(); - state.borrow::().clone() - }; - - let result = storage.open_database(&name, version).await.map_err(|e| { - deno_core::error::generic_error(format!("IDB open failed: {}", e)) - })?; - - Ok(IdbOpenResult { - needs_upgrade: result.needs_upgrade, - old_version: result.old_version, - object_stores: result.object_stores, - }) -} - -/// Close an IndexedDB database. -#[op2(fast)] -fn op_idb_close(state: &mut OpState, #[string] name: &str) { - let storage = state.borrow::(); - storage.close_database(name); -} - -/// Delete an IndexedDB database. -#[op2(async)] -async fn op_idb_delete_database( - state: Rc>, - #[string] name: String, -) -> Result<(), deno_core::error::AnyError> { - let storage = { - let state = state.borrow(); - state.borrow::().clone() - }; - - storage.delete_database(&name).await.map_err(|e| { - deno_core::error::generic_error(format!("IDB delete database failed: {}", e)) - }) -} - -#[derive(Deserialize)] -struct CreateObjectStoreOptions { - #[serde(rename = "keyPath")] - key_path: Option, - #[serde(rename = "autoIncrement")] - auto_increment: Option, -} - -/// Create an object store. -#[op2] -fn op_idb_create_object_store( - state: &mut OpState, - #[string] db_name: &str, - #[string] store_name: &str, - #[serde] options: CreateObjectStoreOptions, -) -> Result<(), deno_core::error::AnyError> { - let storage = state.borrow::(); - - storage - .create_object_store( - db_name, - store_name, - options.key_path.as_deref(), - options.auto_increment.unwrap_or(false), - ) - .map_err(|e| deno_core::error::generic_error(format!("Create object store failed: {}", e))) -} - -/// Delete an object store. -#[op2(fast)] -fn op_idb_delete_object_store( - state: &mut OpState, - #[string] db_name: &str, - #[string] store_name: &str, -) -> Result<(), deno_core::error::AnyError> { - let storage = state.borrow::(); - - storage - .delete_object_store(db_name, store_name) - .map_err(|e| deno_core::error::generic_error(format!("Delete object store failed: {}", e))) -} - -/// Get a value from an object store. -#[op2(async)] -#[serde] -async fn op_idb_get( - state: Rc>, - #[string] db_name: String, - #[string] store_name: String, - #[serde] key: serde_json::Value, -) -> Result, deno_core::error::AnyError> { - let storage = { - let state = state.borrow(); - state.borrow::().clone() - }; - - storage.get(&db_name, &store_name, &key).await.map_err(|e| { - deno_core::error::generic_error(format!("IDB get failed: {}", e)) - }) -} - -/// Put a value into an object store. -#[op2(async)] -async fn op_idb_put( - state: Rc>, - #[string] db_name: String, - #[string] store_name: String, - #[serde] key: serde_json::Value, - #[serde] value: serde_json::Value, -) -> Result<(), deno_core::error::AnyError> { - let storage = { - let state = state.borrow(); - state.borrow::().clone() - }; - - storage.put(&db_name, &store_name, &key, &value).await.map_err(|e| { - deno_core::error::generic_error(format!("IDB put failed: {}", e)) - }) -} - -/// Delete a value from an object store. -#[op2(async)] -async fn op_idb_delete( - state: Rc>, - #[string] db_name: String, - #[string] store_name: String, - #[serde] key: serde_json::Value, -) -> Result<(), deno_core::error::AnyError> { - let storage = { - let state = state.borrow(); - state.borrow::().clone() - }; - - storage.delete(&db_name, &store_name, &key).await.map_err(|e| { - deno_core::error::generic_error(format!("IDB delete failed: {}", e)) - }) -} - -/// Clear all values from an object store. -#[op2(async)] -async fn op_idb_clear( - state: Rc>, - #[string] db_name: String, - #[string] store_name: String, -) -> Result<(), deno_core::error::AnyError> { - let storage = { - let state = state.borrow(); - state.borrow::().clone() - }; - - storage.clear(&db_name, &store_name).await.map_err(|e| { - deno_core::error::generic_error(format!("IDB clear failed: {}", e)) - }) -} - -/// Get all values from an object store. -#[op2(async)] -#[serde] -async fn op_idb_get_all( - state: Rc>, - #[string] db_name: String, - #[string] store_name: String, - count: Option, -) -> Result, deno_core::error::AnyError> { - let storage = { - let state = state.borrow(); - state.borrow::().clone() - }; - - storage.get_all(&db_name, &store_name, count).await.map_err(|e| { - deno_core::error::generic_error(format!("IDB get_all failed: {}", e)) - }) -} - -/// Get all keys from an object store. -#[op2(async)] -#[serde] -async fn op_idb_get_all_keys( - state: Rc>, - #[string] db_name: String, - #[string] store_name: String, - count: Option, -) -> Result, deno_core::error::AnyError> { - let storage = { - let state = state.borrow(); - state.borrow::().clone() - }; - - storage.get_all_keys(&db_name, &store_name, count).await.map_err(|e| { - deno_core::error::generic_error(format!("IDB get_all_keys failed: {}", e)) - }) -} - -/// Count values in an object store. -#[op2(async)] -async fn op_idb_count( - state: Rc>, - #[string] db_name: String, - #[string] store_name: String, -) -> Result { - let storage = { - let state = state.borrow(); - state.borrow::().clone() - }; - - storage.count(&db_name, &store_name).await.map_err(|e| { - deno_core::error::generic_error(format!("IDB count failed: {}", e)) - }) -} - -// ============================================================================ -// Skill Bridge Ops (db, store, net) -// ============================================================================ - -#[op2] -#[bigint] -fn op_db_exec( - state: &mut OpState, - #[string] sql: &str, - #[string] params_json: Option, -) -> Result { - let storage = state.borrow::(); - let ctx = state.borrow::(); - - let params: Vec = match params_json { - Some(p) => serde_json::from_str(&p).unwrap_or_default(), - None => Vec::new(), - }; - - storage - .skill_db_exec(&ctx.skill_id, sql, ¶ms) - .map(|n| n as i64) - .map_err(|e| deno_core::error::generic_error(e)) -} - -#[op2] -#[string] -fn op_db_get( - state: &mut OpState, - #[string] sql: &str, - #[string] params_json: Option, -) -> Result { - let storage = state.borrow::(); - let ctx = state.borrow::(); - - let params: Vec = match params_json { - Some(p) => serde_json::from_str(&p).unwrap_or_default(), - None => Vec::new(), - }; - - storage - .skill_db_get(&ctx.skill_id, sql, ¶ms) - .map(|v| v.to_string()) - .map_err(|e| deno_core::error::generic_error(e)) -} - -#[op2] -#[string] -fn op_db_all( - state: &mut OpState, - #[string] sql: &str, - #[string] params_json: Option, -) -> Result { - let storage = state.borrow::(); - let ctx = state.borrow::(); - - let params: Vec = match params_json { - Some(p) => serde_json::from_str(&p).unwrap_or_default(), - None => Vec::new(), - }; - - storage - .skill_db_all(&ctx.skill_id, sql, ¶ms) - .map(|v| v.to_string()) - .map_err(|e| deno_core::error::generic_error(e)) -} - -#[op2] -#[string] -fn op_db_kv_get(state: &mut OpState, #[string] key: &str) -> Result { - let storage = state.borrow::(); - let ctx = state.borrow::(); - - storage - .skill_kv_get(&ctx.skill_id, key) - .map(|v| v.to_string()) - .map_err(|e| deno_core::error::generic_error(e)) -} - -#[op2(fast)] -fn op_db_kv_set( - state: &mut OpState, - #[string] key: &str, - #[string] value_json: &str, -) -> Result<(), deno_core::error::AnyError> { - let storage = state.borrow::(); - let ctx = state.borrow::(); - - let value: serde_json::Value = - serde_json::from_str(value_json).unwrap_or(serde_json::Value::Null); - - storage - .skill_kv_set(&ctx.skill_id, key, &value) - .map_err(|e| deno_core::error::generic_error(e)) -} - -#[op2] -#[string] -fn op_store_get(state: &mut OpState, #[string] key: &str) -> Result { - let storage = state.borrow::(); - let ctx = state.borrow::(); - - storage - .skill_store_get(&ctx.skill_id, key) - .map(|v| v.to_string()) - .map_err(|e| deno_core::error::generic_error(e)) -} - -#[op2(fast)] -fn op_store_set( - state: &mut OpState, - #[string] key: &str, - #[string] value_json: &str, -) -> Result<(), deno_core::error::AnyError> { - let storage = state.borrow::(); - let ctx = state.borrow::(); - - let value: serde_json::Value = - serde_json::from_str(value_json).unwrap_or(serde_json::Value::Null); - - storage - .skill_store_set(&ctx.skill_id, key, &value) - .map_err(|e| deno_core::error::generic_error(e)) -} - -#[op2(fast)] -fn op_store_delete(state: &mut OpState, #[string] key: &str) -> Result<(), deno_core::error::AnyError> { - let storage = state.borrow::(); - let ctx = state.borrow::(); - - storage - .skill_store_delete(&ctx.skill_id, key) - .map_err(|e| deno_core::error::generic_error(e)) -} - -#[op2] -#[string] -fn op_store_keys(state: &mut OpState) -> Result { - let storage = state.borrow::(); - let ctx = state.borrow::(); - - storage - .skill_store_keys(&ctx.skill_id) - .map(|keys| serde_json::to_string(&keys).unwrap_or_else(|_| "[]".to_string())) - .map_err(|e| deno_core::error::generic_error(e)) -} - -#[op2] -#[string] -fn op_net_fetch( - #[string] url: &str, - #[string] options_json: &str, -) -> Result { - crate::runtime::bridge::net::http_fetch(url, options_json) - .map_err(|e| deno_core::error::generic_error(e)) -} - -// ============================================================================ -// State Bridge Ops -// ============================================================================ - -/// Get a value from the skill's published state. -#[op2] -#[string] -fn op_state_get(state: &mut OpState, #[string] key: &str) -> Result { - let ctx = state.borrow::(); - - let skill_state = ctx.skill_state.as_ref().ok_or_else(|| { - deno_core::error::generic_error("Skill state not initialized") - })?; - - let published_state = &skill_state.read().published_state; - let value = published_state.get(key).cloned().unwrap_or(serde_json::Value::Null); - Ok(value.to_string()) -} - -/// Set a value in the skill's published state. -#[op2(fast)] -fn op_state_set( - state: &mut OpState, - #[string] key: &str, - #[string] value_json: &str, -) -> Result<(), deno_core::error::AnyError> { - let ctx = state.borrow::(); - - let skill_state = ctx.skill_state.as_ref().ok_or_else(|| { - deno_core::error::generic_error("Skill state not initialized") - })?; - - let value: serde_json::Value = - serde_json::from_str(value_json).unwrap_or(serde_json::Value::Null); - skill_state.write().published_state.insert(key.to_string(), value); - Ok(()) -} - -/// Merge a partial object into the skill's published state. -#[op2(fast)] -fn op_state_set_partial( - state: &mut OpState, - #[string] partial_json: &str, -) -> Result<(), deno_core::error::AnyError> { - let ctx = state.borrow::(); - - let skill_state = ctx.skill_state.as_ref().ok_or_else(|| { - deno_core::error::generic_error("Skill state not initialized") - })?; - - let partial: serde_json::Value = - serde_json::from_str(partial_json).unwrap_or(serde_json::Value::Object(Default::default())); - - if let serde_json::Value::Object(map) = partial { - let mut state_guard = skill_state.write(); - for (k, v) in map { - state_guard.published_state.insert(k, v); - } - } - Ok(()) -} - -// ============================================================================ -// Data Bridge Ops -// ============================================================================ - -/// Read a file from the skill's data directory. -#[op2] -#[string] -fn op_data_read( - state: &mut OpState, - #[string] filename: &str, -) -> Result { - let ctx = state.borrow::(); - - let data_dir = ctx.data_dir.as_ref().ok_or_else(|| { - deno_core::error::generic_error("Data directory not configured") - })?; - - let path = data_dir.join(filename); - - // Prevent path traversal - if !path.starts_with(data_dir) { - return Err(deno_core::error::generic_error("Invalid filename: path traversal")); - } - - std::fs::read_to_string(&path).map_err(|e| { - deno_core::error::generic_error(format!("Failed to read file '{}': {}", filename, e)) - }) -} - -/// Write a file to the skill's data directory. -#[op2(fast)] -fn op_data_write( - state: &mut OpState, - #[string] filename: &str, - #[string] content: &str, -) -> Result<(), deno_core::error::AnyError> { - let ctx = state.borrow::(); - - let data_dir = ctx.data_dir.as_ref().ok_or_else(|| { - deno_core::error::generic_error("Data directory not configured") - })?; - - let path = data_dir.join(filename); - - // Prevent path traversal - if !path.starts_with(data_dir) { - return Err(deno_core::error::generic_error("Invalid filename: path traversal")); - } - - // Ensure parent directories exist - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).map_err(|e| { - deno_core::error::generic_error(format!("Failed to create directory: {}", e)) - })?; - } - - std::fs::write(&path, content).map_err(|e| { - deno_core::error::generic_error(format!("Failed to write file '{}': {}", filename, e)) - }) -} - -// ============================================================================ -// TDLib Ops (telegram skill only) -// ============================================================================ - -/// Check if the current skill is the telegram skill. -fn check_telegram_skill(state: &OpState) -> Result<(), deno_core::error::AnyError> { - let ctx = state.borrow::(); - if ctx.skill_id != "telegram" { - return Err(deno_core::error::generic_error( - "TDLib is only available to the telegram skill", - )); - } - Ok(()) -} - -/// Check if TDLib is available (always true on desktop). -#[op2(fast)] -fn op_tdlib_is_available() -> bool { - true -} - -/// Create a TDLib client with the given data directory. -/// This is synchronous since create_client just spawns a worker thread and returns. -#[op2(fast)] -fn op_tdlib_create_client( - state: &mut OpState, - #[string] data_dir: String, -) -> Result { - // Check skill permission - check_telegram_skill(state)?; - - let path = std::path::PathBuf::from(data_dir); - - crate::services::tdlib::TDLIB_MANAGER - .create_client(path) - .map_err(|e| deno_core::error::generic_error(e)) -} - -/// Send a request to TDLib and wait for the response. -#[op2(async)] -#[serde] -async fn op_tdlib_send( - state: Rc>, - #[serde] request: serde_json::Value, -) -> Result { - // Check skill permission - { - let state = state.borrow(); - check_telegram_skill(&state)?; - } - - crate::services::tdlib::TDLIB_MANAGER - .send(request) - .await - .map_err(|e| deno_core::error::generic_error(e)) -} - -/// Receive the next update from TDLib (with timeout in ms). -#[op2(async)] -#[serde] -async fn op_tdlib_receive( - state: Rc>, - timeout_ms: u32, -) -> Result, deno_core::error::AnyError> { - // Check skill permission - { - let state = state.borrow(); - check_telegram_skill(&state)?; - } - - Ok(crate::services::tdlib::TDLIB_MANAGER.receive(timeout_ms).await) -} - -/// Destroy the TDLib client and clean up resources. -#[op2(async)] -async fn op_tdlib_destroy( - state: Rc>, -) -> Result<(), deno_core::error::AnyError> { - // Check skill permission - { - let state = state.borrow(); - check_telegram_skill(&state)?; - } - - crate::services::tdlib::TDLIB_MANAGER - .destroy() - .await - .map_err(|e| deno_core::error::generic_error(e)) -} - -// ============================================================================ -// Model Ops (local LLM inference) -// ============================================================================ - -/// Check if local model API is available (desktop only). -#[op2(fast)] -fn op_model_is_available() -> bool { - true -} - -/// Get model status (loading, ready, error). -#[op2] -#[serde] -fn op_model_get_status() -> serde_json::Value { - let status = crate::services::llama::LLAMA_MANAGER.get_status(); - serde_json::to_value(status).unwrap_or_default() -} - -/// Generate text from prompt (async, blocking inference on thread pool). -#[op2(async)] -#[string] -async fn op_model_generate( - #[string] prompt: String, - #[serde] config: serde_json::Value, -) -> Result { - let cfg: crate::services::llama::GenerateConfig = - serde_json::from_value(config).unwrap_or_default(); - - crate::services::llama::LLAMA_MANAGER - .generate(&prompt, cfg) - .await - .map_err(|e| deno_core::error::generic_error(e)) -} - -/// Summarize text (async). -#[op2(async)] -#[string] -async fn op_model_summarize( - #[string] text: String, - max_tokens: u32, -) -> Result { - crate::services::llama::LLAMA_MANAGER - .summarize(&text, max_tokens) - .await - .map_err(|e| deno_core::error::generic_error(e)) -} diff --git a/src-tauri/src/services/tdlib_v8/qjs_ops/mod.rs b/src-tauri/src/services/tdlib_v8/qjs_ops/mod.rs new file mode 100644 index 000000000..ba6baec73 --- /dev/null +++ b/src-tauri/src/services/tdlib_v8/qjs_ops/mod.rs @@ -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>, + timer_state: Arc>, + ws_state: Arc>, +) -> 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(()) +} diff --git a/src-tauri/src/services/tdlib_v8/qjs_ops/ops_core.rs b/src-tauri/src/services/tdlib_v8/qjs_ops/ops_core.rs new file mode 100644 index 000000000..b9c128e78 --- /dev/null +++ b/src-tauri/src/services/tdlib_v8/qjs_ops/ops_core.rs @@ -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>) -> 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 { + 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 { + 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 { + 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(()) +} diff --git a/src-tauri/src/services/tdlib_v8/qjs_ops/ops_model.rs b/src-tauri/src/services/tdlib_v8/qjs_ops/ops_model.rs new file mode 100644 index 000000000..90b049844 --- /dev/null +++ b/src-tauri/src/services/tdlib_v8/qjs_ops/ops_model.rs @@ -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 { + 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(()) +} diff --git a/src-tauri/src/services/tdlib_v8/qjs_ops/ops_net.rs b/src-tauri/src/services/tdlib_v8/qjs_ops/ops_net.rs new file mode 100644 index 000000000..33c99f386 --- /dev/null +++ b/src-tauri/src/services/tdlib_v8/qjs_ops/ops_net.rs @@ -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>) -> 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 = 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::(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::(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::, 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 { + crate::runtime::bridge::net::http_fetch(&url, &options_json).map_err(|e| js_err(e)) + }, + ))?; + + Ok(()) +} diff --git a/src-tauri/src/services/tdlib_v8/qjs_ops/ops_state.rs b/src-tauri/src/services/tdlib_v8/qjs_ops/ops_state.rs new file mode 100644 index 000000000..af14eea2f --- /dev/null +++ b/src-tauri/src/services/tdlib_v8/qjs_ops/ops_state.rs @@ -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>, + 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 { + 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 = + 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 { + 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(()) +} diff --git a/src-tauri/src/services/tdlib_v8/qjs_ops/ops_storage.rs b/src-tauri/src/services/tdlib_v8/qjs_ops/ops_storage.rs new file mode 100644 index 000000000..0d2466094 --- /dev/null +++ b/src-tauri/src/services/tdlib_v8/qjs_ops/ops_storage.rs @@ -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 { + 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 { + 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| -> rquickjs::Result { + 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| -> rquickjs::Result { + 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 { + 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| -> rquickjs::Result { + let params: Vec = 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, ¶ms).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| -> rquickjs::Result { + let params: Vec = 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, ¶ms).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| -> rquickjs::Result { + let params: Vec = 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, ¶ms).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 { + 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 { + 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 { + 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(()) +} diff --git a/src-tauri/src/services/tdlib_v8/qjs_ops/ops_tdlib.rs b/src-tauri/src/services/tdlib_v8/qjs_ops/ops_tdlib.rs new file mode 100644 index 000000000..881aca2df --- /dev/null +++ b/src-tauri/src/services/tdlib_v8/qjs_ops/ops_tdlib.rs @@ -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 { + 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::, 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(()) +} diff --git a/src-tauri/src/services/tdlib_v8/qjs_ops/types.rs b/src-tauri/src/services/tdlib_v8/qjs_ops/types.rs new file mode 100644 index 000000000..85b60c376 --- /dev/null +++ b/src-tauri/src/services/tdlib_v8/qjs_ops/types.rs @@ -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, +} + +impl TimerState { + pub fn poll_ready(&mut self) -> Vec { + 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 { + let now = Instant::now(); + self.timers + .values() + .map(|e| e.deadline.saturating_duration_since(now)) + .min() + } +} + +pub fn poll_timers(timer_state: &RwLock) -> (Vec, Option) { + 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, +} + +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, + 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) +} diff --git a/src-tauri/src/services/tdlib_v8/storage.rs b/src-tauri/src/services/tdlib_v8/storage.rs index 4f6b27a21..955c9ffb0 100644 --- a/src-tauri/src/services/tdlib_v8/storage.rs +++ b/src-tauri/src/services/tdlib_v8/storage.rs @@ -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>>>>, + connections: Arc>>>>, /// Database version info (used by IndexedDB emulation). #[allow(dead_code)] versions: Arc>>, @@ -49,7 +49,7 @@ impl IdbStorage { } /// Get or create a database connection. - fn get_connection(&self, db_name: &str) -> Result>, String> { + fn get_connection(&self, db_name: &str) -> Result>, 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 { + pub fn open_database(&self, name: &str, version: u32) -> Result { 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, 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, ) -> 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 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, ) -> 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 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 { + pub fn count(&self, db_name: &str, store_name: &str) -> Result { 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 { 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> = params .iter() @@ -418,7 +418,7 @@ impl IdbStorage { ) -> Result { 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> = params .iter() @@ -463,7 +463,7 @@ impl IdbStorage { ) -> Result { 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> = params .iter() @@ -502,7 +502,7 @@ impl IdbStorage { pub fn skill_kv_get(&self, skill_id: &str, key: &str) -> Result { 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, 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 diff --git a/src-tauri/src/utils/config.rs b/src-tauri/src/utils/config.rs index 4f5823a81..db6f20c3b 100644 --- a/src-tauri/src/utils/config.rs +++ b/src-tauri/src/utils/config.rs @@ -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()); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 11c58d4bd..435f130b5 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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": { diff --git a/src-tauri/tdlib-prebuilt/macos-arm64/libtdjson.1.8.29.dylib b/src-tauri/tdlib-prebuilt/macos-arm64/libtdjson.1.8.29.dylib new file mode 100755 index 000000000..cc98ecb17 Binary files /dev/null and b/src-tauri/tdlib-prebuilt/macos-arm64/libtdjson.1.8.29.dylib differ diff --git a/src-tauri/tdlib-prebuilt/macos-x86_64/libtdjson.1.8.29.dylib b/src-tauri/tdlib-prebuilt/macos-x86_64/libtdjson.1.8.29.dylib new file mode 100755 index 000000000..bc5d599f1 Binary files /dev/null and b/src-tauri/tdlib-prebuilt/macos-x86_64/libtdjson.1.8.29.dylib differ diff --git a/src/store/socketSelectors.ts b/src/store/socketSelectors.ts index 7e9292784..1fc8c1040 100644 --- a/src/store/socketSelectors.ts +++ b/src/store/socketSelectors.ts @@ -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; }; diff --git a/src/utils/tauriSocket.ts b/src/utils/tauriSocket.ts index 2f082216c..b78e22795 100644 --- a/src/utils/tauriSocket.ts +++ b/src/utils/tauriSocket.ts @@ -37,10 +37,14 @@ export async function connectRustSocket(token: string): Promise { 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' })); } }