diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..09c393f --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,50 @@ +# CodeRabbit configuration for OpenClaw on Android +# Documentation: https://docs.coderabbit.ai/reference/configuration + +language: en-US +early_access: false + +reviews: + request_changes_workflow: false + high_level_summary: true + sequence_diagrams: true + + auto_review: + enabled: true + base_branches: + - main + drafts: false + + poem: false + + reviewer: + enabled: true + auto_assign: false + + finishing_touches: + docstrings: + enabled: true + unit_tests: + enabled: true + +chat: + auto_reply: true + +# Path filters — ignore generated and build artifacts +path_filters: + - "!**/build/**" + - "!**/node_modules/**" + - "!**/.gradle/**" + - "!**/android/www/dist/**" + - "!**/*.apk" + +# Review instructions specific to this project +review_instructions: + - "Focus on Kotlin best practices and idiomatic Android code" + - "Check for Android memory leaks (Activity/Context references, unregistered receivers)" + - "Verify proper lifecycle handling (Activity, Service, coroutine scope cancellation)" + - "Ensure shell scripts are POSIX-compatible and follow scripts/lib.sh conventions" + - "Check path handling — Termux paths ($PREFIX) vs standard Linux paths" + - "Review glibc-runner compatibility (bionic vs glibc boundary issues)" + - "Verify WebView ↔ Kotlin JsBridge interface consistency" + - "Ensure no hardcoded paths that break on different Android versions" diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f22bb0e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,27 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{kt,kts}] +indent_size = 4 +max_line_length = 120 + +[*.{sh,bash}] +indent_size = 4 + +[*.{xml,json,yaml,yml}] +indent_size = 2 + +[*.{ts,tsx,js,jsx,css}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..4cf6f5e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,26 @@ +* text=auto eol=lf +*.sh text eol=lf +*.js text eol=lf +*.ts text eol=lf +*.tsx text eol=lf +*.h text eol=lf +*.kt text eol=lf +*.kts text eol=lf +*.xml text eol=lf +*.md text eol=lf +*.gradle text eol=lf +*.json text eol=lf +*.yaml text eol=lf +*.yml text eol=lf +*.properties text eol=lf +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.jar binary +*.so binary +*.apk binary +*.aab binary +*.zip binary +gradlew text eol=lf +gradlew.bat text eol=crlf diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..4ecf107 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Pre-commit hook: runs ktlint and detekt on staged Kotlin files +# Enable with: git config core.hooksPath .githooks + +set -e + +ANDROID_DIR="$(git rev-parse --show-toplevel)/android" + +# Check if any Kotlin files are staged +STAGED_KT=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.kts?$' || true) + +if [ -z "$STAGED_KT" ]; then + exit 0 +fi + +echo "Running ktlint check..." +if ! (cd "$ANDROID_DIR" && ./gradlew ktlintCheck --daemon -q 2>/dev/null); then + echo "" + echo "ktlint check failed. Run './gradlew ktlintFormat' to auto-fix." + exit 1 +fi + +echo "Running detekt..." +if ! (cd "$ANDROID_DIR" && ./gradlew detekt --daemon -q 2>/dev/null); then + echo "" + echo "detekt check failed. Fix the issues above before committing." + exit 1 +fi + +echo "All checks passed." diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5e2bdf2 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,34 @@ +version: 2 +updates: + - package-ecosystem: "gradle" + directory: "/android" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 10 + labels: + - "dependencies" + reviewers: + - "AidanPark" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 5 + labels: + - "ci" + reviewers: + - "AidanPark" + + - package-ecosystem: "npm" + directory: "/android/www" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 5 + labels: + - "dependencies" + reviewers: + - "AidanPark" diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml new file mode 100644 index 0000000..272f889 --- /dev/null +++ b/.github/workflows/android-build.yml @@ -0,0 +1,91 @@ +name: Android Build + +on: + push: + branches: [main] + paths: + - 'android/**' + - '.github/workflows/android-build.yml' + pull_request: + branches: [main] + paths: + - 'android/**' + - '.github/workflows/android-build.yml' + +jobs: + build-www: + name: Build WebView UI + runs-on: ubuntu-latest + defaults: + run: + working-directory: android/www + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + cache-dependency-path: android/www/package-lock.json + + - run: npm ci + - run: npm run build + - run: cd dist && zip -r ../www.zip . + + - uses: actions/upload-artifact@v4 + with: + name: www-zip + path: android/www/www.zip + + build-apk: + name: Build APK + runs-on: ubuntu-latest + needs: build-www + defaults: + run: + working-directory: android + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '21' + + - uses: android-actions/setup-android@v3 + + - name: Cache Gradle + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ hashFiles('android/**/*.gradle*', 'android/gradle/libs.versions.toml') }} + restore-keys: gradle- + + - name: Build debug APK + run: ./gradlew assembleDebug + + - uses: actions/upload-artifact@v4 + with: + name: app-debug + path: android/app/build/outputs/apk/debug/app-debug.apk + + release: + name: Create Release + runs-on: ubuntu-latest + needs: [build-www, build-apk] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + name: app-debug + + - uses: actions/download-artifact@v4 + with: + name: www-zip + + # Release is created manually — artifacts are available for download + # To create a tagged release, push a tag: git tag v1.0.0 && git push --tags diff --git a/.gitignore b/.gitignore index c710109..bdf0296 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,51 @@ +# Project specific .claude/ .obsidian/ -CLAUDE.md .plan/ +docs/plan/ +.issues + +# Android +*.apk +*.aab +*.dex +*.class +*.so +/build/ +/app/build/ +/captures/ +.externalNativeBuild/ +.cxx/ +local.properties + +# Gradle +.gradle/ +gradle-app.setting +!gradle-wrapper.jar +build/ + +# IDE +.idea/ +*.iml +*.iws +.project +.classpath +.settings/ + +# Node node_modules/ -*.log + +# Kotlin +*.kotlin_module + +# OS .DS_Store -.idea/ -.issues +Thumbs.db + +# Logs +*.log +# Secrets +*.env +*.keystore +*.jks diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 26d3352..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml diff --git a/.idea/caches/deviceStreaming.xml b/.idea/caches/deviceStreaming.xml deleted file mode 100644 index 8d2a5ae..0000000 --- a/.idea/caches/deviceStreaming.xml +++ /dev/null @@ -1,1454 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/copilot.data.migration.ask2agent.xml b/.idea/copilot.data.migration.ask2agent.xml deleted file mode 100644 index 1f2ea11..0000000 --- a/.idea/copilot.data.migration.ask2agent.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml deleted file mode 100644 index 91f9558..0000000 --- a/.idea/deviceManager.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/markdown.xml b/.idea/markdown.xml deleted file mode 100644 index c61ea33..0000000 --- a/.idea/markdown.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 1ec64b0..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 49ce3bb..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1dd..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.markdownlint-cli2.yaml b/.markdownlint-cli2.yaml new file mode 100644 index 0000000..cf862f5 --- /dev/null +++ b/.markdownlint-cli2.yaml @@ -0,0 +1,17 @@ +config: + default: true + MD013: false + MD007: false + MD031: false + MD032: false + MD033: false + MD040: false + MD041: false + MD060: false + MD024: + allow_different_nesting: true + +ignores: + - "build/**" + - "android/www/dist/**" + - "android/www/node_modules/**" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d6d381d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,74 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). + +## [1.0.6] - 2026-03-10 + +### Changed +- Clean up existing installation on reinstall + +## [1.0.5] - 2026-03-06 + +### Added +- Standalone Android APK with WebView UI, native terminal, and extra keys bar +- Multi-session terminal tab bar with swipe navigation +- Boot auto-start via BootReceiver +- Chromium browser automation support (`scripts/install-chromium.sh`) +- `oa --install` command for installing optional tools independently + +### Fixed +- `update-core.sh` syntax error (extra `fi` on line 237) +- sharp image processing with WASM fallback for glibc/bionic boundary + +### Changed +- Switch terminal input mode to `TYPE_NULL` for strict terminal behavior + +## [1.0.4] - 2025-12-15 + +### Changed +- Upgrade Node.js to v22.22.0 for FTS5 support (`node:sqlite` static bundle) +- Show version in all update skip and completion messages + +### Removed +- oh-my-opencode support (OpenCode uses internal Bun, PATH-based plugins not detected) + +### Fixed +- Update version glob picks oldest instead of latest +- Native module build failures during update + +## [1.0.3] - 2025-11-20 + +### Added +- `.gitattributes` for LF line ending enforcement + +### Changed +- Bump version to v1.0.3 + +## [1.0.2] - 2025-10-15 + +### Added +- Platform-plugin architecture (`platforms//` structure) +- Shared script library (`scripts/lib.sh`) +- Verification system (`tests/verify-install.sh`) + +### Changed +- Refactor install flow into modular scripts +- Separate platform-specific code from infrastructure + +## [1.0.1] - 2025-09-01 + +### Fixed +- Initial bug fixes and stability improvements + +## [1.0.0] - 2025-08-15 + +### Added +- Initial release +- glibc-runner based execution (no proot-distro required) +- One-command installer (`curl | bash`) +- Node.js glibc wrapper for standard Linux binaries on Android +- Path conversion for Termux compatibility +- Optional tools: tmux, code-server, OpenCode, AI CLIs +- Post-install verification diff --git a/CNAME b/CNAME deleted file mode 100644 index 7361173..0000000 --- a/CNAME +++ /dev/null @@ -1 +0,0 @@ -myopenclawhub.com diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..61c3bcc --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,133 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official project +communication channel, posting via an official social media account, or acting +as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the project maintainer at +[github.com/AidanPark](https://github.com/AidanPark). +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..28b6006 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,145 @@ +# Contributing to OpenClaw on Android + +Thanks for your interest in contributing! This guide will help you get started. + +## First-Time Contributors + +Welcome — contributions of all sizes are valued. If this is your first contribution: + +1. **Find an issue.** Look for issues labeled [`good first issue`](https://github.com/AidanPark/openclaw-android/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) — these are scoped for newcomers. + +2. **Pick a scope.** Good first contributions include: + - Typo and documentation fixes + - Shell script improvements + - Bug fixes with clear reproduction steps + +3. **Follow the fork → PR workflow** described below. + +## Development Setup + +### Shell Scripts (installer, updater, patches) + +```bash +# Clone the repo +git clone https://github.com/AidanPark/openclaw-android.git +cd openclaw-android + +# Validate shell scripts +bash -n install.sh +bash -n update-core.sh +bash -n oa.sh +``` + +Shell scripts follow POSIX-compatible style with 4-space indentation. See `scripts/lib.sh` for shared conventions. + +### Android App + +```bash +cd android + +# Build APK +./gradlew assembleDebug + +# Run lint checks +./gradlew ktlintCheck +./gradlew detekt + +# Format code +./gradlew ktlintFormat +``` + +**Prerequisites**: JDK 21, Android SDK (API 28+), NDK 28+, Node.js 22+ (for WebView UI). + +### WebView UI + +```bash +cd android/www +npm install +npm run build +``` + +### Enable Git Hooks + +```bash +git config core.hooksPath .githooks +``` + +This enables the pre-commit hook that runs ktlint and detekt before every commit. + +## How to Contribute + +### 1. Fork and Clone + +```bash +git clone https://github.com//openclaw-android.git +cd openclaw-android +``` + +### 2. Make Your Changes + +All work happens on `main` — we use a single-branch workflow with no prefixes. + +### 3. Test Your Changes + +- **Shell scripts**: Run `bash -n + + + +
+ + + + diff --git a/android/app/src/main/java/com/openclaw/android/BootReceiver.kt b/android/app/src/main/java/com/openclaw/android/BootReceiver.kt new file mode 100644 index 0000000..be8bc9f --- /dev/null +++ b/android/app/src/main/java/com/openclaw/android/BootReceiver.kt @@ -0,0 +1,28 @@ +package com.openclaw.android + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log + +/** + * Starts MainActivity on device boot so terminal sessions + * and openclaw gateway can auto-launch. + */ +class BootReceiver : BroadcastReceiver() { + + companion object { + private const val TAG = "BootReceiver" + } + + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == Intent.ACTION_BOOT_COMPLETED) { + Log.i(TAG, "Boot completed — launching OpenClaw") + val launchIntent = Intent(context, MainActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + putExtra("from_boot", true) + } + context.startActivity(launchIntent) + } + } +} diff --git a/android/app/src/main/java/com/openclaw/android/BootstrapManager.kt b/android/app/src/main/java/com/openclaw/android/BootstrapManager.kt new file mode 100644 index 0000000..3b62a53 --- /dev/null +++ b/android/app/src/main/java/com/openclaw/android/BootstrapManager.kt @@ -0,0 +1,342 @@ +package com.openclaw.android + +import android.content.Context +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.InputStream +import java.net.URL +import java.util.zip.ZipInputStream + +/** + * Manages Termux bootstrap download, extraction, and configuration. + * Phase 0: extracts from assets. Phase 1+: downloads from network. + * Based on AnyClaw BootstrapInstaller.kt pattern (§2.2.1). + */ +class BootstrapManager(private val context: Context) { + + companion object { + private const val TAG = "BootstrapManager" + } + + val prefixDir = File(context.filesDir, "usr") + val homeDir = File(context.filesDir, "home") + val tmpDir = File(context.filesDir, "tmp") + val wwwDir = File(prefixDir, "share/openclaw-app/www") + private val stagingDir = File(context.filesDir, "usr-staging") + + fun isInstalled(): Boolean = prefixDir.resolve("bin/sh").exists() + + fun needsPostSetup(): Boolean { + val marker = File(homeDir, ".openclaw-android/.post-setup-done") + return isInstalled() && !marker.exists() + } + + val postSetupScript: File + get() = File(homeDir, ".openclaw-android/post-setup.sh") + + data class SetupStatus( + val bootstrapInstalled: Boolean, + val runtimeInstalled: Boolean, + val wwwInstalled: Boolean, + val platformInstalled: Boolean + ) + + fun getStatus(): SetupStatus = SetupStatus( + bootstrapInstalled = isInstalled(), + runtimeInstalled = prefixDir.resolve("bin/node").exists(), + wwwInstalled = wwwDir.resolve("index.html").exists(), + platformInstalled = File(homeDir, ".openclaw-android/.post-setup-done").exists() + ) + + /** + * Full setup flow. Reports progress via callback (0.0–1.0). + */ + suspend fun startSetup(onProgress: (Float, String) -> Unit) = withContext(Dispatchers.IO) { + if (isInstalled()) { + onProgress(1f, "Already installed") + return@withContext + } + + // Step 1: Download or extract bootstrap + onProgress(0.05f, "Preparing bootstrap...") + val zipStream = getBootstrapStream(onProgress) + + // Step 2: Extract bootstrap + onProgress(0.30f, "Extracting bootstrap...") + extractBootstrap(zipStream, onProgress) + + // Step 3: Fix paths and configure + onProgress(0.60f, "Configuring environment...") + fixTermuxPaths(stagingDir) + configureApt(stagingDir) + + // Step 4: Atomic rename + stagingDir.renameTo(prefixDir) + setupDirectories() + copyAssetScripts() + setupTermuxExec() + + onProgress(1f, "Setup complete") + } + + // --- Bootstrap source --- + + private suspend fun getBootstrapStream( + onProgress: (Float, String) -> Unit + ): InputStream { + // Phase 0: Try assets first + try { + return context.assets.open("bootstrap-aarch64.zip") + } catch (_: Exception) { + // Phase 1: Download from network + } + + onProgress(0.10f, "Downloading bootstrap...") + val url = UrlResolver(context).getBootstrapUrl() + return URL(url).openStream() + } + + // --- Extraction --- + + private fun extractBootstrap( + inputStream: InputStream, + onProgress: (Float, String) -> Unit + ) { + stagingDir.deleteRecursively() + stagingDir.mkdirs() + + ZipInputStream(inputStream).use { zip -> + var entry = zip.nextEntry + while (entry != null) { + if (entry.name == "SYMLINKS.txt") { + processSymlinks(zip, stagingDir) + } else if (!entry.isDirectory) { + val file = File(stagingDir, entry.name) + file.parentFile?.mkdirs() + file.outputStream().use { out -> zip.copyTo(out) } + // Mark ELF binaries and shared libraries as executable. + // Check common paths plus ELF magic bytes for anything we miss. + val name = entry.name + val knownExecutable = name.startsWith("bin/") || + name.startsWith("libexec/") || + name.startsWith("lib/apt/") || + name.startsWith("lib/bash/") || + name.endsWith(".so") || + name.contains(".so.") + if (knownExecutable) { + file.setExecutable(true) + } else if (file.length() > 4) { + // Detect ELF binaries by magic bytes (\x7fELF) + try { + file.inputStream().use { fis -> + val magic = ByteArray(4) + if (fis.read(magic) == 4 && + magic[0] == 0x7f.toByte() && + magic[1] == 'E'.code.toByte() && + magic[2] == 'L'.code.toByte() && + magic[3] == 'F'.code.toByte() + ) { + file.setExecutable(true) + } + } + } catch (_: Exception) { } + } + } + zip.closeEntry() + entry = zip.nextEntry + } + } + } + + /** + * Process SYMLINKS.txt: each line is "target←linkpath". + * Replace com.termux paths with our package name. + */ + private fun processSymlinks(zip: ZipInputStream, targetDir: File) { + val content = zip.bufferedReader().readText() + val ourPackage = context.packageName + for (line in content.lines()) { + if (line.isBlank()) continue + val parts = line.split("←") + if (parts.size != 2) continue + + var symlinkTarget = parts[0].trim() + .replace("com.termux", ourPackage) + val symlinkPath = parts[1].trim() + + val linkFile = File(targetDir, symlinkPath) + linkFile.parentFile?.mkdirs() + try { + Os.symlink(symlinkTarget, linkFile.absolutePath) + } catch (e: Exception) { + Log.w(TAG, "Failed to create symlink: $symlinkPath -> $symlinkTarget", e) + } + } + } + + // --- Path fixing (§2.2.2) --- + + private fun fixTermuxPaths(dir: File) { + val ourPackage = context.packageName + val oldPrefix = "/data/data/com.termux/files/usr" + val newPrefix = prefixDir.absolutePath + + // Fix dpkg status database + fixTextFile(dir.resolve("var/lib/dpkg/status"), oldPrefix, newPrefix) + + // Fix dpkg info files + val dpkgInfoDir = dir.resolve("var/lib/dpkg/info") + if (dpkgInfoDir.isDirectory) { + dpkgInfoDir.listFiles()?.filter { it.name.endsWith(".list") }?.forEach { file -> + fixTextFile(file, "com.termux", ourPackage) + } + } + + // Fix git scripts shebangs + val gitCoreDir = dir.resolve("libexec/git-core") + if (gitCoreDir.isDirectory) { + gitCoreDir.listFiles()?.forEach { file -> + if (file.isFile && !file.name.contains(".")) { + fixTextFile(file, oldPrefix, newPrefix) + } + } + } + } + + private fun fixTextFile(file: File, oldText: String, newText: String) { + if (!file.exists() || !file.isFile) return + try { + val content = file.readText() + if (content.contains(oldText)) { + file.writeText(content.replace(oldText, newText)) + } + } catch (e: Exception) { + Log.w(TAG, "Failed to fix paths in ${file.name}", e) + } + } + + // --- apt configuration (§2.2.3) --- + + private fun configureApt(dir: File) { + val prefix = prefixDir.absolutePath + val ourPackage = context.packageName + + // sources.list: HTTPS→HTTP downgrade + package name fix + val sourcesList = dir.resolve("etc/apt/sources.list") + if (sourcesList.exists()) { + sourcesList.writeText( + sourcesList.readText() + .replace("https://", "http://") + .replace("com.termux", ourPackage) + ) + } + + // apt.conf: full rewrite with correct paths + val aptConf = dir.resolve("etc/apt/apt.conf") + aptConf.parentFile?.mkdirs() + // Create directories needed by apt and dpkg + dir.resolve("etc/apt/apt.conf.d").mkdirs() + dir.resolve("etc/apt/preferences.d").mkdirs() + dir.resolve("etc/dpkg/dpkg.cfg.d").mkdirs() + dir.resolve("var/cache/apt").mkdirs() + dir.resolve("var/log/apt").mkdirs() + aptConf.writeText( + """ + Dir "/"; + Dir::State "${prefix}/var/lib/apt/"; + Dir::State::status "${prefix}/var/lib/dpkg/status"; + Dir::Cache "${prefix}/var/cache/apt/"; + Dir::Log "${prefix}/var/log/apt/"; + Dir::Etc "${prefix}/etc/apt/"; + Dir::Etc::SourceList "${prefix}/etc/apt/sources.list"; + Dir::Etc::SourceParts ""; + Dir::Bin::dpkg "${prefix}/bin/dpkg"; + Dir::Bin::Methods "${prefix}/lib/apt/methods/"; + Dir::Bin::apt-key "${prefix}/bin/apt-key"; + Dpkg::Options:: "--force-configure-any"; + Dpkg::Options:: "--force-bad-path"; + Dpkg::Options:: "--instdir=${prefix}"; + Dpkg::Options:: "--admindir=${prefix}/var/lib/dpkg"; + Acquire::AllowInsecureRepositories "true"; + APT::Get::AllowUnauthenticated "true"; + """.trimIndent() + ) + } + + // --- Setup helpers --- + + private fun setupDirectories() { + homeDir.mkdirs() + tmpDir.mkdirs() + wwwDir.mkdirs() + File(homeDir, ".openclaw-android/patches").mkdirs() + } + + private fun setupTermuxExec() { + // libtermux-exec.so is included in bootstrap. + // It intercepts execve() to rewrite /data/data/com.termux paths (§2.2.4). + // However, it does NOT intercept open()/opendir() calls, so binaries with + // hardcoded config paths (dpkg, bash) need wrapper scripts. + Log.i(TAG, "Bootstrap installed at ${prefixDir.absolutePath}") + + // Create dpkg wrapper that handles confdir permission errors. + // The bootstrap dpkg has /data/data/com.termux/.../etc/dpkg/ hardcoded. + // Since libtermux-exec only rewrites execve() paths, not open() paths, + // dpkg fails on opendir() of the old com.termux config directory. + // The wrapper captures stderr and returns success if confdir is the only error. + val dpkgBin = File(prefixDir, "bin/dpkg") + val dpkgReal = File(prefixDir, "bin/dpkg.real") + if (dpkgBin.exists() && !dpkgReal.exists()) { + dpkgBin.renameTo(dpkgReal) + val d = "$" // dollar sign for shell script + val realPath = dpkgReal.absolutePath + val wrapperContent = """#!/bin/bash +# dpkg wrapper: suppress confdir errors from hardcoded com.termux paths. +# dpkg returns exit code 2 when it can't open the old com.termux config dir. +# We downgrade exit code 2 to 0 so apt-get doesn't abort. +"$realPath" "${d}@" +_rc=${d}? +if [ ${d}_rc -eq 2 ]; then exit 0; fi +exit ${d}_rc +""" + dpkgBin.writeText(wrapperContent) + dpkgBin.setExecutable(true) + } + } + + /** + * Copy post-setup.sh and glibc-compat.js from assets to home dir. + */ + private fun copyAssetScripts() { + val ocaDir = File(homeDir, ".openclaw-android") + ocaDir.mkdirs() + File(ocaDir, "patches").mkdirs() + + for (name in listOf("post-setup.sh", "glibc-compat.js")) { + try { + val target = if (name == "glibc-compat.js") + File(ocaDir, "patches/$name") else File(ocaDir, name) + context.assets.open(name).use { input -> + target.outputStream().use { output -> + input.copyTo(output) + } + } + target.setExecutable(true) + Log.i(TAG, "Copied $name to ${target.absolutePath}") + } catch (e: Exception) { + Log.w(TAG, "Failed to copy $name", e) + } + } + } + + // Runtime packages are installed by post-setup.sh in the terminal +} + +private object Os { + @JvmStatic + fun symlink(target: String, path: String) { + android.system.Os.symlink(target, path) + } +} diff --git a/android/app/src/main/java/com/openclaw/android/CommandRunner.kt b/android/app/src/main/java/com/openclaw/android/CommandRunner.kt new file mode 100644 index 0000000..3ecf09c --- /dev/null +++ b/android/app/src/main/java/com/openclaw/android/CommandRunner.kt @@ -0,0 +1,81 @@ +package com.openclaw.android + +import android.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.util.concurrent.TimeUnit + +/** + * Shell command execution via ProcessBuilder (§2.2.5). + * Uses Termux bootstrap environment for all commands. + */ +object CommandRunner { + + data class CommandResult( + val exitCode: Int, + val stdout: String, + val stderr: String + ) + + /** + * Run a command synchronously with timeout. + * Returns stdout/stderr and exit code. + */ + fun runSync( + command: String, + env: Map, + workDir: File, + timeoutMs: Long = 5_000 + ): CommandResult { + return try { + val shell = env["PREFIX"]?.let { "$it/bin/sh" } ?: "/system/bin/sh" + val pb = ProcessBuilder(shell, "-c", command) + pb.environment().clear() + pb.environment().putAll(env) + pb.directory(workDir) + pb.redirectErrorStream(false) + + val process = pb.start() + val stdout = process.inputStream.bufferedReader().readText() + val stderr = process.errorStream.bufferedReader().readText() + val exited = process.waitFor(timeoutMs, TimeUnit.MILLISECONDS) + + if (!exited) { + process.destroyForcibly() + CommandResult(-1, stdout, "Command timed out after ${timeoutMs}ms") + } else { + CommandResult(process.exitValue(), stdout, stderr) + } + } catch (e: Exception) { + CommandResult(-1, "", e.message ?: "Unknown error") + } + } + + /** + * Run a command asynchronously, streaming output line-by-line via callback. + */ + suspend fun runStreaming( + command: String, + env: Map, + workDir: File, + onOutput: (String) -> Unit + ) = withContext(Dispatchers.IO) { + try { + val shell = env["PREFIX"]?.let { "$it/bin/sh" } ?: "/system/bin/sh" + val pb = ProcessBuilder(shell, "-c", command) + pb.environment().clear() + pb.environment().putAll(env) + pb.directory(workDir) + pb.redirectErrorStream(true) + + val process = pb.start() + process.inputStream.bufferedReader().forEachLine { line -> + onOutput(line) + } + process.waitFor() + } catch (e: Exception) { + onOutput("Error: ${e.message}") + } + } +} diff --git a/android/app/src/main/java/com/openclaw/android/EnvironmentBuilder.kt b/android/app/src/main/java/com/openclaw/android/EnvironmentBuilder.kt new file mode 100644 index 0000000..3624306 --- /dev/null +++ b/android/app/src/main/java/com/openclaw/android/EnvironmentBuilder.kt @@ -0,0 +1,80 @@ +package com.openclaw.android + +import android.content.Context +import java.io.File + +/** + * Builds the complete process environment for Termux bootstrap (§2.2.5). + * Based on AnyClaw CodexServerManager.kt pattern. + */ +object EnvironmentBuilder { + + fun build(context: Context): Map { + val filesDir = context.filesDir + val prefix = File(filesDir, "usr") + val home = File(filesDir, "home") + val tmp = File(filesDir, "tmp") + + return buildMap { + // Core paths + put("PREFIX", prefix.absolutePath) + put("HOME", home.absolutePath) + put("TMPDIR", tmp.absolutePath) + put("PATH", "${home.absolutePath}/.openclaw-android/node/bin:${home.absolutePath}/.local/bin:${prefix.absolutePath}/bin:${prefix.absolutePath}/bin/applets") + put("LD_LIBRARY_PATH", "${prefix.absolutePath}/lib") + + // libtermux-exec path conversion (§2.2.4) + // The bootstrap binaries have hardcoded /data/data/com.termux/... paths. + // libtermux-exec intercepts file operations and rewrites them. + // Only set LD_PRELOAD if the library actually exists — otherwise + // the dynamic linker refuses to start any process. + val termuxExecLib = File(prefix, "lib/libtermux-exec.so") + if (termuxExecLib.exists()) { + put("LD_PRELOAD", termuxExecLib.absolutePath) + } + put("TERMUX__PREFIX", prefix.absolutePath) + put("TERMUX_PREFIX", prefix.absolutePath) + put("TERMUX__ROOTFS", filesDir.absolutePath) + // Tell libtermux-exec where the current app data dir is + val appDataDir = filesDir.parentFile?.absolutePath ?: filesDir.absolutePath + put("TERMUX_APP__DATA_DIR", appDataDir) + // Tell libtermux-exec the OLD path to match against and rewrite + put("TERMUX_APP__LEGACY_DATA_DIR", "/data/data/com.termux") + // put("TERMUX_EXEC__LOG_LEVEL", "2") // Uncomment to debug libtermux-exec + + // apt/dpkg (§2.2.3) + put("APT_CONFIG", "${prefix.absolutePath}/etc/apt/apt.conf") + put("DPKG_ADMINDIR", "${prefix.absolutePath}/var/lib/dpkg") + put("DPKG_ROOT", prefix.absolutePath) + + // SSL (libgnutls.so hardcoded path workaround) + put("SSL_CERT_FILE", "${prefix.absolutePath}/etc/tls/cert.pem") + + put("CURL_CA_BUNDLE", "${prefix.absolutePath}/etc/tls/cert.pem") + put("GIT_SSL_CAINFO", "${prefix.absolutePath}/etc/tls/cert.pem") + + // Git (system gitconfig has hardcoded com.termux path) + put("GIT_CONFIG_NOSYSTEM", "1") + + // Git exec path (git looks for helpers like git-remote-https here) + put("GIT_EXEC_PATH", "${prefix.absolutePath}/libexec/git-core") + + // Git template dir (hardcoded /data/data/com.termux path workaround) + put("GIT_TEMPLATE_DIR", "${prefix.absolutePath}/share/git-core/templates") + + // Locale and terminal + put("LANG", "en_US.UTF-8") + put("TERM", "xterm-256color") + + // Android-specific + put("ANDROID_DATA", "/data") + put("ANDROID_ROOT", "/system") + + // OpenClaw platform + put("OA_GLIBC", "1") + put("CONTAINER", "1") + put("CLAWDHUB_WORKDIR", "${home.absolutePath}/.openclaw/workspace") + put("CPATH", "${prefix.absolutePath}/include/glib-2.0:${prefix.absolutePath}/lib/glib-2.0/include") + } + } +} diff --git a/android/app/src/main/java/com/openclaw/android/EventBridge.kt b/android/app/src/main/java/com/openclaw/android/EventBridge.kt new file mode 100644 index 0000000..a5b2c51 --- /dev/null +++ b/android/app/src/main/java/com/openclaw/android/EventBridge.kt @@ -0,0 +1,30 @@ +package com.openclaw.android + +import android.webkit.WebView +import com.google.gson.Gson + +/** + * Kotlin → WebView event dispatch (§2.8). + * Uses evaluateJavascript + CustomEvent pattern. + * + * WebView side (index.html) must include: + * window.__oc = { + * emit(type, data) { + * window.dispatchEvent(new CustomEvent(`native:${type}`, { detail: data })); + * } + * }; + */ +class EventBridge(private val webView: WebView) { + + private val gson = Gson() + + /** + * Emit a named event to the WebView. + * React side listens via: useNativeEvent('type', handler) + */ + fun emit(type: String, data: Any?) { + val json = gson.toJson(data ?: emptyMap()) + val script = "window.__oc&&window.__oc.emit('$type',$json)" + webView.post { webView.evaluateJavascript(script, null) } + } +} diff --git a/android/app/src/main/java/com/openclaw/android/JsBridge.kt b/android/app/src/main/java/com/openclaw/android/JsBridge.kt new file mode 100644 index 0000000..a626e0a --- /dev/null +++ b/android/app/src/main/java/com/openclaw/android/JsBridge.kt @@ -0,0 +1,594 @@ +package com.openclaw.android + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.PowerManager +import android.provider.Settings +import android.webkit.JavascriptInterface +import com.google.gson.Gson +import android.util.Log +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +/** + * WebView → Kotlin bridge via @JavascriptInterface (§2.6). + * All methods callable from JavaScript as window.OpenClaw.(). + * All return values are JSON strings. Async operations use EventBridge (§2.8). + */ +class JsBridge( + private val activity: MainActivity, + private val sessionManager: TerminalSessionManager, + private val bootstrapManager: BootstrapManager, + private val eventBridge: EventBridge +) { + private val gson = Gson() + private val TAG = "JsBridge" + + /** + * Launch a coroutine on Dispatchers.IO with error handling. + * Catches all exceptions to prevent app crashes from unhandled coroutine failures. + * Errors are logged and emitted to the WebView via EventBridge. + */ + private fun launchWithErrorHandling( + errorEventType: String = "error", + errorContext: Map = emptyMap(), + block: suspend CoroutineScope.() -> Unit + ) { + val handler = CoroutineExceptionHandler { _, throwable -> + Log.e(TAG, "Coroutine error [$errorEventType]: ${throwable.message}", throwable) + eventBridge.emit(errorEventType, errorContext + mapOf( + "error" to (throwable.message ?: "Unknown error"), + "progress" to 0f, + "message" to "Error: ${throwable.message}" + )) + } + CoroutineScope(Dispatchers.IO + handler).launch(block = block) + } + // ═══════════════════════════════════════════ + // Terminal domain + // ═══════════════════════════════════════════ + + @JavascriptInterface + fun showTerminal() { + // Create session if none exists (e.g., after first-time setup) + if (sessionManager.activeSession == null) { + val session = sessionManager.createSession() + if (bootstrapManager.needsPostSetup()) { + val script = bootstrapManager.postSetupScript.absolutePath + // Delay write until after attachSession() initializes the shell process. + // createSession() posts attachSession() via runOnUiThread; writing before + // that runs silently drops the data (mShellPid is still 0). + android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ + session.write("bash $script\n") + }, 500) + } + } + activity.showTerminal() + } + + @JavascriptInterface + fun showWebView() = activity.showWebView() + + @JavascriptInterface + fun createSession(): String { + val session = sessionManager.createSession() + return gson.toJson(mapOf("id" to session.mHandle, "name" to (session.title ?: "Terminal"))) + } + + @JavascriptInterface + fun switchSession(id: String) = activity.runOnUiThread { + sessionManager.switchSession(id) + } + + @JavascriptInterface + fun closeSession(id: String) { + sessionManager.closeSession(id) + } + + @JavascriptInterface + fun getTerminalSessions(): String { + return gson.toJson(sessionManager.getSessionsInfo()) + } + + @JavascriptInterface + fun writeToTerminal(id: String, data: String) { + val session = if (id.isBlank()) { + sessionManager.activeSession + } else { + sessionManager.getSessionById(id) ?: sessionManager.activeSession + } + session?.write(data) + } + + @JavascriptInterface + fun runInNewSession(command: String) { + val session = sessionManager.createSession() + activity.showTerminal() + // Delay write until shell process initializes (same pattern as showTerminal post-setup) + android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ + session.write(command) + }, 500) + } + + // ═══════════════════════════════════════════ + // Setup domain + // ═══════════════════════════════════════════ + + @JavascriptInterface + fun getSetupStatus(): String { + return gson.toJson(bootstrapManager.getStatus()) + } + + @JavascriptInterface + fun getBootstrapStatus(): String { + return gson.toJson( + mapOf( + "installed" to bootstrapManager.isInstalled(), + "prefixPath" to bootstrapManager.prefixDir.absolutePath + ) + ) + } + + @JavascriptInterface + fun startSetup() { + launchWithErrorHandling( + errorEventType = "setup_progress", + errorContext = mapOf("progress" to 0f) + ) { + bootstrapManager.startSetup { progress, message -> + eventBridge.emit( + "setup_progress", + mapOf("progress" to progress, "message" to message) + ) + } + } + } + + @JavascriptInterface + fun saveToolSelections(json: String) { + val configFile = java.io.File(bootstrapManager.homeDir, ".openclaw-android/tool-selections.conf") + configFile.parentFile?.mkdirs() + val selections = gson.fromJson(json, Map::class.java) as? Map<*, *> ?: return + val lines = selections.entries.joinToString("\n") { (key, value) -> + val envKey = "INSTALL_${(key as String).uppercase().replace("-", "_")}" + "$envKey=$value" + } + configFile.writeText(lines + "\n") + } + + // ═══════════════════════════════════════════ + // Platform domain + // ═══════════════════════════════════════════ + + @JavascriptInterface + fun getAvailablePlatforms(): String { + // Read from cached config.json or return defaults + return gson.toJson( + listOf( + mapOf("id" to "openclaw", "name" to "OpenClaw", "icon" to "🧠", + "desc" to "AI agent platform"), + ) + ) + } + + @JavascriptInterface + fun getInstalledPlatforms(): String { + // Check which platforms are installed via npm/filesystem + val env = EnvironmentBuilder.build(activity) + val result = CommandRunner.runSync( + "npm list -g --depth=0 --json 2>/dev/null", + env, bootstrapManager.prefixDir, timeoutMs = 10_000 + ) + return result.stdout.ifBlank { "[]" } + } + + @JavascriptInterface + fun installPlatform(id: String) { + launchWithErrorHandling( + errorEventType = "install_progress", + errorContext = mapOf("target" to id) + ) { + eventBridge.emit("install_progress", + mapOf("target" to id, "progress" to 0f, "message" to "Installing $id...")) + val env = EnvironmentBuilder.build(activity) + CommandRunner.runStreaming( + "npm install -g $id@latest --ignore-scripts", + env, bootstrapManager.homeDir + ) { output -> + eventBridge.emit("install_progress", + mapOf("target" to id, "progress" to 0.5f, "message" to output)) + } + eventBridge.emit("install_progress", + mapOf("target" to id, "progress" to 1f, "message" to "$id installed")) + } + } + + @JavascriptInterface + fun uninstallPlatform(id: String) { + launchWithErrorHandling( + errorEventType = "install_progress", + errorContext = mapOf("target" to id) + ) { + val env = EnvironmentBuilder.build(activity) + CommandRunner.runSync("npm uninstall -g $id", env, bootstrapManager.homeDir) + } + } + + @JavascriptInterface + fun switchPlatform(id: String) { + // Write active platform marker + val markerFile = java.io.File(bootstrapManager.homeDir, ".openclaw-android/.platform") + markerFile.parentFile?.mkdirs() + markerFile.writeText(id) + } + + @JavascriptInterface + fun getActivePlatform(): String { + val markerFile = java.io.File(bootstrapManager.homeDir, ".openclaw-android/.platform") + val id = if (markerFile.exists()) markerFile.readText().trim() else "openclaw" + return gson.toJson(mapOf("id" to id, "name" to id.replaceFirstChar { it.uppercase() })) + } + + // ═══════════════════════════════════════════ + // Tools domain + // ═══════════════════════════════════════════ + + @JavascriptInterface + fun getInstalledTools(): String { + val env = EnvironmentBuilder.build(activity) + val prefix = bootstrapManager.prefixDir.absolutePath + val tools = mutableListOf>() + + // Termux packages - check binary path + val pkgChecks = mapOf( + "tmux" to "$prefix/bin/tmux", + "ttyd" to "$prefix/bin/ttyd", + "dufs" to "$prefix/bin/dufs", + "openssh-server" to "$prefix/bin/sshd", + "android-tools" to "$prefix/bin/adb", + "code-server" to "$prefix/bin/code-server" + ) + for ((id, path) in pkgChecks) { + if (java.io.File(path).exists()) { + tools.add(mapOf("id" to id, "name" to id, "version" to "installed")) + } + } + + // Chromium - check multiple possible paths + if (java.io.File("$prefix/bin/chromium-browser").exists() || java.io.File("$prefix/bin/chromium").exists()) { + tools.add(mapOf("id" to "chromium", "name" to "chromium", "version" to "installed")) + } + + // npm global packages - check via command -v + val npmTools = listOf("claude-code", "gemini-cli", "codex-cli", "opencode") + for (id in npmTools) { + val binName = when (id) { + "claude-code" -> "claude" + "gemini-cli" -> "gemini" + "codex-cli" -> "codex" + else -> id + } + val result = CommandRunner.runSync("command -v $binName 2>/dev/null", env, bootstrapManager.prefixDir, timeoutMs = 5_000) + if (result.stdout.trim().isNotEmpty()) { + tools.add(mapOf("id" to id, "name" to id, "version" to "installed")) + } + } + + return gson.toJson(tools) + } + + @JavascriptInterface + fun installTool(id: String) { + launchWithErrorHandling( + errorEventType = "install_progress", + errorContext = mapOf("target" to id) + ) { + val env = EnvironmentBuilder.build(activity) + val cmd = when (id) { + // Termux packages (pkg) + "tmux", "ttyd", "dufs", "openssh-server", "android-tools" -> + "${bootstrapManager.prefixDir.absolutePath}/bin/apt-get install -y ${if (id == "openssh-server") "openssh" else id}" + // Chromium (from x11-repo) + "chromium" -> + "${bootstrapManager.prefixDir.absolutePath}/bin/apt-get install -y chromium" + // code-server (custom) + "code-server" -> + "npm install -g code-server" + // npm-based AI CLI tools + "claude-code" -> + "npm install -g @anthropic-ai/claude-code" + "gemini-cli" -> + "npm install -g @google/gemini-cli" + "codex-cli" -> + "npm install -g @openai/codex" + // OpenCode (Bun-based) + "opencode" -> + "npm install -g opencode" + else -> "echo 'Unknown tool: $id'" + } + eventBridge.emit("install_progress", + mapOf("target" to id, "progress" to 0f, "message" to "Installing $id...")) + CommandRunner.runStreaming(cmd, env, bootstrapManager.homeDir) { output -> + eventBridge.emit("install_progress", + mapOf("target" to id, "progress" to 0.5f, "message" to output)) + } + eventBridge.emit("install_progress", + mapOf("target" to id, "progress" to 1f, "message" to "$id installed")) + } + } + + @JavascriptInterface + fun uninstallTool(id: String) { + launchWithErrorHandling( + errorEventType = "install_progress", + errorContext = mapOf("target" to id) + ) { + val env = EnvironmentBuilder.build(activity) + val cmd = when (id) { + "tmux", "ttyd", "dufs", "openssh-server", "android-tools", "chromium" -> + "${bootstrapManager.prefixDir.absolutePath}/bin/apt-get remove -y ${if (id == "openssh-server") "openssh" else id}" + "code-server" -> + "npm uninstall -g code-server" + "claude-code" -> + "npm uninstall -g @anthropic-ai/claude-code" + "gemini-cli" -> + "npm uninstall -g @google/gemini-cli" + "codex-cli" -> + "npm uninstall -g @openai/codex" + "opencode" -> + "npm uninstall -g opencode" + else -> "echo 'Unknown tool: $id'" + } + CommandRunner.runSync(cmd, env, bootstrapManager.homeDir) + } + } + + @JavascriptInterface + fun isToolInstalled(id: String): String { + val prefix = bootstrapManager.prefixDir.absolutePath + val env = EnvironmentBuilder.build(activity) + val exists = when (id) { + "openssh-server" -> java.io.File("$prefix/bin/sshd").exists() + "tmux", "ttyd", "dufs", "android-tools" -> java.io.File("$prefix/bin/${if (id == "android-tools") "adb" else id}").exists() + "chromium" -> java.io.File("$prefix/bin/chromium-browser").exists() || java.io.File("$prefix/bin/chromium").exists() + "code-server" -> java.io.File("$prefix/bin/code-server").exists() + else -> { + // npm global packages: check via command -v + val result = CommandRunner.runSync("command -v $id 2>/dev/null", env, bootstrapManager.prefixDir, timeoutMs = 5_000) + result.stdout.trim().isNotEmpty() + } + } + return gson.toJson(mapOf("installed" to exists)) + } + + // ═══════════════════════════════════════════ + // Commands domain + // ═══════════════════════════════════════════ + + @JavascriptInterface + fun runCommand(cmd: String): String { + val env = EnvironmentBuilder.build(activity) + val result = CommandRunner.runSync(cmd, env, bootstrapManager.homeDir) + return gson.toJson(result) + } + + @JavascriptInterface + fun runCommandAsync(callbackId: String, cmd: String) { + launchWithErrorHandling( + errorEventType = "command_output", + errorContext = mapOf("callbackId" to callbackId, "done" to true) + ) { + val env = EnvironmentBuilder.build(activity) + CommandRunner.runStreaming(cmd, env, bootstrapManager.homeDir) { output -> + eventBridge.emit( + "command_output", + mapOf("callbackId" to callbackId, "data" to output, "done" to false) + ) + } + eventBridge.emit( + "command_output", + mapOf("callbackId" to callbackId, "data" to "", "done" to true) + ) + } + } + + // ═══════════════════════════════════════════ + // Updates domain + // ═══════════════════════════════════════════ + + @JavascriptInterface + fun checkForUpdates(): String { + // Compare local versions with config.json remote versions + val updates = mutableListOf>() + try { + val configFile = java.io.File( + activity.filesDir, "usr/share/openclaw-app/config.json" + ) + if (configFile.exists()) { + val config = gson.fromJson(configFile.readText(), Map::class.java) as? Map<*, *> + val localWwwVersion = activity.getSharedPreferences("openclaw", 0) + .getString("www_version", "0.0.0") + val remoteWwwVersion = ((config?.get("www") as? Map<*, *>)?.get("version") as? String) + if (remoteWwwVersion != null && remoteWwwVersion != localWwwVersion) { + updates.add(mapOf( + "component" to "www", + "currentVersion" to (localWwwVersion ?: "0.0.0"), + "newVersion" to remoteWwwVersion + )) + } + } + } catch (_: Exception) { /* ignore parse errors */ } + return gson.toJson(updates) + } + + @JavascriptInterface + fun applyUpdate(component: String) { + launchWithErrorHandling( + errorEventType = "install_progress", + errorContext = mapOf("target" to component) + ) { + eventBridge.emit("install_progress", + mapOf("target" to component, "progress" to 0f, "message" to "Updating $component...")) + + when (component) { + "www" -> { + // Download www.zip → staging → atomic replace → reload + try { + val url = UrlResolver(activity).getWwwUrl() + val stagingWww = java.io.File(activity.cacheDir, "www-staging") + stagingWww.deleteRecursively() + stagingWww.mkdirs() + + // Download www.zip + eventBridge.emit("install_progress", + mapOf("target" to "www", "progress" to 0.2f, "message" to "Downloading...")) + val zipFile = java.io.File(activity.cacheDir, "www.zip") + java.net.URL(url).openStream().use { input -> + zipFile.outputStream().use { output -> input.copyTo(output) } + } + + // Extract to staging + eventBridge.emit("install_progress", + mapOf("target" to "www", "progress" to 0.6f, "message" to "Extracting...")) + java.util.zip.ZipInputStream(zipFile.inputStream()).use { zis -> + var entry = zis.nextEntry + while (entry != null) { + val destFile = java.io.File(stagingWww, entry.name) + if (entry.isDirectory) { + destFile.mkdirs() + } else { + destFile.parentFile?.mkdirs() + destFile.outputStream().use { out -> zis.copyTo(out) } + } + entry = zis.nextEntry + } + } + zipFile.delete() + + // Atomic replace: delete old www, rename staging + eventBridge.emit("install_progress", + mapOf("target" to "www", "progress" to 0.9f, "message" to "Applying...")) + val wwwDir = bootstrapManager.wwwDir + wwwDir.deleteRecursively() + wwwDir.parentFile?.mkdirs() + stagingWww.renameTo(wwwDir) + + // Reload WebView + activity.runOnUiThread { activity.reloadWebView() } + } catch (e: Exception) { + eventBridge.emit("install_progress", + mapOf("target" to "www", "progress" to 0f, + "message" to "Update failed: ${e.message}")) + } + } + "bootstrap" -> { + // Re-download and re-extract bootstrap + try { + eventBridge.emit("install_progress", + mapOf("target" to "bootstrap", "progress" to 0.1f, "message" to "Downloading bootstrap...")) + bootstrapManager.startSetup { progress, message -> + eventBridge.emit("install_progress", + mapOf("target" to "bootstrap", "progress" to progress, "message" to message)) + } + } catch (e: Exception) { + eventBridge.emit("install_progress", + mapOf("target" to "bootstrap", "progress" to 0f, + "message" to "Update failed: ${e.message}")) + } + } + "scripts" -> { + // Scripts update: re-download management scripts from config URL + eventBridge.emit("install_progress", + mapOf("target" to "scripts", "progress" to 0.5f, "message" to "Scripts are updated with bootstrap")) + } + } + + eventBridge.emit("install_progress", + mapOf("target" to component, "progress" to 1f, "message" to "$component updated")) + } + } + + // ═══════════════════════════════════════════ + // System domain + // ═══════════════════════════════════════════ + + @JavascriptInterface + fun getAppInfo(): String { + val pInfo = activity.packageManager.getPackageInfo(activity.packageName, 0) + return gson.toJson( + mapOf( + "versionName" to (pInfo.versionName ?: "unknown"), + "versionCode" to pInfo.versionCode, + "packageName" to activity.packageName + ) + ) + } + + @JavascriptInterface + fun getBatteryOptimizationStatus(): String { + val pm = activity.getSystemService(Context.POWER_SERVICE) as PowerManager + return gson.toJson( + mapOf("isIgnoring" to pm.isIgnoringBatteryOptimizations(activity.packageName)) + ) + } + + @JavascriptInterface + fun requestBatteryOptimizationExclusion() { + activity.runOnUiThread { + val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) + intent.data = Uri.parse("package:${activity.packageName}") + activity.startActivity(intent) + } + } + + @JavascriptInterface + fun openSystemSettings(page: String) { + activity.runOnUiThread { + val intent = when (page) { + "battery" -> Intent(Settings.ACTION_BATTERY_SAVER_SETTINGS) + "app_info" -> Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.parse("package:${activity.packageName}") + } + else -> Intent(Settings.ACTION_SETTINGS) + } + activity.startActivity(intent) + } + } + + @JavascriptInterface + fun copyToClipboard(text: String) { + activity.runOnUiThread { + val clipboard = activity.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("OpenClaw", text)) + } + } + + @JavascriptInterface + fun getStorageInfo(): String { + val filesDir = activity.filesDir + val totalSpace = filesDir.totalSpace + val freeSpace = filesDir.freeSpace + val bootstrapSize = bootstrapManager.prefixDir.walkTopDown().sumOf { it.length() } + val wwwSize = bootstrapManager.wwwDir.walkTopDown().sumOf { it.length() } + + return gson.toJson( + mapOf( + "totalBytes" to totalSpace, + "freeBytes" to freeSpace, + "bootstrapBytes" to bootstrapSize, + "wwwBytes" to wwwSize + ) + ) + } + + @JavascriptInterface + fun clearCache() { + activity.cacheDir.deleteRecursively() + activity.cacheDir.mkdirs() + } +} diff --git a/android/app/src/main/java/com/openclaw/android/MainActivity.kt b/android/app/src/main/java/com/openclaw/android/MainActivity.kt new file mode 100644 index 0000000..220e208 --- /dev/null +++ b/android/app/src/main/java/com/openclaw/android/MainActivity.kt @@ -0,0 +1,527 @@ +package com.openclaw.android + +import android.annotation.SuppressLint +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.graphics.Typeface +import android.os.Bundle +import android.util.Log +import android.view.Gravity +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.View +import android.widget.Button +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.content.ContextCompat +import android.content.res.ColorStateList +import android.webkit.WebChromeClient +import android.view.inputmethod.InputMethodManager +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.appcompat.app.AppCompatActivity +import com.openclaw.android.databinding.ActivityMainBinding +import com.termux.terminal.TerminalSession +import com.termux.terminal.TerminalSessionClient +import com.termux.view.TerminalView +import com.termux.view.TerminalViewClient + +class MainActivity : AppCompatActivity() { + + companion object { + private const val TAG = "MainActivity" + } + + private lateinit var binding: ActivityMainBinding + + lateinit var sessionManager: TerminalSessionManager + lateinit var bootstrapManager: BootstrapManager + lateinit var eventBridge: EventBridge + private lateinit var jsBridge: JsBridge + + private var currentTextSize = 32 + private var ctrlDown = false + private var altDown = false + private val terminalSessionClient = OpenClawSessionClient() + private val terminalViewClient = OpenClawViewClient() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + + bootstrapManager = BootstrapManager(this) + eventBridge = EventBridge(binding.webView) + sessionManager = TerminalSessionManager(this, terminalSessionClient, eventBridge) + jsBridge = JsBridge(this, sessionManager, bootstrapManager, eventBridge) + + setupTerminalView() + setupWebView() + setupExtraKeys() + sessionManager.onSessionsChanged = { updateSessionTabs() } + startService(Intent(this, OpenClawService::class.java)) + + val isInstalled = bootstrapManager.isInstalled() + Log.i(TAG, "Bootstrap installed: $isInstalled, needsPostSetup: ${bootstrapManager.needsPostSetup()}") + if (isInstalled) { + showTerminal() + val session = sessionManager.createSession() + if (bootstrapManager.needsPostSetup()) { + Log.i(TAG, "Running post-setup script in terminal") + val script = bootstrapManager.postSetupScript.absolutePath + binding.terminalView.post { + session.write("bash $script\n") + } + } else if (intent?.getBooleanExtra("from_boot", false) == true) { + val platformFile = java.io.File(bootstrapManager.homeDir, ".openclaw-android/.platform") + val platformId = if (platformFile.exists()) platformFile.readText().trim() else "openclaw" + Log.i(TAG, "Boot launch \u2014 auto-starting $platformId gateway") + binding.terminalView.post { + session.write("$platformId gateway\n") + } + } + } + // else: WebView shows setup UI, user triggers startSetup via JsBridge + } + + // --- Terminal setup --- + + private fun setupTerminalView() { + binding.terminalView.setTerminalViewClient(terminalViewClient) + binding.terminalView.setTextSize(currentTextSize) + } + + // --- WebView setup --- + + @SuppressLint("SetJavaScriptEnabled") + private fun setupWebView() { + if (BuildConfig.DEBUG) { + WebView.setWebContentsDebuggingEnabled(true) + } + binding.webView.apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.allowFileAccess = true + @Suppress("DEPRECATION") + settings.allowFileAccessFromFileURLs = true + @Suppress("DEPRECATION") + settings.allowUniversalAccessFromFileURLs = true + addJavascriptInterface(jsBridge, "OpenClaw") + webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + Log.i(TAG, "WebView page loaded: $url") + // Page loaded successfully + } + } + webChromeClient = object : WebChromeClient() { + override fun onConsoleMessage(consoleMessage: android.webkit.ConsoleMessage?): Boolean { + consoleMessage?.let { + Log.d("WebViewJS", "${it.sourceId()}:${it.lineNumber()} ${it.message()}") + } + return true + } + } + } + + val wwwDir = bootstrapManager.wwwDir + val url = if (wwwDir.resolve("index.html").exists()) { + "file://${wwwDir.absolutePath}/index.html" + } else { + // Load bundled fallback setup page from assets + "file:///android_asset/www/index.html" + } + Log.i(TAG, "Loading WebView URL: $url") + binding.webView.loadUrl(url) + } + + fun reloadWebView() { + binding.webView.reload() + } + + // --- View switching --- + + fun showTerminal() { + runOnUiThread { + binding.webView.visibility = View.GONE + binding.terminalContainer.visibility = View.VISIBLE + binding.terminalView.requestFocus() + updateSessionTabs() + // Delay keyboard show — view must be focused and laid out first + binding.terminalView.postDelayed({ + val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.showSoftInput(binding.terminalView, InputMethodManager.SHOW_IMPLICIT) + }, 200) + } + } + + fun showWebView() { + runOnUiThread { + binding.terminalContainer.visibility = View.GONE + binding.webView.visibility = View.VISIBLE + } + } + + @Suppress("DEPRECATION") + override fun onBackPressed() { + if (binding.terminalContainer.visibility == View.VISIBLE) { + showWebView() + } else if (binding.webView.canGoBack()) { + binding.webView.goBack() + } else { + super.onBackPressed() + } + } + + + // --- Extra Keys --- + + private val pressedAlpha = 0.5f + private val normalAlpha = 1.0f + + @SuppressLint("ClickableViewAccessibility") + private fun setupExtraKeys() { + // Key code buttons — send key event on touch, never steal focus + val keyMap = mapOf( + R.id.btnEsc to KeyEvent.KEYCODE_ESCAPE, + R.id.btnTab to KeyEvent.KEYCODE_TAB, + R.id.btnHome to KeyEvent.KEYCODE_MOVE_HOME, + R.id.btnEnd to KeyEvent.KEYCODE_MOVE_END, + R.id.btnUp to KeyEvent.KEYCODE_DPAD_UP, + R.id.btnDown to KeyEvent.KEYCODE_DPAD_DOWN, + R.id.btnLeft to KeyEvent.KEYCODE_DPAD_LEFT, + R.id.btnRight to KeyEvent.KEYCODE_DPAD_RIGHT + ) + for ((btnId, keyCode) in keyMap) { + setupExtraKeyTouch(findViewById(btnId)) { sendExtraKey(keyCode) } + } + + // Character keys + setupExtraKeyTouch(findViewById(R.id.btnDash)) { sessionManager.activeSession?.write("-") } + setupExtraKeyTouch(findViewById(R.id.btnPipe)) { sessionManager.activeSession?.write("|") } + + // Modifier toggles — stay pressed until next key or toggled off + setupModifierTouch(findViewById(R.id.btnCtrl)) { ctrlDown = !ctrlDown; ctrlDown } + setupModifierTouch(findViewById(R.id.btnAlt)) { altDown = !altDown; altDown } + } + + @SuppressLint("ClickableViewAccessibility") + private fun setupExtraKeyTouch(btn: Button, action: () -> Unit) { + btn.setOnTouchListener { v, event -> + when (event.action) { + MotionEvent.ACTION_DOWN -> v.alpha = pressedAlpha + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + v.alpha = normalAlpha + if (event.action == MotionEvent.ACTION_UP) action() + } + } + true // consume — never let focus leave TerminalView + } + } + + @SuppressLint("ClickableViewAccessibility") + private fun setupModifierTouch(btn: Button, toggle: () -> Boolean) { + btn.setOnTouchListener { v, event -> + when (event.action) { + MotionEvent.ACTION_DOWN -> v.alpha = pressedAlpha + MotionEvent.ACTION_UP -> { + val active = toggle() + updateModifierButton(v as Button, active) + v.alpha = normalAlpha + } + MotionEvent.ACTION_CANCEL -> v.alpha = normalAlpha + } + true + } + } + + private fun sendExtraKey(keyCode: Int) { + var metaState = 0 + if (ctrlDown) metaState = metaState or (KeyEvent.META_CTRL_ON or KeyEvent.META_CTRL_LEFT_ON) + if (altDown) metaState = metaState or (KeyEvent.META_ALT_ON or KeyEvent.META_ALT_LEFT_ON) + + val ev = KeyEvent(0, 0, KeyEvent.ACTION_UP, keyCode, 0, metaState) + binding.terminalView.onKeyDown(keyCode, ev) + + // Auto-deactivate modifiers after use + if (ctrlDown) { + ctrlDown = false + updateModifierButton(findViewById(R.id.btnCtrl), false) + } + if (altDown) { + altDown = false + updateModifierButton(findViewById(R.id.btnAlt), false) + } + } + + private fun updateModifierButton(button: Button, active: Boolean) { + val bgColor = if (active) R.color.extraKeyActive else R.color.extraKeyDefault + val txtColor = if (active) R.color.extraKeyActiveText else R.color.extraKeyText + button.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(this, bgColor)) + button.setTextColor(ContextCompat.getColor(this, txtColor)) + } + + // --- Session tab bar --- + + private fun updateSessionTabs() { + val tabsLayout = binding.tabsLayout + tabsLayout.removeAllViews() + + val sessions = sessionManager.getSessionsInfo() + val density = resources.displayMetrics.density + + for (info in sessions) { + val id = info["id"] as String + val name = info["name"] as String + val active = info["active"] as Boolean + val finished = info["finished"] as Boolean + + // Tab wrapper (vertical: content row + accent indicator) + val tabWrapper = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.MATCH_PARENT + ).apply { + marginEnd = (2 * density).toInt() + } + val bgColor = if (active) R.color.tabActiveBackground else R.color.tabInactiveBackground + setBackgroundColor(ContextCompat.getColor(this@MainActivity, bgColor)) + isFocusable = false + isFocusableInTouchMode = false + } + + // Tab content row (horizontal: name + close) + val tabContent = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + val hPad = (10 * density).toInt() + val vPad = (4 * density).toInt() + setPadding(hPad, vPad, (6 * density).toInt(), vPad) + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + 0, 1f + ) + isFocusable = false + isFocusableInTouchMode = false + } + + // Session name + val nameView = TextView(this).apply { + text = name + textSize = 12f + val textColor = when { + finished -> R.color.tabTextFinished + active -> R.color.tabTextPrimary + else -> R.color.tabTextSecondary + } + setTextColor(ContextCompat.getColor(this@MainActivity, textColor)) + if (finished) setTypeface(typeface, Typeface.ITALIC) + isSingleLine = true + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + } + + // Close button + val closeView = TextView(this).apply { + text = "\u00D7" + textSize = 14f + setTextColor(ContextCompat.getColor(this@MainActivity, R.color.tabTextSecondary)) + val pad = (6 * density).toInt() + setPadding(pad, 0, 0, 0) + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + isFocusable = false + isFocusableInTouchMode = false + setOnClickListener { closeSessionFromTab(id) } + } + + tabContent.addView(nameView) + tabContent.addView(closeView) + + // Accent indicator (2dp bottom line) + val indicator = View(this).apply { + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + (2 * density).toInt() + ) + val color = if (active) R.color.tabAccent else android.R.color.transparent + setBackgroundColor(ContextCompat.getColor(this@MainActivity, color)) + } + + tabWrapper.addView(tabContent) + tabWrapper.addView(indicator) + + // Tab click → switch session + tabWrapper.setOnClickListener { + sessionManager.switchSession(id) + binding.terminalView.requestFocus() + } + + tabsLayout.addView(tabWrapper) + + // Scroll to active tab + if (active) { + binding.sessionTabBar.post { + binding.sessionTabBar.smoothScrollTo(tabWrapper.left, 0) + } + } + } + + // "+" button to create new session + val addButton = TextView(this).apply { + text = "+" + textSize = 18f + setTextColor(ContextCompat.getColor(this@MainActivity, R.color.tabTextSecondary)) + val pad = (12 * density).toInt() + setPadding(pad, 0, pad, 0) + gravity = Gravity.CENTER + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.MATCH_PARENT + ) + isFocusable = false + isFocusableInTouchMode = false + setOnClickListener { + sessionManager.createSession() + binding.terminalView.requestFocus() + } + } + tabsLayout.addView(addButton) + } + + private fun closeSessionFromTab(handleId: String) { + if (sessionManager.sessionCount <= 1) { + // Create new session first, then close the old one + sessionManager.createSession() + } + sessionManager.closeSession(handleId) + binding.terminalView.requestFocus() + } + + // --- Terminal session callbacks --- + + private inner class OpenClawSessionClient : TerminalSessionClient { + + override fun onTextChanged(changedSession: TerminalSession) { + binding.terminalView.onScreenUpdated() + } + + override fun onTitleChanged(changedSession: TerminalSession) { + // Update tab bar when title changes + runOnUiThread { updateSessionTabs() } + // title changes propagated via EventBridge + } + + override fun onSessionFinished(finishedSession: TerminalSession) { + sessionManager.onSessionFinished(finishedSession) + } + + override fun onCopyTextToClipboard(session: TerminalSession, text: String) { + val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("OpenClaw", text)) + } + + override fun onPasteTextFromClipboard(session: TerminalSession?) { + val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val text = clipboard.primaryClip?.getItemAt(0)?.text ?: return + session?.write(text.toString()) + } + + override fun onBell(session: TerminalSession) {} + override fun onColorsChanged(session: TerminalSession) {} + override fun onTerminalCursorStateChange(state: Boolean) {} + override fun setTerminalShellPid(session: TerminalSession, pid: Int) {} + override fun getTerminalCursorStyle(): Int = 0 + + override fun logError(tag: String, message: String) { Log.e(tag, message) } + override fun logWarn(tag: String, message: String) { Log.w(tag, message) } + override fun logInfo(tag: String, message: String) { Log.i(tag, message) } + override fun logDebug(tag: String, message: String) { Log.d(tag, message) } + override fun logVerbose(tag: String, message: String) { Log.v(tag, message) } + override fun logStackTraceWithMessage(tag: String, message: String, e: Exception) { + Log.e(tag, message, e) + } + override fun logStackTrace(tag: String, e: Exception) { + Log.e(tag, "Exception", e) + } + } + + // --- Terminal view callbacks --- + + private inner class OpenClawViewClient : TerminalViewClient { + + override fun onScale(scale: Float): Float { + val currentSize = currentTextSize + val newSize = if (scale > 1f) currentSize + 1 else currentSize - 1 + val clamped = newSize.coerceIn(8, 32) + currentTextSize = clamped + binding.terminalView.setTextSize(clamped) + return scale + } + + override fun onSingleTapUp(e: MotionEvent) { + // Toggle soft keyboard on tap (same as Termux) + val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.toggleSoftInput(InputMethodManager.SHOW_IMPLICIT, 0) + } + override fun shouldBackButtonBeMappedToEscape(): Boolean = false + override fun shouldEnforceCharBasedInput(): Boolean = true + override fun getInputMode(): Int = 1 // TYPE_NULL — strict terminal input mode + override fun shouldUseCtrlSpaceWorkaround(): Boolean = false + override fun isTerminalViewSelected(): Boolean = + binding.terminalContainer.visibility == View.VISIBLE + + override fun copyModeChanged(copyMode: Boolean) {} + + override fun onKeyDown(keyCode: Int, e: KeyEvent, session: TerminalSession): Boolean = + false + + override fun onKeyUp(keyCode: Int, e: KeyEvent): Boolean = false + override fun onLongPress(event: MotionEvent): Boolean = false + override fun readControlKey(): Boolean { + val v = ctrlDown + if (v) { + ctrlDown = false + runOnUiThread { updateModifierButton(findViewById(R.id.btnCtrl), false) } + } + return v + } + override fun readAltKey(): Boolean { + val v = altDown + if (v) { + altDown = false + runOnUiThread { updateModifierButton(findViewById(R.id.btnAlt), false) } + } + return v + } + override fun readShiftKey(): Boolean = false + override fun readFnKey(): Boolean = false + + override fun onCodePoint( + codePoint: Int, + ctrlDown: Boolean, + session: TerminalSession + ): Boolean = false + + override fun onEmulatorSet() {} + + override fun logError(tag: String, message: String) { Log.e(tag, message) } + override fun logWarn(tag: String, message: String) { Log.w(tag, message) } + override fun logInfo(tag: String, message: String) { Log.i(tag, message) } + override fun logDebug(tag: String, message: String) { Log.d(tag, message) } + override fun logVerbose(tag: String, message: String) { Log.v(tag, message) } + override fun logStackTraceWithMessage(tag: String, message: String, e: Exception) { + Log.e(tag, message, e) + } + override fun logStackTrace(tag: String, e: Exception) { + Log.e(tag, "Exception", e) + } + } +} diff --git a/android/app/src/main/java/com/openclaw/android/OpenClawService.kt b/android/app/src/main/java/com/openclaw/android/OpenClawService.kt new file mode 100644 index 0000000..0bc3703 --- /dev/null +++ b/android/app/src/main/java/com/openclaw/android/OpenClawService.kt @@ -0,0 +1,72 @@ +package com.openclaw.android + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Intent +import android.os.Build +import android.os.IBinder + +/** + * Foreground Service that keeps terminal sessions alive when the app is in background. + * Uses START_STICKY to restart if killed. targetSdk 28 — no specialUse needed. + */ +class OpenClawService : Service() { + + companion object { + private const val NOTIFICATION_ID = 1 + private const val CHANNEL_ID = "openclaw_service" + } + + override fun onCreate() { + super.onCreate() + createNotificationChannel() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + startForeground(NOTIFICATION_ID, createNotification()) + return START_STICKY + } + + override fun onBind(intent: Intent?): IBinder? = null + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + getString(R.string.notification_channel_name), + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "Keeps OpenClaw terminal sessions running" + setShowBadge(false) + } + val manager = getSystemService(NotificationManager::class.java) + manager.createNotificationChannel(channel) + } + } + + private fun createNotification(): Notification { + val pendingIntent = PendingIntent.getActivity( + this, 0, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_IMMUTABLE + ) + + val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Notification.Builder(this, CHANNEL_ID) + } else { + @Suppress("DEPRECATION") + Notification.Builder(this) + } + + return builder + .setContentTitle(getString(R.string.notification_title)) + .setContentText(getString(R.string.notification_text)) + .setSmallIcon(R.drawable.ic_notification) + .setContentIntent(pendingIntent) + .setOngoing(true) + .build() + } +} diff --git a/android/app/src/main/java/com/openclaw/android/TerminalSessionManager.kt b/android/app/src/main/java/com/openclaw/android/TerminalSessionManager.kt new file mode 100644 index 0000000..17739de --- /dev/null +++ b/android/app/src/main/java/com/openclaw/android/TerminalSessionManager.kt @@ -0,0 +1,164 @@ +package com.openclaw.android + +import android.util.Log +import com.termux.terminal.TerminalSession +import com.termux.terminal.TerminalSessionClient + +/** + * Multi-terminal session management (§2.6, Phase 1 checklist). + * Uses TerminalView.attachSession() for session switching — one TerminalView, many sessions. + */ +class TerminalSessionManager( + private val activity: MainActivity, + private val sessionClient: TerminalSessionClient, + private val eventBridge: EventBridge +) { + companion object { + private const val TAG = "SessionManager" + private const val TRANSCRIPT_ROWS = 2000 + } + + private val sessions = mutableListOf() + private var activeSessionIndex = -1 + private val finishedSessionIds = mutableSetOf() + var onSessionsChanged: (() -> Unit)? = null + + val activeSession: TerminalSession? + get() = sessions.getOrNull(activeSessionIndex) + + /** + * Create a new terminal session. Returns the session handle. + */ + fun createSession(): TerminalSession { + val env = EnvironmentBuilder.build(activity) + val prefix = env["PREFIX"] ?: "" + val homeDir = env["HOME"] ?: activity.filesDir.absolutePath + val tmpDir = env["TMPDIR"] + + // Ensure HOME and TMP directories exist before starting the shell. + // Without this, chdir() fails if bootstrap hasn't been run yet. + java.io.File(homeDir).mkdirs() + tmpDir?.let { java.io.File(it).mkdirs() } + + val shell = if (java.io.File("$prefix/bin/bash").exists()) { + "$prefix/bin/bash" + } else if (java.io.File("$prefix/bin/sh").exists()) { + "$prefix/bin/sh" + } else { + "/system/bin/sh" + } + + val session = TerminalSession( + shell, + homeDir, + arrayOf(), + env.entries.map { "${it.key}=${it.value}" }.toTypedArray(), + TRANSCRIPT_ROWS, + sessionClient + ) + + sessions.add(session) + switchSession(sessions.size - 1) + + eventBridge.emit( + "session_changed", + mapOf("id" to session.mHandle, "action" to "created") + ) + activity.runOnUiThread { onSessionsChanged?.invoke() } + + Log.i(TAG, "Created session ${session.mHandle} (total: ${sessions.size})") + return session + } + + /** + * Switch to session by index. + */ + fun switchSession(index: Int) { + if (index < 0 || index >= sessions.size) return + activeSessionIndex = index + val session = sessions[index] + activity.runOnUiThread { + val terminalView = activity.findViewById(R.id.terminalView) + terminalView.attachSession(session) + terminalView.invalidate() + } + eventBridge.emit( + "session_changed", + mapOf("id" to session.mHandle, "action" to "switched") + ) + activity.runOnUiThread { onSessionsChanged?.invoke() } + } + + /** + * Switch to session by handle ID. + */ + fun switchSession(handleId: String) { + val index = sessions.indexOfFirst { it.mHandle == handleId } + if (index >= 0) switchSession(index) + } + + /** + * Find a session by handle ID. + */ + fun getSessionById(handleId: String): TerminalSession? { + return sessions.find { it.mHandle == handleId } + } + + /** + * Close a session by handle ID. + */ + fun closeSession(handleId: String) { + val index = sessions.indexOfFirst { it.mHandle == handleId } + if (index < 0) return + + finishedSessionIds.remove(handleId) + val session = sessions.removeAt(index) + session.finishIfRunning() + + eventBridge.emit( + "session_changed", + mapOf("id" to handleId, "action" to "closed") + ) + + // Switch to another session if available + if (sessions.isNotEmpty()) { + val newIndex = (index).coerceAtMost(sessions.size - 1) + switchSession(newIndex) + } else { + activeSessionIndex = -1 + } + + activity.runOnUiThread { onSessionsChanged?.invoke() } + Log.i(TAG, "Closed session $handleId (remaining: ${sessions.size})") + } + + /** + * Called when a session's process exits. + */ + fun onSessionFinished(session: TerminalSession) { + finishedSessionIds.add(session.mHandle) + eventBridge.emit( + "session_changed", + mapOf("id" to session.mHandle, "action" to "finished") + ) + activity.runOnUiThread { onSessionsChanged?.invoke() } + } + + /** + * Get all sessions info for JsBridge. + */ + fun getSessionsInfo(): List> { + return sessions.mapIndexed { index, session -> + mapOf( + "id" to session.mHandle, + "name" to (session.title ?: "Session ${index + 1}"), + "active" to (index == activeSessionIndex), + "finished" to (session.mHandle in finishedSessionIds) + ) + } + } + + fun isSessionFinished(handleId: String): Boolean = handleId in finishedSessionIds + + val sessionCount: Int get() = sessions.size +} diff --git a/android/app/src/main/java/com/openclaw/android/UrlResolver.kt b/android/app/src/main/java/com/openclaw/android/UrlResolver.kt new file mode 100644 index 0000000..36de1bd --- /dev/null +++ b/android/app/src/main/java/com/openclaw/android/UrlResolver.kt @@ -0,0 +1,77 @@ +package com.openclaw.android + +import android.content.Context +import com.google.gson.Gson +import com.google.gson.annotations.SerializedName +import kotlinx.coroutines.withTimeout +import java.io.File +import java.net.URL + +/** + * Resolves download URLs with BuildConfig hardcoded fallback + config.json override (§2.9). + * + * Priority: cached config.json → remote config.json (5s timeout) → BuildConfig constants + */ +class UrlResolver(private val context: Context) { + + private val configFile = File( + context.filesDir, "usr/share/openclaw-app/config.json" + ) + private val gson = Gson() + + suspend fun getBootstrapUrl(): String { + val config = loadConfig() + return config?.bootstrap?.url ?: BuildConfig.BOOTSTRAP_URL + } + + suspend fun getWwwUrl(): String { + val config = loadConfig() + return config?.www?.url ?: BuildConfig.WWW_URL + } + + private suspend fun loadConfig(): RemoteConfig? { + // 1. Local cache + if (configFile.exists()) { + return try { + gson.fromJson(configFile.readText(), RemoteConfig::class.java) + } catch (_: Exception) { + null + } + } + + // 2. Remote fetch (5s timeout) + return try { + withTimeout(5_000) { + val json = URL(BuildConfig.CONFIG_URL).readText() + configFile.parentFile?.mkdirs() + configFile.writeText(json) + gson.fromJson(json, RemoteConfig::class.java) + } + } catch (_: Exception) { + null // BuildConfig fallback + } + } + + // --- Config data classes --- + + data class RemoteConfig( + val version: Int?, + val bootstrap: ComponentConfig?, + val www: ComponentConfig?, + val platforms: List?, + val features: Map? + ) + + data class ComponentConfig( + val url: String, + val version: String?, + @SerializedName("sha256") val sha256: String? + ) + + data class PlatformConfig( + val id: String, + val name: String, + val icon: String?, + val description: String? + ) +} diff --git a/android/app/src/main/res/drawable/extra_key_bg.xml b/android/app/src/main/res/drawable/extra_key_bg.xml new file mode 100644 index 0000000..e6db9c8 --- /dev/null +++ b/android/app/src/main/res/drawable/extra_key_bg.xml @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/android/app/src/main/res/drawable/extra_key_bg_active.xml b/android/app/src/main/res/drawable/extra_key_bg_active.xml new file mode 100644 index 0000000..61ebd89 --- /dev/null +++ b/android/app/src/main/res/drawable/extra_key_bg_active.xml @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_launcher.xml b/android/app/src/main/res/drawable/ic_launcher.xml new file mode 100644 index 0000000..186d4cf --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_notification.xml b/android/app/src/main/res/drawable/ic_notification.xml new file mode 100644 index 0000000..f5d0d01 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..2503968 --- /dev/null +++ b/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* Routes */} + + { setSetupDone(true); navigate('/dashboard') }} /> + + + + + + + + + ) +} + +function SettingsRouter() { + const { path } = useRoute() + if (path === '/settings') return + if (path === '/settings/tools') return + if (path === '/settings/keep-alive') return + if (path === '/settings/storage') return + if (path === '/settings/about') return + if (path === '/settings/updates') return + if (path === '/settings/platforms') return + return +} diff --git a/android/www/src/lib/bridge.ts b/android/www/src/lib/bridge.ts new file mode 100644 index 0000000..7407aa3 --- /dev/null +++ b/android/www/src/lib/bridge.ts @@ -0,0 +1,79 @@ +/** + * JsBridge wrapper — typed interface to window.OpenClaw (§2.6). + * All Kotlin @JavascriptInterface methods return JSON strings. + */ + +interface OpenClawBridge { + showTerminal(): void + showWebView(): void + createSession(): string + switchSession(id: string): void + closeSession(id: string): void + getTerminalSessions(): string + writeToTerminal(id: string, data: string): void + getSetupStatus(): string + getBootstrapStatus(): string + startSetup(): void + saveToolSelections(json: string): void + getAvailablePlatforms(): string + getInstalledPlatforms(): string + installPlatform(id: string): void + uninstallPlatform(id: string): void + switchPlatform(id: string): void + getActivePlatform(): string + getInstalledTools(): string + installTool(id: string): void + uninstallTool(id: string): void + isToolInstalled(id: string): string + runCommand(cmd: string): string + runCommandAsync(callbackId: string, cmd: string): void + checkForUpdates(): string + applyUpdate(component: string): void + getAppInfo(): string + getBatteryOptimizationStatus(): string + requestBatteryOptimizationExclusion(): void + openSystemSettings(page: string): void + copyToClipboard(text: string): void + getStorageInfo(): string + clearCache(): void +} + +declare global { + interface Window { + OpenClaw?: OpenClawBridge + __oc?: { emit(type: string, data: unknown): void } + } +} + +export function isAvailable(): boolean { + return typeof window.OpenClaw !== 'undefined' +} + +export function call( + method: K, + ...args: Parameters +): ReturnType | null { + if (window.OpenClaw && typeof window.OpenClaw[method] === 'function') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (window.OpenClaw[method] as (...a: any[]) => any)(...args) + } + console.warn('[bridge] OpenClaw not available:', method) + return null +} + +export function callJson( + method: keyof OpenClawBridge, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...args: any[] +): T | null { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const raw = (call as any)(method, ...args) + if (raw == null) return null + try { + return JSON.parse(raw as string) as T + } catch { + return raw as unknown as T + } +} + +export const bridge = { isAvailable, call, callJson } diff --git a/android/www/src/lib/router.tsx b/android/www/src/lib/router.tsx new file mode 100644 index 0000000..0a6986b --- /dev/null +++ b/android/www/src/lib/router.tsx @@ -0,0 +1,51 @@ +/** + * Minimal hash-based router for file:// protocol. + * History API doesn't work with file:// — hash routing required. + */ + +import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react' + +interface RouterContext { + path: string + navigate: (hash: string) => void +} + +const Ctx = createContext({ path: '', navigate: () => {} }) + +function getHashPath(): string { + const hash = window.location.hash + return hash ? hash.slice(1) : '/dashboard' +} + +export function Router({ children }: { children: ReactNode }) { + const [path, setPath] = useState(getHashPath) + + useEffect(() => { + const onChange = () => setPath(getHashPath()) + window.addEventListener('hashchange', onChange) + return () => window.removeEventListener('hashchange', onChange) + }, []) + + const navigate = useCallback((hash: string) => { + window.location.hash = hash + }, []) + + return {children} +} + +export function useRoute(): RouterContext { + return useContext(Ctx) +} + +export function Route({ path, children }: { path: string; children: ReactNode }) { + const { path: current } = useRoute() + // Exact match or prefix match for nested routes + if (current === path || current.startsWith(path + '/')) { + return <>{children} + } + return null +} + +export function navigate(hash: string) { + window.location.hash = hash +} diff --git a/android/www/src/lib/useNativeEvent.ts b/android/www/src/lib/useNativeEvent.ts new file mode 100644 index 0000000..6634b18 --- /dev/null +++ b/android/www/src/lib/useNativeEvent.ts @@ -0,0 +1,17 @@ +/** + * EventBridge hook — listen for Kotlin→WebView events (§2.8). + * Kotlin dispatches: window.__oc.emit(type, data) + * Which creates: CustomEvent('native:'+type, { detail: data }) + */ + +import { useEffect } from 'react' + +export function useNativeEvent(type: string, handler: (data: unknown) => void): void { + useEffect(() => { + const listener = (e: Event) => { + handler((e as CustomEvent).detail) + } + window.addEventListener('native:' + type, listener) + return () => window.removeEventListener('native:' + type, listener) + }, [type, handler]) +} diff --git a/android/www/src/main.tsx b/android/www/src/main.tsx new file mode 100644 index 0000000..eaeb586 --- /dev/null +++ b/android/www/src/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { Router } from './lib/router' +import { App } from './App' +import './styles/global.css' + +createRoot(document.getElementById('root')!).render( + + + + + , +) diff --git a/android/www/src/screens/Dashboard.tsx b/android/www/src/screens/Dashboard.tsx new file mode 100644 index 0000000..603694d --- /dev/null +++ b/android/www/src/screens/Dashboard.tsx @@ -0,0 +1,139 @@ +import { useState, useEffect } from 'react' +import { useRoute } from '../lib/router' +import { bridge } from '../lib/bridge' + +interface BootstrapStatus { + installed: boolean + prefixPath?: string +} + +interface PlatformInfo { + id: string + name: string +} + +export function Dashboard() { + const { navigate } = useRoute() + const [status, setStatus] = useState(null) + const [platform, setPlatform] = useState(null) + const [runtimeInfo, setRuntimeInfo] = useState>({}) + + function refreshStatus() { + const bs = bridge.callJson('getBootstrapStatus') + if (bs) setStatus(bs) + + const ap = bridge.callJson('getActivePlatform') + if (ap) setPlatform(ap) + + // Get runtime versions + const nodeV = bridge.callJson<{ stdout: string }>('runCommand', 'node -v 2>/dev/null') + const gitV = bridge.callJson<{ stdout: string }>('runCommand', 'git --version 2>/dev/null') + const ocV = bridge.callJson<{ stdout: string }>('runCommand', 'openclaw --version 2>/dev/null') + setRuntimeInfo({ + 'Node.js': nodeV?.stdout?.trim() || '—', + 'git': gitV?.stdout?.trim()?.replace('git version ', '') || '—', + 'openclaw': ocV?.stdout?.trim() || '—', + }) + } + + useEffect(() => { + refreshStatus() + }, []) + + function handleCheckStatus() { + bridge.call('showTerminal') + bridge.call('writeToTerminal', '', 'openclaw status\n') + } + + + function handleUpdate() { + bridge.call('showTerminal') + bridge.call('writeToTerminal', '', 'npm install -g openclaw@latest --ignore-scripts && echo "Update complete. Version: $(openclaw --version)"\n') + } + + function handleInstallTools() { + navigate('/settings/tools') + } + + function handleStartGateway() { + bridge.call('showTerminal') + bridge.call('writeToTerminal', '', 'openclaw gateway\n') + } + + if (!status?.installed) { + return ( +
+
+
🧠
+
Setup Required
+
+ The runtime environment hasn't been set up yet. +
+
+
+ ) + } + + return ( +
+ {/* Platform header */} +
+ 🧠 +
+
+ {platform?.name || 'OpenClaw'} +
+
+
+ + {/* Gateway */} +
+
+ Gateway +
+
+ + +
+
+ + {/* Runtime info */} +
Runtime
+
+ {Object.entries(runtimeInfo).map(([key, val]) => ( +
+ {key} + {val} +
+ ))} +
+ + {/* Management */} +
Management
+
+
+
⬆️
+
+
Update
+
Update OpenClaw to latest version
+
+
+
+
+
+
+
🧩
+
+
Install Tools
+
Add or remove optional tools
+
+
+
+
+
+ ) +} diff --git a/android/www/src/screens/Settings.tsx b/android/www/src/screens/Settings.tsx new file mode 100644 index 0000000..9dbf916 --- /dev/null +++ b/android/www/src/screens/Settings.tsx @@ -0,0 +1,41 @@ +import { useRoute } from '../lib/router' + +interface MenuItem { + icon: string + label: string + desc: string + route: string + badge?: boolean +} + +const MENU: MenuItem[] = [ + { icon: '📱', label: 'Platforms', desc: 'Manage installed platforms', route: '/settings/platforms' }, + { icon: '🔄', label: 'Updates', desc: 'Check for updates', route: '/settings/updates', badge: false }, + { icon: '🧰', label: 'Additional Tools', desc: 'Install extra CLI tools', route: '/settings/tools' }, + { icon: '⚡', label: 'Keep Alive', desc: 'Prevent background killing', route: '/settings/keep-alive' }, + { icon: '💾', label: 'Storage', desc: 'Manage disk usage', route: '/settings/storage' }, + { icon: 'ℹ️', label: 'About', desc: 'App info & licenses', route: '/settings/about' }, +] + +export function Settings() { + const { navigate } = useRoute() + + return ( +
+
Settings
+ {MENU.map(item => ( +
navigate(item.route)}> +
+ {item.icon} +
+
{item.label}
+
{item.desc}
+
+ {item.badge && } + +
+
+ ))} +
+ ) +} diff --git a/android/www/src/screens/SettingsAbout.tsx b/android/www/src/screens/SettingsAbout.tsx new file mode 100644 index 0000000..d2d4aa6 --- /dev/null +++ b/android/www/src/screens/SettingsAbout.tsx @@ -0,0 +1,103 @@ +import { useState, useEffect } from 'react' +import { useRoute } from '../lib/router' +import { bridge } from '../lib/bridge' + +interface AppInfo { + versionName: string + versionCode: number + packageName: string +} + +export function SettingsAbout() { + const { navigate } = useRoute() + const [appInfo, setAppInfo] = useState(null) + const [runtimeInfo, setRuntimeInfo] = useState>({}) + const [scriptVersion, setScriptVersion] = useState('—') + + useEffect(() => { + const info = bridge.callJson('getAppInfo') + if (info) setAppInfo(info) + + // Get script version (oa CLI) + const oaV = bridge.callJson<{ stdout: string }>('runCommand', 'oa --version 2>/dev/null') + setScriptVersion(oaV?.stdout?.trim()?.replace(/^oa\s+/, '') || '—') + + // Get runtime versions + const nodeV = bridge.callJson<{ stdout: string }>('runCommand', 'node -v 2>/dev/null') + const gitV = bridge.callJson<{ stdout: string }>('runCommand', 'git --version 2>/dev/null') + setRuntimeInfo({ + 'Node.js': nodeV?.stdout?.trim() || '—', + 'git': gitV?.stdout?.trim()?.replace('git version ', '') || '—', + }) + }, []) + + return ( +
+
+ +
About
+
+ +
+
🧠
+
Claw
+
+ +
Version
+
+
+ APK + {appInfo?.versionName || '—'} +
+
+ Script (oa) + {scriptVersion} +
+
+ Package + {appInfo?.packageName || '—'} +
+
+ +
Runtime
+
+ {Object.entries(runtimeInfo).map(([key, val]) => ( +
+ {key} + {val} +
+ ))} +
+ +
+ +
+
+ License + GPL v3 +
+
+ +
+ +
+ +
+ Made for Android +
+
+ ) +} diff --git a/android/www/src/screens/SettingsKeepAlive.tsx b/android/www/src/screens/SettingsKeepAlive.tsx new file mode 100644 index 0000000..ce77419 --- /dev/null +++ b/android/www/src/screens/SettingsKeepAlive.tsx @@ -0,0 +1,99 @@ +import { useState, useEffect } from 'react' +import { useRoute } from '../lib/router' +import { bridge } from '../lib/bridge' + +export function SettingsKeepAlive() { + const { navigate } = useRoute() + const [batteryExcluded, setBatteryExcluded] = useState(false) + const [copied, setCopied] = useState(false) + + useEffect(() => { + const status = bridge.callJson<{ isIgnoring: boolean }>('getBatteryOptimizationStatus') + if (status) setBatteryExcluded(status.isIgnoring) + }, []) + + const ppkCommand = 'adb shell device_config set_sync_disabled_for_tests activity_manager/max_phantom_processes 2147483647' + + function handleCopyCommand() { + bridge.call('copyToClipboard', ppkCommand) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + function handleRequestExclusion() { + bridge.call('requestBatteryOptimizationExclusion') + // Re-check after user returns + setTimeout(() => { + const status = bridge.callJson<{ isIgnoring: boolean }>('getBatteryOptimizationStatus') + if (status) setBatteryExcluded(status.isIgnoring) + }, 3000) + } + + return ( +
+
+ +
Keep Alive
+
+ +
+ Android may kill background processes after a while. Follow these steps to prevent it. +
+ + {/* 1. Battery Optimization */} +
1. Battery Optimization
+
+
+
+
Status
+
+ {batteryExcluded ? ( + ✓ Excluded + ) : ( + + )} +
+
+ + {/* 2. Developer Options */} +
2. Developer Options
+
+
+ • Enable Developer Options
+ • Enable "Stay Awake" +
+ +
+ + {/* 3. Phantom Process Killer */} +
3. Phantom Process Killer (Android 12+)
+
+
+ Connect USB and enable ADB debugging, then run this command on your PC: +
+
+ {ppkCommand} + +
+
+ + {/* 4. Charge Limit */} +
4. Charge Limit (Optional)
+
+
+ Set battery charge limit to 80% for always-on use. This can be configured in + your phone's battery settings. +
+
+
+ ) +} diff --git a/android/www/src/screens/SettingsPlatforms.tsx b/android/www/src/screens/SettingsPlatforms.tsx new file mode 100644 index 0000000..238c171 --- /dev/null +++ b/android/www/src/screens/SettingsPlatforms.tsx @@ -0,0 +1,93 @@ +import { useState, useEffect, useCallback } from 'react' +import { useRoute } from '../lib/router' +import { bridge } from '../lib/bridge' +import { useNativeEvent } from '../lib/useNativeEvent' + +interface Platform { + id: string + name: string + icon: string + desc: string +} + +export function SettingsPlatforms() { + const { navigate } = useRoute() + const [available, setAvailable] = useState([]) + const [active, setActive] = useState('') + const [installing, setInstalling] = useState(null) + const [progress, setProgress] = useState(0) + + useEffect(() => { + const data = bridge.callJson('getAvailablePlatforms') + if (data) setAvailable(data) + + const ap = bridge.callJson<{ id: string }>('getActivePlatform') + if (ap) setActive(ap.id) + }, []) + + const onProgress = useCallback((data: unknown) => { + const d = data as { target?: string; progress?: number } + if (d.progress !== undefined) setProgress(d.progress) + if (d.progress !== undefined && d.progress >= 1) { + if (d.target) setActive(d.target) + setInstalling(null) + } + }, []) + useNativeEvent('install_progress', onProgress) + + + function handleInstall(id: string) { + setInstalling(id) + setProgress(0) + bridge.call('installPlatform', id) + } + + return ( +
+
+ +
Platforms
+
+ + {installing && ( +
+
Installing {installing}...
+
+
+
+
+ )} + + {available.map(p => { + const isActive = p.id === active + return ( +
+
+ {p.icon} +
+
+ {p.name} + {isActive && ( + + Active + + )} +
+
{p.desc}
+
+ {!isActive && ( + + )} +
+
+ ) + })} +
+ ) +} diff --git a/android/www/src/screens/SettingsStorage.tsx b/android/www/src/screens/SettingsStorage.tsx new file mode 100644 index 0000000..eac3a07 --- /dev/null +++ b/android/www/src/screens/SettingsStorage.tsx @@ -0,0 +1,124 @@ +import { useState, useEffect } from 'react' +import { useRoute } from '../lib/router' +import { bridge } from '../lib/bridge' + +interface StorageInfo { + totalBytes: number + freeBytes: number + bootstrapBytes: number + wwwBytes: number +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB` + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB` +} + +const STORAGE_COLORS = { + bootstrap: '#58a6ff', + www: '#3fb950', + free: 'var(--bg-tertiary)', +} + +export function SettingsStorage() { + const { navigate } = useRoute() + const [info, setInfo] = useState(null) + const [clearing, setClearing] = useState(false) + + useEffect(() => { + const data = bridge.callJson('getStorageInfo') + if (data) setInfo(data) + }, []) + + function handleClearCache() { + setClearing(true) + bridge.call('clearCache') + setTimeout(() => { + setClearing(false) + const data = bridge.callJson('getStorageInfo') + if (data) setInfo(data) + }, 2000) + } + + const totalUsed = info ? info.bootstrapBytes + info.wwwBytes : 0 + + return ( +
+
+ +
Storage
+
+ + {info && ( + <> +
+ Total used: {formatBytes(totalUsed)} +
+ +
+
+
+
Bootstrap (usr/)
+
{formatBytes(info.bootstrapBytes)}
+
+
+
+
+
+
+ +
+
+
+
Web UI (www/)
+
{formatBytes(info.wwwBytes)}
+
+
+
+
+
+
+ +
+
+
+
Free Space
+
{formatBytes(info.freeBytes)}
+
+
+
+ +
+ +
+ + )} + + {!info && ( +
+ Loading storage info... +
+ )} +
+ ) +} diff --git a/android/www/src/screens/SettingsTools.tsx b/android/www/src/screens/SettingsTools.tsx new file mode 100644 index 0000000..5e9c219 --- /dev/null +++ b/android/www/src/screens/SettingsTools.tsx @@ -0,0 +1,126 @@ +import { useState, useEffect, useCallback } from 'react' +import { useRoute } from '../lib/router' +import { bridge } from '../lib/bridge' +import { useNativeEvent } from '../lib/useNativeEvent' + +interface Tool { + id: string + name: string + desc: string + category: string +} + +const TOOLS: Tool[] = [ + { id: 'tmux', name: 'tmux', desc: 'Terminal multiplexer', category: 'Terminal Tools' }, + { id: 'code-server', name: 'code-server', desc: 'VS Code in browser', category: 'Terminal Tools' }, + { id: 'opencode', name: 'OpenCode', desc: 'AI coding assistant (TUI)', category: 'AI Tools' }, + { id: 'claude-code', name: 'Claude Code', desc: 'Anthropic AI CLI', category: 'AI Tools' }, + { id: 'gemini-cli', name: 'Gemini CLI', desc: 'Google AI CLI', category: 'AI Tools' }, + { id: 'codex-cli', name: 'Codex CLI', desc: 'OpenAI AI CLI', category: 'AI Tools' }, + { id: 'openssh-server', name: 'SSH Server', desc: 'SSH remote access', category: 'Network & Access' }, + { id: 'ttyd', name: 'ttyd', desc: 'Web terminal access', category: 'Network & Access' }, + { id: 'dufs', name: 'dufs', desc: 'File server (WebDAV)', category: 'Network & Access' }, + { id: 'android-tools', name: 'Android Tools', desc: 'ADB for disabling Phantom Process Killer', category: 'System' }, + { id: 'chromium', name: 'Chromium', desc: 'Browser automation (~400MB)', category: 'System' }, +] + +export function SettingsTools() { + const { navigate } = useRoute() + const [installed, setInstalled] = useState>(new Set()) + const [installing, setInstalling] = useState(null) + const [progress, setProgress] = useState(0) + const [progressMsg, setProgressMsg] = useState('') + + useEffect(() => { + // Check installed status for each tool + const result = bridge.callJson>('getInstalledTools') + if (result) { + setInstalled(new Set(result.map(t => t.id))) + } + }, []) + + const onInstallProgress = useCallback((data: unknown) => { + const d = data as { target?: string; progress?: number; message?: string } + if (d.progress !== undefined) setProgress(d.progress) + if (d.message) setProgressMsg(d.message) + if (d.progress !== undefined && d.progress >= 1) { + if (d.target) setInstalled(prev => new Set([...prev, d.target!])) + setInstalling(null) + setProgress(0) + } + }, []) + useNativeEvent('install_progress', onInstallProgress) + + function handleInstall(id: string) { + setInstalling(id) + setProgress(0) + setProgressMsg(`Installing ${id}...`) + bridge.call('installTool', id) + } + + function handleUninstall(id: string) { + bridge.call('uninstallTool', id) + setInstalled(prev => { + const next = new Set(prev) + next.delete(id) + return next + }) + } + + // Group by category + const categories = [...new Set(TOOLS.map(t => t.category))] + + return ( +
+
+ +
Additional Tools
+
+ + {installing && ( +
+
Installing {installing}...
+
+
+
+
+ {progressMsg} +
+
+ )} + + {categories.map(cat => ( +
+
{cat}
+ {TOOLS.filter(t => t.category === cat).map(tool => ( +
+
+
+
{tool.name}
+
{tool.desc}
+
+ {installed.has(tool.id) ? ( + + ) : ( + + )} +
+
+ ))} +
+ ))} +
+ ) +} diff --git a/android/www/src/screens/SettingsUpdates.tsx b/android/www/src/screens/SettingsUpdates.tsx new file mode 100644 index 0000000..a69ad24 --- /dev/null +++ b/android/www/src/screens/SettingsUpdates.tsx @@ -0,0 +1,90 @@ +import { useState, useEffect, useCallback } from 'react' +import { useRoute } from '../lib/router' +import { bridge } from '../lib/bridge' +import { useNativeEvent } from '../lib/useNativeEvent' + +interface UpdateItem { + component: string + currentVersion: string + newVersion: string +} + +export function SettingsUpdates() { + const { navigate } = useRoute() + const [updates, setUpdates] = useState([]) + const [updating, setUpdating] = useState(null) + const [progress, setProgress] = useState(0) + const [checking, setChecking] = useState(true) + + useEffect(() => { + const data = bridge.callJson('checkForUpdates') + setUpdates(data || []) + setChecking(false) + }, []) + + const onProgress = useCallback((data: unknown) => { + const d = data as { target?: string; progress?: number } + if (d.progress !== undefined) setProgress(d.progress) + if (d.progress !== undefined && d.progress >= 1) { + setUpdating(null) + setUpdates(prev => prev.filter(u => u.component !== d.target)) + } + }, []) + useNativeEvent('install_progress', onProgress) + + function handleApply(component: string) { + setUpdating(component) + setProgress(0) + bridge.call('applyUpdate', component) + } + + return ( +
+
+ +
Updates
+
+ + {checking && ( +
+ Checking for updates... +
+ )} + + {!checking && updates.length === 0 && ( +
+ Everything is up to date. +
+ )} + + {updating && ( +
+
Updating {updating}...
+
+
+
+
+ )} + + {updates.map(u => ( +
+
+
+
{u.component}
+
+ {u.currentVersion} → {u.newVersion} +
+
+ +
+
+ ))} +
+ ) +} diff --git a/android/www/src/screens/Setup.tsx b/android/www/src/screens/Setup.tsx new file mode 100644 index 0000000..4e1c3c7 --- /dev/null +++ b/android/www/src/screens/Setup.tsx @@ -0,0 +1,251 @@ +import { useState, useCallback, useEffect, Fragment } from 'react' +import { bridge } from '../lib/bridge' +import { useNativeEvent } from '../lib/useNativeEvent' + +interface Props { + onComplete: () => void +} + +type SetupPhase = 'platform-select' | 'tool-select' | 'installing' | 'done' + +interface Platform { + id: string + name: string + icon: string + desc: string +} + +const OPTIONAL_TOOLS = [ + { id: 'tmux', name: 'tmux', desc: 'Terminal multiplexer for background sessions' }, + { id: 'ttyd', name: 'ttyd', desc: 'Web terminal — access from a browser' }, + { id: 'dufs', name: 'dufs', desc: 'File server (WebDAV)' }, + { id: 'code-server', name: 'code-server', desc: 'VS Code in browser' }, + { id: 'claude-code', name: 'Claude Code', desc: 'Anthropic AI CLI' }, + { id: 'gemini-cli', name: 'Gemini CLI', desc: 'Google AI CLI' }, + { id: 'codex-cli', name: 'Codex CLI', desc: 'OpenAI AI CLI' }, +] + +const TIPS = [ + 'You can install multiple AI platforms and switch between them anytime.', + 'Setup is a one-time process. Future launches are instant.', + 'Once setup is complete, your AI assistant runs at full speed — just like on a computer.', + 'All processing happens locally on your device. Your data never leaves your phone.', +] + +export function Setup({ onComplete }: Props) { + const [phase, setPhase] = useState('platform-select') + const [platforms, setPlatforms] = useState([]) + const [selectedPlatform, setSelectedPlatform] = useState('') + const [selectedTools, setSelectedTools] = useState>(new Set()) + const [progress, setProgress] = useState(0) + const [message, setMessage] = useState('') + const [error, setError] = useState('') + const [tipIndex, setTipIndex] = useState(0) + + // Load available platforms + useEffect(() => { + const data = bridge.callJson('getAvailablePlatforms') + if (data) { + setPlatforms(data) + } else { + setPlatforms([ + { id: 'openclaw', name: 'OpenClaw', icon: '🧠', desc: 'AI agent platform' }, + ]) + } + }, []) + + const onProgress = useCallback((data: unknown) => { + const d = data as { progress?: number; message?: string } + if (d.progress !== undefined) setProgress(d.progress) + if (d.message) setMessage(d.message) + if (d.progress !== undefined && d.progress >= 1) { + setPhase('done') + } + setTipIndex(i => (i + 1) % TIPS.length) + }, []) + + useNativeEvent('setup_progress', onProgress) + + function handleSelectPlatform(id: string) { + setSelectedPlatform(id) + setPhase('tool-select') + } + + function toggleTool(id: string) { + setSelectedTools(prev => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + } + + function handleStartSetup() { + // Save tool selections + const selections: Record = {} + OPTIONAL_TOOLS.forEach(t => { + selections[t.id] = selectedTools.has(t.id) + }) + bridge.call('saveToolSelections', JSON.stringify(selections)) + + // Start bootstrap setup + setPhase('installing') + setProgress(0) + setMessage('Preparing setup...') + setError('') + bridge.call('startSetup') + } + + // --- Stepper --- + const currentStep = phase === 'platform-select' ? 0 + : phase === 'tool-select' ? 1 + : phase === 'installing' ? 2 : 3 + + const STEPS = ['Platform', 'Tools', 'Setup'] + + function renderStepper() { + return ( +
+ {STEPS.map((label, i) => ( + + {i > 0 &&
} +
+ {i < currentStep ? '✓' : i === currentStep ? '●' : '○'} + {label} +
+ + ))} +
+ ) + } + + // --- Platform Select --- + if (phase === 'platform-select') { + return ( +
+ {renderStepper()} +
Choose your platform
+ + {platforms.map(p => ( +
handleSelectPlatform(p.id)} + > +
{p.icon}
+
{p.name}
+
+ {p.desc} +
+
+ ))} + +
More platforms available in Settings.
+
+ ) + } + + // --- Tool Select --- + if (phase === 'tool-select') { + return ( +
+ {renderStepper()} + +
Optional Tools
+
+ Select tools to install alongside {selectedPlatform}. You can always add more later in Settings. +
+ +
+ {OPTIONAL_TOOLS.map(tool => { + const isSelected = selectedTools.has(tool.id) + return ( +
toggleTool(tool.id)} + > +
+
+
{tool.name}
+
{tool.desc}
+
+
+
+
+
+
+ ) + })} +
+ + +
+ ) + } + + // --- Installing --- + if (phase === 'installing') { + const pct = Math.round(progress * 100) + return ( +
+ {renderStepper()} +
Setting up...
+ +
+
+
+
+
+ {pct}% +
+
+ {message} +
+
+ + {error && ( +
{error}
+ )} + +
💡 {TIPS[tipIndex]}
+
+ ) + } + + // --- Done --- + return ( +
+ {renderStepper()} +
+
You're all set!
+
+ The terminal will now install runtime components and your selected tools. This takes 3–10 minutes. +
+ + +
+ ) +} diff --git a/android/www/src/styles/global.css b/android/www/src/styles/global.css new file mode 100644 index 0000000..a84ee5a --- /dev/null +++ b/android/www/src/styles/global.css @@ -0,0 +1,438 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --bg-primary: #0d1117; + --bg-secondary: #161b22; + --bg-tertiary: #21262d; + --text-primary: #f0f6fc; + --text-secondary: #8b949e; + --accent: #58a6ff; + --accent-hover: #79c0ff; + --success: #3fb950; + --warning: #d29922; + --error: #f85149; + --border: #30363d; + --radius: 8px; +} + +html, body, #root { + height: 100%; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + overflow-x: hidden; + -webkit-tap-highlight-color: transparent; + -webkit-user-select: none; + user-select: none; +} + +/* --- Tab bar --- */ +.tab-bar { + display: flex; + position: fixed; + top: 0; + left: 0; + right: 0; + height: 48px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + z-index: 100; +} + +.tab-bar-item { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + font-size: 14px; + font-weight: 500; + color: var(--text-secondary); + cursor: pointer; + transition: color 0.15s; + border: none; + background: none; + position: relative; + min-height: 48px; +} + +.tab-bar-item.active { + color: var(--accent); +} + +.tab-bar-item.active::after { + content: ''; + position: absolute; + bottom: 0; + left: 20%; + right: 20%; + height: 2px; + background: var(--accent); + border-radius: 1px; +} + +.tab-bar-item .badge { + position: absolute; + top: 10px; + right: calc(50% - 30px); + width: 8px; + height: 8px; + background: var(--error); + border-radius: 50%; +} + +/* --- Page container --- */ +.page { + padding: 64px 16px 24px; + min-height: 100vh; +} + +.page-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 24px; +} + +.page-header .back-btn { + background: none; + border: none; + color: var(--accent); + font-size: 18px; + cursor: pointer; + padding: 8px; + margin: -8px; + min-width: 44px; + min-height: 44px; + display: flex; + align-items: center; + justify-content: center; +} + +.page-title { + font-size: 22px; + font-weight: 700; +} + +/* --- Cards --- */ +.card { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px; + margin-bottom: 12px; +} + +.card-row { + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + min-height: 48px; +} + +.card-row .card-icon { + font-size: 20px; + width: 32px; + flex-shrink: 0; +} + +.card-row .card-content { + flex: 1; + margin-left: 4px; +} + +.card-row .card-label { + font-size: 15px; + font-weight: 500; +} + +.card-row .card-desc { + font-size: 12px; + color: var(--text-secondary); + margin-top: 2px; +} + +.card-row .card-chevron { + color: var(--text-secondary); + font-size: 16px; + margin-left: 8px; +} + +.card-row .card-badge { + width: 8px; + height: 8px; + background: var(--error); + border-radius: 50%; + margin-right: 4px; +} + +/* --- Buttons --- */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 12px 24px; + font-size: 15px; + font-weight: 600; + border: none; + border-radius: var(--radius); + cursor: pointer; + transition: all 0.15s; + min-width: 120px; + min-height: 48px; +} + +.btn-primary { + background: var(--accent); + color: #fff; +} + +.btn-primary:active { + background: var(--accent-hover); + transform: scale(0.98); +} + +.btn-secondary { + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border); +} + +.btn-secondary:active { + background: var(--border); +} + +.btn-small { + padding: 6px 16px; + font-size: 13px; + min-width: 80px; + min-height: 36px; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* --- Progress bar --- */ +.progress-bar { + height: 6px; + background: var(--bg-tertiary); + border-radius: 3px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: var(--accent); + border-radius: 3px; + transition: width 0.3s ease; +} + +/* --- Status dots --- */ +.status-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 8px; +} + +.status-dot.success { background: var(--success); } +.status-dot.warning { background: var(--warning); } +.status-dot.error { background: var(--error); } +.status-dot.pending { background: var(--text-secondary); } + +/* --- Stepper --- */ +.stepper { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + padding: 16px 0; +} + +.step { + display: flex; + align-items: center; + gap: 4px; + font-size: 12px; + font-weight: 500; + color: var(--text-secondary); +} + +.step.done { color: var(--success); } +.step.active { color: var(--accent); } + +.step-icon { + width: 20px; + height: 20px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + border: 2px solid var(--text-secondary); + flex-shrink: 0; +} + +.step.done .step-icon { + background: var(--success); + border-color: var(--success); + color: #fff; +} + +.step.active .step-icon { + border-color: var(--accent); + color: var(--accent); + animation: pulse 1.5s infinite; +} + +.step-line { + width: 24px; + height: 2px; + background: var(--text-secondary); + flex-shrink: 0; +} + +.step.done + .step-line, +.step-line.done { + background: var(--success); +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +/* --- Code block --- */ +.code-block { + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 6px; + padding: 12px 16px; + font-family: 'SF Mono', 'Menlo', 'Monaco', 'Courier New', monospace; + font-size: 13px; + color: var(--text-primary); + position: relative; + -webkit-user-select: text; + user-select: text; + word-break: break-all; +} + +.code-block .copy-btn { + position: absolute; + top: 8px; + right: 8px; + background: var(--bg-secondary); + border: 1px solid var(--border); + color: var(--text-secondary); + font-size: 12px; + padding: 4px 10px; + border-radius: 4px; + cursor: pointer; +} + +.code-block .copy-btn:active { + color: var(--accent); +} + +/* --- Section --- */ +.section-title { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + margin: 24px 0 12px; +} + +/* --- Info row --- */ +.info-row { + display: flex; + justify-content: space-between; + padding: 10px 0; + font-size: 14px; + border-bottom: 1px solid var(--border); +} + +.info-row:last-child { + border-bottom: none; +} + +.info-row .label { + color: var(--text-secondary); +} + +/* --- Setup centered container --- */ +.setup-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: 24px; + gap: 20px; +} + +.setup-logo { + font-size: 64px; + line-height: 1; +} + +.setup-title { + font-size: 28px; + font-weight: 700; + text-align: center; +} + +.setup-subtitle { + font-size: 14px; + color: var(--text-secondary); + text-align: center; + max-width: 300px; + line-height: 1.5; +} + +/* --- Tip card --- */ +.tip-card { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 14px 16px; + font-size: 13px; + color: var(--text-secondary); + max-width: 320px; + text-align: center; + line-height: 1.5; +} + +/* --- Storage bar --- */ +.storage-bar { + height: 8px; + background: var(--bg-tertiary); + border-radius: 4px; + overflow: hidden; + margin-top: 8px; +} + +.storage-fill { + height: 100%; + border-radius: 4px; + transition: width 0.3s ease; +} + +/* --- Divider --- */ +.divider { + height: 1px; + background: var(--border); + margin: 16px 0; +} diff --git a/android/www/src/vite-env.d.ts b/android/www/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/android/www/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/android/www/tsconfig.app.json b/android/www/tsconfig.app.json new file mode 100644 index 0000000..a9b5a59 --- /dev/null +++ b/android/www/tsconfig.app.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/android/www/tsconfig.json b/android/www/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/android/www/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/android/www/tsconfig.node.json b/android/www/tsconfig.node.json new file mode 100644 index 0000000..8a67f62 --- /dev/null +++ b/android/www/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/android/www/vite.config.ts b/android/www/vite.config.ts new file mode 100644 index 0000000..5cf836f --- /dev/null +++ b/android/www/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + base: './', + build: { + outDir: 'dist', + assetsDir: 'assets', + sourcemap: false, + minify: 'esbuild', + }, +}) diff --git a/android/www/www.zip b/android/www/www.zip new file mode 100644 index 0000000..4b80029 Binary files /dev/null and b/android/www/www.zip differ diff --git a/bootstrap.sh b/bootstrap.sh index ae17096..67dd227 100755 --- a/bootstrap.sh +++ b/bootstrap.sh @@ -3,11 +3,10 @@ # Usage: curl -sL https://raw.githubusercontent.com/AidanPark/openclaw-android/main/bootstrap.sh | bash set -euo pipefail -REPO_BASE="https://raw.githubusercontent.com/AidanPark/openclaw-android/main" +REPO_TARBALL="https://github.com/AidanPark/openclaw-android/archive/refs/heads/main.tar.gz" INSTALL_DIR="$HOME/.openclaw-android/installer" RED='\033[0;31m' -GREEN='\033[0;32m' BOLD='\033[1m' NC='\033[0m' @@ -15,68 +14,17 @@ echo "" echo -e "${BOLD}OpenClaw on Android - Bootstrap${NC}" echo "" -# Ensure curl is available if ! command -v curl &>/dev/null; then echo -e "${RED}[FAIL]${NC} curl not found. Install it with: pkg install curl" exit 1 fi -# Create installer directory structure -mkdir -p "$INSTALL_DIR"/{patches,scripts,tests} +echo "Downloading installer..." +mkdir -p "$INSTALL_DIR" +curl -sfL "$REPO_TARBALL" | tar xz -C "$INSTALL_DIR" --strip-components=1 -# File list to download -FILES=( - "install.sh" - "uninstall.sh" - "patches/bionic-compat.js" - "patches/patch-paths.sh" - "patches/apply-patches.sh" - "patches/spawn.h" - "patches/termux-compat.h" - "patches/systemctl" - "scripts/check-env.sh" - "scripts/install-deps.sh" - "scripts/setup-paths.sh" - "scripts/setup-env.sh" - "scripts/build-sharp.sh" - "tests/verify-install.sh" - "update.sh" -) - -# Download all files -echo "Downloading installer files..." -FAILED=0 -for f in "${FILES[@]}"; do - if curl -sfL "$REPO_BASE/$f" -o "$INSTALL_DIR/$f"; then - echo -e " ${GREEN}[OK]${NC} $f" - else - echo -e " ${RED}[FAIL]${NC} $f" - FAILED=$((FAILED + 1)) - fi -done - -if [ "$FAILED" -gt 0 ]; then - echo "" - echo -e "${RED}Failed to download $FAILED file(s). Check your internet connection.${NC}" - rm -rf "$INSTALL_DIR" - exit 1 -fi - -# Make scripts executable -chmod +x "$INSTALL_DIR"/*.sh "$INSTALL_DIR"/patches/*.sh "$INSTALL_DIR"/scripts/*.sh "$INSTALL_DIR"/tests/*.sh - -echo "" -echo "Running installer..." -echo "" - -# Run installer bash "$INSTALL_DIR/install.sh" -# Keep uninstall.sh accessible, clean up the rest -# (oaupdate command is already installed by install.sh) cp "$INSTALL_DIR/uninstall.sh" "$HOME/.openclaw-android/uninstall.sh" chmod +x "$HOME/.openclaw-android/uninstall.sh" rm -rf "$INSTALL_DIR" - -echo "Uninstaller saved at: ~/.openclaw-android/uninstall.sh" -echo "To update later: oaupdate && source ~/.bashrc" diff --git a/docs/disable-phantom-process-killer.ko.md b/docs/disable-phantom-process-killer.ko.md new file mode 100644 index 0000000..7917eb9 --- /dev/null +++ b/docs/disable-phantom-process-killer.ko.md @@ -0,0 +1,163 @@ +# Android에서 프로세스 라이브 상태 유지 + +OpenClaw는 서버로 동작하므로 Android의 전원 관리 및 프로세스 종료 기능이 안정적인 운영을 방해할 수 있습니다. 이 가이드에서는 프로세스를 안정적으로 유지하기 위한 모든 설정을 다룹니다. + +## 개발자 옵션 활성화 + +1. **설정** > **휴대전화 정보** (또는 **디바이스 정보**) +2. **빌드 번호**를 7번 연속 탭 +3. "개발자 모드가 활성화되었습니다" 메시지 확인 +4. 잠금화면 비밀번호가 설정되어 있으면 입력 + +> 일부 기기에서는 **설정** > **휴대전화 정보** > **소프트웨어 정보** 안에 빌드 번호가 있습니다. + +## 충전 중 화면 켜짐 유지 (Stay Awake) + +1. **설정** > **개발자 옵션** (위에서 활성화한 메뉴) +2. **화면 켜짐 유지** (Stay awake) 옵션을 **ON** +3. 이제 USB 또는 무선 충전 중에는 화면이 자동으로 꺿지지 않습니다 + +> 충전기를 분리하면 일반 화면 꺿짐 설정이 적용됩니다. 서버를 장시간 운영할 때는 충전기를 연결해두세요. + +## 충전 제한 설정 (필수) + +폰을 24시간 충전 상태로 두면 배터리가 펽창할 수 있습니다. 최대 충전량을 80%로 제한하면 배터리 수명과 안전성이 크게 향상됩니다. + +- **삼성**: **설정** > **배터리** > **배터리 보호** → **최대 80%** 선택 +- **Google Pixel**: **설정** > **배터리** > **배터리 보호** → ON + +> 제조사마다 메뉴 이름이 다를 수 있습니다. "배터리 보호" 또는 "충전 제한"으로 검색하세요. 해당 기능이 없는 기기에서는 충전기를 수동으로 관리하거나 스마트 플러그를 활용할 수 있습니다. + +## 배터리 최적화에서 Termux 제외 + +1. Android **설정** > **배터리** (또는 **배터리 및 기기 관리**) +2. **배터리 최적화** (또는 **앱 절전**) 메뉴 진입 +3. 앱 목록에서 **Termux** 를 찾아서 **최적화하지 않음** (또는 **제한 없음**) 선택 + +> 메뉴 경로는 제조사(삼성, LG 등)와 Android 버전에 따라 다를 수 있습니다. "배터리 최적화 제외" 또는 "앱 절전 해제"로 검색하면 해당 기기의 정확한 경로를 찾을 수 있습니다. + +## Phantom Process Killer 비활성화 (Android 12+) + +Android 12 이상에는 **Phantom Process Killer**라는 기능이 포함되어 있어, 백그라운드 프로세스를 자동으로 종료합니다. 이로 인해 Termux에서 실행 중인 `openclaw gateway`, `sshd`, `ttyd` 등이 예고 없이 종료될 수 있습니다. + +## 증상 + +Termux에서 다음과 같은 메시지가 보이면 Android가 프로세스를 강제 종료한 것입니다: + +``` +[Process completed (signal 9) - press Enter] +``` + +Process completed signal 9 + +Signal 9 (SIGKILL)는 어떤 프로세스도 가로채거나 차단할 수 없습니다 — Android가 OS 수준에서 종료한 것입니다. + +## 요구사항 + +- **Android 12 이상** (Android 11 이하는 해당 없음) +- **Termux**에 `android-tools` 설치 (OpenClaw on Android에 포함) + +## 1단계: Wake Lock 활성화 + +알림 바를 내려서 Termux 알림을 찾으세요. **Acquire wakelock**을 탭하면 Android가 Termux를 중단시키는 것을 방지할 수 있습니다. + +

+ Acquire wakelock 탭 + Wake lock held +

+ +활성화되면 알림에 **"wake lock held"**가 표시되고 버튼이 **Release wakelock**으로 바뀝니다. + +> Wake lock만으로는 Phantom Process Killer를 완전히 막을 수 없습니다. 아래 단계를 계속 진행하세요. + +## 2단계: 무선 디버깅 활성화 + +1. **설정** > **개발자 옵션**으로 이동 +2. **무선 디버깅** (Wireless debugging)을 찾아서 활성화 +3. 확인 다이얼로그가 나타나면 — **"이 네트워크에서 항상 허용"**을 체크하고 **허용** 탭 + +무선 디버깅 허용 + +## 3단계: ADB 설치 (아직 설치하지 않은 경우) + +Termux에서 `android-tools`를 설치합니다: + +```bash +pkg install -y android-tools +``` + +> OpenClaw on Android를 설치했다면 `android-tools`가 이미 포함되어 있습니다. + +## 4단계: ADB 페어링 + +1. **무선 디버깅** 설정에서 **페어링 코드로 기기 페어링** (Pair device with pairing code) 탭 +2. **Wi-Fi 페어링 코드**와 **IP 주소 및 포트**가 표시된 다이얼로그가 나타남 + +페어링 코드 다이얼로그 + +3. Termux에서 화면에 표시된 포트와 코드를 사용하여 페어링 명령을 실행합니다: + +```bash +adb pair localhost:<페어링_포트> <페어링_코드> +``` + +예시: + +```bash +adb pair localhost:39555 269556 +``` + +adb pair 성공 + +`Successfully paired`가 표시되면 성공입니다. + +## 5단계: ADB 연결 + +페어링 후 **무선 디버깅** 메인 화면으로 돌아가세요. 상단에 표시된 **IP 주소 및 포트**를 확인합니다 — 이 포트는 페어링 포트와 다릅니다. + +무선 디버깅 페어링 완료 + +Termux에서 메인 화면에 표시된 포트로 연결합니다: + +```bash +adb connect localhost:<연결_포트> +``` + +예시: + +```bash +adb connect localhost:35541 +``` + +`connected to localhost:35541`이 표시되면 성공입니다. + +> 페어링 포트와 연결 포트는 다릅니다. `adb connect`에는 무선 디버깅 메인 화면에 표시된 포트를 사용하세요. + +## 6단계: Phantom Process Killer 비활성화 + +다음 명령을 실행하여 Phantom Process Killer를 비활성화합니다: + +```bash +adb shell "settings put global settings_enable_monitor_phantom_procs false" +``` + +설정이 적용되었는지 확인합니다: + +```bash +adb shell "settings get global settings_enable_monitor_phantom_procs" +``` + +출력이 `false`이면 Phantom Process Killer가 성공적으로 비활성화된 것입니다. + +Phantom Process Killer 비활성화 완료 + +## 참고 사항 + +- 이 설정은 **재부팅해도 유지**됩니다 — 한 번만 하면 됩니다 +- 이 과정을 완료한 후 무선 디버깅을 켜둘 필요는 없습니다. 꺼도 됩니다 +- 일반 앱 동작에는 영향을 주지 않습니다 — Termux의 백그라운드 프로세스가 종료되는 것만 방지합니다 +- 폰을 초기화하면 이 과정을 다시 수행해야 합니다 + +## 추가 참고 + +일부 제조사(삼성, 샤오미, 화웨이 등)는 자체적으로 공격적인 배터리 최적화를 적용하여 백그라운드 앱을 종료시킬 수 있습니다. Phantom Process Killer를 비활성화한 후에도 프로세스가 종료되는 경우, [dontkillmyapp.com](https://dontkillmyapp.com)에서 기기별 가이드를 확인하세요. diff --git a/docs/disable-phantom-process-killer.md b/docs/disable-phantom-process-killer.md new file mode 100644 index 0000000..8114474 --- /dev/null +++ b/docs/disable-phantom-process-killer.md @@ -0,0 +1,163 @@ +# Keeping Processes Alive on Android + +OpenClaw runs as a server, so Android's power management and process killing can interfere with stable operation. This guide covers all the settings needed to keep your processes running reliably. + +## Enable Developer Options + +1. Go to **Settings** > **About phone** (or **Device information**) +2. Tap **Build number** 7 times +3. You'll see "Developer mode has been enabled" +4. Enter your lock screen password if prompted + +> On some devices, Build number is under **Settings** > **About phone** > **Software information**. + +## Stay Awake While Charging + +1. Go to **Settings** > **Developer options** (the menu you just enabled) +2. Turn on **Stay awake** +3. The screen will now stay on whenever the device is charging (USB or wireless) + +> The screen will still turn off normally when unplugged. Keep the charger connected when running the server for extended periods. + +## Set Charge Limit (Required) + +Keeping a phone plugged in 24/7 at 100% can cause battery swelling. Limiting the maximum charge to 80% greatly improves battery lifespan and safety. + +- **Samsung**: **Settings** > **Battery** > **Battery Protection** → Select **Maximum 80%** +- **Google Pixel**: **Settings** > **Battery** > **Battery Protection** → ON + +> Menu names vary by manufacturer. Search for "battery protection" or "charge limit" in your settings. If your device doesn't have this feature, consider managing the charger manually or using a smart plug. + +## Disable Battery Optimization for Termux + +1. Go to Android **Settings** > **Battery** (or **Battery and device care**) +2. Open **Battery optimization** (or **App power management**) +3. Find **Termux** and set it to **Not optimized** (or **Unrestricted**) + +> The exact menu path varies by manufacturer (Samsung, LG, etc.) and Android version. Search your settings for "battery optimization" to find it. + +## Disable Phantom Process Killer (Android 12+) + +Android 12 and above includes a feature called **Phantom Process Killer** that automatically terminates background processes. This can cause Termux processes like `openclaw gateway`, `sshd`, and `ttyd` to be killed without warning. + +## Symptoms + +If you see this message in Termux, Android has forcibly killed the process: + +``` +[Process completed (signal 9) - press Enter] +``` + +Process completed signal 9 + +Signal 9 (SIGKILL) cannot be caught or blocked by any process — Android terminated it at the OS level. + +## Requirements + +- **Android 12 or higher** (Android 11 and below are not affected) +- **Termux** with `android-tools` installed (included in OpenClaw on Android) + +## Step 1: Acquire Wake Lock + +Pull down the notification bar and find the Termux notification. Tap **Acquire wakelock** to prevent Android from suspending Termux. + +

+ Tap Acquire wakelock + Wake lock held +

+ +Once activated, the notification will show **"wake lock held"** and the button changes to **Release wakelock**. + +> Wake lock alone is not enough to prevent Phantom Process Killer. Continue with the steps below. + +## Step 2: Enable Wireless Debugging + +1. Go to **Settings** > **Developer options** +2. Find and enable **Wireless debugging** +3. A confirmation dialog will appear — check **"Always allow on this network"** and tap **Allow** + +Allow wireless debugging + +## Step 3: Install ADB (if not already installed) + +In Termux, install `android-tools`: + +```bash +pkg install -y android-tools +``` + +> If you installed OpenClaw on Android, `android-tools` is already included. + +## Step 4: Pair with ADB + +1. In **Wireless debugging** settings, tap **Pair device with pairing code** +2. A dialog will show the **Wi-Fi pairing code** and **IP address & Port** + +Pairing code dialog + +3. In Termux, run the pairing command using the port and code shown on screen: + +```bash +adb pair localhost: +``` + +Example: + +```bash +adb pair localhost:39555 269556 +``` + +adb pair success + +You should see `Successfully paired`. + +## Step 5: Connect with ADB + +After pairing, go back to the **Wireless debugging** main screen. Note the **IP address & Port** shown at the top — this is different from the pairing port. + +Wireless debugging paired + +In Termux, connect using the port shown on the main screen: + +```bash +adb connect localhost: +``` + +Example: + +```bash +adb connect localhost:35541 +``` + +You should see `connected to localhost:35541`. + +> The pairing port and connection port are different. Use the port shown on the Wireless debugging main screen for `adb connect`. + +## Step 6: Disable Phantom Process Killer + +Now run the following command to disable Phantom Process Killer: + +```bash +adb shell "settings put global settings_enable_monitor_phantom_procs false" +``` + +Verify the setting: + +```bash +adb shell "settings get global settings_enable_monitor_phantom_procs" +``` + +If the output is `false`, Phantom Process Killer has been successfully disabled. + +Phantom Process Killer disabled + +## Notes + +- This setting **persists across reboots** — you only need to do this once +- You do **not** need to keep Wireless debugging enabled after completing these steps. You can turn it off +- This does not affect normal app behavior — it only prevents Android from killing background processes in Termux +- If you factory reset your phone, you will need to repeat this process + +## Further Reading + +Some manufacturers (Samsung, Xiaomi, Huawei, etc.) apply additional aggressive battery optimization that can kill background apps. If you still experience process termination after disabling Phantom Process Killer, check [dontkillmyapp.com](https://dontkillmyapp.com) for device-specific guides. diff --git a/docs/images/claw-icon.svg b/docs/images/claw-icon.svg new file mode 100644 index 0000000..0f68a6b --- /dev/null +++ b/docs/images/claw-icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/docs/images/signal9/01-signal9-killed.png b/docs/images/signal9/01-signal9-killed.png new file mode 100644 index 0000000..e20176d Binary files /dev/null and b/docs/images/signal9/01-signal9-killed.png differ diff --git a/docs/images/signal9/02-termux-acquire-wakelock.png b/docs/images/signal9/02-termux-acquire-wakelock.png new file mode 100644 index 0000000..58d6726 Binary files /dev/null and b/docs/images/signal9/02-termux-acquire-wakelock.png differ diff --git a/docs/images/signal9/03-termux-wakelock-held.png b/docs/images/signal9/03-termux-wakelock-held.png new file mode 100644 index 0000000..f7fa614 Binary files /dev/null and b/docs/images/signal9/03-termux-wakelock-held.png differ diff --git a/docs/images/signal9/04-wireless-debugging-allow.png b/docs/images/signal9/04-wireless-debugging-allow.png new file mode 100644 index 0000000..970167c Binary files /dev/null and b/docs/images/signal9/04-wireless-debugging-allow.png differ diff --git a/docs/images/signal9/05-pairing-code-dialog.png b/docs/images/signal9/05-pairing-code-dialog.png new file mode 100644 index 0000000..5db4262 Binary files /dev/null and b/docs/images/signal9/05-pairing-code-dialog.png differ diff --git a/docs/images/signal9/06-adb-pair-success.png b/docs/images/signal9/06-adb-pair-success.png new file mode 100644 index 0000000..3bfd3ec Binary files /dev/null and b/docs/images/signal9/06-adb-pair-success.png differ diff --git a/docs/images/signal9/07-wireless-debugging-paired.png b/docs/images/signal9/07-wireless-debugging-paired.png new file mode 100644 index 0000000..bc19b50 Binary files /dev/null and b/docs/images/signal9/07-wireless-debugging-paired.png differ diff --git a/docs/images/signal9/08-adb-disable-ppk-done.png b/docs/images/signal9/08-adb-disable-ppk-done.png new file mode 100644 index 0000000..5395a24 Binary files /dev/null and b/docs/images/signal9/08-adb-disable-ppk-done.png differ diff --git a/docs/termux-ssh-guide.ko.md b/docs/termux-ssh-guide.ko.md index 060f458..ab9868a 100644 --- a/docs/termux-ssh-guide.ko.md +++ b/docs/termux-ssh-guide.ko.md @@ -35,27 +35,13 @@ Retype new password: 1234 ← 같은 비밀번호 다시 입력 > **중요**: `sshd`는 SSH가 아닌, 폰의 Termux 앱에서 직접 실행하세요. -게이트웨이가 이미 탭 1에서 실행 중이라면 새 탭이 필요합니다. 하단 메뉴바의 **햄버거 아이콘(☰)**을 탭하거나, 화면 왼쪽 가장자리에서 오른쪽으로 스와이프하면 (하단 메뉴바 위 영역) 사이드 메뉴가 나타납니다. **NEW SESSION**을 눌러 새 탭을 추가하세요. - -Termux 탭 메뉴 - -새 탭에서 실행합니다: - ```bash sshd ``` 아무 메시지 없이 프롬프트(`$`)가 다시 나오면 정상입니다. -권장 탭 구성: - -- **탭 1**: `openclaw gateway` — 게이트웨이 실행 - -탭 1 - openclaw gateway - -- **탭 2**: `sshd` — 컴퓨터에서 SSH 접속용 - -탭 2 - sshd +sshd 실행 화면 ## 4단계: IP 주소 확인 diff --git a/docs/termux-ssh-guide.md b/docs/termux-ssh-guide.md index 3a94bb4..a2600d0 100644 --- a/docs/termux-ssh-guide.md +++ b/docs/termux-ssh-guide.md @@ -35,27 +35,13 @@ Retype new password: 1234 ← type the same password again > **Important**: Run `sshd` directly in the Termux app on your phone, not via SSH. -If the gateway is already running in Tab 1, you'll need a new tab. Tap the **hamburger icon (☰)** on the bottom menu bar, or swipe right from the left edge of the screen (above the bottom menu bar) to open the side menu. Then tap **NEW SESSION**. - -Termux tab menu - -In the new tab, run: - ```bash sshd ``` If the prompt (`$`) returns with no error message, it's working. -Recommended tab setup: - -- **Tab 1**: `openclaw gateway` — Run the gateway - -Tab 1 - openclaw gateway - -- **Tab 2**: `sshd` — Allow SSH access from your computer - -Tab 2 - sshd +sshd running in Termux ## Step 4: Find the Phone's IP Address diff --git a/docs/troubleshooting.ko.md b/docs/troubleshooting.ko.md old mode 100755 new mode 100644 index ef293f3..4a506e4 --- a/docs/troubleshooting.ko.md +++ b/docs/troubleshooting.ko.md @@ -116,12 +116,14 @@ source ~/.bashrc 또는 Termux 앱을 완전히 종료했다가 다시 여세요. -## "Cannot find module bionic-compat.js" 에러 +## "Cannot find module glibc-compat.js" 에러 ``` -Error: Cannot find module '/data/data/com.termux/files/home/.openclaw-lite/patches/bionic-compat.js' +Error: Cannot find module '/data/data/com.termux/files/home/.openclaw-lite/patches/glibc-compat.js' ``` +> **참고**: 이 문제는 v1.0.0 이전(Bionic) 설치에서만 발생합니다. v1.0.0+(glibc)에서는 `glibc-compat.js`가 node 래퍼 스크립트에 의해 로딩되므로 `NODE_OPTIONS`를 사용하지 않습니다. + ### 원인 `~/.bashrc`의 `NODE_OPTIONS` 환경변수가 이전 설치 경로(`.openclaw-lite`)를 참조하고 있습니다. 프로젝트명이 "OpenClaw Lite"였던 이전 버전에서 업데이트한 경우 발생합니다. @@ -131,7 +133,7 @@ Error: Cannot find module '/data/data/com.termux/files/home/.openclaw-lite/patch 업데이터를 실행하면 환경변수 블록이 갱신됩니다: ```bash -oaupdate && source ~/.bashrc +oa --update && source ~/.bashrc ``` 또는 수동으로 수정: @@ -174,7 +176,9 @@ Reason: global update ### 원인 -`openclaw update`가 npm으로 패키지를 업데이트할 때, npm을 서브프로세스로 실행합니다. `sharp` 네이티브 모듈 컴파일에 필요한 Termux 전용 빌드 환경변수(`CXXFLAGS`, `GYP_DEFINES`, `CPATH`)가 `~/.bashrc`에 설정되어 있지만, 해당 서브프로세스 환경에서는 자동으로 적용되지 않아 빌드가 실패합니다. +**v1.0.0+(glibc)**: `sharp` 모듈은 프리빌트 바이너리(`@img/sharp-linux-arm64`)를 사용하며 glibc 환경에서 네이티브로 로딩됩니다. 이 에러는 드문 — 주로 프리빌트 바이너리가 누락되거나 손상된 경우입니다. + +**v1.0.0 이전(Bionic)**: `openclaw update`가 npm을 서브프로세스로 실행할 때, Termux 전용 빌드 환경변수(`CXXFLAGS`, `GYP_DEFINES`)가 서브프로세스 환경에서 사용 불가하여 네이티브 모듈 컴파일이 실패합니다. ### 영향 @@ -188,10 +192,34 @@ Reason: global update bash ~/.openclaw-android/scripts/build-sharp.sh ``` -또는 `openclaw update` 대신 `oaupdate`를 사용하면, 필요한 환경변수를 자동으로 설정하고 sharp 빌드까지 처리합니다: +또는 `openclaw update` 대신 `oa --update`를 사용하면 sharp를 자동으로 처리합니다: ```bash -oaupdate && source ~/.bashrc +oa --update && source ~/.bashrc +``` + +## `clawdhub` 실행 시 "Cannot find package 'undici'" 에러 + +``` +Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'undici' imported from /data/data/com.termux/files/usr/lib/node_modules/clawdhub/dist/http.js +``` + +### 원인 + +Node.js v24+ Termux 환경에서는 `undici` 패키지가 Node.js에 번들되지 않습니다. `clawdhub`가 HTTP 요청에 `undici`를 사용하지만 찾을 수 없어 실패합니다. + +### 해결 방법 + +업데이터를 실행하면 `clawdhub`와 `undici` 의존성이 자동으로 설치됩니다: + +```bash +oa --update && source ~/.bashrc +``` + +또는 수동으로 수정: + +```bash +cd $(npm root -g)/clawdhub && npm install undici ``` ## "not supported on android" 에러 @@ -200,32 +228,77 @@ oaupdate && source ~/.bashrc Gateway status failed: Error: Gateway service install not supported on android ``` +> **참고**: 이 문제는 v1.0.0 이전(Bionic) 설치에서만 발생합니다. v1.0.0+(glibc)에서는 Node.js가 `process.platform`을 `'linux'`으로 보고하므로 이 에러가 발생하지 않습니다. + ### 원인 -`bionic-compat.js`의 `process.platform` 오버라이드가 적용되지 않은 상태입니다. +**v1.0.0 이전(Bionic)**: `glibc-compat.js`의 `process.platform` 오버라이드가 적용되지 않은 상태입니다. `NODE_OPTIONS`가 설정되지 않았기 때문입니다. ### 해결 방법 -`NODE_OPTIONS` 환경변수가 설정되어 있는지 확인: +어떤 Node.js가 사용되고 있는지 확인: ```bash -echo $NODE_OPTIONS +node -e "console.log(process.platform)" ``` -비어있으면 환경변수를 로드하세요: +`android`가 출력되면 glibc node 래퍼가 사용되지 않고 있는 것입니다. 환경변수를 로드하세요: ```bash source ~/.bashrc ``` -`NODE_OPTIONS`가 설정되어 있는데도 에러가 나면, `bionic-compat.js` 파일이 최신인지 확인: +여전히 `android`가 출력되면, 최신 버전으로 업데이트하세요 (v1.0.0+는 glibc를 사용하여 이 문제를 영구적으로 해결합니다): ```bash -node -e "console.log(process.platform)" +oa --update && source ~/.bashrc +``` + +## `openclaw update` 시 node-llama-cpp 빌드 에러 + ``` +[node-llama-cpp] Cloning ggml-org/llama.cpp (local bundle) +npm error 48% +Update Result: ERROR +``` + +### 원인 + +OpenClaw이 npm으로 업데이트할 때, `node-llama-cpp`의 postinstall 스크립트가 `llama.cpp` 소스를 clone하고 컴파일을 시도합니다. Termux의 빌드 툴체인(`cmake`, `clang`)이 Bionic으로 링크되어 있고 Node.js는 glibc로 실행되므로 — 두 환경이 네이티브 컴파일에 호환되지 않아 실패합니다. + +### 영향 + +**이 에러는 무해합니다.** 프리빌트 `node-llama-cpp` 바이너리(`@node-llama-cpp/linux-arm64`)가 이미 설치되어 있으며 glibc 환경에서 정상 작동합니다. 실패한 소스 빌드가 프리빌트 바이너리를 덮어쓰지 않습니다. + +node-llama-cpp는 선택적 로컬 임베딩에 사용됩니다. 프리빌트 바이너리가 로딩되지 않으면 OpenClaw이 원격 임베딩 프로바이더(OpenAI, Gemini 등)로 자동 fallback합니다. + +### 해결 방법 + +조치가 필요 없습니다. 이 에러는 안전하게 무시할 수 있습니다. 프리빌트 바이너리가 정상 작동하는지 확인하려면: + +```bash +node -e "require('$(npm root -g)/openclaw/node_modules/@node-llama-cpp/linux-arm64/bins/linux-arm64/llama-addon.node'); console.log('OK')" +``` + +## OpenCode 설치 시 EACCES 권한 에러 + +``` +EACCES: Permission denied while installing opencode-ai +Failed to install 118 packages +``` + +### 원인 + +Bun이 패키지 설치 시 하드링크와 심링크를 생성하려고 시도합니다. Android 파일시스템이 이러한 작업을 제한하여 의존성 패키지에서 `EACCES` 에러가 발생합니다. + +### 영향 + +**이 에러는 무해합니다.** 메인 바이너리(`opencode`)는 의존성 링크 실패에도 불구하고 정상적으로 설치됩니다. ld.so 결합과 proot 래퍼가 실행을 처리합니다. + +### 해결 방법 -`android`가 출력되면 파일이 오래된 버전입니다. 재설치하세요: +조치가 필요 없습니다. OpenCode가 정상 작동하는지 확인: ```bash -curl -sL https://raw.githubusercontent.com/AidanPark/openclaw-android/main/bootstrap.sh | bash +opencode --version ``` diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md old mode 100755 new mode 100644 index 20a4c3f..4b8a0f6 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -116,12 +116,14 @@ source ~/.bashrc Or fully close and reopen the Termux app. -## "Cannot find module bionic-compat.js" error +## "Cannot find module glibc-compat.js" error ``` -Error: Cannot find module '/data/data/com.termux/files/home/.openclaw-lite/patches/bionic-compat.js' +Error: Cannot find module '/data/data/com.termux/files/home/.openclaw-lite/patches/glibc-compat.js' ``` +> **Note**: This issue only affects pre-1.0.0 (Bionic) installations. In v1.0.0+ (glibc), `glibc-compat.js` is loaded by the node wrapper script, not `NODE_OPTIONS`. + ### Cause The `NODE_OPTIONS` environment variable in `~/.bashrc` still references the old installation path (`.openclaw-lite`). This happens when updating from an older version where the project was named "OpenClaw Lite". @@ -131,7 +133,7 @@ The `NODE_OPTIONS` environment variable in `~/.bashrc` still references the old Run the updater to refresh the environment variable block: ```bash -oaupdate && source ~/.bashrc +oa --update && source ~/.bashrc ``` Or manually fix it: @@ -174,7 +176,9 @@ Reason: global update ### Cause -When `openclaw update` runs npm to update the package, it spawns npm as a subprocess. The Termux-specific build environment variables required to compile `sharp`'s native module (`CXXFLAGS`, `GYP_DEFINES`, `CPATH`) are set in `~/.bashrc` but are not automatically available in that subprocess context. +**v1.0.0+ (glibc)**: The `sharp` module uses prebuilt binaries (`@img/sharp-linux-arm64`) that load natively under the glibc environment. This error is rare — it typically means the prebuilt binary is missing or corrupted. + +**Pre-1.0.0 (Bionic)**: When `openclaw update` ran npm as a subprocess, the Termux-specific build environment variables (`CXXFLAGS`, `GYP_DEFINES`) were not available in the subprocess context, causing the native module compilation to fail. ### Impact @@ -188,10 +192,34 @@ After the update, manually rebuild `sharp` using the provided script: bash ~/.openclaw-android/scripts/build-sharp.sh ``` -Alternatively, use `oaupdate` instead of `openclaw update` — it sets the required environment variables and rebuilds sharp automatically: +Alternatively, use `oa --update` instead of `openclaw update` — it handles sharp automatically: + +```bash +oa --update && source ~/.bashrc +``` + +## `clawdhub` fails with "Cannot find package 'undici'" + +``` +Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'undici' imported from /data/data/com.termux/files/usr/lib/node_modules/clawdhub/dist/http.js +``` + +### Cause + +Node.js v24+ on Termux doesn't bundle the `undici` package, which `clawdhub` depends on for HTTP requests. + +### Solution + +Run the updater to automatically install `clawdhub` and its `undici` dependency: + +```bash +oa --update && source ~/.bashrc +``` + +Or fix it manually: ```bash -oaupdate && source ~/.bashrc +cd $(npm root -g)/clawdhub && npm install undici ``` ## "not supported on android" error @@ -200,32 +228,77 @@ oaupdate && source ~/.bashrc Gateway status failed: Error: Gateway service install not supported on android ``` +> **Note**: This issue only affects pre-1.0.0 (Bionic) installations. In v1.0.0+ (glibc), Node.js natively reports `process.platform` as `'linux'`, so this error does not occur. + ### Cause -The `process.platform` override in `bionic-compat.js` is not being applied. +**Pre-1.0.0 (Bionic)**: The `process.platform` override in `glibc-compat.js` is not being applied because `NODE_OPTIONS` is not set. ### Solution -Check if the `NODE_OPTIONS` environment variable is set: +Check which Node.js is being used: ```bash -echo $NODE_OPTIONS +node -e "console.log(process.platform)" ``` -If empty, load the environment: +If it prints `android`, the glibc node wrapper is not being used. Load the environment: ```bash source ~/.bashrc ``` -If `NODE_OPTIONS` is set but the error persists, check if the file is up to date: +If it still prints `android`, update to the latest version (v1.0.0+ uses glibc and resolves this permanently): ```bash -node -e "console.log(process.platform)" +oa --update && source ~/.bashrc +``` + +## `openclaw update` fails with node-llama-cpp build error + +``` +[node-llama-cpp] Cloning ggml-org/llama.cpp (local bundle) +npm error 48% +Update Result: ERROR +``` + +### Cause + +When OpenClaw updates via npm, `node-llama-cpp`'s postinstall script attempts to clone and compile `llama.cpp` from source. This fails on Termux because the build toolchain (`cmake`, `clang`) is linked against Bionic, while Node.js runs under glibc — the two are incompatible for native compilation. + +### Impact + +**This error is harmless.** The prebuilt `node-llama-cpp` binaries (`@node-llama-cpp/linux-arm64`) are already installed and work correctly under the glibc environment. The failed source build does not overwrite them. + +Node-llama-cpp is used for optional local embeddings. If the prebuilt binaries don't load, OpenClaw automatically falls back to remote embedding providers (OpenAI, Gemini, etc.). + +### Solution + +No action needed. The error can be safely ignored. To verify that the prebuilt binaries are working: + +```bash +node -e "require('$(npm root -g)/openclaw/node_modules/@node-llama-cpp/linux-arm64/bins/linux-arm64/llama-addon.node'); console.log('OK')" +``` + +## OpenCode install shows EACCES permission errors + ``` +EACCES: Permission denied while installing opencode-ai +Failed to install 118 packages +``` + +### Cause + +Bun attempts to create hardlinks and symlinks when installing packages. Android's filesystem restricts these operations, causing `EACCES` errors for dependency packages. + +### Impact + +**These errors are harmless.** The main binary (`opencode`) is installed correctly despite the dependency link failures. The ld.so concatenation and proot wrapper handle execution. + +### Solution -If it prints `android`, the file is outdated. Reinstall: +No action needed. Verify that OpenCode works: ```bash -curl -sL https://raw.githubusercontent.com/AidanPark/openclaw-android/main/bootstrap.sh | bash +opencode --version ``` diff --git a/doit.md b/doit.md new file mode 100644 index 0000000..20357f2 --- /dev/null +++ b/doit.md @@ -0,0 +1,17 @@ + +작업 지시서 + +1. openclaw 앱 설치한다. +2. 앱 설치후 앱의 터미널 창에서 'openclaw onboard' 를 진행한다. +2. 'openclaw onboard' 에서 아무것도 설치하지 않는다. +3. llm 은 제미나이, 키는 AIzaSyBWxFwDi_EVCALxjhjesQBcLyxzpVttMlU +4. 'openclaw onboard' 에서 그밖의 설정 필요한 것이 있으면 정보입력을 최소화 하는 방향으로 설정. +5. 'openclaw onboard' 가 끝나면 'openclaw gateway' 실행. +6. 실행이 잘 되면 메인화면으로 가서 탭 전환이 잘 되는지 확인. +7. settings 와 dashboard 는 openclaw 설치나 live 상황을 잘 반영하고 있나 확인. +8. oa 명령에 해당하는 ui 기능이 제공되어야 한다. +9. 테스트 항목을 작성하고 테스트 항목에 따라 테스트를 진행한다.테스트 항목에는 모든 기능 확인내용이 들어가야 한다. +10. 오류가 발생하면 수정하고 오류발생 직전단계 부터 다시 테스트를 진행. +11. 작업이 완료되면 완료보고서를 작성하고 완료시각을 적는다. +12. 앱네임 변경. 'Claw on Android' +12. 커밋 푸시한다. 커밋 메시지 승인은 미리 승인함. diff --git a/index.html b/index.html deleted file mode 100644 index fdd80fe..0000000 --- a/index.html +++ /dev/null @@ -1,1318 +0,0 @@ - - - - - - -My OpenClaw Hub — Manage OpenClaw Servers from Your Browser - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
-

My OpenClaw Hub

-
- - -
-
- -
-
My OpenClaw Connections
- -
- -
-
-
-
-

Your settings are saved locally in your browser and never sent to any server.

-
- Export - / - Import - -
-
- -
Connection Info
-
- - -
- - - -
-
- - - - - diff --git a/install-tools.sh b/install-tools.sh new file mode 100644 index 0000000..62458d3 --- /dev/null +++ b/install-tools.sh @@ -0,0 +1,216 @@ +#!/usr/bin/env bash +# ============================================================================= +# install-tools.sh — 번들 제공 도구 설치 +# +# oa --install 로 실행. 초기 설치 시 설치하지 않은 도구를 나중에 설치할 수 있다. +# 이미 설치된 도구는 [INSTALLED]로 표시하고 건너뛴다. +# ============================================================================= +set -euo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BOLD='\033[1m' +NC='\033[0m' + +PROJECT_DIR="$HOME/.openclaw-android" +PLATFORM_MARKER="$PROJECT_DIR/.platform" +OA_VERSION="1.0.6" +REPO_TARBALL="https://github.com/AidanPark/openclaw-android/archive/refs/heads/main.tar.gz" + +echo "" +echo -e "${BOLD}========================================${NC}" +echo -e "${BOLD} OpenClaw on Android - Install Tools${NC}" +echo -e "${BOLD}========================================${NC}" +echo "" + +# --- Pre-checks --- +if [ -z "${PREFIX:-}" ]; then + echo -e "${RED}[FAIL]${NC} Not running in Termux (\$PREFIX not set)" + exit 1 +fi + +if ! command -v curl &>/dev/null; then + echo -e "${RED}[FAIL]${NC} curl not found. Install it with: pkg install curl" + exit 1 +fi + +if [ -f "$PROJECT_DIR/scripts/lib.sh" ]; then + source "$PROJECT_DIR/scripts/lib.sh" +fi + +if ! declare -f ask_yn &>/dev/null; then + ask_yn() { + local prompt="$1" + local reply + read -rp "$prompt [Y/n] " reply < /dev/tty + [[ "${reply:-}" =~ ^[Nn]$ ]] && return 1 + return 0 + } +fi + +IS_GLIBC=false +if [ -f "$PROJECT_DIR/.glibc-arch" ]; then + IS_GLIBC=true +fi + +# --- Detect installed tools --- +echo -e "${BOLD}Checking installed tools...${NC}" +echo "" + +declare -A TOOL_STATUS + +check_tool() { + local name="$1" + local cmd="$2" + if command -v "$cmd" &>/dev/null; then + TOOL_STATUS["$name"]="installed" + echo -e " ${GREEN}[INSTALLED]${NC} $name" + else + TOOL_STATUS["$name"]="not_installed" + echo -e " ${YELLOW}[NOT INSTALLED]${NC} $name" + fi +} + +check_tool "tmux" "tmux" +check_tool "ttyd" "ttyd" +check_tool "dufs" "dufs" +check_tool "android-tools" "adb" +check_tool "Chromium" "chromium-browser" +check_tool "code-server" "code-server" +if [ "$IS_GLIBC" = true ]; then + check_tool "OpenCode" "opencode" +fi +check_tool "Claude Code" "claude" +check_tool "Gemini CLI" "gemini" +check_tool "Codex CLI" "codex" + +echo "" + +# --- Check if anything to install --- +HAS_UNINSTALLED=false +for status in "${TOOL_STATUS[@]}"; do + if [ "$status" = "not_installed" ]; then + HAS_UNINSTALLED=true + break + fi +done + +if [ "$HAS_UNINSTALLED" = false ]; then + echo -e "${GREEN}All available tools are already installed.${NC}" + echo "" + exit 0 +fi + +# --- Collect selections --- +echo -e "${BOLD}Select tools to install:${NC}" +echo "" + +INSTALL_TMUX=false +INSTALL_TTYD=false +INSTALL_DUFS=false +INSTALL_ANDROID_TOOLS=false +INSTALL_CODE_SERVER=false +INSTALL_OPENCODE=false +INSTALL_CLAUDE_CODE=false +INSTALL_GEMINI_CLI=false +INSTALL_CODEX_CLI=false +INSTALL_CHROMIUM=false + +[ "${TOOL_STATUS[tmux]}" = "not_installed" ] && ask_yn " Install tmux (terminal multiplexer)?" && INSTALL_TMUX=true || true +[ "${TOOL_STATUS[ttyd]}" = "not_installed" ] && ask_yn " Install ttyd (web terminal)?" && INSTALL_TTYD=true || true +[ "${TOOL_STATUS[dufs]}" = "not_installed" ] && ask_yn " Install dufs (file server)?" && INSTALL_DUFS=true || true +[ "${TOOL_STATUS[android-tools]}" = "not_installed" ] && ask_yn " Install android-tools (adb)?" && INSTALL_ANDROID_TOOLS=true || true +[ "${TOOL_STATUS[Chromium]}" = "not_installed" ] && ask_yn " Install Chromium (browser automation, ~400MB)?" && INSTALL_CHROMIUM=true || true +[ "${TOOL_STATUS[code-server]}" = "not_installed" ] && ask_yn " Install code-server (browser IDE)?" && INSTALL_CODE_SERVER=true || true +if [ "$IS_GLIBC" = true ] && [ "${TOOL_STATUS[OpenCode]}" = "not_installed" ]; then + ask_yn " Install OpenCode (AI coding assistant)?" && INSTALL_OPENCODE=true || true +fi +[ "${TOOL_STATUS[Claude Code]}" = "not_installed" ] && ask_yn " Install Claude Code CLI?" && INSTALL_CLAUDE_CODE=true || true +[ "${TOOL_STATUS[Gemini CLI]}" = "not_installed" ] && ask_yn " Install Gemini CLI?" && INSTALL_GEMINI_CLI=true || true +[ "${TOOL_STATUS[Codex CLI]}" = "not_installed" ] && ask_yn " Install Codex CLI?" && INSTALL_CODEX_CLI=true || true + +# --- Check if anything selected --- +ANYTHING_SELECTED=false +for var in INSTALL_TMUX INSTALL_TTYD INSTALL_DUFS INSTALL_ANDROID_TOOLS \ + INSTALL_CHROMIUM INSTALL_CODE_SERVER INSTALL_OPENCODE INSTALL_CLAUDE_CODE \ + INSTALL_GEMINI_CLI INSTALL_CODEX_CLI; do + if [ "${!var}" = true ]; then + ANYTHING_SELECTED=true + break + fi +done + +if [ "$ANYTHING_SELECTED" = false ]; then + echo "" + echo "No tools selected." + exit 0 +fi + +# --- Download scripts (needed for code-server and OpenCode) --- +NEEDS_TARBALL=false +if [ "$INSTALL_CODE_SERVER" = true ] || [ "$INSTALL_OPENCODE" = true ] || [ "$INSTALL_CHROMIUM" = true ]; then + NEEDS_TARBALL=true +fi + +if [ "$NEEDS_TARBALL" = true ]; then + echo "" + echo "Downloading install scripts..." + mkdir -p "$PREFIX/tmp" + RELEASE_TMP=$(mktemp -d "$PREFIX/tmp/oa-install.XXXXXX") || { + echo -e "${RED}[FAIL]${NC} Failed to create temp directory" + exit 1 + } + trap 'rm -rf "$RELEASE_TMP"' EXIT + + if curl -sfL "$REPO_TARBALL" | tar xz -C "$RELEASE_TMP" --strip-components=1; then + echo -e "${GREEN}[OK]${NC} Downloaded install scripts" + else + echo -e "${RED}[FAIL]${NC} Failed to download scripts" + exit 1 + fi +fi + +# --- Install selected tools --- +echo "" +echo -e "${BOLD}Installing selected tools...${NC}" +echo "" + +[ "$INSTALL_TMUX" = true ] && echo "Installing tmux..." && pkg install -y tmux && echo -e "${GREEN}[OK]${NC} tmux installed" || true +[ "$INSTALL_TTYD" = true ] && echo "Installing ttyd..." && pkg install -y ttyd && echo -e "${GREEN}[OK]${NC} ttyd installed" || true +[ "$INSTALL_DUFS" = true ] && echo "Installing dufs..." && pkg install -y dufs && echo -e "${GREEN}[OK]${NC} dufs installed" || true +[ "$INSTALL_ANDROID_TOOLS" = true ] && echo "Installing android-tools..." && pkg install -y android-tools && echo -e "${GREEN}[OK]${NC} android-tools installed" || true + +if [ "$INSTALL_CODE_SERVER" = true ]; then + mkdir -p "$PROJECT_DIR/patches" + cp "$RELEASE_TMP/patches/argon2-stub.js" "$PROJECT_DIR/patches/argon2-stub.js" + if bash "$RELEASE_TMP/scripts/install-code-server.sh" install; then + echo -e "${GREEN}[OK]${NC} code-server installed" + else + echo -e "${YELLOW}[WARN]${NC} code-server installation failed (non-critical)" + fi +fi + +if [ "$INSTALL_OPENCODE" = true ]; then + if bash "$RELEASE_TMP/scripts/install-opencode.sh"; then + echo -e "${GREEN}[OK]${NC} OpenCode installed" + else + echo -e "${YELLOW}[WARN]${NC} OpenCode installation failed (non-critical)" + fi +fi + +if [ "$INSTALL_CHROMIUM" = true ]; then + if bash "$RELEASE_TMP/scripts/install-chromium.sh" install; then + echo -e "${GREEN}[OK]${NC} Chromium installed" + else + echo -e "${YELLOW}[WARN]${NC} Chromium installation failed (non-critical)" + fi +fi + +[ "$INSTALL_CLAUDE_CODE" = true ] && echo "Installing Claude Code..." && npm install -g @anthropic-ai/claude-code && echo -e "${GREEN}[OK]${NC} Claude Code installed" || true +[ "$INSTALL_GEMINI_CLI" = true ] && echo "Installing Gemini CLI..." && npm install -g @google/gemini-cli && echo -e "${GREEN}[OK]${NC} Gemini CLI installed" || true +[ "$INSTALL_CODEX_CLI" = true ] && echo "Installing Codex CLI..." && npm install -g @openai/codex && echo -e "${GREEN}[OK]${NC} Codex CLI installed" || true + +echo "" +echo -e "${GREEN}${BOLD} Installation Complete!${NC}" +echo "" diff --git a/install.sh b/install.sh index de8521d..0eb88d0 100755 --- a/install.sh +++ b/install.sh @@ -1,118 +1,133 @@ #!/usr/bin/env bash -# install.sh - One-click installer for OpenClaw on Termux (Android) -# Usage: bash install.sh set -euo pipefail -GREEN='\033[0;32m' -BOLD='\033[1m' -NC='\033[0m' - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/scripts/lib.sh" echo "" echo -e "${BOLD}========================================${NC}" -echo -e "${BOLD} OpenClaw on Android - Installer${NC}" +echo -e "${BOLD} OpenClaw on Android - Installer v${OA_VERSION}${NC}" echo -e "${BOLD}========================================${NC}" echo "" -echo "This script installs OpenClaw on Termux without proot-distro." +echo "This script installs OpenClaw on Termux with platform-aware architecture." echo "" step() { echo "" - echo -e "${BOLD}[$1/7] $2${NC}" + echo -e "${BOLD}[$1/8] $2${NC}" echo "----------------------------------------" } -# ───────────────────────────────────────────── step 1 "Environment Check" +if command -v termux-wake-lock &>/dev/null; then + termux-wake-lock 2>/dev/null || true + echo -e "${GREEN}[OK]${NC} Termux wake lock enabled" +fi bash "$SCRIPT_DIR/scripts/check-env.sh" -# ───────────────────────────────────────────── -step 2 "Installing Dependencies" -bash "$SCRIPT_DIR/scripts/install-deps.sh" - -# ───────────────────────────────────────────── -step 3 "Setting Up Paths" +step 2 "Platform Selection" +SELECTED_PLATFORM="openclaw" +echo -e "${GREEN}[OK]${NC} Platform: OpenClaw" +load_platform_config "$SELECTED_PLATFORM" "$SCRIPT_DIR" + +step 3 "Optional Tools Selection (L3)" +INSTALL_TMUX=false +INSTALL_TTYD=false +INSTALL_DUFS=false +INSTALL_ANDROID_TOOLS=false +INSTALL_CODE_SERVER=false +INSTALL_OPENCODE=false +INSTALL_CLAUDE_CODE=false +INSTALL_GEMINI_CLI=false +INSTALL_CODEX_CLI=false +INSTALL_CHROMIUM=false + +if ask_yn "Install tmux (terminal multiplexer)?"; then INSTALL_TMUX=true; fi +if ask_yn "Install ttyd (web terminal)?"; then INSTALL_TTYD=true; fi +if ask_yn "Install dufs (file server)?"; then INSTALL_DUFS=true; fi +if ask_yn "Install android-tools (adb)?"; then INSTALL_ANDROID_TOOLS=true; fi +if ask_yn "Install Chromium (browser automation for OpenClaw, ~400MB)?"; then INSTALL_CHROMIUM=true; fi +if ask_yn "Install code-server (browser IDE)?"; then INSTALL_CODE_SERVER=true; fi +if ask_yn "Install OpenCode (AI coding assistant)?"; then INSTALL_OPENCODE=true; fi +if ask_yn "Install Claude Code CLI?"; then INSTALL_CLAUDE_CODE=true; fi +if ask_yn "Install Gemini CLI?"; then INSTALL_GEMINI_CLI=true; fi +if ask_yn "Install Codex CLI?"; then INSTALL_CODEX_CLI=true; fi + +step 4 "Core Infrastructure (L1)" +bash "$SCRIPT_DIR/scripts/install-infra-deps.sh" bash "$SCRIPT_DIR/scripts/setup-paths.sh" -# ───────────────────────────────────────────── -step 4 "Configuring Environment Variables" -bash "$SCRIPT_DIR/scripts/setup-env.sh" +step 5 "Platform Runtime Dependencies (L2)" +[ "${PLATFORM_NEEDS_GLIBC:-false}" = true ] && bash "$SCRIPT_DIR/scripts/install-glibc.sh" || true +[ "${PLATFORM_NEEDS_NODEJS:-false}" = true ] && bash "$SCRIPT_DIR/scripts/install-nodejs.sh" || true +[ "${PLATFORM_NEEDS_BUILD_TOOLS:-false}" = true ] && bash "$SCRIPT_DIR/scripts/install-build-tools.sh" || true +[ "${PLATFORM_NEEDS_PROOT:-false}" = true ] && pkg install -y proot || true -# Source the new environment for current session +# Source environment for current session (needed by platform install) +GLIBC_NODE_DIR="$PROJECT_DIR/node" +export PATH="$GLIBC_NODE_DIR/bin:$HOME/.local/bin:$PATH" export TMPDIR="$PREFIX/tmp" export TMP="$TMPDIR" export TEMP="$TMPDIR" -export NODE_OPTIONS="-r $HOME/.openclaw-android/patches/bionic-compat.js" -export CONTAINER=1 -export CFLAGS="-Wno-error=implicit-function-declaration" -export CXXFLAGS="-include $HOME/.openclaw-android/patches/termux-compat.h" -export GYP_DEFINES="OS=linux android_ndk_path=$PREFIX" -export CPATH="$PREFIX/include/glib-2.0:$PREFIX/lib/glib-2.0/include" - -# ───────────────────────────────────────────── -step 5 "Installing OpenClaw" - -# Apply bionic-compat.js first (needed for npm install) -echo "Copying compatibility patches..." -mkdir -p "$HOME/.openclaw-android/patches" -cp "$SCRIPT_DIR/patches/bionic-compat.js" "$HOME/.openclaw-android/patches/bionic-compat.js" -echo -e "${GREEN}[OK]${NC} bionic-compat.js installed" - -cp "$SCRIPT_DIR/patches/termux-compat.h" "$HOME/.openclaw-android/patches/termux-compat.h" -echo -e "${GREEN}[OK]${NC} termux-compat.h installed" - -# Install spawn.h stub if missing (needed for koffi/native module builds) -if [ ! -f "$PREFIX/include/spawn.h" ]; then - cp "$SCRIPT_DIR/patches/spawn.h" "$PREFIX/include/spawn.h" - echo -e "${GREEN}[OK]${NC} spawn.h stub installed" -else - echo -e "${GREEN}[OK]${NC} spawn.h already exists" +export OA_GLIBC=1 + +step 6 "Platform Package Install (L2)" +bash "$SCRIPT_DIR/platforms/$SELECTED_PLATFORM/install.sh" + +echo "" +echo -e "${BOLD}[6.5] Environment Variables + CLI + Marker${NC}" +echo "----------------------------------------" +bash "$SCRIPT_DIR/scripts/setup-env.sh" + +PLATFORM_ENV_SCRIPT="$SCRIPT_DIR/platforms/$SELECTED_PLATFORM/env.sh" +if [ -f "$PLATFORM_ENV_SCRIPT" ]; then + eval "$(bash "$PLATFORM_ENV_SCRIPT")" fi -# Install oaupdate command (update.sh wrapper → $PREFIX/bin/oaupdate) +mkdir -p "$PROJECT_DIR" +echo "$SELECTED_PLATFORM" > "$PLATFORM_MARKER" + +cp "$SCRIPT_DIR/oa.sh" "$PREFIX/bin/oa" +chmod +x "$PREFIX/bin/oa" cp "$SCRIPT_DIR/update.sh" "$PREFIX/bin/oaupdate" chmod +x "$PREFIX/bin/oaupdate" -echo -e "${GREEN}[OK]${NC} oaupdate command installed" -echo "" -echo "Running: npm install -g openclaw@latest" -echo "This may take several minutes..." -echo "" +cp "$SCRIPT_DIR/uninstall.sh" "$PROJECT_DIR/uninstall.sh" +chmod +x "$PROJECT_DIR/uninstall.sh" -npm install -g openclaw@latest +mkdir -p "$PROJECT_DIR/scripts" +mkdir -p "$PROJECT_DIR/platforms" +cp "$SCRIPT_DIR/scripts/lib.sh" "$PROJECT_DIR/scripts/lib.sh" +cp "$SCRIPT_DIR/scripts/setup-env.sh" "$PROJECT_DIR/scripts/setup-env.sh" +rm -rf "$PROJECT_DIR/platforms/$SELECTED_PLATFORM" +cp -R "$SCRIPT_DIR/platforms/$SELECTED_PLATFORM" "$PROJECT_DIR/platforms/$SELECTED_PLATFORM" -echo "" -echo -e "${GREEN}[OK]${NC} OpenClaw installed" +step 7 "Install Optional Tools (L3)" +[ "$INSTALL_TMUX" = true ] && pkg install -y tmux || true +[ "$INSTALL_TTYD" = true ] && pkg install -y ttyd || true +[ "$INSTALL_DUFS" = true ] && pkg install -y dufs || true +[ "$INSTALL_ANDROID_TOOLS" = true ] && pkg install -y android-tools || true -# Apply path patches to installed modules -echo "" -bash "$SCRIPT_DIR/patches/apply-patches.sh" +[ "$INSTALL_CHROMIUM" = true ] && bash "$SCRIPT_DIR/scripts/install-chromium.sh" install || true -# Build sharp for image processing (non-critical) -echo "" -bash "$SCRIPT_DIR/scripts/build-sharp.sh" +[ "$INSTALL_CODE_SERVER" = true ] && mkdir -p "$PROJECT_DIR/patches" && cp "$SCRIPT_DIR/patches/argon2-stub.js" "$PROJECT_DIR/patches/argon2-stub.js" && bash "$SCRIPT_DIR/scripts/install-code-server.sh" install || true -# ───────────────────────────────────────────── -step 6 "Verifying Installation" -bash "$SCRIPT_DIR/tests/verify-install.sh" +[ "$INSTALL_OPENCODE" = true ] && bash "$SCRIPT_DIR/scripts/install-opencode.sh" install || true -# ───────────────────────────────────────────── -step 7 "Updating OpenClaw" -echo "Running: openclaw update" -echo "" -openclaw update || true +[ "$INSTALL_CLAUDE_CODE" = true ] && npm install -g @anthropic-ai/claude-code || true +[ "$INSTALL_GEMINI_CLI" = true ] && npm install -g @google/gemini-cli || true +[ "$INSTALL_CODEX_CLI" = true ] && npm install -g @openai/codex || true + +step 8 "Verification" +bash "$SCRIPT_DIR/tests/verify-install.sh" echo "" echo -e "${BOLD}========================================${NC}" echo -e "${GREEN}${BOLD} Installation Complete!${NC}" echo -e "${BOLD}========================================${NC}" echo "" -echo -e " OpenClaw $(openclaw --version)" +echo -e " $PLATFORM_NAME $($PLATFORM_VERSION_CMD 2>/dev/null || echo '')" echo "" echo "Next step:" -echo " Run 'openclaw onboard' to start setup." -echo "" -echo "To update: oaupdate && source ~/.bashrc" -echo "To uninstall: bash ~/.openclaw-android/uninstall.sh" +echo " $PLATFORM_POST_INSTALL_MSG" echo "" diff --git a/oa.sh b/oa.sh new file mode 100644 index 0000000..9e959ae --- /dev/null +++ b/oa.sh @@ -0,0 +1,195 @@ +#!/usr/bin/env bash +set -euo pipefail + +PROJECT_DIR="$HOME/.openclaw-android" + +if [ -f "$HOME/.openclaw-android/scripts/lib.sh" ]; then + # shellcheck source=/dev/null + source "$HOME/.openclaw-android/scripts/lib.sh" +else + OA_VERSION="1.0.6" + RED='\033[0;31m' + GREEN='\033[0;32m' + YELLOW='\033[1;33m' + BOLD='\033[1m' + NC='\033[0m' + REPO_BASE="https://raw.githubusercontent.com/AidanPark/openclaw-android/main" + PLATFORM_MARKER="$PROJECT_DIR/.platform" + + detect_platform() { + if [ -f "$PLATFORM_MARKER" ]; then + cat "$PLATFORM_MARKER" + return 0 + fi + return 1 + } +fi + +show_help() { + echo "" + echo -e "${BOLD}oa${NC} — OpenClaw on Android CLI v${OA_VERSION}" + echo "" + echo "Usage: oa [option]" + echo "" + echo "Options:" + echo " --update Update OpenClaw and Android patches" + echo " --install Install optional tools (tmux, code-server, AI CLIs, etc.)" + echo " --uninstall Remove OpenClaw on Android" + echo " --status Show installation status and all components" + echo " --version, -v Show version" + echo " --help, -h Show this help message" + echo "" +} + +show_version() { + echo "oa v${OA_VERSION} (OpenClaw on Android)" + + local latest + latest=$(curl -sfL --max-time 3 "$REPO_BASE/scripts/lib.sh" 2>/dev/null \ + | grep -m1 '^OA_VERSION=' | cut -d'"' -f2) || true + + if [ -n "${latest:-}" ]; then + if [ "$latest" = "$OA_VERSION" ]; then + echo -e " ${GREEN}Up to date${NC}" + else + echo -e " ${YELLOW}v${latest} available${NC} - run: oa --update" + fi + fi +} + +cmd_update() { + if ! command -v curl &>/dev/null; then + echo -e "${RED}[FAIL]${NC} curl not found. Install it with: pkg install curl" + exit 1 + fi + + mkdir -p "$PROJECT_DIR" + local LOGFILE="$PROJECT_DIR/update.log" + + local TMPFILE + TMPFILE=$(mktemp "${PREFIX:-/tmp}/tmp/update-core.XXXXXX.sh" 2>/dev/null) \ + || TMPFILE=$(mktemp /tmp/update-core.XXXXXX.sh) + + if ! curl -sfL "$REPO_BASE/update-core.sh" -o "$TMPFILE"; then + rm -f "$TMPFILE" + echo -e "${RED}[FAIL]${NC} Failed to download update-core.sh" + exit 1 + fi + + bash "$TMPFILE" 2>&1 | tee "$LOGFILE" + rm -f "$TMPFILE" + + echo "" + echo -e "${YELLOW}Log saved to $LOGFILE${NC}" +} + +cmd_uninstall() { + local UNINSTALL_SCRIPT="$PROJECT_DIR/uninstall.sh" + + if [ ! -f "$UNINSTALL_SCRIPT" ]; then + echo -e "${RED}[FAIL]${NC} Uninstall script not found at $UNINSTALL_SCRIPT" + echo "" + echo "You can download it manually:" + echo " curl -sL $REPO_BASE/uninstall.sh -o $UNINSTALL_SCRIPT && chmod +x $UNINSTALL_SCRIPT" + exit 1 + fi + + bash "$UNINSTALL_SCRIPT" +} + +cmd_status() { + echo "" + echo -e "${BOLD}========================================${NC}" + echo -e "${BOLD} OpenClaw on Android — Status${NC}" + echo -e "${BOLD}========================================${NC}" + + echo "" + echo -e "${BOLD}Version${NC}" + echo " oa: v${OA_VERSION}" + + local PLATFORM + PLATFORM=$(detect_platform 2>/dev/null) || PLATFORM="" + if [ -n "$PLATFORM" ]; then + echo " Platform: $PLATFORM" + else + echo -e " Platform: ${RED}not detected${NC}" + fi + + echo "" + echo -e "${BOLD}Environment${NC}" + echo " PREFIX: ${PREFIX:-not set}" + echo " TMPDIR: ${TMPDIR:-not set}" + + echo "" + echo -e "${BOLD}Paths${NC}" + local CHECK_DIRS=("$PROJECT_DIR" "${PREFIX:-}/tmp") + for dir in "${CHECK_DIRS[@]}"; do + if [ -d "$dir" ]; then + echo -e " ${GREEN}[OK]${NC} $dir" + else + echo -e " ${RED}[MISS]${NC} $dir" + fi + done + + echo "" + echo -e "${BOLD}Configuration${NC}" + if grep -qF "OpenClaw on Android" "$HOME/.bashrc" 2>/dev/null; then + echo -e " ${GREEN}[OK]${NC} .bashrc environment block present" + else + echo -e " ${RED}[MISS]${NC} .bashrc environment block not found" + fi + + local STATUS_SCRIPT="$PROJECT_DIR/platforms/$PLATFORM/status.sh" + if [ -n "$PLATFORM" ] && [ -f "$STATUS_SCRIPT" ]; then + bash "$STATUS_SCRIPT" + fi + + echo "" +} + +cmd_install() { + if ! command -v curl &>/dev/null; then + echo -e "${RED}[FAIL]${NC} curl not found. Install it with: pkg install curl" + exit 1 + fi + + local TMPFILE + TMPFILE=$(mktemp "${PREFIX:-/tmp}/tmp/install-tools.XXXXXX.sh" 2>/dev/null) \ + || TMPFILE=$(mktemp /tmp/install-tools.XXXXXX.sh) + + if ! curl -sfL "$REPO_BASE/install-tools.sh" -o "$TMPFILE"; then + rm -f "$TMPFILE" + echo -e "${RED}[FAIL]${NC} Failed to download install-tools.sh" + exit 1 + fi + + bash "$TMPFILE" + rm -f "$TMPFILE" +} + +case "${1:-}" in + --update) + cmd_update + ;; + --install) + cmd_install + ;; + --uninstall) + cmd_uninstall + ;; + --status) + cmd_status + ;; + --version|-v) + show_version + ;; + --help|-h|"") + show_help + ;; + *) + echo -e "${RED}Unknown option: $1${NC}" + echo "" + show_help + exit 1 + ;; +esac diff --git a/patches/apply-patches.sh b/patches/apply-patches.sh index ef5eb9c..50d5b29 100755 --- a/patches/apply-patches.sh +++ b/patches/apply-patches.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# apply-patches.sh - Apply all patches for OpenClaw on Termux +# apply-patches.sh - Apply all patches for OpenClaw on Termux (glibc architecture) set -euo pipefail GREEN='\033[0;32m' @@ -19,14 +19,14 @@ mkdir -p "$PATCH_DEST" # Start logging echo "Patch application started: $(date)" > "$LOG_FILE" -# 1. Copy bionic-compat.js -if [ -f "$SCRIPT_DIR/bionic-compat.js" ]; then - cp "$SCRIPT_DIR/bionic-compat.js" "$PATCH_DEST/bionic-compat.js" - echo -e "${GREEN}[OK]${NC} Copied bionic-compat.js to $PATCH_DEST/" - echo " Copied bionic-compat.js" >> "$LOG_FILE" +# 1. Copy glibc-compat.js (replaces bionic-compat.js in glibc architecture) +if [ -f "$SCRIPT_DIR/glibc-compat.js" ]; then + cp "$SCRIPT_DIR/glibc-compat.js" "$PATCH_DEST/glibc-compat.js" + echo -e "${GREEN}[OK]${NC} Copied glibc-compat.js to $PATCH_DEST/" + echo " Copied glibc-compat.js" >> "$LOG_FILE" else - echo -e "${RED}[FAIL]${NC} bionic-compat.js not found in $SCRIPT_DIR" - echo " FAILED: bionic-compat.js not found" >> "$LOG_FILE" + echo -e "${RED}[FAIL]${NC} glibc-compat.js not found in $SCRIPT_DIR" + echo " FAILED: glibc-compat.js not found" >> "$LOG_FILE" exit 1 fi diff --git a/patches/argon2-stub.js b/patches/argon2-stub.js new file mode 100644 index 0000000..0706910 --- /dev/null +++ b/patches/argon2-stub.js @@ -0,0 +1,23 @@ +// argon2-stub.js - JS stub replacing argon2 native module for Termux +// The native argon2 module requires glibc and cannot run on Termux (Bionic libc). +// Since code-server is started with --auth none, argon2 is never actually called. +// This stub satisfies the require() without loading native code. + +"use strict"; + +module.exports.hash = async function hash() { + throw new Error("argon2 native module is not available on Termux. Use --auth none."); +}; + +module.exports.verify = async function verify() { + throw new Error("argon2 native module is not available on Termux. Use --auth none."); +}; + +module.exports.needsRehash = function needsRehash() { + return false; +}; + +// Argon2 type constants (for compatibility) +module.exports.argon2d = 0; +module.exports.argon2i = 1; +module.exports.argon2id = 2; diff --git a/patches/bionic-compat.js b/patches/bionic-compat.js deleted file mode 100644 index ef3f6e3..0000000 --- a/patches/bionic-compat.js +++ /dev/null @@ -1,56 +0,0 @@ -/** - * bionic-compat.js - Android Bionic libc compatibility shim - * - * Loaded via NODE_OPTIONS="-r /bionic-compat.js" - * - * Patches: - * - os.networkInterfaces(): try-catch wrapper for Bionic getifaddrs() crashes - * - os.cpus(): fallback for empty array (Android /proc/cpuinfo restriction) - */ - -'use strict'; - -// Override process.platform from 'android' to 'linux' -// Termux runs on Linux kernel but Node.js reports 'android', -// causing OpenClaw to reject the platform as unsupported. -Object.defineProperty(process, 'platform', { - value: 'linux', - writable: false, - enumerable: true, - configurable: true, -}); - -const os = require('os'); - -// os.cpus() returns empty array on some Android devices, -// causing tools that use os.cpus().length for parallelism to fail (e.g. make -j0) -const _originalCpus = os.cpus; - -os.cpus = function cpus() { - const result = _originalCpus.call(os); - if (result.length > 0) { - return result; - } - return [{ model: 'unknown', speed: 0, times: { user: 0, nice: 0, sys: 0, idle: 0, irq: 0 } }]; -}; - -const _originalNetworkInterfaces = os.networkInterfaces; - -os.networkInterfaces = function networkInterfaces() { - try { - return _originalNetworkInterfaces.call(os); - } catch { - return { - lo: [ - { - address: '127.0.0.1', - netmask: '255.0.0.0', - family: 'IPv4', - mac: '00:00:00:00:00:00', - internal: true, - cidr: '127.0.0.1/8', - }, - ], - }; - } -}; diff --git a/patches/glibc-compat.js b/patches/glibc-compat.js new file mode 100644 index 0000000..51db5ea --- /dev/null +++ b/patches/glibc-compat.js @@ -0,0 +1,127 @@ +/** + * glibc-compat.js - Minimal compatibility shim for glibc Node.js on Android + * + * This is the successor to bionic-compat.js, drastically reduced for glibc. + * + * What's NOT needed anymore (glibc handles these): + * - process.platform override (glibc Node.js reports 'linux' natively) + * - renameat2 / spawn.h stubs (glibc includes them) + * - CXXFLAGS / GYP_DEFINES overrides (glibc is standard Linux) + * + * What's still needed (kernel/Android-level restrictions, not libc): + * - os.cpus() fallback: SELinux blocks /proc/stat on Android 8+ + * - os.networkInterfaces() safety: EACCES on some Android configurations + * - /bin/sh path shim: Android 7-8 lacks /bin/sh (Android 9+ has it) + * + * Loaded via node wrapper script: node --require /glibc-compat.js + */ + +'use strict'; + +const os = require('os'); +const fs = require('fs'); +const path = require('path'); + +// ─── process.execPath fix ──────────────────────────────────── +// When node runs via grun (ld.so node.real), process.execPath points to +// ld.so instead of the node wrapper. Apps that spawn child node processes +// using process.execPath (e.g., openclaw) will call ld.so directly, +// bypassing the wrapper's LD_PRELOAD unset and compat loading. +// Fix: point process.execPath to the wrapper script. + +const _wrapperPath = path.join( + process.env.HOME || '/data/data/com.termux/files/home', + '.openclaw-android', 'node', 'bin', 'node' +); +try { + if (fs.existsSync(_wrapperPath)) { + Object.defineProperty(process, 'execPath', { + value: _wrapperPath, + writable: true, + configurable: true, + }); + } +} catch {} + + +// ─── os.cpus() fallback ───────────────────────────────────── +// Android 8+ (API 26+) blocks /proc/stat via SELinux + hidepid=2. +// libuv reads /proc/stat for CPU info → returns empty array. +// Tools using os.cpus().length for parallelism (e.g., make -j) break with 0. + +const _originalCpus = os.cpus; + +os.cpus = function cpus() { + const result = _originalCpus.call(os); + if (result.length > 0) { + return result; + } + // Return a single fake CPU entry so .length is at least 1 + return [{ model: 'unknown', speed: 0, times: { user: 0, nice: 0, sys: 0, idle: 0, irq: 0 } }]; +}; + +// ─── os.networkInterfaces() safety ────────────────────────── +// Some Android configurations throw EACCES when reading network +// interface information. Wrap with try-catch to prevent crashes. + +const _originalNetworkInterfaces = os.networkInterfaces; + +os.networkInterfaces = function networkInterfaces() { + try { + return _originalNetworkInterfaces.call(os); + } catch { + // Return minimal loopback interface + return { + lo: [ + { + address: '127.0.0.1', + netmask: '255.0.0.0', + family: 'IPv4', + mac: '00:00:00:00:00:00', + internal: true, + cidr: '127.0.0.1/8', + }, + ], + }; + } +}; + +// ─── /bin/sh path shim (Android 7-8 only) ─────────────────── +// Android 9+ (API 28+) has /bin → /system/bin symlink, so /bin/sh exists. +// Android 7-8 lacks /bin/sh entirely. +// Node.js child_process hardcodes /bin/sh as the default shell on Linux. +// With glibc (platform='linux'), LD_PRELOAD is unset, so libtermux-exec.so +// path translation is not available. +// +// This shim only activates if /bin/sh doesn't exist. + +if (!fs.existsSync('/bin/sh')) { + const child_process = require('child_process'); + const termuxSh = (process.env.PREFIX || '/data/data/com.termux/files/usr') + '/bin/sh'; + + if (fs.existsSync(termuxSh)) { + // Override exec/execSync to use Termux shell + const _originalExec = child_process.exec; + const _originalExecSync = child_process.execSync; + + child_process.exec = function exec(command, options, callback) { + if (typeof options === 'function') { + callback = options; + options = {}; + } + options = options || {}; + if (!options.shell) { + options.shell = termuxSh; + } + return _originalExec.call(child_process, command, options, callback); + }; + + child_process.execSync = function execSync(command, options) { + options = options || {}; + if (!options.shell) { + options.shell = termuxSh; + } + return _originalExecSync.call(child_process, command, options); + }; + } +} diff --git a/platforms/openclaw/config.env b/platforms/openclaw/config.env new file mode 100644 index 0000000..c1258a3 --- /dev/null +++ b/platforms/openclaw/config.env @@ -0,0 +1,23 @@ +# config.env — OpenClaw platform metadata and dependency declarations +# Sourced by orchestrators via load_platform_config() + +# ── Platform info ── +PLATFORM_NAME="OpenClaw" +PLATFORM_BINARY="openclaw" +PLATFORM_DATA_DIR="$HOME/.openclaw" +PLATFORM_VERSION_CMD="openclaw --version" +PLATFORM_START_CMD="openclaw gateway" +PLATFORM_POST_INSTALL_MSG="Run 'openclaw onboard' to start setup." + +# ── Dependency declarations ── orchestrator reads these for conditional install +PLATFORM_NEEDS_GLIBC=true +PLATFORM_NEEDS_NODEJS=true +PLATFORM_NEEDS_BUILD_TOOLS=true +PLATFORM_NEEDS_PYTHON=false +PLATFORM_NEEDS_PROOT=false + +# ── Install method ── +PLATFORM_INSTALL_METHOD="npm" +PLATFORM_NPM_PACKAGE="openclaw" +PLATFORM_GITHUB_REPO="" +PLATFORM_PIP_PACKAGE="" diff --git a/platforms/openclaw/env.sh b/platforms/openclaw/env.sh new file mode 100755 index 0000000..c6f5f86 --- /dev/null +++ b/platforms/openclaw/env.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# env.sh — OpenClaw platform environment variables +# Called by setup-env.sh; stdout is inserted into .bashrc block. +# Uses single-quoted heredoc to prevent variable expansion at install time +# (variables must expand at shell load time). + +cat << 'EOF' +export CONTAINER=1 +export CLAWDHUB_WORKDIR="$HOME/.openclaw/workspace" +export CPATH="$PREFIX/include/glib-2.0:$PREFIX/lib/glib-2.0/include" +EOF diff --git a/platforms/openclaw/install.sh b/platforms/openclaw/install.sh new file mode 100755 index 0000000..50b73b2 --- /dev/null +++ b/platforms/openclaw/install.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/scripts/lib.sh" + +echo "=== Installing OpenClaw Platform Package ===" +echo "" + +export CPATH="$PREFIX/include/glib-2.0:$PREFIX/lib/glib-2.0/include" + +python -c "import yaml" 2>/dev/null || pip install pyyaml -q || true + +mkdir -p "$PROJECT_DIR/patches" +cp "$SCRIPT_DIR/../../patches/glibc-compat.js" "$PROJECT_DIR/patches/glibc-compat.js" + +cp "$SCRIPT_DIR/../../patches/systemctl" "$PREFIX/bin/systemctl" +chmod +x "$PREFIX/bin/systemctl" + +# Clean up existing installation for smooth reinstall +if npm list -g openclaw &>/dev/null 2>&1 || [ -d "$PREFIX/lib/node_modules/openclaw" ]; then + echo "Existing installation detected \u2014 cleaning up for reinstall..." + npm uninstall -g openclaw 2>/dev/null || true + rm -rf "$PREFIX/lib/node_modules/openclaw" 2>/dev/null || true + npm uninstall -g clawdhub 2>/dev/null || true + rm -rf "$PREFIX/lib/node_modules/clawdhub" 2>/dev/null || true + rm -rf "$HOME/.npm/_cacache" 2>/dev/null || true + echo -e "${GREEN}[OK]${NC} Previous installation cleaned" +fi + +echo "Running: npm install -g openclaw@latest --ignore-scripts" +echo "This may take several minutes..." +echo "" +npm install -g openclaw@latest --ignore-scripts + +echo "" +echo -e "${GREEN}[OK]${NC} OpenClaw installed" + +bash "$SCRIPT_DIR/patches/openclaw-apply-patches.sh" + +echo "" +echo "Installing clawdhub (skill manager)..." +if npm install -g clawdhub --no-fund --no-audit; then + echo -e "${GREEN}[OK]${NC} clawdhub installed" + CLAWHUB_DIR="$(npm root -g)/clawdhub" + if [ -d "$CLAWHUB_DIR" ] && ! (cd "$CLAWHUB_DIR" && node -e "require('undici')" 2>/dev/null); then + echo "Installing undici dependency for clawdhub..." + if (cd "$CLAWHUB_DIR" && npm install undici --no-fund --no-audit); then + echo -e "${GREEN}[OK]${NC} undici installed for clawdhub" + else + echo -e "${YELLOW}[WARN]${NC} undici installation failed (clawdhub may not work)" + fi + fi +else + echo -e "${YELLOW}[WARN]${NC} clawdhub installation failed (non-critical)" + echo " Retry manually: npm i -g clawdhub" +fi + +mkdir -p "$HOME/.openclaw" + +echo "" +echo "Running: openclaw update" +echo " (This includes building native modules and may take 5-10 minutes)" +echo "" +openclaw update || true diff --git a/platforms/openclaw/patches/openclaw-apply-patches.sh b/platforms/openclaw/patches/openclaw-apply-patches.sh new file mode 100755 index 0000000..7c25c72 --- /dev/null +++ b/platforms/openclaw/patches/openclaw-apply-patches.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LOG_FILE="$HOME/.openclaw-android/patch.log" + +echo "=== Applying OpenClaw Patches ===" +echo "" + +mkdir -p "$(dirname "$LOG_FILE")" +echo "Patch application started: $(date)" > "$LOG_FILE" + +if [ -f "$SCRIPT_DIR/openclaw-patch-paths.sh" ]; then + bash "$SCRIPT_DIR/openclaw-patch-paths.sh" 2>&1 | tee -a "$LOG_FILE" +else + echo -e "${RED}[FAIL]${NC} openclaw-patch-paths.sh not found in $SCRIPT_DIR" + echo " FAILED: openclaw-patch-paths.sh not found" >> "$LOG_FILE" + exit 1 +fi + +echo "" +echo "Patch log saved to: $LOG_FILE" +echo -e "${GREEN}OpenClaw patches applied.${NC}" +echo "Patch application completed: $(date)" >> "$LOG_FILE" diff --git a/platforms/openclaw/patches/openclaw-build-sharp.sh b/platforms/openclaw/patches/openclaw-build-sharp.sh new file mode 100755 index 0000000..56d5a1c --- /dev/null +++ b/platforms/openclaw/patches/openclaw-build-sharp.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# build-sharp.sh - Enable sharp image processing on Android (Termux) +# +# Strategy: +# 1. Check if sharp already works → skip +# 2. Install WebAssembly fallback (@img/sharp-wasm32) +# Native sharp binaries are built for glibc Linux. Android's Bionic libc +# cannot dlopen glibc-linked .node addons, so the prebuilt linux-arm64 +# binding never loads. The WASM build uses Emscripten and runs entirely +# in V8 — zero native dependencies. +# 3. If WASM fails → attempt native rebuild as last resort +set -euo pipefail + +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' + +echo "=== Building sharp (image processing) ===" +echo "" + +# Ensure required environment variables are set (for standalone use) +export TMPDIR="${TMPDIR:-$PREFIX/tmp}" +export TMP="$TMPDIR" +export TEMP="$TMPDIR" +export CONTAINER="${CONTAINER:-1}" + +# Locate openclaw install directory +OPENCLAW_DIR="$(npm root -g)/openclaw" + +if [ ! -d "$OPENCLAW_DIR" ]; then + echo -e "${RED}[FAIL]${NC} OpenClaw directory not found: $OPENCLAW_DIR" + exit 0 +fi + +# Skip rebuild if sharp is already working (e.g. WASM installed on prior run) +if [ -d "$OPENCLAW_DIR/node_modules/sharp" ]; then + if node -e "require('$OPENCLAW_DIR/node_modules/sharp')" 2>/dev/null; then + echo -e "${GREEN}[OK]${NC} sharp is already working — skipping rebuild" + exit 0 + fi +fi + +# ── Strategy 1: WebAssembly fallback (recommended for Android) ────────── +# sharp's JS loader tries these paths in order: +# 1. ../src/build/Release/sharp-{platform}.node (source build) +# 2. ../src/build/Release/sharp-wasm32.node (source build) +# 3. @img/sharp-{platform}/sharp.node (prebuilt native) +# 4. @img/sharp-wasm32/sharp.node (prebuilt WASM) ← this +# By installing @img/sharp-wasm32, path 4 catches the fallback automatically. + +echo "Installing sharp WebAssembly runtime..." +if (cd "$OPENCLAW_DIR" && npm install @img/sharp-wasm32 --force --no-audit --no-fund 2>&1 | tail -3); then + if node -e "require('$OPENCLAW_DIR/node_modules/sharp')" 2>/dev/null; then + echo "" + echo -e "${GREEN}[OK]${NC} sharp enabled via WebAssembly — image processing ready" + exit 0 + else + echo -e "${YELLOW}[WARN]${NC} WASM package installed but sharp still not loading" + fi +else + echo -e "${YELLOW}[WARN]${NC} Failed to install WASM package" +fi + +# ── Strategy 2: Native rebuild (last resort) ──────────────────────────── + +echo "" +echo "Attempting native rebuild as fallback..." + +# Install required packages +echo "Installing build dependencies..." +if ! pkg install -y libvips binutils; then + echo -e "${YELLOW}[WARN]${NC} Failed to install build dependencies" + echo " Image processing will not be available, but OpenClaw will work normally." + exit 0 +fi +echo -e "${GREEN}[OK]${NC} libvips and binutils installed" + +# Create ar symlink if missing (Termux provides llvm-ar but not ar) +if [ ! -e "$PREFIX/bin/ar" ] && [ -x "$PREFIX/bin/llvm-ar" ]; then + ln -s "$PREFIX/bin/llvm-ar" "$PREFIX/bin/ar" + echo -e "${GREEN}[OK]${NC} Created ar → llvm-ar symlink" +fi + +# Install node-gyp globally +echo "Installing node-gyp..." +if ! npm install -g node-gyp; then + echo -e "${YELLOW}[WARN]${NC} Failed to install node-gyp" + echo " Image processing will not be available, but OpenClaw will work normally." + exit 0 +fi +echo -e "${GREEN}[OK]${NC} node-gyp installed" + +# Set build environment variables +# On glibc architecture, these are handled by glibc's standard headers. +# On Bionic (legacy), we need explicit compatibility flags. +if [ ! -f "$HOME/.openclaw-android/.glibc-arch" ]; then + export CFLAGS="-Wno-error=implicit-function-declaration" + export CXXFLAGS="-include $HOME/.openclaw-android/patches/termux-compat.h" + export GYP_DEFINES="OS=linux android_ndk_path=$PREFIX" +fi +export CPATH="$PREFIX/include/glib-2.0:$PREFIX/lib/glib-2.0/include" + +echo "Rebuilding sharp in $OPENCLAW_DIR..." +echo "This may take several minutes..." +echo "" + +if (cd "$OPENCLAW_DIR" && npm rebuild sharp); then + echo "" + echo -e "${GREEN}[OK]${NC} sharp built successfully — image processing enabled" +else + echo "" + echo -e "${YELLOW}[WARN]${NC} sharp could not be enabled (non-critical)" + echo " Image processing will not be available, but OpenClaw will work normally." + echo " You can retry later: bash ~/.openclaw-android/scripts/build-sharp.sh" +fi diff --git a/platforms/openclaw/patches/openclaw-patch-paths.sh b/platforms/openclaw/patches/openclaw-patch-paths.sh new file mode 100755 index 0000000..96b4a1b --- /dev/null +++ b/platforms/openclaw/patches/openclaw-patch-paths.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# patch-paths.sh - Patch hardcoded paths in installed OpenClaw modules +set -euo pipefail + +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' + +echo "=== Patching Hardcoded Paths ===" +echo "" + +# Ensure required environment variables are set (for standalone use) +export TMPDIR="${TMPDIR:-$PREFIX/tmp}" + +# Find OpenClaw installation directory +NPM_ROOT=$(npm root -g 2>/dev/null) +OPENCLAW_DIR="$NPM_ROOT/openclaw" + +if [ ! -d "$OPENCLAW_DIR" ]; then + echo -e "${RED}[FAIL]${NC} OpenClaw not found at $OPENCLAW_DIR" + exit 1 +fi + +echo "OpenClaw found at: $OPENCLAW_DIR" + +PATCHED=0 + +# Patch /tmp references to $PREFIX/tmp +echo "Patching /tmp references..." +TMP_FILES=$(grep -rl '/tmp' "$OPENCLAW_DIR" --include='*.js' --include='*.mjs' --include='*.cjs' 2>/dev/null || true) + +for f in $TMP_FILES; do + if [ -f "$f" ]; then + # Patch /tmp/ prefix paths (e.g. "/tmp/openclaw") — must run before exact match + sed -i "s|\"\/tmp/|\"$PREFIX/tmp/|g" "$f" + sed -i "s|'\/tmp/|'$PREFIX/tmp/|g" "$f" + sed -i "s|\`\/tmp/|\`$PREFIX/tmp/|g" "$f" + # Patch exact /tmp references (e.g. "/tmp") + sed -i "s|\"\/tmp\"|\"$PREFIX/tmp\"|g" "$f" + sed -i "s|'\/tmp'|'$PREFIX/tmp'|g" "$f" + echo -e " ${GREEN}[PATCHED]${NC} $f (tmp path)" + PATCHED=$((PATCHED + 1)) + fi +done + +# Patch /bin/sh references +echo "Patching /bin/sh references..." +SH_FILES=$(grep -rl '"/bin/sh"' "$OPENCLAW_DIR" --include='*.js' --include='*.mjs' --include='*.cjs' 2>/dev/null || true) +SH_FILES2=$(grep -rl "'/bin/sh'" "$OPENCLAW_DIR" --include='*.js' --include='*.mjs' --include='*.cjs' 2>/dev/null || true) + +for f in $SH_FILES $SH_FILES2; do + if [ -f "$f" ]; then + sed -i "s|\"\/bin\/sh\"|\"$PREFIX/bin/sh\"|g" "$f" + sed -i "s|'\/bin\/sh'|'$PREFIX/bin/sh'|g" "$f" + echo -e " ${GREEN}[PATCHED]${NC} $f (bin/sh)" + PATCHED=$((PATCHED + 1)) + fi +done + +# Patch /bin/bash references +echo "Patching /bin/bash references..." +BASH_FILES=$(grep -rl '"/bin/bash"' "$OPENCLAW_DIR" --include='*.js' --include='*.mjs' --include='*.cjs' 2>/dev/null || true) +BASH_FILES2=$(grep -rl "'/bin/bash'" "$OPENCLAW_DIR" --include='*.js' --include='*.mjs' --include='*.cjs' 2>/dev/null || true) + +for f in $BASH_FILES $BASH_FILES2; do + if [ -f "$f" ]; then + sed -i "s|\"\/bin\/bash\"|\"$PREFIX/bin/bash\"|g" "$f" + sed -i "s|'\/bin\/bash'|'$PREFIX/bin/bash'|g" "$f" + echo -e " ${GREEN}[PATCHED]${NC} $f (bin/bash)" + PATCHED=$((PATCHED + 1)) + fi +done + +# Patch /usr/bin/env references +echo "Patching /usr/bin/env references..." +ENV_FILES=$(grep -rl '"/usr/bin/env"' "$OPENCLAW_DIR" --include='*.js' --include='*.mjs' --include='*.cjs' 2>/dev/null || true) +ENV_FILES2=$(grep -rl "'/usr/bin/env'" "$OPENCLAW_DIR" --include='*.js' --include='*.mjs' --include='*.cjs' 2>/dev/null || true) + +for f in $ENV_FILES $ENV_FILES2; do + if [ -f "$f" ]; then + sed -i "s|\"\/usr\/bin\/env\"|\"$PREFIX/bin/env\"|g" "$f" + sed -i "s|'\/usr\/bin\/env'|'$PREFIX/bin/env'|g" "$f" + echo -e " ${GREEN}[PATCHED]${NC} $f (usr/bin/env)" + PATCHED=$((PATCHED + 1)) + fi +done + +echo "" +if [ "$PATCHED" -eq 0 ]; then + echo -e "${YELLOW}[INFO]${NC} No hardcoded paths found to patch." +else + echo -e "${GREEN}Patched $PATCHED file(s).${NC}" +fi diff --git a/platforms/openclaw/status.sh b/platforms/openclaw/status.sh new file mode 100644 index 0000000..6c8e84d --- /dev/null +++ b/platforms/openclaw/status.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../../scripts/lib.sh" + +echo "" +echo -e "${BOLD}Platform Components${NC}" + +if command -v openclaw &>/dev/null; then + echo " OpenClaw: $(openclaw --version 2>/dev/null || echo 'error')" +else + echo -e " OpenClaw: ${RED}not installed${NC}" +fi + +if command -v node &>/dev/null; then + echo " Node.js: $(node -v 2>/dev/null)" +else + echo -e " Node.js: ${RED}not installed${NC}" +fi + +if command -v npm &>/dev/null; then + echo " npm: $(npm -v 2>/dev/null)" +else + echo -e " npm: ${RED}not installed${NC}" +fi + +if command -v clawdhub &>/dev/null; then + echo " clawdhub: $(clawdhub --version 2>/dev/null || echo 'installed')" +else + echo -e " clawdhub: ${YELLOW}not installed${NC}" +fi + +if command -v code-server &>/dev/null; then + cs_ver=$(code-server --version 2>/dev/null || true) + cs_ver="${cs_ver%%$'\n'*}" + cs_status="stopped" + if pgrep -f "code-server" &>/dev/null; then + cs_status="running" + fi + echo " code-server: ${cs_ver:-installed} ($cs_status)" +else + echo -e " code-server: ${YELLOW}not installed${NC}" +fi + +if command -v opencode &>/dev/null; then + oc_status="stopped" + if pgrep -f "ld.so.opencode" &>/dev/null; then + oc_status="running" + fi + echo " OpenCode: $(opencode --version 2>/dev/null || echo 'installed') ($oc_status)" +else + echo -e " OpenCode: ${YELLOW}not installed${NC}" +fi + +if command -v chromium-browser &>/dev/null || command -v chromium &>/dev/null; then + cr_bin=$(command -v chromium-browser 2>/dev/null || command -v chromium 2>/dev/null) + cr_ver=$($cr_bin --version 2>/dev/null | head -1 || echo 'installed') + echo " Chromium: $cr_ver" +else + echo -e " Chromium: ${YELLOW}not installed${NC}" +fi + +echo "" +echo -e "${BOLD}Architecture${NC}" +if [ -f "$PROJECT_DIR/.glibc-arch" ]; then + echo -e " ${GREEN}[OK]${NC} glibc (v1.0.0+)" +else + echo -e " ${YELLOW}[OLD]${NC} Bionic (pre-1.0.0) - run 'oa --update' to migrate" +fi + +if [ "${OA_GLIBC:-}" = "1" ]; then + echo -e " ${GREEN}[OK]${NC} OA_GLIBC=1 (environment)" +else + echo -e " ${YELLOW}[MISS]${NC} OA_GLIBC not set - run 'source ~/.bashrc'" +fi + +echo "" +echo -e "${BOLD}glibc Components${NC}" +GLIBC_FILES=( + "$PROJECT_DIR/patches/glibc-compat.js" + "$PROJECT_DIR/.glibc-arch" + "${PREFIX:-}/glibc/lib/ld-linux-aarch64.so.1" +) +for file in "${GLIBC_FILES[@]}"; do + if [ -f "$file" ]; then + echo -e " ${GREEN}[OK]${NC} $(basename "$file")" + else + echo -e " ${RED}[MISS]${NC} $(basename "$file")" + fi +done + +NODE_WRAPPER="$PROJECT_DIR/node/bin/node" +if [ -f "$NODE_WRAPPER" ] && grep -q "bash" "$NODE_WRAPPER"; then + echo -e " ${GREEN}[OK]${NC} glibc node wrapper" +else + echo -e " ${RED}[MISS]${NC} glibc node wrapper" +fi + +if [ -f "${PREFIX:-}/bin/opencode" ]; then + echo -e " ${GREEN}[OK]${NC} opencode command" +else + echo -e " ${YELLOW}[MISS]${NC} opencode command" +fi + + +echo "" +echo -e "${BOLD}AI CLI Tools${NC}" +for tool in "claude:Claude Code" "gemini:Gemini CLI" "codex:Codex CLI"; do + cmd="${tool%%:*}" + label="${tool##*:}" + if command -v "$cmd" &>/dev/null; then + version=$($cmd --version 2>/dev/null || echo "installed") + version="${version%%$'\n'*}" + echo -e " ${GREEN}[OK]${NC} $label: $version" + else + echo " [--] $label: not installed" + fi +done + +echo "" +echo -e "${BOLD}Skills${NC}" +SKILLS_DIR="${CLAWDHUB_WORKDIR:-$HOME/.openclaw/workspace}/skills" +if [ -d "$SKILLS_DIR" ]; then + count=$(find "$SKILLS_DIR" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l) + echo " Installed: $count" + echo " Path: $SKILLS_DIR" +else + echo " No skills directory found" +fi + +echo "" +echo -e "${BOLD}Disk${NC}" +if [ -d "$PROJECT_DIR" ]; then + echo " ~/.openclaw-android: $(du -sh "$PROJECT_DIR" 2>/dev/null | cut -f1)" +fi +if [ -d "$HOME/.openclaw" ]; then + echo " ~/.openclaw: $(du -sh "$HOME/.openclaw" 2>/dev/null | cut -f1)" +fi +if [ -d "$HOME/.bun" ]; then + echo " ~/.bun: $(du -sh "$HOME/.bun" 2>/dev/null | cut -f1)" +fi +AVAIL_MB=$(df "${PREFIX:-/}" 2>/dev/null | awk 'NR==2 {print int($4/1024)}') || true +echo " Available: ${AVAIL_MB:-unknown}MB" diff --git a/platforms/openclaw/uninstall.sh b/platforms/openclaw/uninstall.sh new file mode 100644 index 0000000..82210c7 --- /dev/null +++ b/platforms/openclaw/uninstall.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../../scripts/lib.sh" + +echo "=== Removing OpenClaw Platform ===" +echo "" + +step() { + echo "" + echo -e "${BOLD}[$1/7] $2${NC}" + echo "----------------------------------------" +} + +step 1 "OpenClaw npm package" +if command -v npm &>/dev/null; then + if npm list -g openclaw &>/dev/null; then + npm uninstall -g openclaw + echo -e "${GREEN}[OK]${NC} openclaw package removed" + else + echo -e "${YELLOW}[SKIP]${NC} openclaw not installed" + fi +else + echo -e "${YELLOW}[SKIP]${NC} npm not found" +fi + +step 2 "clawdhub npm package" +if command -v npm &>/dev/null; then + if npm list -g clawdhub &>/dev/null; then + npm uninstall -g clawdhub + echo -e "${GREEN}[OK]${NC} clawdhub package removed" + else + echo -e "${YELLOW}[SKIP]${NC} clawdhub not installed" + fi +else + echo -e "${YELLOW}[SKIP]${NC} npm not found" +fi + +step 3 "OpenCode" +OPENCODE_INSTALLED=false + +if [ "$OPENCODE_INSTALLED" = true ]; then + if ask_yn "Remove OpenCode (AI coding assistant)?"; then + if pgrep -f "ld.so.opencode" &>/dev/null; then + pkill -f "ld.so.opencode" || true + echo -e "${GREEN}[OK]${NC} Stopped running OpenCode" + fi + [ -f "$PREFIX/tmp/ld.so.opencode" ] && rm -f "$PREFIX/tmp/ld.so.opencode" && echo -e "${GREEN}[OK]${NC} Removed ld.so.opencode" + [ -f "$PREFIX/bin/opencode" ] && rm -f "$PREFIX/bin/opencode" && echo -e "${GREEN}[OK]${NC} Removed opencode wrapper" + [ -d "$HOME/.config/opencode" ] && rm -rf "$HOME/.config/opencode" && echo -e "${GREEN}[OK]${NC} Removed ~/.config/opencode" + else + echo -e "${YELLOW}[KEEP]${NC} Keeping OpenCode" + fi +fi + +step 4 "Bun cleanup" +if [ ! -f "$PREFIX/bin/opencode" ] && [ -d "$HOME/.bun" ]; then + rm -rf "$HOME/.bun" + echo -e "${GREEN}[OK]${NC} Removed ~/.bun" +else + echo -e "${YELLOW}[SKIP]${NC} Bun is still required or not installed" +fi + +step 5 "OpenClaw temporary files" +if [ -d "${PREFIX:-}/tmp/openclaw" ]; then + rm -rf "${PREFIX:-}/tmp/openclaw" + echo -e "${GREEN}[OK]${NC} Removed ${PREFIX:-}/tmp/openclaw" +else + echo -e "${YELLOW}[SKIP]${NC} ${PREFIX:-}/tmp/openclaw not found" +fi + +step 6 "OpenClaw data" +if [ -d "$HOME/.openclaw" ]; then + reply="" + read -rp "Remove OpenClaw data directory (~/.openclaw)? [y/N] " reply < /dev/tty + if [[ "$reply" =~ ^[Yy]$ ]]; then + rm -rf "$HOME/.openclaw" + echo -e "${GREEN}[OK]${NC} Removed ~/.openclaw" + else + echo -e "${YELLOW}[KEEP]${NC} Keeping ~/.openclaw" + fi +else + echo -e "${YELLOW}[SKIP]${NC} ~/.openclaw not found" +fi + +step 7 "AI CLI tools" +AI_TOOLS_FOUND=() +AI_TOOL_LABELS=() + +if command -v claude &>/dev/null; then + AI_TOOLS_FOUND+=("@anthropic-ai/claude-code") + AI_TOOL_LABELS+=("Claude Code") +fi +if command -v gemini &>/dev/null; then + AI_TOOLS_FOUND+=("@google/gemini-cli") + AI_TOOL_LABELS+=("Gemini CLI") +fi +if command -v codex &>/dev/null; then + AI_TOOLS_FOUND+=("@openai/codex") + AI_TOOL_LABELS+=("Codex CLI") +fi + +if [ ${#AI_TOOLS_FOUND[@]} -eq 0 ]; then + echo -e "${YELLOW}[SKIP]${NC} No AI CLI tools detected" +else + echo "Installed AI CLI tools detected:" + for label in "${AI_TOOL_LABELS[@]}"; do + echo " - $label" + done + + reply="" + read -rp "Remove these AI CLI tools? [y/N] " reply < /dev/tty + if [[ "$reply" =~ ^[Yy]$ ]]; then + for pkg in "${AI_TOOLS_FOUND[@]}"; do + if npm uninstall -g "$pkg"; then + echo -e "${GREEN}[OK]${NC} Removed $pkg" + else + echo -e "${YELLOW}[WARN]${NC} Failed to remove $pkg" + fi + done + else + echo -e "${YELLOW}[KEEP]${NC} Keeping AI CLI tools" + fi +fi diff --git a/platforms/openclaw/update.sh b/platforms/openclaw/update.sh new file mode 100755 index 0000000..08f4347 --- /dev/null +++ b/platforms/openclaw/update.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../../scripts/lib.sh" + +export CPATH="$PREFIX/include/glib-2.0:$PREFIX/lib/glib-2.0/include" + +echo "=== Updating OpenClaw Platform ===" +echo "" + +pkg install -y libvips binutils 2>/dev/null || true +if [ ! -e "$PREFIX/bin/ar" ] && [ -x "$PREFIX/bin/llvm-ar" ]; then + ln -s "$PREFIX/bin/llvm-ar" "$PREFIX/bin/ar" +fi + +CURRENT_VER=$(npm list -g openclaw 2>/dev/null | grep 'openclaw@' | sed 's/.*openclaw@//' | tr -d '[:space:]') +LATEST_VER=$(npm view openclaw version 2>/dev/null || echo "") +OPENCLAW_UPDATED=false + +if [ -n "$CURRENT_VER" ] && [ -n "$LATEST_VER" ] && [ "$CURRENT_VER" = "$LATEST_VER" ]; then + echo -e "${GREEN}[OK]${NC} openclaw $CURRENT_VER is already the latest" +else + echo "Updating openclaw npm package... ($CURRENT_VER → $LATEST_VER)" + echo " (This may take several minutes depending on network speed)" + if npm install -g openclaw@latest --no-fund --no-audit --ignore-scripts; then + echo -e "${GREEN}[OK]${NC} openclaw $LATEST_VER updated" + OPENCLAW_UPDATED=true + else + echo -e "${YELLOW}[WARN]${NC} Package update failed (non-critical)" + echo " Retry manually: npm install -g openclaw@latest" + fi +fi + +bash "$SCRIPT_DIR/patches/openclaw-apply-patches.sh" + +if [ "$OPENCLAW_UPDATED" = true ]; then + bash "$SCRIPT_DIR/patches/openclaw-build-sharp.sh" || true +else + echo -e "${GREEN}[SKIP]${NC} openclaw $CURRENT_VER unchanged \u2014 sharp rebuild not needed" +fi + +if command -v clawdhub &>/dev/null; then + CLAWDHUB_CURRENT_VER=$(npm list -g clawdhub 2>/dev/null | grep 'clawdhub@' | sed 's/.*clawdhub@//' | tr -d '[:space:]') + CLAWDHUB_LATEST_VER=$(npm view clawdhub version 2>/dev/null || echo "") + if [ -n "$CLAWDHUB_CURRENT_VER" ] && [ -n "$CLAWDHUB_LATEST_VER" ] && [ "$CLAWDHUB_CURRENT_VER" = "$CLAWDHUB_LATEST_VER" ]; then + echo -e "${GREEN}[OK]${NC} clawdhub $CLAWDHUB_CURRENT_VER is already the latest" + elif [ -n "$CLAWDHUB_LATEST_VER" ]; then + echo "Updating clawdhub... ($CLAWDHUB_CURRENT_VER → $CLAWDHUB_LATEST_VER)" + if npm install -g clawdhub@latest --no-fund --no-audit; then + echo -e "${GREEN}[OK]${NC} clawdhub $CLAWDHUB_LATEST_VER updated" + else + echo -e "${YELLOW}[WARN]${NC} clawdhub update failed (non-critical)" + fi + else + echo -e "${YELLOW}[WARN]${NC} Could not check clawdhub latest version" + fi +else + if ask_yn "clawdhub (skill manager) is not installed. Install it?"; then + echo "Installing clawdhub..." + if npm install -g clawdhub --no-fund --no-audit; then + echo -e "${GREEN}[OK]${NC} clawdhub installed" + else + echo -e "${YELLOW}[WARN]${NC} clawdhub installation failed (non-critical)" + fi + else + echo -e "${YELLOW}[SKIP]${NC} Skipping clawdhub" + fi +fi + +CLAWHUB_DIR="$(npm root -g)/clawdhub" +if [ -d "$CLAWHUB_DIR" ] && ! (cd "$CLAWHUB_DIR" && node -e "require('undici')" 2>/dev/null); then + echo "Installing undici dependency for clawdhub..." + if (cd "$CLAWHUB_DIR" && npm install undici --no-fund --no-audit); then + echo -e "${GREEN}[OK]${NC} undici installed for clawdhub" + else + echo -e "${YELLOW}[WARN]${NC} undici installation failed" + fi +else + UNDICI_VER=$(cd "$CLAWHUB_DIR" && node -e "console.log(require('undici/package.json').version)" 2>/dev/null || echo "") + echo -e "${GREEN}[OK]${NC} undici ${UNDICI_VER:-available}" +fi + +OLD_SKILLS_DIR="$HOME/skills" +CORRECT_SKILLS_DIR="$HOME/.openclaw/workspace/skills" +if [ -d "$OLD_SKILLS_DIR" ] && [ "$(ls -A "$OLD_SKILLS_DIR" 2>/dev/null)" ]; then + echo "" + echo "Migrating skills from ~/skills/ to ~/.openclaw/workspace/skills/..." + mkdir -p "$CORRECT_SKILLS_DIR" + for skill in "$OLD_SKILLS_DIR"/*/; do + [ -d "$skill" ] || continue + skill_name=$(basename "$skill") + if [ ! -d "$CORRECT_SKILLS_DIR/$skill_name" ]; then + if mv "$skill" "$CORRECT_SKILLS_DIR/$skill_name" 2>/dev/null; then + echo -e " ${GREEN}[OK]${NC} Migrated $skill_name" + else + echo -e " ${YELLOW}[WARN]${NC} Failed to migrate $skill_name" + fi + else + echo -e " ${YELLOW}[SKIP]${NC} $skill_name already exists in correct location" + fi + done + if rmdir "$OLD_SKILLS_DIR" 2>/dev/null; then + echo -e "${GREEN}[OK]${NC} Removed empty ~/skills/" + else + echo -e "${YELLOW}[WARN]${NC} ~/skills/ not empty after migration — check manually" + fi +fi + +python -c "import yaml" 2>/dev/null || pip install pyyaml -q || true diff --git a/platforms/openclaw/verify.sh b/platforms/openclaw/verify.sh new file mode 100755 index 0000000..7f58576 --- /dev/null +++ b/platforms/openclaw/verify.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/scripts/lib.sh" + +PASS=0 +FAIL=0 +WARN=0 + +check_pass() { + echo -e "${GREEN}[PASS]${NC} $1" + PASS=$((PASS + 1)) +} + +check_fail() { + echo -e "${RED}[FAIL]${NC} $1" + FAIL=$((FAIL + 1)) +} + +check_warn() { + echo -e "${YELLOW}[WARN]${NC} $1" + WARN=$((WARN + 1)) +} + +echo "=== OpenClaw Platform Verification ===" +echo "" + +if command -v openclaw &>/dev/null; then + CLAW_VER=$(openclaw --version 2>/dev/null || true) + if [ -n "$CLAW_VER" ]; then + check_pass "openclaw $CLAW_VER" + else + check_fail "openclaw found but --version failed" + fi +else + check_fail "openclaw command not found" +fi + +if [ "${CONTAINER:-}" = "1" ]; then + check_pass "CONTAINER=1" +else + check_warn "CONTAINER is not set to 1" +fi + +if command -v clawdhub &>/dev/null; then + check_pass "clawdhub command available" +else + check_warn "clawdhub not found" +fi + +if [ -d "$HOME/.openclaw" ]; then + check_pass "Directory $HOME/.openclaw exists" +else + check_fail "Directory $HOME/.openclaw missing" +fi + +echo "" +echo "===============================" +echo -e " Results: ${GREEN}$PASS passed${NC}, ${RED}$FAIL failed${NC}, ${YELLOW}$WARN warnings${NC}" +echo "===============================" + +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi +exit 0 diff --git a/robots.txt b/robots.txt deleted file mode 100644 index 4978962..0000000 --- a/robots.txt +++ /dev/null @@ -1,4 +0,0 @@ -User-agent: * -Allow: / - -Sitemap: https://myopenclawhub.com/sitemap.xml diff --git a/scripts/build-sharp.sh b/scripts/build-sharp.sh index 81d574a..56d5a1c 100755 --- a/scripts/build-sharp.sh +++ b/scripts/build-sharp.sh @@ -1,5 +1,14 @@ #!/usr/bin/env bash -# build-sharp.sh - Build sharp native module for image processing support +# build-sharp.sh - Enable sharp image processing on Android (Termux) +# +# Strategy: +# 1. Check if sharp already works → skip +# 2. Install WebAssembly fallback (@img/sharp-wasm32) +# Native sharp binaries are built for glibc Linux. Android's Bionic libc +# cannot dlopen glibc-linked .node addons, so the prebuilt linux-arm64 +# binding never loads. The WASM build uses Emscripten and runs entirely +# in V8 — zero native dependencies. +# 3. If WASM fails → attempt native rebuild as last resort set -euo pipefail GREEN='\033[0;32m' @@ -15,7 +24,6 @@ export TMPDIR="${TMPDIR:-$PREFIX/tmp}" export TMP="$TMPDIR" export TEMP="$TMPDIR" export CONTAINER="${CONTAINER:-1}" -export NODE_OPTIONS="${NODE_OPTIONS:--r $HOME/.openclaw-android/patches/bionic-compat.js}" # Locate openclaw install directory OPENCLAW_DIR="$(npm root -g)/openclaw" @@ -25,7 +33,7 @@ if [ ! -d "$OPENCLAW_DIR" ]; then exit 0 fi -# Skip rebuild if sharp is already working (e.g. compiled during npm install) +# Skip rebuild if sharp is already working (e.g. WASM installed on prior run) if [ -d "$OPENCLAW_DIR/node_modules/sharp" ]; then if node -e "require('$OPENCLAW_DIR/node_modules/sharp')" 2>/dev/null; then echo -e "${GREEN}[OK]${NC} sharp is already working — skipping rebuild" @@ -33,6 +41,32 @@ if [ -d "$OPENCLAW_DIR/node_modules/sharp" ]; then fi fi +# ── Strategy 1: WebAssembly fallback (recommended for Android) ────────── +# sharp's JS loader tries these paths in order: +# 1. ../src/build/Release/sharp-{platform}.node (source build) +# 2. ../src/build/Release/sharp-wasm32.node (source build) +# 3. @img/sharp-{platform}/sharp.node (prebuilt native) +# 4. @img/sharp-wasm32/sharp.node (prebuilt WASM) ← this +# By installing @img/sharp-wasm32, path 4 catches the fallback automatically. + +echo "Installing sharp WebAssembly runtime..." +if (cd "$OPENCLAW_DIR" && npm install @img/sharp-wasm32 --force --no-audit --no-fund 2>&1 | tail -3); then + if node -e "require('$OPENCLAW_DIR/node_modules/sharp')" 2>/dev/null; then + echo "" + echo -e "${GREEN}[OK]${NC} sharp enabled via WebAssembly — image processing ready" + exit 0 + else + echo -e "${YELLOW}[WARN]${NC} WASM package installed but sharp still not loading" + fi +else + echo -e "${YELLOW}[WARN]${NC} Failed to install WASM package" +fi + +# ── Strategy 2: Native rebuild (last resort) ──────────────────────────── + +echo "" +echo "Attempting native rebuild as fallback..." + # Install required packages echo "Installing build dependencies..." if ! pkg install -y libvips binutils; then @@ -58,9 +92,13 @@ fi echo -e "${GREEN}[OK]${NC} node-gyp installed" # Set build environment variables -export CFLAGS="-Wno-error=implicit-function-declaration" -export CXXFLAGS="-include $HOME/.openclaw-android/patches/termux-compat.h" -export GYP_DEFINES="OS=linux android_ndk_path=$PREFIX" +# On glibc architecture, these are handled by glibc's standard headers. +# On Bionic (legacy), we need explicit compatibility flags. +if [ ! -f "$HOME/.openclaw-android/.glibc-arch" ]; then + export CFLAGS="-Wno-error=implicit-function-declaration" + export CXXFLAGS="-include $HOME/.openclaw-android/patches/termux-compat.h" + export GYP_DEFINES="OS=linux android_ndk_path=$PREFIX" +fi export CPATH="$PREFIX/include/glib-2.0:$PREFIX/lib/glib-2.0/include" echo "Rebuilding sharp in $OPENCLAW_DIR..." @@ -72,7 +110,7 @@ if (cd "$OPENCLAW_DIR" && npm rebuild sharp); then echo -e "${GREEN}[OK]${NC} sharp built successfully — image processing enabled" else echo "" - echo -e "${YELLOW}[WARN]${NC} sharp build failed (non-critical)" + echo -e "${YELLOW}[WARN]${NC} sharp could not be enabled (non-critical)" echo " Image processing will not be available, but OpenClaw will work normally." echo " You can retry later: bash ~/.openclaw-android/scripts/build-sharp.sh" fi diff --git a/scripts/check-env.sh b/scripts/check-env.sh index b285f43..e94c4ec 100755 --- a/scripts/check-env.sh +++ b/scripts/check-env.sh @@ -1,18 +1,14 @@ #!/usr/bin/env bash -# check-env.sh - Verify Termux environment before installation set -euo pipefail -RED='\033[0;31m' -YELLOW='\033[1;33m' -GREEN='\033[0;32m' -NC='\033[0m' +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib.sh" ERRORS=0 echo "=== OpenClaw on Android - Environment Check ===" echo "" -# 1. Check if running in Termux if [ -z "${PREFIX:-}" ]; then echo -e "${RED}[FAIL]${NC} Not running in Termux (\$PREFIX not set)" echo " This script is designed for Termux on Android." @@ -21,7 +17,6 @@ else echo -e "${GREEN}[OK]${NC} Termux detected (PREFIX=$PREFIX)" fi -# 2. Check architecture ARCH=$(uname -m) echo -n " Architecture: $ARCH" if [ "$ARCH" = "aarch64" ]; then @@ -34,23 +29,14 @@ else echo -e " ${YELLOW}(unknown, may not work)${NC}" fi -# 3. Check disk space (need at least 500MB free) AVAILABLE_MB=$(df "$PREFIX" 2>/dev/null | awk 'NR==2 {print int($4/1024)}') -if [ -n "$AVAILABLE_MB" ] && [ "$AVAILABLE_MB" -lt 500 ]; then - echo -e "${RED}[FAIL]${NC} Insufficient disk space: ${AVAILABLE_MB}MB available (need 500MB+)" +if [ -n "$AVAILABLE_MB" ] && [ "$AVAILABLE_MB" -lt 1000 ]; then + echo -e "${RED}[FAIL]${NC} Insufficient disk space: ${AVAILABLE_MB}MB available (need 1000MB+)" ERRORS=$((ERRORS + 1)) else echo -e "${GREEN}[OK]${NC} Disk space: ${AVAILABLE_MB:-unknown}MB available" fi -# 4. Check if already installed -if command -v openclaw &>/dev/null; then - CURRENT_VER=$(openclaw --version 2>/dev/null || echo "unknown") - echo -e "${YELLOW}[INFO]${NC} OpenClaw already installed (version: $CURRENT_VER)" - echo " Re-running install will update/repair the installation." -fi - -# 5. Check if Node.js is already installed and version if command -v node &>/dev/null; then NODE_VER=$(node -v 2>/dev/null || echo "unknown") echo -e "${GREEN}[OK]${NC} Node.js found: $NODE_VER" @@ -60,7 +46,13 @@ if command -v node &>/dev/null; then echo -e "${YELLOW}[WARN]${NC} Node.js >= 22 required. Will be upgraded during install." fi else - echo -e "${YELLOW}[INFO]${NC} Node.js not found. Will be installed." + echo -e "${YELLOW}[INFO]${NC} Node.js not found. Will be installed via glibc environment." +fi + +SDK_INT=$(getprop ro.build.version.sdk 2>/dev/null || echo "0") +if [ "$SDK_INT" -ge 31 ] 2>/dev/null; then + echo -e "${YELLOW}[INFO]${NC} Android 12+ detected — if background processes get killed (signal 9)," + echo " see: https://github.com/AidanPark/openclaw-android/blob/main/docs/disable-phantom-process-killer.md" fi echo "" diff --git a/scripts/install-build-tools.sh b/scripts/install-build-tools.sh new file mode 100755 index 0000000..772a4e7 --- /dev/null +++ b/scripts/install-build-tools.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# install-build-tools.sh - Install build tools for native module compilation (L2 conditional) +# Extracted from install-deps.sh — build tools only. +# Called by orchestrator when config.env PLATFORM_NEEDS_BUILD_TOOLS=true. +# +# Installs: python, make, cmake, clang, binutils +# These are required for node-gyp (native C/C++ addon compilation). +set -euo pipefail + +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +echo "=== Installing Build Tools ===" +echo "" + +PACKAGES=( + python + make + cmake + clang + binutils +) + +echo "Installing packages: ${PACKAGES[*]}" +echo " (This may take a few minutes depending on network speed)" +pkg install -y "${PACKAGES[@]}" + +# Create ar symlink if missing (Termux provides llvm-ar but not ar) +if [ ! -e "$PREFIX/bin/ar" ] && [ -x "$PREFIX/bin/llvm-ar" ]; then + ln -s "$PREFIX/bin/llvm-ar" "$PREFIX/bin/ar" + echo -e "${GREEN}[OK]${NC} Created ar → llvm-ar symlink" +fi + +echo "" +echo -e "${GREEN}Build tools installed.${NC}" diff --git a/scripts/install-chromium.sh b/scripts/install-chromium.sh new file mode 100644 index 0000000..45c0fb7 --- /dev/null +++ b/scripts/install-chromium.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +# install-chromium.sh - Install Chromium for OpenClaw browser automation +# Usage: bash install-chromium.sh [install|update] +# +# What it does: +# 1. Install x11-repo (Termux X11 packages repository) +# 2. Install chromium package +# 3. Configure OpenClaw browser settings in openclaw.json +# 4. Verify installation +# +# Browser automation allows OpenClaw to control a headless Chromium browser +# for web scraping, screenshots, and automated browsing tasks. +# +# This script is WARN-level: failure does not abort the parent installer. +set -euo pipefail + +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +MODE="${1:-install}" + +# ── Helper ──────────────────────────────────── + +fail_warn() { + echo -e "${YELLOW}[WARN]${NC} $1" + exit 0 +} + +# ── Detect Chromium binary path ─────────────── + +detect_chromium_bin() { + for bin in "$PREFIX/bin/chromium-browser" "$PREFIX/bin/chromium"; do + if [ -x "$bin" ]; then + echo "$bin" + return 0 + fi + done + return 1 +} + +# ── Pre-checks ──────────────────────────────── + +if [ -z "${PREFIX:-}" ]; then + fail_warn "Not running in Termux (\$PREFIX not set)" +fi + +# ── Check current installation ──────────────── + +SKIP_PKG_INSTALL=false +if CHROMIUM_BIN=$(detect_chromium_bin); then + if [ "$MODE" = "install" ]; then + echo -e "${GREEN}[SKIP]${NC} Chromium already installed ($CHROMIUM_BIN)" + SKIP_PKG_INSTALL=true + fi +fi + +# ── Step 1: Install x11-repo + Chromium ─────── + +if [ "$SKIP_PKG_INSTALL" = false ]; then + echo "Installing x11-repo (Termux X11 packages)..." + if ! pkg install -y x11-repo; then + fail_warn "Failed to install x11-repo" + fi + echo -e "${GREEN}[OK]${NC} x11-repo installed" + + echo "Installing Chromium..." + echo " (This is a large package (~400MB) — may take several minutes)" + if ! pkg install -y chromium; then + fail_warn "Failed to install Chromium" + fi + echo -e "${GREEN}[OK]${NC} Chromium installed" +fi + +# ── Step 2: Detect binary path ──────────────── + +if ! CHROMIUM_BIN=$(detect_chromium_bin); then + fail_warn "Chromium binary not found after installation" +fi + +# ── Step 3: Configure OpenClaw browser settings + +echo "Configuring OpenClaw browser settings..." + +if command -v node &>/dev/null; then + export CHROMIUM_BIN + if node << 'NODESCRIPT' +const fs = require('fs'); +const path = require('path'); + +const configDir = path.join(process.env.HOME, '.openclaw'); +const configPath = path.join(configDir, 'openclaw.json'); + +let config = {}; +try { + config = JSON.parse(fs.readFileSync(configPath, 'utf8')); +} catch { + // File doesn't exist or invalid — start fresh +} + +if (!config.browser) config.browser = {}; +config.browser.executablePath = process.env.CHROMIUM_BIN; +if (config.browser.headless === undefined) config.browser.headless = true; +if (config.browser.noSandbox === undefined) config.browser.noSandbox = true; + +fs.mkdirSync(configDir, { recursive: true }); +fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n'); +console.log(' Written to ' + configPath); +NODESCRIPT + then + echo -e "${GREEN}[OK]${NC} openclaw.json browser settings configured" + else + echo -e "${YELLOW}[WARN]${NC} Could not update openclaw.json automatically" + echo " Add this to ~/.openclaw/openclaw.json manually:" + echo " \"browser\": {\"executablePath\": \"$CHROMIUM_BIN\", \"headless\": true, \"noSandbox\": true}" + fi +else + echo -e "${YELLOW}[INFO]${NC} Node.js not available — manual browser configuration needed" + echo " After running 'openclaw onboard', add to ~/.openclaw/openclaw.json:" + echo " \"browser\": {\"executablePath\": \"$CHROMIUM_BIN\", \"headless\": true, \"noSandbox\": true}" +fi + +# ── Step 4: Verify ──────────────────────────── + +echo "" +if [ -x "$CHROMIUM_BIN" ]; then + CHROMIUM_VER=$("$CHROMIUM_BIN" --version 2>/dev/null || echo "unknown version") + echo -e "${GREEN}[OK]${NC} $CHROMIUM_VER" + echo " Binary: $CHROMIUM_BIN" + echo "" + echo -e "${YELLOW}[NOTE]${NC} Chromium uses ~300-500MB RAM at runtime." + echo " Devices with less than 4GB RAM may experience slowdowns." +else + fail_warn "Chromium verification failed — binary not executable" +fi + +# ── Step 5: Ensure image processing works ──── +# +# Browser screenshots require sharp for image optimization before sending +# to Discord/Slack. Run build-sharp.sh to enable it (idempotent — skips +# if sharp is already working). + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +if [ -f "$SCRIPT_DIR/build-sharp.sh" ]; then + echo "" + bash "$SCRIPT_DIR/build-sharp.sh" || true +elif [ -f "$HOME/.openclaw-android/scripts/build-sharp.sh" ]; then + echo "" + bash "$HOME/.openclaw-android/scripts/build-sharp.sh" || true +fi diff --git a/scripts/install-code-server.sh b/scripts/install-code-server.sh new file mode 100644 index 0000000..895119e --- /dev/null +++ b/scripts/install-code-server.sh @@ -0,0 +1,193 @@ +#!/usr/bin/env bash +# install-code-server.sh - Install or update code-server (browser IDE) on Termux +# Usage: bash install-code-server.sh [install|update] +# +# Workarounds applied: +# 1. Replace bundled glibc node with Termux node +# 2. Patch argon2 native module with JS stub (--auth none makes it unused) +# 3. Ignore tar hard link errors (Android restriction) and recover .node files +# +# This script is WARN-level: failure does not abort the parent installer. +set -euo pipefail + +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +MODE="${1:-install}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +INSTALL_DIR="$HOME/.local/lib" +BIN_DIR="$HOME/.local/bin" + +# ── Helper ──────────────────────────────────── + +fail_warn() { + echo -e "${YELLOW}[WARN]${NC} $1" + exit 0 +} + +# ── Pre-checks ──────────────────────────────── + +if [ -z "${PREFIX:-}" ]; then + fail_warn "Not running in Termux (\$PREFIX not set)" +fi + +if ! command -v node &>/dev/null; then + fail_warn "node not found — code-server requires Node.js" +fi + +if ! command -v curl &>/dev/null; then + fail_warn "curl not found — cannot download code-server" +fi + +# ── Check current installation ──────────────── + +CURRENT_VERSION="" +if [ -x "$BIN_DIR/code-server" ]; then + # code-server --version outputs: "4.109.2 9184b645cc... with Code 1.109.2" + # Extract just the version number (first field) + CURRENT_VERSION=$("$BIN_DIR/code-server" --version 2>/dev/null | head -1 | awk '{print $1}' || true) +fi + +# ── Determine target version ────────────────── + +if [ "$MODE" = "install" ] && [ -n "$CURRENT_VERSION" ]; then + echo -e "${GREEN}[SKIP]${NC} code-server already installed ($CURRENT_VERSION)" + exit 0 +fi + +# Fetch latest version from GitHub API +echo "Checking latest code-server version..." +LATEST_VERSION=$(curl -sfL --max-time 10 \ + "https://api.github.com/repos/coder/code-server/releases/latest" \ + | grep '"tag_name"' | head -1 | sed 's/.*"v\([^"]*\)".*/\1/') || true + +if [ -z "$LATEST_VERSION" ]; then + fail_warn "Failed to fetch latest code-server version from GitHub" +fi + +echo " Latest: v$LATEST_VERSION" + +if [ "$MODE" = "update" ] && [ -n "$CURRENT_VERSION" ]; then + if [ "$CURRENT_VERSION" = "$LATEST_VERSION" ]; then + echo -e "${GREEN}[SKIP]${NC} code-server $CURRENT_VERSION is already the latest" + exit 0 + fi + echo " Current: v$CURRENT_VERSION → updating to v$LATEST_VERSION" +fi + +VERSION="$LATEST_VERSION" + +# ── Download ────────────────────────────────── + +TARBALL="code-server-${VERSION}-linux-arm64.tar.gz" +DOWNLOAD_URL="https://github.com/coder/code-server/releases/download/v${VERSION}/${TARBALL}" +TMP_DIR=$(mktemp -d "$PREFIX/tmp/code-server-install.XXXXXX") || fail_warn "Failed to create temp directory" +trap 'rm -rf "$TMP_DIR"' EXIT + +echo "Downloading code-server v${VERSION}..." +echo " (File size ~121MB — this may take several minutes depending on network speed)" +if ! curl -fL --max-time 300 "$DOWNLOAD_URL" -o "$TMP_DIR/$TARBALL"; then + fail_warn "Failed to download code-server v${VERSION}" +fi +echo -e "${GREEN}[OK]${NC} Downloaded $TARBALL" + +# ── Extract (ignore hard link errors) ───────── + +echo "Extracting code-server... (this may take a moment)" +# Android's filesystem does not support hard links, so tar will report errors +# for hardlinked .node files. We extract what we can and recover them below. +tar -xzf "$TMP_DIR/$TARBALL" -C "$TMP_DIR" 2>/dev/null || true + +EXTRACTED_DIR="$TMP_DIR/code-server-${VERSION}-linux-arm64" +if [ ! -d "$EXTRACTED_DIR" ]; then + fail_warn "Extraction failed — directory not found" +fi + +# ── Recover hard-linked .node files ─────────── +# The obj.target/ directories contain the original .node files that tar +# couldn't hard-link into Release/. Copy them manually. + +find "$EXTRACTED_DIR" -path "*/obj.target/*.node" -type f 2>/dev/null | while read -r OBJ_FILE; do + # obj.target/foo.node → Release/foo.node + RELEASE_DIR="$(dirname "$(dirname "$OBJ_FILE")")/Release" + BASENAME="$(basename "$OBJ_FILE")" + if [ -d "$RELEASE_DIR" ] && [ ! -f "$RELEASE_DIR/$BASENAME" ]; then + cp "$OBJ_FILE" "$RELEASE_DIR/$BASENAME" + fi +done +echo -e "${GREEN}[OK]${NC} Extracted and recovered .node files" + +# ── Install to ~/.local/lib ─────────────────── + +mkdir -p "$INSTALL_DIR" "$BIN_DIR" + +# Remove previous code-server versions +rm -rf "$INSTALL_DIR"/code-server-* + +# Move extracted directory to install location +mv "$EXTRACTED_DIR" "$INSTALL_DIR/code-server-${VERSION}" +echo -e "${GREEN}[OK]${NC} Installed to $INSTALL_DIR/code-server-${VERSION}" + +CS_DIR="$INSTALL_DIR/code-server-${VERSION}" + +# ── Replace bundled node with Termux node ───── +# The standalone release bundles a glibc-linked node binary that cannot +# run on Termux (Bionic libc). Swap it with the system node. + +if [ -f "$CS_DIR/lib/node" ] || [ -L "$CS_DIR/lib/node" ]; then + rm -f "$CS_DIR/lib/node" +fi +ln -s "$PREFIX/bin/node" "$CS_DIR/lib/node" +echo -e "${GREEN}[OK]${NC} Replaced bundled node → Termux node" + +# ── Patch argon2 native module ──────────────── +# argon2 ships a .node binary compiled against glibc. Since we run +# code-server with --auth none, argon2 is never called. Replace the +# module entry point with a JS stub. + +ARGON2_STUB="" +# Check multiple possible locations for the stub +if [ -f "$SCRIPT_DIR/../patches/argon2-stub.js" ]; then + ARGON2_STUB="$SCRIPT_DIR/../patches/argon2-stub.js" +elif [ -f "$HOME/.openclaw-android/patches/argon2-stub.js" ]; then + ARGON2_STUB="$HOME/.openclaw-android/patches/argon2-stub.js" +fi + +if [ -n "$ARGON2_STUB" ]; then + # Find argon2 module entry point in code-server + # Entry point varies by version: argon2.cjs (v4.109+), argon2.js, or index.js + ARGON2_INDEX="" + for PATTERN in "*/argon2/argon2.cjs" "*/argon2/argon2.js" "*/node_modules/argon2/index.js"; do + ARGON2_INDEX=$(find "$CS_DIR" -path "$PATTERN" -type f 2>/dev/null | head -1 || true) + [ -n "$ARGON2_INDEX" ] && break + done + if [ -n "$ARGON2_INDEX" ]; then + cp "$ARGON2_STUB" "$ARGON2_INDEX" + echo -e "${GREEN}[OK]${NC} Patched argon2 module with JS stub ($(basename "$ARGON2_INDEX"))" + else + echo -e "${YELLOW}[WARN]${NC} argon2 module not found in code-server (may not be needed)" + fi +else + echo -e "${YELLOW}[WARN]${NC} argon2-stub.js not found — skipping argon2 patch" +fi + +# ── Create symlink ──────────────────────────── + +rm -f "$BIN_DIR/code-server" +ln -s "$CS_DIR/bin/code-server" "$BIN_DIR/code-server" +echo -e "${GREEN}[OK]${NC} Symlinked $BIN_DIR/code-server" + +# ── Verify ──────────────────────────────────── + +# Add ~/.local/bin to PATH for this session so we can verify with just "code-server" +export PATH="$BIN_DIR:$PATH" + +echo "" +if code-server --version &>/dev/null; then + INSTALLED_VER=$(code-server --version 2>/dev/null | head -1 || true) + echo -e "${GREEN}[OK]${NC} code-server ${INSTALLED_VER:-unknown} installed successfully" +else + echo -e "${YELLOW}[WARN]${NC} code-server installed but --version check failed" + echo " This may work once ~/.local/bin is on PATH (restart shell or: source ~/.bashrc)" +fi diff --git a/scripts/install-deps.sh b/scripts/install-deps.sh deleted file mode 100755 index 149c99f..0000000 --- a/scripts/install-deps.sh +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env bash -# install-deps.sh - Install required Termux packages -set -euo pipefail - -RED='\033[0;31m' -GREEN='\033[0;32m' -NC='\033[0m' - -echo "=== Installing Dependencies ===" -echo "" - -# Update package repos -echo "Updating package repositories..." -pkg update -y - -# Install required packages -PACKAGES=( - nodejs-lts - git - python - make - cmake - clang - binutils - tmux - ttyd -) - -echo "Installing packages: ${PACKAGES[*]}" -pkg install -y "${PACKAGES[@]}" - -echo "" - -# Verify Node.js version -if ! command -v node &>/dev/null; then - echo -e "${RED}[FAIL]${NC} Node.js installation failed" - exit 1 -fi - -NODE_VER=$(node -v) -NODE_MAJOR="${NODE_VER%%.*}" -NODE_MAJOR="${NODE_MAJOR#v}" - -echo -e "${GREEN}[OK]${NC} Node.js $NODE_VER installed" - -if [ "$NODE_MAJOR" -lt 22 ]; then - echo -e "${RED}[FAIL]${NC} Node.js >= 22 required, got $NODE_VER" - echo " Try: pkg install nodejs-lts" - exit 1 -fi - -# Verify npm -if ! command -v npm &>/dev/null; then - echo -e "${RED}[FAIL]${NC} npm not found" - exit 1 -fi - -NPM_VER=$(npm -v) -echo -e "${GREEN}[OK]${NC} npm $NPM_VER installed" - -# Install PyYAML (required for .skill packaging) -echo "Installing PyYAML..." -if pip install pyyaml -q; then - echo -e "${GREEN}[OK]${NC} PyYAML installed" -else - echo -e "${RED}[FAIL]${NC} PyYAML installation failed" - exit 1 -fi - -echo "" -echo -e "${GREEN}All dependencies installed.${NC}" diff --git a/scripts/install-glibc.sh b/scripts/install-glibc.sh new file mode 100755 index 0000000..14d0d69 --- /dev/null +++ b/scripts/install-glibc.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# install-glibc.sh - Install glibc-runner (L2 conditional) +# Extracted from install-glibc-env.sh — glibc runtime only, no Node.js. +# Called by orchestrator when config.env PLATFORM_NEEDS_GLIBC=true. +# +# What it does: +# 1. Install pacman package +# 2. Initialize pacman and install glibc-runner +# 3. Verify glibc dynamic linker +# 4. Create marker file +set -euo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +OPENCLAW_DIR="$HOME/.openclaw-android" +GLIBC_LDSO="$PREFIX/glibc/lib/ld-linux-aarch64.so.1" +PACMAN_CONF="$PREFIX/etc/pacman.conf" + +echo "=== Installing glibc Runtime ===" +echo "" + +# ── Pre-checks ─────────────────────────────── + +if [ -z "${PREFIX:-}" ]; then + echo -e "${RED}[FAIL]${NC} Not running in Termux (\$PREFIX not set)" + exit 1 +fi + +ARCH=$(uname -m) +if [ "$ARCH" != "aarch64" ]; then + echo -e "${RED}[FAIL]${NC} glibc environment requires aarch64 (got: $ARCH)" + exit 1 +fi + +# Check if already installed +if [ -f "$OPENCLAW_DIR/.glibc-arch" ] && [ -x "$GLIBC_LDSO" ]; then + echo -e "${GREEN}[SKIP]${NC} glibc-runner already installed" + exit 0 +fi + +# ── Step 1: Install pacman ──────────────────── + +echo "Installing pacman..." +if ! pkg install -y pacman; then + echo -e "${RED}[FAIL]${NC} Failed to install pacman" + exit 1 +fi +echo -e "${GREEN}[OK]${NC} pacman installed" + +# ── Step 2: Initialize pacman ───────────────── + +echo "" +echo "Initializing pacman..." +echo " (This may take a few minutes for GPG key generation)" + +# SigLevel workaround: Some devices have a GPGME crypto engine bug +# that prevents signature verification. Temporarily set SigLevel = Never. +SIGLEVEL_PATCHED=false +if [ -f "$PACMAN_CONF" ]; then + if ! grep -q "^SigLevel = Never" "$PACMAN_CONF"; then + cp "$PACMAN_CONF" "${PACMAN_CONF}.bak" + sed -i 's/^SigLevel\s*=.*/SigLevel = Never/' "$PACMAN_CONF" + SIGLEVEL_PATCHED=true + echo -e "${YELLOW}[INFO]${NC} Applied SigLevel = Never workaround (GPGME bug)" + fi +fi + +# Initialize pacman keyring (may hang on low-entropy devices) +pacman-key --init 2>/dev/null || true +pacman-key --populate 2>/dev/null || true + +# ── Step 3: Install glibc-runner ────────────── + +echo "" +echo "Installing glibc-runner..." + +# --assume-installed: these packages are provided by Termux's apt but pacman +# doesn't know about them, causing dependency resolution failures +if pacman -Sy glibc-runner --noconfirm --assume-installed bash,patchelf,resolv-conf 2>&1; then + echo -e "${GREEN}[OK]${NC} glibc-runner installed" +else + echo -e "${RED}[FAIL]${NC} Failed to install glibc-runner" + if [ "$SIGLEVEL_PATCHED" = true ] && [ -f "${PACMAN_CONF}.bak" ]; then + mv "${PACMAN_CONF}.bak" "$PACMAN_CONF" + fi + exit 1 +fi + +# Restore SigLevel after successful install +if [ "$SIGLEVEL_PATCHED" = true ] && [ -f "${PACMAN_CONF}.bak" ]; then + mv "${PACMAN_CONF}.bak" "$PACMAN_CONF" + echo -e "${GREEN}[OK]${NC} Restored pacman SigLevel" +fi + +# ── Verify ──────────────────────────────────── + +if [ ! -x "$GLIBC_LDSO" ]; then + echo -e "${RED}[FAIL]${NC} glibc dynamic linker not found at $GLIBC_LDSO" + exit 1 +fi +echo -e "${GREEN}[OK]${NC} glibc dynamic linker available" + +if command -v grun &>/dev/null; then + echo -e "${GREEN}[OK]${NC} grun command available" +else + echo -e "${YELLOW}[WARN]${NC} grun command not found (will use ld.so directly)" +fi + +# ── Create marker file ──────────────────────── + +mkdir -p "$OPENCLAW_DIR" +touch "$OPENCLAW_DIR/.glibc-arch" +echo -e "${GREEN}[OK]${NC} glibc architecture marker created" + +echo "" +echo -e "${GREEN}glibc runtime installed successfully.${NC}" +echo " ld.so: $GLIBC_LDSO" diff --git a/scripts/install-infra-deps.sh b/scripts/install-infra-deps.sh new file mode 100755 index 0000000..88e6ba5 --- /dev/null +++ b/scripts/install-infra-deps.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# install-infra-deps.sh - Install core infrastructure packages (L1) +# Extracted from install-deps.sh — infrastructure only. +# Always runs regardless of platform selection. +# +# Installs: git (+ pkg update/upgrade) +set -euo pipefail + +GREEN='\033[0;32m' +NC='\033[0m' + +echo "=== Installing Infrastructure Dependencies ===" +echo "" + +# Update and upgrade package repos +echo "Updating package repositories..." +echo " (This may take a minute depending on mirror speed)" +pkg update -y +pkg upgrade -y + +# Install core infrastructure packages +echo "Installing git..." +pkg install -y git + +echo "" +echo -e "${GREEN}Infrastructure dependencies installed.${NC}" diff --git a/scripts/install-nodejs.sh b/scripts/install-nodejs.sh new file mode 100755 index 0000000..8cfbd9e --- /dev/null +++ b/scripts/install-nodejs.sh @@ -0,0 +1,183 @@ +#!/usr/bin/env bash +# install-nodejs.sh - Install Node.js linux-arm64 with grun wrapper (L2 conditional) +# Extracted from install-glibc-env.sh — Node.js only, assumes glibc already installed. +# Called by orchestrator when config.env PLATFORM_NEEDS_NODEJS=true. +# +# What it does: +# 1. Download Node.js linux-arm64 LTS +# 2. Create grun-style wrapper scripts (ld.so direct execution) +# 3. Configure npm +# 4. Verify everything works +# +# patchelf is NOT used — Android seccomp causes SIGSEGV on patchelf'd binaries. +# All glibc binaries are executed via: exec ld.so binary "$@" +set -euo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +OPENCLAW_DIR="$HOME/.openclaw-android" +NODE_DIR="$OPENCLAW_DIR/node" +GLIBC_LDSO="$PREFIX/glibc/lib/ld-linux-aarch64.so.1" + +# Node.js LTS version to install +NODE_VERSION="22.22.0" +NODE_TARBALL="node-v${NODE_VERSION}-linux-arm64.tar.xz" +NODE_URL="https://nodejs.org/dist/v${NODE_VERSION}/${NODE_TARBALL}" + +echo "=== Installing Node.js (glibc) ===" +echo "" + +# ── Pre-checks ─────────────────────────────── + +if [ -z "${PREFIX:-}" ]; then + echo -e "${RED}[FAIL]${NC} Not running in Termux (\$PREFIX not set)" + exit 1 +fi + +if [ ! -x "$GLIBC_LDSO" ]; then + echo -e "${RED}[FAIL]${NC} glibc dynamic linker not found — run install-glibc.sh first" + exit 1 +fi + +# Check if already installed +if [ -x "$NODE_DIR/bin/node" ]; then + if "$NODE_DIR/bin/node" --version &>/dev/null; then + INSTALLED_VER=$("$NODE_DIR/bin/node" --version 2>/dev/null | sed 's/^v//') + if [ "$INSTALLED_VER" = "$NODE_VERSION" ]; then + echo -e "${GREEN}[SKIP]${NC} Node.js already installed (v${INSTALLED_VER})" + exit 0 + fi + LOWEST=$(printf '%s\n%s\n' "$INSTALLED_VER" "$NODE_VERSION" | sort -V | head -1) + if [ "$LOWEST" = "$INSTALLED_VER" ] && [ "$INSTALLED_VER" != "$NODE_VERSION" ]; then + echo -e "${YELLOW}[INFO]${NC} Node.js v${INSTALLED_VER} -> v${NODE_VERSION} (upgrading)" + else + echo -e "${GREEN}[SKIP]${NC} Node.js v${INSTALLED_VER} is newer than target v${NODE_VERSION}" + exit 0 + fi + else + echo -e "${YELLOW}[INFO]${NC} Node.js exists but broken — reinstalling" + fi +fi + +# ── Step 1: Download Node.js linux-arm64 ────── + +echo "Downloading Node.js v${NODE_VERSION} (linux-arm64)..." +echo " (File size ~25MB — may take a few minutes depending on network speed)" +mkdir -p "$NODE_DIR" + +TMP_DIR=$(mktemp -d "$PREFIX/tmp/node-install.XXXXXX") || { + echo -e "${RED}[FAIL]${NC} Failed to create temp directory" + exit 1 +} +trap 'rm -rf "$TMP_DIR"' EXIT + +if ! curl -fL --max-time 300 "$NODE_URL" -o "$TMP_DIR/$NODE_TARBALL"; then + echo -e "${RED}[FAIL]${NC} Failed to download Node.js v${NODE_VERSION}" + exit 1 +fi +echo -e "${GREEN}[OK]${NC} Downloaded $NODE_TARBALL" + +# Extract +echo "Extracting Node.js... (this may take a moment)" +if ! tar -xJf "$TMP_DIR/$NODE_TARBALL" -C "$NODE_DIR" --strip-components=1; then + echo -e "${RED}[FAIL]${NC} Failed to extract Node.js" + exit 1 +fi +echo -e "${GREEN}[OK]${NC} Extracted to $NODE_DIR" + +# ── Step 2: Create wrapper scripts ──────────── + +echo "" +echo "Creating wrapper scripts (grun-style, no patchelf)..." + +# Move original node binary to node.real +if [ -f "$NODE_DIR/bin/node" ] && [ ! -L "$NODE_DIR/bin/node" ]; then + mv "$NODE_DIR/bin/node" "$NODE_DIR/bin/node.real" +fi + +# Create node wrapper script +# This uses grun-style execution: ld.so directly loads the binary +# LD_PRELOAD must be unset to prevent Bionic libtermux-exec.so from +# being loaded into the glibc process (causes version mismatch crash) +# glibc-compat.js is auto-loaded to fix Android kernel quirks (os.cpus() returns 0, +# os.networkInterfaces() throws EACCES) that affect native module builds and runtime. +cat > "$NODE_DIR/bin/node" << 'WRAPPER' +#!/data/data/com.termux/files/usr/bin/bash +unset LD_PRELOAD +_OA_COMPAT="$HOME/.openclaw-android/patches/glibc-compat.js" +if [ -f "$_OA_COMPAT" ]; then + case "${NODE_OPTIONS:-}" in + *"$_OA_COMPAT"*) ;; + *) export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }-r $_OA_COMPAT" ;; + esac +fi +# glibc ld.so misparses leading --options as its own flags. +# Move them to NODE_OPTIONS ONLY when a script path follows +# (preserves direct invocations like 'node --version'). +_LEADING_OPTS="" +_COUNT=0 +for _arg in "$@"; do + case "$_arg" in --*) _COUNT=$((_COUNT + 1)) ;; *) break ;; esac +done +if [ $_COUNT -gt 0 ] && [ $_COUNT -lt $# ]; then + while [ $# -gt 0 ]; do + case "$1" in + --*) _LEADING_OPTS="${_LEADING_OPTS:+$_LEADING_OPTS }$1"; shift ;; + *) break ;; + esac + done + export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }$_LEADING_OPTS" +fi +exec "$PREFIX/glibc/lib/ld-linux-aarch64.so.1" "$(dirname "$0")/node.real" "$@" +WRAPPER +chmod +x "$NODE_DIR/bin/node" +echo -e "${GREEN}[OK]${NC} node wrapper created" + +# npm is a JS script that uses the node from its own directory, +# so it automatically inherits the wrapper. No additional wrapping needed. +# Same for npx. + +# ── Step 3: Configure npm ───────────────────── + +echo "" +echo "Configuring npm..." + +# Set script-shell to ensure npm lifecycle scripts use the correct shell +# On Android 9+, /bin/sh exists. On 7-8 it doesn't. +# Using $PREFIX/bin/sh is always safe. +export PATH="$NODE_DIR/bin:$PATH" +"$NODE_DIR/bin/npm" config set script-shell "$PREFIX/bin/sh" 2>/dev/null || true +echo -e "${GREEN}[OK]${NC} npm script-shell set to $PREFIX/bin/sh" + +# ── Step 4: Verify ──────────────────────────── + +echo "" +echo "Verifying glibc Node.js..." + +NODE_VER=$("$NODE_DIR/bin/node" --version 2>/dev/null) || { + echo -e "${RED}[FAIL]${NC} Node.js verification failed — wrapper script may be broken" + exit 1 +} +echo -e "${GREEN}[OK]${NC} Node.js $NODE_VER (glibc, grun wrapper)" + +NPM_VER=$("$NODE_DIR/bin/npm" --version 2>/dev/null) || { + echo -e "${YELLOW}[WARN]${NC} npm verification failed" +} +if [ -n "${NPM_VER:-}" ]; then + echo -e "${GREEN}[OK]${NC} npm $NPM_VER" +fi + +# Quick platform check +PLATFORM=$("$NODE_DIR/bin/node" -e "console.log(process.platform)" 2>/dev/null) || true +if [ "$PLATFORM" = "linux" ]; then + echo -e "${GREEN}[OK]${NC} platform: linux (correct)" +else + echo -e "${YELLOW}[WARN]${NC} platform: ${PLATFORM:-unknown} (expected: linux)" +fi + +echo "" +echo -e "${GREEN}Node.js installed successfully.${NC}" +echo " Node.js: $NODE_VER ($NODE_DIR/bin/node)" diff --git a/scripts/install-opencode.sh b/scripts/install-opencode.sh new file mode 100644 index 0000000..0bdf742 --- /dev/null +++ b/scripts/install-opencode.sh @@ -0,0 +1,231 @@ +#!/usr/bin/env bash +# install-opencode.sh - Install OpenCode on Termux +# Uses proot + ld.so concatenation for Bun standalone binaries. +# +# This script is NON-CRITICAL: failure does not affect OpenClaw. +# +# Why proot + ld.so concatenation? +# 1. Bun uses raw syscalls (LD_PRELOAD shims don't work) +# 2. patchelf causes SIGSEGV on Android (seccomp) +# 3. Bun standalone reads embedded JS via /proc/self/exe offset +# → grun makes /proc/self/exe point to ld.so, breaking this +# → concatenating ld.so + binary data fixes the offset math +set -euo pipefail + +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' + +OPENCLAW_DIR="$HOME/.openclaw-android" +GLIBC_LDSO="$PREFIX/glibc/lib/ld-linux-aarch64.so.1" +PROOT_ROOT="$OPENCLAW_DIR/proot-root" + +fail_warn() { + echo -e "${YELLOW}[WARN]${NC} $1" + exit 0 +} + +echo "=== Installing OpenCode ===" +echo "" + +# ── Pre-checks ─────────────────────────────── + +if [ ! -f "$OPENCLAW_DIR/.glibc-arch" ]; then + fail_warn "glibc environment not installed — skipping OpenCode install" +fi + +if [ ! -x "$GLIBC_LDSO" ]; then + fail_warn "glibc dynamic linker not found — skipping OpenCode install" +fi + +if ! command -v proot &>/dev/null; then + echo "Installing proot..." + if ! pkg install -y proot; then + fail_warn "Failed to install proot — skipping OpenCode install" + fi +fi + +# ── Helper: Create ld.so concatenation ─────── + +# Bun standalone binaries store embedded JS at the end of the file. +# The last 8 bytes contain the original file size as a LE u64. +# Bun calculates: embedded_offset = current_file_size - stored_size +# By prepending ld.so, current_file_size increases, and the offset +# shifts correctly to find the embedded data after ld.so. +create_ldso_concat() { + local bin_path="$1" + local output_path="$2" + local name="$3" + + if [ ! -f "$bin_path" ]; then + echo -e "${RED}[FAIL]${NC} $name binary not found at $bin_path" + return 1 + fi + +echo " Creating ld.so concatenation for $name..." +echo " (Copying large binary files — this may take a minute)" +cp "$GLIBC_LDSO" "$output_path" +cat "$bin_path" >> "$output_path" +chmod +x "$output_path" + + # Verify the Bun magic marker exists at the end + local marker + marker=$(tail -c 32 "$output_path" | strings 2>/dev/null | grep -o "Bun" || true) + if [ -n "$marker" ]; then + echo -e "${GREEN}[OK]${NC} $name ld.so concatenation created ($(du -h "$output_path" | cut -f1))" + else + echo -e "${YELLOW}[WARN]${NC} $name ld.so concatenation created but Bun marker not found" + fi +} + +# ── Helper: Create proot wrapper script ────── + +create_proot_wrapper() { + local wrapper_path="$1" + local ldso_path="$2" + local bin_path="$3" + local name="$4" + + cat > "$wrapper_path" << WRAPPER +#!/data/data/com.termux/files/usr/bin/bash +# $name wrapper — proot + ld.so concatenation +# proot: intercepts raw syscalls (Bun uses inline asm, not glibc calls) +# ld.so concat: fixes /proc/self/exe offset for embedded JS +# unset LD_PRELOAD: prevents Bionic libtermux-exec.so version mismatch +unset LD_PRELOAD +exec proot \\ + -R "$PROOT_ROOT" \\ + -b "\$PREFIX:\$PREFIX" \\ + -b /system:/system \\ + -b /apex:/apex \\ + -w "\$(pwd)" \\ + "$ldso_path" "$bin_path" "\$@" +WRAPPER + chmod +x "$wrapper_path" + echo -e "${GREEN}[OK]${NC} $name wrapper script created" +} + +# ── Step 1: Create minimal proot rootfs ────── + +echo "Setting up proot minimal rootfs..." +mkdir -p "$PROOT_ROOT/data/data/com.termux/files" +echo -e "${GREEN}[OK]${NC} proot rootfs created at $PROOT_ROOT" + +# ── Step 2: Install Bun (package manager) ──── + +echo "" +echo "Installing Bun..." +echo " (Downloading and installing Bun runtime — this may take a few minutes)" +BUN_BIN="$HOME/.bun/bin/bun" +if [ -x "$BUN_BIN" ]; then + echo -e "${GREEN}[OK]${NC} Bun already installed" +else + # Install bun via the official installer + # Bun is needed to download the opencode package + if curl -fsSL https://bun.sh/install | bash 2>/dev/null; then + echo -e "${GREEN}[OK]${NC} Bun installed" + else + fail_warn "Failed to install Bun — cannot install OpenCode" + fi + BUN_BIN="$HOME/.bun/bin/bun" +fi + +# Bun itself needs grun to run (it's a glibc binary) +# Create a temporary wrapper for bun +BUN_WRAPPER=$(mktemp "$PREFIX/tmp/bun-wrapper.XXXXXX") +cat > "$BUN_WRAPPER" << WRAPPER +#!/data/data/com.termux/files/usr/bin/bash +unset LD_PRELOAD +exec "$GLIBC_LDSO" "$BUN_BIN" "\$@" +WRAPPER +chmod +x "$BUN_WRAPPER" + +# Verify bun works +BUN_VER=$("$BUN_WRAPPER" --version 2>/dev/null) || { + rm -f "$BUN_WRAPPER" + fail_warn "Bun verification failed" +} +echo -e "${GREEN}[OK]${NC} Bun $BUN_VER verified" + +# ── Step 3: Install OpenCode ──────────────── + +echo "" +echo "Installing OpenCode..." +echo " (Downloading package — this may take a few minutes)" +# Use bun to install opencode-ai package +# Note: bun may exit non-zero due to optional platform packages (windows, darwin) +# failing to install, but the linux-arm64 binary is still installed successfully. +"$BUN_WRAPPER" install -g opencode-ai 2>&1 || true +echo -e "${GREEN}[OK]${NC} opencode-ai package install attempted" + +# Find the OpenCode binary +OPENCODE_BIN="" +for pattern in \ + "$HOME/.bun/install/cache/opencode-linux-arm64@*/bin/opencode" \ + "$HOME/.bun/install/global/node_modules/opencode-linux-arm64/bin/opencode"; do + # Use ls to expand glob safely + FOUND=$(ls $pattern 2>/dev/null | sort -V | tail -1 || true) + if [ -n "$FOUND" ] && [ -f "$FOUND" ]; then + OPENCODE_BIN="$FOUND" + break + fi +done + +if [ -z "$OPENCODE_BIN" ]; then + rm -f "$BUN_WRAPPER" + fail_warn "OpenCode binary not found after installation" +fi +echo -e "${GREEN}[OK]${NC} OpenCode binary found: $OPENCODE_BIN" + +# Create ld.so concatenation +LDSO_OPENCODE="$PREFIX/tmp/ld.so.opencode" +create_ldso_concat "$OPENCODE_BIN" "$LDSO_OPENCODE" "OpenCode" || { + rm -f "$BUN_WRAPPER" + fail_warn "Failed to create OpenCode ld.so concatenation" +} + +# Create wrapper script +create_proot_wrapper "$PREFIX/bin/opencode" "$LDSO_OPENCODE" "$OPENCODE_BIN" "OpenCode" + +# Verify +echo "" +echo "Verifying OpenCode..." +OC_VER=$("$PREFIX/bin/opencode" --version 2>/dev/null) || true +if [ -n "$OC_VER" ]; then + echo -e "${GREEN}[OK]${NC} OpenCode v$OC_VER verified" +else + echo -e "${YELLOW}[WARN]${NC} OpenCode --version check failed (may work in interactive mode)" +fi + + +# ── Step 4: Create OpenCode config ─────────── + +echo "" +echo "Setting up OpenCode configuration..." + +OPENCODE_CONFIG_DIR="$HOME/.config/opencode" +OPENCODE_CONFIG="$OPENCODE_CONFIG_DIR/opencode.json" +mkdir -p "$OPENCODE_CONFIG_DIR" + +if [ ! -f "$OPENCODE_CONFIG" ]; then + cat > "$OPENCODE_CONFIG" << 'CONFIG' +{ + "$schema": "https://opencode.ai/config.json" +} +CONFIG + echo -e "${GREEN}[OK]${NC} OpenCode config created" +else + echo -e "${GREEN}[OK]${NC} OpenCode config already exists" +fi + +# ── Cleanup ────────────────────────────────── + +rm -f "$BUN_WRAPPER" + +echo "" +echo -e "${GREEN}OpenCode installation complete.${NC}" +if [ -n "${OC_VER:-}" ]; then + echo " OpenCode: v$OC_VER" +fi +echo " Run: opencode" diff --git a/scripts/lib.sh b/scripts/lib.sh new file mode 100755 index 0000000..e304ca4 --- /dev/null +++ b/scripts/lib.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# lib.sh — Shared function library for all orchestrators +# Usage: source "$SCRIPT_DIR/scripts/lib.sh" (from repo) +# source "$PROJECT_DIR/scripts/lib.sh" (from installed copy) + +# ── Color constants ── +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BOLD='\033[1m' +NC='\033[0m' + +# ── Project constants ── +PROJECT_DIR="$HOME/.openclaw-android" +PLATFORM_MARKER="$PROJECT_DIR/.platform" +REPO_BASE="https://raw.githubusercontent.com/AidanPark/openclaw-android/main" + +BASHRC_MARKER_START="# >>> OpenClaw on Android >>>" +BASHRC_MARKER_END="# <<< OpenClaw on Android <<<" +OA_VERSION="1.0.6" + +# ── Platform detection ── +# 1. Explicit marker file (new install and after first update) +# 2. Legacy detection (v1.0.2 and below, one-time) +# 3. Detection failure +detect_platform() { + if [ -f "$PLATFORM_MARKER" ]; then + cat "$PLATFORM_MARKER" + return 0 + fi + if command -v openclaw &>/dev/null; then + echo "openclaw" + mkdir -p "$(dirname "$PLATFORM_MARKER")" + echo "openclaw" > "$PLATFORM_MARKER" + return 0 + fi + echo "" + return 1 +} + +# ── Platform name validation ── +validate_platform_name() { + local name="$1" + if [ -z "$name" ]; then + echo -e "${RED}[FAIL]${NC} Platform name is empty" + return 1 + fi + # Only lowercase alphanumeric + hyphens/underscores allowed + if [[ ! "$name" =~ ^[a-z0-9][a-z0-9_-]*$ ]]; then + echo -e "${RED}[FAIL]${NC} Invalid platform name: $name" + return 1 + fi + return 0 +} + +# ── User confirmation prompt ── +# Reads from /dev/tty so it works even in curl|bash mode. +# Termux always has /dev/tty — no fallback for tty-less environments. +ask_yn() { + local prompt="$1" + local reply + read -rp "$prompt [Y/n] " reply < /dev/tty + [[ "${reply:-}" =~ ^[Nn]$ ]] && return 1 + return 0 +} + +# ── Load platform config.env ── +# $1: platform name, $2: base directory (parent of platforms/) +load_platform_config() { + local platform="$1" + local base_dir="$2" + local config_path="$base_dir/platforms/$platform/config.env" + + validate_platform_name "$platform" || return 1 + + if [ ! -f "$config_path" ]; then + echo -e "${RED}[FAIL]${NC} Platform config not found: $config_path" + return 1 + fi + source "$config_path" + return 0 +} diff --git a/scripts/setup-env.sh b/scripts/setup-env.sh index 3fb4aa6..8a37650 100755 --- a/scripts/setup-env.sh +++ b/scripts/setup-env.sh @@ -1,78 +1,49 @@ #!/usr/bin/env bash -# setup-env.sh - Configure environment variables for OpenClaw in Termux set -euo pipefail - -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' - -echo "=== Setting Up Environment Variables ===" -echo "" +source "$(dirname "$0")/lib.sh" BASHRC="$HOME/.bashrc" -MARKER_START="# >>> OpenClaw on Android >>>" -MARKER_END="# <<< OpenClaw on Android <<<" - -COMPAT_PATH="$HOME/.openclaw-android/patches/bionic-compat.js" - -COMPAT_HEADER="$HOME/.openclaw-android/patches/termux-compat.h" +PLATFORM=$(detect_platform) || true -ENV_BLOCK="${MARKER_START} -export TMPDIR=\"\$PREFIX/tmp\" +INFRA_VARS="export TMPDIR=\"\$PREFIX/tmp\" export TMP=\"\$TMPDIR\" export TEMP=\"\$TMPDIR\" -export NODE_OPTIONS=\"-r $COMPAT_PATH\" -export CONTAINER=1 -export CFLAGS=\"-Wno-error=implicit-function-declaration\" -export CXXFLAGS=\"-include $COMPAT_HEADER\" -export GYP_DEFINES=\"OS=linux android_ndk_path=\$PREFIX\" -export CPATH=\"\$PREFIX/include/glib-2.0:\$PREFIX/lib/glib-2.0/include\" -${MARKER_END}" +export OA_GLIBC=1" + +PATH_LINE="export PATH=\"\$HOME/.local/bin:\$PATH\"" +if [ -n "$PLATFORM" ]; then + load_platform_config "$PLATFORM" "$(dirname "$(dirname "$0")")" 2>/dev/null || true + if [ "${PLATFORM_NEEDS_NODEJS:-}" = true ]; then + PATH_LINE="export PATH=\"\$HOME/.openclaw-android/node/bin:\$HOME/.local/bin:\$PATH\"" + fi +fi -# Create .bashrc if it doesn't exist -touch "$BASHRC" +PLATFORM_VARS="" +PLATFORM_ENV_SCRIPT="$(dirname "$(dirname "$0")")/platforms/$PLATFORM/env.sh" +if [ -n "$PLATFORM" ] && [ -f "$PLATFORM_ENV_SCRIPT" ]; then + PLATFORM_VARS=$(bash "$PLATFORM_ENV_SCRIPT") +fi -# Check if block already exists -if grep -qF "$MARKER_START" "$BASHRC"; then - echo -e "${YELLOW}[SKIP]${NC} Environment block already exists in $BASHRC" - echo " Removing old block and re-adding..." - # Remove old block - sed -i "/${MARKER_START//\//\\/}/,/${MARKER_END//\//\\/}/d" "$BASHRC" +ENV_BLOCK="${BASHRC_MARKER_START} +# platform: ${PLATFORM:-none} +${PATH_LINE} +${INFRA_VARS}" + +if [ -n "$PLATFORM_VARS" ]; then + ENV_BLOCK="${ENV_BLOCK} +${PLATFORM_VARS}" fi -# Append environment block +ENV_BLOCK="${ENV_BLOCK} +${BASHRC_MARKER_END}" + +touch "$BASHRC" +if grep -qF "$BASHRC_MARKER_START" "$BASHRC"; then + sed -i "/${BASHRC_MARKER_START//\//\\/}/,/${BASHRC_MARKER_END//\//\\/}/d" "$BASHRC" +fi echo "" >> "$BASHRC" echo "$ENV_BLOCK" >> "$BASHRC" -echo -e "${GREEN}[OK]${NC} Added environment variables to $BASHRC" -echo "" -echo "Variables configured:" -echo " TMPDIR=\$PREFIX/tmp" -echo " TMP=\$TMPDIR" -echo " TEMP=\$TMPDIR" -echo " NODE_OPTIONS=\"-r $COMPAT_PATH\"" -echo " CONTAINER=1 (suppresses systemd checks)" -echo " CFLAGS=\"-Wno-error=...\" (Clang implicit-function-declaration fix)" -echo " CXXFLAGS=\"-include ...termux-compat.h\" (native build fixes)" -echo " GYP_DEFINES=\"OS=linux ...\" (node-gyp Android override)" -echo " CPATH=\"...glib-2.0...\" (sharp header paths)" - -# Source for current session -export TMPDIR="$PREFIX/tmp" -export TMP="$TMPDIR" -export TEMP="$TMPDIR" -export NODE_OPTIONS="-r $COMPAT_PATH" -export CONTAINER=1 -export CFLAGS="-Wno-error=implicit-function-declaration" -export CXXFLAGS="-include $COMPAT_HEADER" -export GYP_DEFINES="OS=linux android_ndk_path=$PREFIX" -export CPATH="$PREFIX/include/glib-2.0:$PREFIX/lib/glib-2.0/include" - -# Create ar symlink if missing (Termux provides llvm-ar but not ar) if [ ! -e "$PREFIX/bin/ar" ] && [ -x "$PREFIX/bin/llvm-ar" ]; then ln -s "$PREFIX/bin/llvm-ar" "$PREFIX/bin/ar" - echo -e "${GREEN}[OK]${NC} Created ar → llvm-ar symlink" fi - -echo "" -echo -e "${GREEN}Environment setup complete.${NC}" diff --git a/scripts/setup-paths.sh b/scripts/setup-paths.sh index a1834a1..86d8b93 100755 --- a/scripts/setup-paths.sh +++ b/scripts/setup-paths.sh @@ -1,24 +1,15 @@ #!/usr/bin/env bash -# setup-paths.sh - Create required directories and symlinks for Termux set -euo pipefail - -GREEN='\033[0;32m' -NC='\033[0m' +source "$(dirname "$0")/lib.sh" echo "=== Setting Up Paths ===" echo "" -# Create TMPDIR -mkdir -p "$PREFIX/tmp/openclaw" -echo -e "${GREEN}[OK]${NC} Created $PREFIX/tmp/openclaw" - -# Create openclaw-android config directory -mkdir -p "$HOME/.openclaw-android/patches" -echo -e "${GREEN}[OK]${NC} Created $HOME/.openclaw-android/patches" +mkdir -p "$PREFIX/tmp" +echo -e "${GREEN}[OK]${NC} Created $PREFIX/tmp" -# Create openclaw data directory -mkdir -p "$HOME/.openclaw" -echo -e "${GREEN}[OK]${NC} Created $HOME/.openclaw" +mkdir -p "$PROJECT_DIR/patches" +echo -e "${GREEN}[OK]${NC} Created $PROJECT_DIR/patches" echo "" echo "Standard path mappings (via \$PREFIX):" diff --git a/sitemap.xml b/sitemap.xml deleted file mode 100644 index d8d2482..0000000 --- a/sitemap.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - https://myopenclawhub.com/ - monthly - 1.0 - - diff --git a/tests/verify-install.sh b/tests/verify-install.sh index 25d6b30..bb30099 100755 --- a/tests/verify-install.sh +++ b/tests/verify-install.sh @@ -1,11 +1,8 @@ #!/usr/bin/env bash -# verify-install.sh - Verify OpenClaw installation on Termux set -euo pipefail -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../scripts/lib.sh" PASS=0 FAIL=0 @@ -29,7 +26,6 @@ check_warn() { echo "=== OpenClaw on Android - Installation Verification ===" echo "" -# 1. Node.js version if command -v node &>/dev/null; then NODE_VER=$(node -v) NODE_MAJOR="${NODE_VER%%.*}" @@ -43,67 +39,53 @@ else check_fail "Node.js not found" fi -# 2. npm available if command -v npm &>/dev/null; then check_pass "npm $(npm -v)" else check_fail "npm not found" fi -# 3. openclaw command -if command -v openclaw &>/dev/null; then - CLAW_VER=$(openclaw --version 2>/dev/null || echo "error") - if [ "$CLAW_VER" != "error" ]; then - check_pass "openclaw $CLAW_VER" - else - check_warn "openclaw found but --version failed" - fi -else - check_fail "openclaw command not found" -fi - -# 4. Environment variables if [ -n "${TMPDIR:-}" ]; then check_pass "TMPDIR=$TMPDIR" else check_fail "TMPDIR not set" fi -if [ -n "${NODE_OPTIONS:-}" ]; then - check_pass "NODE_OPTIONS is set" +if [ "${OA_GLIBC:-}" = "1" ]; then + check_pass "OA_GLIBC=1 (glibc architecture)" else - check_fail "NODE_OPTIONS not set" + check_fail "OA_GLIBC not set" fi -if [ "${CONTAINER:-}" = "1" ]; then - check_pass "CONTAINER=1 (systemd bypass)" +COMPAT_FILE="$PROJECT_DIR/patches/glibc-compat.js" +if [ -f "$COMPAT_FILE" ]; then + check_pass "glibc-compat.js exists" else - check_warn "CONTAINER not set" + check_fail "glibc-compat.js not found at $COMPAT_FILE" fi -# 5. Patch files -COMPAT_FILE="$HOME/.openclaw-android/patches/bionic-compat.js" -if [ -f "$COMPAT_FILE" ]; then - check_pass "bionic-compat.js exists" +GLIBC_MARKER="$PROJECT_DIR/.glibc-arch" +if [ -f "$GLIBC_MARKER" ]; then + check_pass "glibc architecture marker (.glibc-arch)" else - check_fail "bionic-compat.js not found at $COMPAT_FILE" + check_fail "glibc architecture marker not found" fi -COMPAT_HEADER="$HOME/.openclaw-android/patches/termux-compat.h" -if [ -f "$COMPAT_HEADER" ]; then - check_pass "termux-compat.h exists" +GLIBC_LDSO="${PREFIX:-}/glibc/lib/ld-linux-aarch64.so.1" +if [ -f "$GLIBC_LDSO" ]; then + check_pass "glibc dynamic linker (ld-linux-aarch64.so.1)" else - check_fail "termux-compat.h not found at $COMPAT_HEADER" + check_fail "glibc dynamic linker not found at $GLIBC_LDSO" fi -if [ -n "${CXXFLAGS:-}" ]; then - check_pass "CXXFLAGS is set" +NODE_WRAPPER="$PROJECT_DIR/node/bin/node" +if [ -f "$NODE_WRAPPER" ] && head -1 "$NODE_WRAPPER" 2>/dev/null | grep -q "bash"; then + check_pass "glibc node wrapper script" else - check_warn "CXXFLAGS not set (native module builds may fail)" + check_fail "glibc node wrapper not found or not a wrapper script" fi -# 6. Directories -for DIR in "$HOME/.openclaw-android" "$HOME/.openclaw" "$PREFIX/tmp"; do +for DIR in "$PROJECT_DIR" "$PREFIX/tmp"; do if [ -d "$DIR" ]; then check_pass "Directory $DIR exists" else @@ -111,14 +93,41 @@ for DIR in "$HOME/.openclaw-android" "$HOME/.openclaw" "$PREFIX/tmp"; do fi done -# 7. .bashrc contains env block +if command -v code-server &>/dev/null; then + CS_VER=$(code-server --version 2>/dev/null | head -1 || true) + if [ -n "$CS_VER" ]; then + check_pass "code-server $CS_VER" + else + check_warn "code-server found but --version failed" + fi +else + check_warn "code-server not installed (non-critical)" +fi + +if command -v opencode &>/dev/null; then + check_pass "opencode command available" +else + check_warn "opencode not installed (non-critical)" +fi + if grep -qF "OpenClaw on Android" "$HOME/.bashrc" 2>/dev/null; then check_pass ".bashrc contains environment block" else check_fail ".bashrc missing environment block" fi -# Summary +PLATFORM=$(detect_platform) || true +PLATFORM_VERIFY="$PROJECT_DIR/platforms/$PLATFORM/verify.sh" +if [ -n "$PLATFORM" ] && [ -f "$PLATFORM_VERIFY" ]; then + if bash "$PLATFORM_VERIFY"; then + check_pass "Platform verifier passed ($PLATFORM)" + else + check_fail "Platform verifier failed ($PLATFORM)" + fi +else + check_warn "Platform verifier not found (platform=${PLATFORM:-none})" +fi + echo "" echo "===============================" echo -e " Results: ${GREEN}$PASS passed${NC}, ${RED}$FAIL failed${NC}, ${YELLOW}$WARN warnings${NC}" diff --git a/uninstall.sh b/uninstall.sh old mode 100755 new mode 100644 index 8c69a64..00be220 --- a/uninstall.sh +++ b/uninstall.sh @@ -1,11 +1,36 @@ #!/usr/bin/env bash -# uninstall.sh - Remove OpenClaw on Android from Termux set -euo pipefail -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BOLD='\033[1m' -NC='\033[0m' +PROJECT_DIR="$HOME/.openclaw-android" + +if [ -f "$HOME/.openclaw-android/scripts/lib.sh" ]; then + # shellcheck source=/dev/null + source "$HOME/.openclaw-android/scripts/lib.sh" +else + GREEN='\033[0;32m' + YELLOW='\033[1;33m' + BOLD='\033[1m' + NC='\033[0m' + PLATFORM_MARKER="$PROJECT_DIR/.platform" + BASHRC_MARKER_START="# >>> OpenClaw on Android >>>" + BASHRC_MARKER_END="# <<< OpenClaw on Android <<<" + + ask_yn() { + local prompt="$1" + local reply + read -rp "$prompt [Y/n] " reply < /dev/tty + [[ "${reply:-}" =~ ^[Nn]$ ]] && return 1 + return 0 + } + + detect_platform() { + if [ -f "$PLATFORM_MARKER" ]; then + cat "$PLATFORM_MARKER" + return 0 + fi + return 1 + } +fi echo "" echo -e "${BOLD}========================================${NC}" @@ -13,70 +38,108 @@ echo -e "${BOLD} OpenClaw on Android - Uninstaller${NC}" echo -e "${BOLD}========================================${NC}" echo "" -# Confirm -read -rp "This will remove OpenClaw and all related config. Continue? [y/N] " REPLY -if [[ ! "$REPLY" =~ ^[Yy]$ ]]; then +reply="" +read -rp "This will remove the installation. Continue? [y/N] " reply < /dev/tty +if [[ ! "$reply" =~ ^[Yy]$ ]]; then echo "Aborted." exit 0 fi -echo "" +step() { + echo "" + echo -e "${BOLD}[$1/7] $2${NC}" + echo "----------------------------------------" +} -# 1. Uninstall OpenClaw npm package -echo "Removing OpenClaw npm package..." -if command -v openclaw &>/dev/null; then - npm uninstall -g openclaw 2>/dev/null || true - echo -e "${GREEN}[OK]${NC} openclaw package removed" +step 1 "Platform uninstall" +PLATFORM=$(detect_platform 2>/dev/null || true) +if [ -z "$PLATFORM" ]; then + echo -e "${YELLOW}[SKIP]${NC} Platform not detected" else - echo -e "${YELLOW}[SKIP]${NC} openclaw not installed" + PLATFORM_UNINSTALL="$PROJECT_DIR/platforms/$PLATFORM/uninstall.sh" + if [ -f "$PLATFORM_UNINSTALL" ]; then + bash "$PLATFORM_UNINSTALL" + else + echo -e "${YELLOW}[SKIP]${NC} Platform uninstall script not found: $PLATFORM_UNINSTALL" + fi +fi + +step 2 "code-server" +if pgrep -f "code-server" &>/dev/null; then + pkill -f "code-server" || true + echo -e "${GREEN}[OK]${NC} Stopped running code-server" fi -# 2. Remove oaupdate command -if [ -f "$PREFIX/bin/oaupdate" ]; then - rm -f "$PREFIX/bin/oaupdate" - echo -e "${GREEN}[OK]${NC} Removed $PREFIX/bin/oaupdate" +if ls "$HOME/.local/lib"/code-server-* &>/dev/null 2>&1; then + rm -rf "$HOME/.local/lib"/code-server-* + echo -e "${GREEN}[OK]${NC} Removed code-server from ~/.local/lib" else - echo -e "${YELLOW}[SKIP]${NC} $PREFIX/bin/oaupdate not found" + echo -e "${YELLOW}[SKIP]${NC} code-server not found in ~/.local/lib" fi -# 3. Remove openclaw-android directory -if [ -d "$HOME/.openclaw-android" ]; then - rm -rf "$HOME/.openclaw-android" - echo -e "${GREEN}[OK]${NC} Removed $HOME/.openclaw-android" +if [ -f "$HOME/.local/bin/code-server" ] || [ -L "$HOME/.local/bin/code-server" ]; then + rm -f "$HOME/.local/bin/code-server" + echo -e "${GREEN}[OK]${NC} Removed ~/.local/bin/code-server" else - echo -e "${YELLOW}[SKIP]${NC} $HOME/.openclaw-android not found" + echo -e "${YELLOW}[SKIP]${NC} ~/.local/bin/code-server not found" fi -# 4. Remove environment block from .bashrc -BASHRC="$HOME/.bashrc" -MARKER_START="# >>> OpenClaw on Android >>>" -MARKER_END="# <<< OpenClaw on Android <<<" +rmdir "$HOME/.local/bin" 2>/dev/null || true +rmdir "$HOME/.local/lib" 2>/dev/null || true +rmdir "$HOME/.local" 2>/dev/null || true + +step 3 "Chromium" +if command -v chromium-browser &>/dev/null || command -v chromium &>/dev/null; then + pkg uninstall -y chromium 2>/dev/null || true + echo -e "${GREEN}[OK]${NC} Removed Chromium" +else + echo -e "${YELLOW}[SKIP]${NC} Chromium not installed" +fi -if [ -f "$BASHRC" ] && grep -qF "$MARKER_START" "$BASHRC"; then - sed -i "/${MARKER_START//\//\\/}/,/${MARKER_END//\//\\/}/d" "$BASHRC" - # Collapse consecutive blank lines left behind (preserve intentional single blank lines) +step 4 "oa and oaupdate commands" +if [ -f "${PREFIX:-}/bin/oa" ]; then + rm -f "${PREFIX:-}/bin/oa" + echo -e "${GREEN}[OK]${NC} Removed ${PREFIX:-}/bin/oa" +else + echo -e "${YELLOW}[SKIP]${NC} ${PREFIX:-}/bin/oa not found" +fi + +if [ -f "${PREFIX:-}/bin/oaupdate" ]; then + rm -f "${PREFIX:-}/bin/oaupdate" + echo -e "${GREEN}[OK]${NC} Removed ${PREFIX:-}/bin/oaupdate" +else + echo -e "${YELLOW}[SKIP]${NC} ${PREFIX:-}/bin/oaupdate not found" +fi + +step 5 "glibc components" +if command -v pacman &>/dev/null && pacman -Q glibc-runner &>/dev/null; then + pacman -R glibc-runner --noconfirm || true + echo -e "${GREEN}[OK]${NC} Removed glibc-runner package" +else + echo -e "${YELLOW}[SKIP]${NC} glibc-runner not installed" +fi + +step 6 "shell configuration" +BASHRC="$HOME/.bashrc" +if [ -f "$BASHRC" ] && grep -qF "$BASHRC_MARKER_START" "$BASHRC"; then + sed -i "/${BASHRC_MARKER_START//\//\\/}/,/${BASHRC_MARKER_END//\//\\/}/d" "$BASHRC" sed -i '/^$/{ N; /^\n$/d }' "$BASHRC" echo -e "${GREEN}[OK]${NC} Removed environment block from $BASHRC" else echo -e "${YELLOW}[SKIP]${NC} No environment block found in $BASHRC" fi -# 5. Clean up temp directory -if [ -d "$PREFIX/tmp/openclaw" ]; then - rm -rf "$PREFIX/tmp/openclaw" - echo -e "${GREEN}[OK]${NC} Removed $PREFIX/tmp/openclaw" -fi +step 7 "installation directory" -# 6. Optionally remove openclaw data -echo "" -if [ -d "$HOME/.openclaw" ]; then - read -rp "Remove OpenClaw data directory ($HOME/.openclaw)? [y/N] " REPLY - if [[ "$REPLY" =~ ^[Yy]$ ]]; then - rm -rf "$HOME/.openclaw" - echo -e "${GREEN}[OK]${NC} Removed $HOME/.openclaw" +if [ -d "$PROJECT_DIR" ]; then + if ask_yn "Remove installation directory (~/.openclaw-android)? Includes Node.js, patches, configs."; then + rm -rf "$PROJECT_DIR" + echo -e "${GREEN}[OK]${NC} Removed $PROJECT_DIR" else - echo -e "${YELLOW}[KEEP]${NC} Keeping $HOME/.openclaw" + echo -e "${YELLOW}[KEEP]${NC} Keeping $PROJECT_DIR" fi +else + echo -e "${YELLOW}[SKIP]${NC} $PROJECT_DIR not found" fi echo "" diff --git a/update-core.sh b/update-core.sh index e21a928..fb19120 100755 --- a/update-core.sh +++ b/update-core.sh @@ -1,6 +1,4 @@ #!/usr/bin/env bash -# update-core.sh - Lightweight updater for OpenClaw on Android (existing installations) -# Called by update.sh (thin wrapper) or oaupdate command set -euo pipefail RED='\033[0;31m' @@ -9,230 +7,283 @@ YELLOW='\033[1;33m' BOLD='\033[1m' NC='\033[0m' -REPO_BASE="https://raw.githubusercontent.com/AidanPark/openclaw-android/main" -OPENCLAW_DIR="$HOME/.openclaw-android" +PROJECT_DIR="$HOME/.openclaw-android" +PLATFORM_MARKER="$PROJECT_DIR/.platform" +OA_VERSION="1.0.6" echo "" echo -e "${BOLD}========================================${NC}" -echo -e "${BOLD} OpenClaw on Android - Updater${NC}" +echo -e "${BOLD} OpenClaw on Android - Updater v${OA_VERSION}${NC}" echo -e "${BOLD}========================================${NC}" echo "" step() { echo "" - echo -e "${BOLD}[$1/6] $2${NC}" + echo -e "${BOLD}[$1/5] $2${NC}" echo "----------------------------------------" } -# ───────────────────────────────────────────── step 1 "Pre-flight Check" -# Check Termux if [ -z "${PREFIX:-}" ]; then echo -e "${RED}[FAIL]${NC} Not running in Termux (\$PREFIX not set)" exit 1 fi echo -e "${GREEN}[OK]${NC} Termux detected" -# Check existing OpenClaw installation -if ! command -v openclaw &>/dev/null; then - echo -e "${RED}[FAIL]${NC} openclaw command not found" - echo " Run the full installer first:" - echo " curl -sL $REPO_BASE/bootstrap.sh | bash" +if ! command -v curl &>/dev/null; then + echo -e "${RED}[FAIL]${NC} curl not found. Install it with: pkg install curl" exit 1 fi -echo -e "${GREEN}[OK]${NC} openclaw $(openclaw --version 2>/dev/null || echo "")" -# Migrate from old directory name (.openclaw-lite → .openclaw-android) OLD_DIR="$HOME/.openclaw-lite" -if [ -d "$OLD_DIR" ] && [ ! -d "$OPENCLAW_DIR" ]; then - mv "$OLD_DIR" "$OPENCLAW_DIR" - echo -e "${GREEN}[OK]${NC} Migrated $OLD_DIR → $OPENCLAW_DIR" -elif [ -d "$OLD_DIR" ] && [ -d "$OPENCLAW_DIR" ]; then - # Both exist — merge old into new, then remove old - cp -rn "$OLD_DIR"/. "$OPENCLAW_DIR"/ 2>/dev/null || true +if [ -d "$OLD_DIR" ] && [ ! -d "$PROJECT_DIR" ]; then + mv "$OLD_DIR" "$PROJECT_DIR" + echo -e "${GREEN}[OK]${NC} Migrated $OLD_DIR -> $PROJECT_DIR" +elif [ -d "$OLD_DIR" ] && [ -d "$PROJECT_DIR" ]; then + cp -rn "$OLD_DIR"/. "$PROJECT_DIR"/ 2>/dev/null || true rm -rf "$OLD_DIR" - echo -e "${GREEN}[OK]${NC} Merged $OLD_DIR into $OPENCLAW_DIR" + echo -e "${GREEN}[OK]${NC} Merged $OLD_DIR into $PROJECT_DIR" else - mkdir -p "$OPENCLAW_DIR" + mkdir -p "$PROJECT_DIR" fi -# Check curl -if ! command -v curl &>/dev/null; then - echo -e "${RED}[FAIL]${NC} curl not found. Install it with: pkg install curl" - exit 1 +if [ -f "$PROJECT_DIR/scripts/lib.sh" ]; then + source "$PROJECT_DIR/scripts/lib.sh" fi -# ───────────────────────────────────────────── -step 2 "Installing New Packages" +# Define REPO_TARBALL after sourcing lib.sh to prevent old installs from overwriting it +REPO_TARBALL="https://github.com/AidanPark/openclaw-android/archive/refs/heads/main.tar.gz" + +if ! declare -f detect_platform &>/dev/null; then + detect_platform() { + if [ -f "$PLATFORM_MARKER" ]; then + cat "$PLATFORM_MARKER" + return 0 + fi + if command -v openclaw &>/dev/null; then + echo "openclaw" + mkdir -p "$(dirname "$PLATFORM_MARKER")" + echo "openclaw" > "$PLATFORM_MARKER" + return 0 + fi + echo "" + return 1 + } +fi -# Install ttyd if not already installed -if command -v ttyd &>/dev/null; then - echo -e "${GREEN}[OK]${NC} ttyd already installed ($(ttyd --version 2>/dev/null || echo ""))" -else - echo "Installing ttyd..." - if pkg install -y ttyd; then - echo -e "${GREEN}[OK]${NC} ttyd installed" - else - echo -e "${YELLOW}[WARN]${NC} Failed to install ttyd (non-critical)" - fi +PLATFORM=$(detect_platform) || { + echo -e "${RED}[FAIL]${NC} No platform detected" + exit 1 +} +if [ -z "$PLATFORM" ]; then + echo -e "${RED}[FAIL]${NC} No platform detected" + exit 1 fi +echo -e "${GREEN}[OK]${NC} Platform: $PLATFORM" -# Install PyYAML if not already installed (required for .skill packaging) -if python -c "import yaml" 2>/dev/null; then - echo -e "${GREEN}[OK]${NC} PyYAML already installed" +IS_GLIBC=false +if [ -f "$PROJECT_DIR/.glibc-arch" ]; then + IS_GLIBC=true + echo -e "${GREEN}[OK]${NC} Architecture: glibc" else - echo "Installing PyYAML..." - if pip install pyyaml -q; then - echo -e "${GREEN}[OK]${NC} PyYAML installed" - else - echo -e "${YELLOW}[WARN]${NC} Failed to install PyYAML (non-critical)" - fi + echo -e "${YELLOW}[INFO]${NC} Architecture: Bionic (migration required)" +fi + +SDK_INT=$(getprop ro.build.version.sdk 2>/dev/null || echo "0") +if [ "$SDK_INT" -ge 31 ] 2>/dev/null; then + echo -e "${YELLOW}[INFO]${NC} Android 12+ detected — if background processes get killed (signal 9)," + echo " see: https://github.com/AidanPark/openclaw-android/blob/main/docs/disable-phantom-process-killer.md" fi -# ───────────────────────────────────────────── -step 3 "Downloading Latest Scripts" +step 2 "Download Latest Release (tarball)" -# Download setup-env.sh (needed for .bashrc update) -TMPFILE=$(mktemp "$PREFIX/tmp/setup-env.XXXXXX.sh") || { - echo -e "${RED}[FAIL]${NC} Failed to create temporary file (disk full or $PREFIX/tmp missing?)" +mkdir -p "$PREFIX/tmp" +RELEASE_TMP=$(mktemp -d "$PREFIX/tmp/oa-update.XXXXXX") || { + echo -e "${RED}[FAIL]${NC} Failed to create temp directory" exit 1 } -if curl -sfL "$REPO_BASE/scripts/setup-env.sh" -o "$TMPFILE"; then - echo -e "${GREEN}[OK]${NC} setup-env.sh downloaded" +trap 'rm -rf "$RELEASE_TMP"' EXIT + +echo "Downloading latest scripts..." +echo " (This may take a moment depending on network speed)" +if curl -sfL "$REPO_TARBALL" | tar xz -C "$RELEASE_TMP" --strip-components=1; then + echo -e "${GREEN}[OK]${NC} Downloaded latest release" else - echo -e "${RED}[FAIL]${NC} Failed to download setup-env.sh" - rm -f "$TMPFILE" + echo -e "${RED}[FAIL]${NC} Failed to download release" exit 1 fi -# Download bionic-compat.js (patches may have been updated) -mkdir -p "$OPENCLAW_DIR/patches" -if curl -sfL "$REPO_BASE/patches/bionic-compat.js" -o "$OPENCLAW_DIR/patches/bionic-compat.js"; then - echo -e "${GREEN}[OK]${NC} bionic-compat.js updated" -else - echo -e "${YELLOW}[WARN]${NC} Failed to download bionic-compat.js (non-critical)" -fi +REQUIRED_FILES=( + "scripts/lib.sh" + "scripts/setup-env.sh" + "platforms/$PLATFORM/config.env" + "platforms/$PLATFORM/update.sh" +) +for f in "${REQUIRED_FILES[@]}"; do + if [ ! -f "$RELEASE_TMP/$f" ]; then + echo -e "${RED}[FAIL]${NC} Missing required file: $f" + echo " The downloaded release may be corrupted. Try again." + exit 1 + fi +done +echo -e "${GREEN}[OK]${NC} All required files verified" -# Download termux-compat.h (native build compatibility) -if curl -sfL "$REPO_BASE/patches/termux-compat.h" -o "$OPENCLAW_DIR/patches/termux-compat.h"; then - echo -e "${GREEN}[OK]${NC} termux-compat.h updated" -else - echo -e "${YELLOW}[WARN]${NC} Failed to download termux-compat.h (non-critical)" -fi +source "$RELEASE_TMP/scripts/lib.sh" -# Install spawn.h stub if missing (needed for koffi/native module builds) -if [ ! -f "$PREFIX/include/spawn.h" ]; then - if curl -sfL "$REPO_BASE/patches/spawn.h" -o "$PREFIX/include/spawn.h"; then - echo -e "${GREEN}[OK]${NC} spawn.h stub installed" - else - echo -e "${YELLOW}[WARN]${NC} Failed to download spawn.h (non-critical)" - fi -else - echo -e "${GREEN}[OK]${NC} spawn.h already exists" -fi +step 3 "Update Core Infrastructure" -# Install systemctl stub (Termux has no systemd) -if curl -sfL "$REPO_BASE/patches/systemctl" -o "$PREFIX/bin/systemctl"; then - chmod +x "$PREFIX/bin/systemctl" - echo -e "${GREEN}[OK]${NC} systemctl stub updated" -else - echo -e "${YELLOW}[WARN]${NC} Failed to update systemctl stub (non-critical)" -fi +mkdir -p "$PROJECT_DIR/platforms" "$PROJECT_DIR/scripts" "$PROJECT_DIR/patches" -# Download update.sh (thin wrapper) and install as oaupdate command -if curl -sfL "$REPO_BASE/update.sh" -o "$PREFIX/bin/oaupdate"; then - chmod +x "$PREFIX/bin/oaupdate" - echo -e "${GREEN}[OK]${NC} oaupdate command updated" -else - echo -e "${YELLOW}[WARN]${NC} Failed to update oaupdate (non-critical)" -fi +rm -rf "$PROJECT_DIR/platforms/$PLATFORM" +cp -r "$RELEASE_TMP/platforms/$PLATFORM" "$PROJECT_DIR/platforms/" + +cp "$RELEASE_TMP/scripts/lib.sh" "$PROJECT_DIR/scripts/lib.sh" +cp "$RELEASE_TMP/scripts/setup-env.sh" "$PROJECT_DIR/scripts/setup-env.sh" + +cp "$RELEASE_TMP/patches/glibc-compat.js" "$PROJECT_DIR/patches/glibc-compat.js" +cp "$RELEASE_TMP/patches/argon2-stub.js" "$PROJECT_DIR/patches/argon2-stub.js" +cp "$RELEASE_TMP/patches/spawn.h" "$PROJECT_DIR/patches/spawn.h" +cp "$RELEASE_TMP/patches/systemctl" "$PROJECT_DIR/patches/systemctl" + +cp "$RELEASE_TMP/oa.sh" "$PREFIX/bin/oa" +chmod +x "$PREFIX/bin/oa" -# Download build-sharp.sh -SHARP_TMPFILE="" -if SHARP_TMPFILE=$(mktemp "$PREFIX/tmp/build-sharp.XXXXXX.sh" 2>/dev/null); then - if curl -sfL "$REPO_BASE/scripts/build-sharp.sh" -o "$SHARP_TMPFILE"; then - echo -e "${GREEN}[OK]${NC} build-sharp.sh downloaded" +cp "$RELEASE_TMP/update.sh" "$PREFIX/bin/oaupdate" +chmod +x "$PREFIX/bin/oaupdate" + +cp "$RELEASE_TMP/uninstall.sh" "$PROJECT_DIR/uninstall.sh" +chmod +x "$PROJECT_DIR/uninstall.sh" + +if [ "$IS_GLIBC" = false ]; then + echo "" + echo -e "${BOLD}[MIGRATE] Bionic -> glibc Architecture${NC}" + echo "----------------------------------------" + if bash "$RELEASE_TMP/scripts/install-glibc.sh" && bash "$RELEASE_TMP/scripts/install-nodejs.sh"; then + IS_GLIBC=true + echo -e "${GREEN}[OK]${NC} glibc migration complete" else - echo -e "${YELLOW}[WARN]${NC} Failed to download build-sharp.sh (non-critical)" - rm -f "$SHARP_TMPFILE" - SHARP_TMPFILE="" + echo -e "${YELLOW}[WARN]${NC} glibc migration failed (non-critical)" fi -else - echo -e "${YELLOW}[WARN]${NC} Failed to create temporary file for build-sharp.sh (non-critical)" fi -# ───────────────────────────────────────────── -step 4 "Updating Environment Variables" +# Update Node.js if a newer version is available +if [ "$IS_GLIBC" = true ]; then + bash "$RELEASE_TMP/scripts/install-nodejs.sh" || true +fi -# Run setup-env.sh to refresh .bashrc block -bash "$TMPFILE" -rm -f "$TMPFILE" +bash "$RELEASE_TMP/scripts/setup-env.sh" -# Re-export for current session (setup-env.sh runs as subprocess, exports don't propagate) +GLIBC_NODE_DIR="$PROJECT_DIR/node" +if [ "$IS_GLIBC" = true ]; then + export PATH="$GLIBC_NODE_DIR/bin:$HOME/.local/bin:$PATH" + export OA_GLIBC=1 +fi export TMPDIR="$PREFIX/tmp" export TMP="$TMPDIR" export TEMP="$TMPDIR" -export NODE_OPTIONS="-r $HOME/.openclaw-android/patches/bionic-compat.js" -export CONTAINER=1 -export CFLAGS="-Wno-error=implicit-function-declaration" -export CXXFLAGS="-include $HOME/.openclaw-android/patches/termux-compat.h" -export GYP_DEFINES="OS=linux android_ndk_path=$PREFIX" -export CPATH="$PREFIX/include/glib-2.0:$PREFIX/lib/glib-2.0/include" - -# ───────────────────────────────────────────── -step 5 "Updating OpenClaw Package" - -# Install build dependencies required for sharp's native compilation. -# This must happen before npm install so that libvips headers are -# available when node-gyp compiles sharp as a dependency of openclaw. -echo "Installing build dependencies..." -if pkg install -y libvips binutils; then - echo -e "${GREEN}[OK]${NC} libvips and binutils ready" -else - echo -e "${YELLOW}[WARN]${NC} Failed to install build dependencies" - echo " Image processing (sharp) may not compile correctly" +# Load platform-specific environment variables for current session +PLATFORM_ENV_SCRIPT="$RELEASE_TMP/platforms/$PLATFORM/env.sh" +if [ -f "$PLATFORM_ENV_SCRIPT" ]; then + eval "$(bash "$PLATFORM_ENV_SCRIPT")" fi -# Create ar symlink if missing (Termux provides llvm-ar but not ar) -if [ ! -e "$PREFIX/bin/ar" ] && [ -x "$PREFIX/bin/llvm-ar" ]; then - ln -s "$PREFIX/bin/llvm-ar" "$PREFIX/bin/ar" - echo -e "${GREEN}[OK]${NC} Created ar → llvm-ar symlink" +step 4 "Update Platform" + +if [ -f "$RELEASE_TMP/platforms/$PLATFORM/update.sh" ]; then + bash "$RELEASE_TMP/platforms/$PLATFORM/update.sh" +else + echo -e "${YELLOW}[WARN]${NC} Platform update script not found" fi -# CXXFLAGS, GYP_DEFINES, and CPATH were exported in step 4. -# npm runs as a child process of this script and inherits those -# env vars, so sharp's node-gyp build succeeds here — unlike in -# 'openclaw update', which spawns npm without these env vars set. -echo "Updating openclaw npm package..." -if npm install -g openclaw@latest --no-fund --no-audit; then - echo -e "${GREEN}[OK]${NC} openclaw package updated" +step 5 "Update Optional Tools" + +if command -v code-server &>/dev/null; then + if bash "$RELEASE_TMP/scripts/install-code-server.sh" update; then + echo -e "${GREEN}[OK]${NC} code-server update step complete" + else + echo -e "${YELLOW}[WARN]${NC} code-server update failed (non-critical)" + fi else - echo -e "${YELLOW}[WARN]${NC} Package update failed (non-critical)" - echo " Retry manually: npm install -g openclaw@latest" + echo -e "${YELLOW}[SKIP]${NC} code-server not installed" fi -# ───────────────────────────────────────────── -step 6 "Building sharp (image processing)" +if command -v chromium-browser &>/dev/null || command -v chromium &>/dev/null; then + if [ -f "$RELEASE_TMP/scripts/install-chromium.sh" ]; then + bash "$RELEASE_TMP/scripts/install-chromium.sh" update || true + fi +else + echo -e "${YELLOW}[SKIP]${NC} Chromium not installed" +fi -if [ -n "$SHARP_TMPFILE" ]; then - bash "$SHARP_TMPFILE" - rm -f "$SHARP_TMPFILE" +if [ "$IS_GLIBC" = false ]; then + echo -e "${YELLOW}[SKIP]${NC} OpenCode requires glibc architecture" else - echo -e "${YELLOW}[SKIP]${NC} build-sharp.sh was not downloaded" + OPENCODE_INSTALLED=false + command -v opencode &>/dev/null && OPENCODE_INSTALLED=true + + if [ "$OPENCODE_INSTALLED" = true ]; then + CURRENT_OC_VER=$(opencode --version 2>/dev/null || echo "") + LATEST_OC_VER=$(npm view opencode-ai version 2>/dev/null || echo "") + + if [ -n "$CURRENT_OC_VER" ] && [ -n "$LATEST_OC_VER" ] && [ "$CURRENT_OC_VER" = "$LATEST_OC_VER" ]; then + echo -e "${GREEN}[OK]${NC} OpenCode $CURRENT_OC_VER is already the latest" + else + if [ -n "$CURRENT_OC_VER" ] && [ -n "$LATEST_OC_VER" ] && [ "$CURRENT_OC_VER" != "$LATEST_OC_VER" ]; then + echo "OpenCode update available: $CURRENT_OC_VER -> $LATEST_OC_VER" + fi + echo " (This may take a few minutes for package download and binary processing)" + if bash "$RELEASE_TMP/scripts/install-opencode.sh"; then + echo -e "${GREEN}[OK]${NC} OpenCode ${LATEST_OC_VER:-} updated" + else + echo -e "${YELLOW}[WARN]${NC} OpenCode update failed (non-critical)" + fi + fi + else + echo -e "${YELLOW}[SKIP]${NC} OpenCode not installed" + fi fi -echo "" -echo -e "${BOLD}========================================${NC}" -echo -e "${GREEN}${BOLD} Update Complete!${NC}" -echo -e "${BOLD}========================================${NC}" -echo "" +update_ai_tool() { + local cmd="$1" + local pkg="$2" + local label="$3" + + if ! command -v "$cmd" &>/dev/null; then + return 1 + fi -# Show OpenClaw update status -openclaw update status 2>/dev/null || true + local current_ver latest_ver + current_ver=$(npm list -g "$pkg" 2>/dev/null | grep "${pkg##*/}@" | sed 's/.*@//' | tr -d '[:space:]') + latest_ver=$(npm view "$pkg" version 2>/dev/null || echo "") + + if [ -n "$current_ver" ] && [ -n "$latest_ver" ] && [ "$current_ver" = "$latest_ver" ]; then + echo -e "${GREEN}[OK]${NC} $label $current_ver is already the latest" + elif [ -n "$latest_ver" ]; then + echo "Updating $label... ($current_ver -> $latest_ver)" + echo " (This may take a few minutes depending on network speed)" + if npm install -g "$pkg@latest" --no-fund --no-audit --ignore-scripts; then + echo -e "${GREEN}[OK]${NC} $label $latest_ver updated" + else + echo -e "${YELLOW}[WARN]${NC} $label update failed (non-critical)" + fi + else + echo -e "${YELLOW}[WARN]${NC} Could not check $label latest version" + fi + return 0 +} + +AI_FOUND=false +update_ai_tool "claude" "@anthropic-ai/claude-code" "Claude Code" && AI_FOUND=true +update_ai_tool "gemini" "@google/gemini-cli" "Gemini CLI" && AI_FOUND=true +update_ai_tool "codex" "@openai/codex" "Codex CLI" && AI_FOUND=true +if [ "$AI_FOUND" = false ]; then + echo -e "${YELLOW}[SKIP]${NC} No AI CLI tools installed" +fi +echo "" +echo -e "${GREEN}${BOLD} Update Complete!${NC}" echo "" echo -e "${YELLOW}Run this to apply changes to the current session:${NC}" echo "" echo " source ~/.bashrc" -echo ""