commit a06359fc3089406bdd0a7b4b028cc422d809e543 Author: wehub-resource-sync Date: Mon Jul 13 13:16:30 2026 +0800 chore: import upstream snapshot with attribution 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..9e3bc15 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# Pre-commit hook: runs code quality checks on staged files +# Enable with: git config core.hooksPath .githooks + +set -e + +ROOT_DIR="$(git rev-parse --show-toplevel)" +ANDROID_DIR="$ROOT_DIR/android" + +# --- Kotlin checks (ktlint + detekt) --- + +STAGED_KT=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.kts?$' || true) + +if [ -n "$STAGED_KT" ]; then + 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 +fi + +# --- Shell script checks (shellcheck) --- + +STAGED_SH=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.sh$' || true) + +if [ -n "$STAGED_SH" ]; then + if command -v shellcheck >/dev/null 2>&1; then + echo "Running shellcheck..." + FAILED=0 + for file in $STAGED_SH; do + # Skip build artifacts + case "$file" in + android/app/build/*) continue ;; + esac + if [ -f "$ROOT_DIR/$file" ]; then + if ! shellcheck "$ROOT_DIR/$file"; then + FAILED=1 + fi + fi + done + if [ "$FAILED" -eq 1 ]; then + echo "" + echo "shellcheck failed. Fix the issues above before committing." + exit 1 + fi + else + echo "Warning: shellcheck not installed. Skipping shell script lint." + echo "Install with: sudo apt-get install shellcheck" + fi +fi + +# --- Markdown lint --- + +STAGED_MD=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.md$' || true) + +if [ -n "$STAGED_MD" ]; then + if command -v markdownlint-cli2 >/dev/null 2>&1; then + echo "Running markdownlint..." + if ! markdownlint-cli2 $STAGED_MD 2>&1; then + echo "" + echo "markdownlint failed. Fix the issues above before committing." + exit 1 + fi + else + echo "Warning: markdownlint-cli2 not installed. Skipping markdown lint." + fi +fi + +# --- WebView/React lint (www/) --- + +STAGED_TS=$(git diff --cached --name-only --diff-filter=ACM | grep -E 'android/www/.*\.(ts|tsx)$' || true) + +if [ -n "$STAGED_TS" ]; then + if [ -f "$ANDROID_DIR/www/node_modules/.bin/eslint" ]; then + echo "Running ESLint on www/..." + STAGED_TS_REL=$(echo "$STAGED_TS" | sed 's|^android/www/||') + if ! (cd "$ANDROID_DIR/www" && npx eslint $STAGED_TS_REL 2>&1); then + echo "" + echo "ESLint failed. Fix the issues above before committing." + exit 1 + fi + else + echo "Warning: www/node_modules not found. Run 'cd android/www && npm install'." + fi +fi + +# --- Script sync check (post-setup.sh) --- + +ROOT_POST_SETUP="$ROOT_DIR/post-setup.sh" +APP_POST_SETUP="$ROOT_DIR/android/app/src/main/assets/post-setup.sh" + +STAGED_SYNC=$(git diff --cached --name-only | grep -E '(^post-setup\.sh$|android/app/src/main/assets/post-setup\.sh)' || true) + +if [ -n "$STAGED_SYNC" ]; then + if [ -f "$ROOT_POST_SETUP" ] && [ -f "$APP_POST_SETUP" ]; then + if ! diff -q "$ROOT_POST_SETUP" "$APP_POST_SETUP" >/dev/null 2>&1; then + echo "" + echo "ERROR: post-setup.sh sync mismatch!" + echo " Root: post-setup.sh" + echo " App: android/app/src/main/assets/post-setup.sh" + echo "" + echo "Both files must have identical content." + echo "Copy one to the other and stage both before committing." + exit 1 + fi + fi +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..a2a109a --- /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@v4 + + - name: Cache Gradle + uses: actions/cache@v5 + 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/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml new file mode 100644 index 0000000..388f2ac --- /dev/null +++ b/.github/workflows/code-quality.yml @@ -0,0 +1,187 @@ +name: Code Quality + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + shellcheck: + name: ShellCheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install shellcheck + run: sudo apt-get install -y shellcheck + + - name: Run shellcheck + run: | + find . -name '*.sh' \ + -not -path './android/app/build/*' \ + -not -path './node_modules/*' \ + -not -path './.agent/*' \ + -print0 | xargs -0 --no-run-if-empty shellcheck --format=gcc + + script-sync: + name: Script Sync Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Verify post-setup.sh sync + run: | + if ! diff -q post-setup.sh android/app/src/main/assets/post-setup.sh; then + echo "ERROR: post-setup.sh files are out of sync!" + echo " Root: post-setup.sh" + echo " App: android/app/src/main/assets/post-setup.sh" + echo "Both files must have identical content." + exit 1 + fi + echo "post-setup.sh files are in sync." + + markdownlint: + name: Markdown Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Install markdownlint-cli2 + run: npm install -g markdownlint-cli2 + + - name: Run markdownlint + run: markdownlint-cli2 '**/*.md' '!node_modules' '!android/www/node_modules' '!android/www/dist' '!android/app/build' + + doc-freshness: + name: Document Freshness + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check document freshness + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + CHANGED=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA") + + WARN=0 + + check_doc() { + local pattern="$1" + local doc="$2" + local label="$3" + if echo "$CHANGED" | grep -qE "$pattern"; then + if ! echo "$CHANGED" | grep -qE "$doc"; then + echo "Warning: $label code changed but $doc was not updated." + WARN=1 + fi + fi + } + + check_doc '^install\.sh$' '^README\.md$' 'install.sh' + check_doc '^oa\.sh$' '^README\.md$' 'oa.sh' + check_doc '^(update\.sh|update-core\.sh)$' '^(README\.md|CHANGELOG\.md)$' 'update scripts' + check_doc '^bootstrap\.sh$' '^README\.md$' 'bootstrap.sh' + check_doc '^scripts/install-' '^README\.md$' 'install scripts' + check_doc '^platforms/openclaw/' '^README\.md$' 'platform scripts' + + # Check 3-language sync for docs/ files + for doc in $(echo "$CHANGED" | grep -E '^docs/.*\.md$' | grep -v '\.(ko|zh)\.md$'); do + ko_doc="${doc%.md}.ko.md" + zh_doc="${doc%.md}.zh.md" + if [ -f "$ko_doc" ] && ! echo "$CHANGED" | grep -qF "$ko_doc"; then + echo "Warning: $doc changed but Korean version $ko_doc was not updated." + WARN=1 + fi + if [ -f "$zh_doc" ] && ! echo "$CHANGED" | grep -qF "$zh_doc"; then + echo "Warning: $doc changed but Chinese version $zh_doc was not updated." + WARN=1 + fi + done + + # Check 3-language sync for README + if echo "$CHANGED" | grep -qE '^README\.md$'; then + if ! echo "$CHANGED" | grep -qE '^README\.ko\.md$'; then + echo "Warning: README.md changed but README.ko.md was not updated." + WARN=1 + fi + if ! echo "$CHANGED" | grep -qE '^README\.zh\.md$'; then + echo "Warning: README.md changed but README.zh.md was not updated." + WARN=1 + fi + fi + + if [ "$WARN" -eq 1 ]; then + echo "" + echo "Document freshness check: some docs may need updating." + else + echo "Document freshness check passed." + fi + + kotlin-lint: + name: Kotlin Lint + runs-on: ubuntu-latest + 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@v4 + + - name: Cache Gradle + uses: actions/cache@v5 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ hashFiles('android/**/*.gradle*', 'android/gradle/libs.versions.toml') }} + restore-keys: gradle- + + - name: Run ktlint + run: ./gradlew ktlintCheck + + - name: Run detekt + run: ./gradlew detekt + + unit-tests: + name: Unit Tests + runs-on: ubuntu-latest + 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@v4 + + - name: Cache Gradle + uses: actions/cache@v5 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ hashFiles('android/**/*.gradle*', 'android/gradle/libs.versions.toml') }} + restore-keys: gradle- + + - name: Run unit tests + run: ./gradlew test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e9590fc --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +# Project specific +.obsidian/ +.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/ + +# Kotlin +*.kotlin_module + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Secrets +*.env +*.keystore +*.jks diff --git a/.markdownlint-cli2.yaml b/.markdownlint-cli2.yaml new file mode 100644 index 0000000..ddb1a0b --- /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: + siblings_only: true + +ignores: + - "build/**" + - "android/www/dist/**" + - "android/www/node_modules/**" diff --git a/.nojekyll b/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/.shellcheckrc b/.shellcheckrc new file mode 100644 index 0000000..6ff5d3f --- /dev/null +++ b/.shellcheckrc @@ -0,0 +1,13 @@ +# ShellCheck configuration for OpenClaw on Android +# Termux 환경에서 실행되는 Shell Script를 위한 설정 + +# Default shell dialect +shell=bash + +# Enable following sourced files +external-sources=true + +# SC2155: Declare and assign separately — Termux 스크립트에서 자주 사용하는 패턴 +# SC1091: Not following sourced file — source 대상이 런타임에만 존재 (Termux $PREFIX 등) +# SC2034: Variable appears unused — lib.sh에서 정의하고 다른 스크립트에서 사용 +disable=SC2155,SC1091,SC2034 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..32b3331 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,241 @@ +# 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/). + +## [Script v1.0.27] - 2026-04-13 + +### Fixed + +- Fix `oa --update` showing syntax error after self-update — the running shell process continued reading the replaced `oa` script file, causing a parse error at the new file's line 240. Added `exit 0` after update completes to prevent this ([#110](https://github.com/AidanPark/openclaw-android/issues/110)) + +## [Script v1.0.26] - 2026-04-12 + +### Changed + +- Switch Codex CLI from upstream `@openai/codex` to Termux-optimized `@mmmbuto/codex-cli-termux` (DioNanos/codex-termux fork). The upstream package ships a static musl binary whose DNS resolver hardcodes `/etc/resolv.conf` — a file that doesn't exist on Android — causing unreliable network connections. The fork builds as a dynamic Bionic binary that uses Android's native DNS stack, fixing the `Stream disconnected` / `error sending request` pattern reported by users behind proxies. CLI command name (`codex`) is unchanged. ([#108](https://github.com/AidanPark/openclaw-android/issues/108)) + +### Fixed + +- Fix Codex CLI launcher failing on `com.openclaw.android` namespace — the npm-created `$PREFIX/bin/codex` symlink points to a JS launcher chain that miscalculates paths under the non-standard Android app namespace. Replace the symlink with a bash wrapper that sets `LD_LIBRARY_PATH` and directly exec's `codex.bin`, matching the pattern used for the openclaw CLI wrapper. Applied across all delivery paths (App Install, Termux Install, Update) via npm wrapper hook and inline post-install creation. ([#108](https://github.com/AidanPark/openclaw-android/issues/108)) + +## [Script v1.0.25] - 2026-04-11 + +### Fixed + +- Fix `bad interpreter: Permission denied` when running npm globally-installed CLI tools (codex, claude, clawdhub, etc.) directly from shell on Android/Termux. The root cause is `#!/usr/bin/env node` shebang in `.js` entry points, which Android cannot resolve. Two-layer fix: (1) npm wrapper hook automatically rewrites shebangs after every `npm install -g`, (2) defense-in-depth calls in install/update scripts catch anything Layer 1 missed. + +## [Script v1.0.24] - 2026-04-11 + +### Fixed + +- Stop permanently polluting user's `~/.npmrc` during install — previously `post-setup.sh` would detect slow `registry.npmjs.org` access and write `registry=https://registry.npmmirror.com` to `~/.npmrc`, affecting all of the user's npm projects forever with no self-recovery. Now the installer uses session-scoped `NPM_CONFIG_REGISTRY` env var and caches the chosen registry at `~/.openclaw-android/.npm-registry`, re-exporting from `~/.bashrc` on each login. Users bitten by v1.0.22/v1.0.23 are auto-rescued on next `oa --update` because env vars override `~/.npmrc`, and their personal npmrc is left untouched (preserving auth tokens, scope registries, etc.) ([#107](https://github.com/AidanPark/openclaw-android/issues/107)) +- Cover all three install paths for the npm registry detection — App Install (`post-setup.sh`), Termux Install (`install.sh`), and Update (`update-core.sh`). `scripts/setup-env.sh` now injects the `NPM_CONFIG_REGISTRY` re-export line inside the `# >>> OpenClaw on Android >>>` marker block of `~/.bashrc` so Termux-install and update paths get the same session-to-session re-evaluation as App Install. + +## [Script v1.0.23] - 2026-04-11 + +### Fixed + +- Preserve user's existing `~/.gitconfig` during post-setup — previously `cat > ~/.gitconfig` overwrote all user settings (name, email, aliases). Now uses `git config --global` to set only `http.sslCAInfo` and `url.https://github.com/.insteadOf` keys while keeping user entries intact ([#107](https://github.com/AidanPark/openclaw-android/issues/107)) + +## [Script v1.0.22] - 2026-04-10 + +### Added + +- ELF binary auto-wrapping: detect glibc binaries via PT_INTERP and route through ld.so, enabling npx-installed native binaries like codex-acp to run on Android ([#103](https://github.com/AidanPark/openclaw-android/issues/103)) +- Shebang resolution: handle `#!/usr/bin/env` scripts without libtermux-exec.so by resolving interpreters from PATH in JavaScript +- Shell invocation interception: detect `spawn('sh', ['-c', 'cmd'])` pattern used by npm/npx and resolve commands directly +- Supplementary glibc library deployment: bundle libcap.so.2 for third-party native binary support +- Localhost DNS shortcut: return 127.0.0.1 immediately for localhost lookups without querying external DNS ([#105](https://github.com/AidanPark/openclaw-android/issues/105)) +- Create `$PREFIX/glibc/etc/hosts` if missing, ensuring getaddrinfo can resolve localhost ([#105](https://github.com/AidanPark/openclaw-android/issues/105)) + +### Changed + +- Stop restoring LD_PRELOAD in glibc-compat.js — libtermux-exec.so re-injects it via execve hook, crashing glibc child processes with "Could not find a PHDR" errors +- Always use Termux shell for exec/execSync on all Android versions (previously only Android 7-8) + +## [Script v1.0.21] - 2026-04-07 + +### Fixed + +- Fix `oa --backup` exiting with error code 1 due to `tmpdir: unbound variable` — trap used local variable that went out of scope +- Fix `oa --backup` / `oa --restore` and `ask_yn` failing in tty-less environments (SSH pipe, non-interactive) — fallback to stdin when `/dev/tty` is unavailable + +## [Script v1.0.20] - 2026-04-06 + +### Fixed + +- Fix dep restore blocked by sharp build failure — `npm install` inside openclaw dir triggers sharp's native build which fails on Termux, blocking all other deps. Now runs `postinstall-bundled-plugins.mjs` directly with `npm_config_ignore_scripts=true` to skip sharp while installing channel deps ([#92](https://github.com/AidanPark/openclaw-android/issues/92)) +- Fix dep restore skipped when openclaw already at latest version — check `@buape/carbon` presence instead of `OPENCLAW_UPDATED` flag + +## [Script v1.0.19] - 2026-04-06 + +### Fixed + +- Fix missing channel dependencies after `--ignore-scripts` install — reinstall deps inside openclaw package dir to restore optional modules like `@buape/carbon`, `grammy` ([#92](https://github.com/AidanPark/openclaw-android/issues/92)) + +## [Script v1.0.18] - 2026-04-04 + +### Fixed + +- Fix `process.execPath` pointing to `ld-linux-aarch64.so.1` instead of node wrapper — glibc-compat.js had wrong path (`node/bin/node` instead of `bin/node`), causing OpenClaw 4.2 child process spawns with `--disable-warning=ExperimentalWarning` to fail ([#88](https://github.com/AidanPark/openclaw-android/issues/88)) +- Add `_OA_WRAPPER_PATH` env var to node wrapper — eliminates path guessing in glibc-compat.js +- Fix verify-compat.sh checking wrong wrapper paths — tests now verify behavior (executable script, not ELF) instead of hardcoded paths +- Fix `install.sh` session PATH missing `$BIN_DIR` — node/npm commands could fail to resolve after Step 5 +- Fix README (en/ko/zh) documenting wrong wrapper path (`node/bin/node` → `bin/node`) +- Fix npm wrapper writing through symlink and corrupting `openclaw.mjs` — npm creates symlink `$PREFIX/bin/openclaw` → `openclaw.mjs`, our shim writer followed it and destroyed the original file ([#89](https://github.com/AidanPark/openclaw-android/issues/89)) + +## [Script v1.0.17] - 2026-04-03 + +### Fixed + +- Fix false-positive "glibc node wrapper not found" in install verification — verify-install.sh and status.sh referenced old `node/bin/` path instead of new `bin/` path ([#87](https://github.com/AidanPark/openclaw-android/issues/87)) +- Add `BIN_DIR` constant to lib.sh to prevent path hardcoding drift across verification scripts + +## [Script v1.0.16] - 2026-04-02 + +### Fixed + +- Auto-repatch openclaw CLI wrapper after `npm install/update -g openclaw` — prevents `/usr/bin/env` shebang breakage on Termux ([#86](https://github.com/AidanPark/openclaw-android/issues/86)) +- Move node/npm/npx wrappers to dedicated `bin/` directory safe from npm overwrites +- Fix missing `bin/node` wrapper creation in already-installed repair path + +## [Script v1.0.15] - 2026-04-01 + +### Fixed + +- Fix `dns.promises.lookup` not patched in glibc-compat.js — OpenClaw's web_search SSRF guard uses `node:dns/promises` which bypassed the c-ares DNS fix, causing `getaddrinfo EAI_AGAIN` on hosts without `resolv.conf` ([#83](https://github.com/AidanPark/openclaw-android/issues/83)) + +## [Script v1.0.14] - 2026-04-01 + +### Fixed + +- Auto-disable Bonjour/mDNS at runtime when only loopback interface is visible — Android/Termux cannot send multicast, causing repeated "Announcement failed as of socket errors!" gateway logs ([#84](https://github.com/AidanPark/openclaw-android/issues/84)) + +## [Script v1.0.13] - 2026-03-31 + +### Added + +- Playwright as optional install tool (`oa --install`) — installs `playwright-core`, auto-configures Chromium path and environment variables +- Auto-repatch openclaw CLI wrapper after `npm install/update -g openclaw` — prevents shebang breakage on Termux (#86) + +### Changed + +- Bump Gson 2.12.1 → 2.13.2 +- Bump androidx.core:core-ktx 1.17.0 → 1.18.0 +- Bump ktlint gradle plugin 14.1.0 → 14.2.0 +- Bump Gradle wrapper 9.3.1 → 9.4.1 +- Bump eslint 9.39.4 → 10.0.3 +- Bump globals 16.5.0 → 17.4.0 +- Bump eslint-plugin-react-refresh 0.4.24 → 0.5.2 +- Bump GitHub Actions: checkout v4→v6, setup-node v4→v6, setup-java v4→v5, upload-artifact v4→v7, download-artifact v4→v8 + +## [App v0.4.0 / Script v1.0.12] - 2026-03-30 + +### Added + +- App: i18n support — English, Korean (한국어), Chinese (中文) with auto-detection +- App: Language selector in Settings +- Add Chinese README (README.zh.md) with China mirror download link +- Add language switcher links to README.md, README.ko.md, README.zh.md +- GitHub mirror fallback for China/restricted networks (ghfast.top, ghproxy.net) +- npm registry auto-switch to npmmirror.com when npmjs.org is unreachable +- Add AppLogger centralized logging wrapper, replace all android.util.Log calls +- Add unit test infrastructure (JUnit5 + MockK, 22 tests) +- Add CI code-quality workflow (shellcheck, sync check, markdownlint, doc freshness, kotlin lint, unit tests) +- Add shellcheck, markdownlint to pre-commit hook +- Add post-setup.sh sync verification to pre-commit hook +- Add Claude Code hooks (push warning, document freshness, shellcheck auto-run) + +### Changed + +- Resolve all 48 detekt violations — no baseline needed +- Resolve all 43 shellcheck violations across all scripts +- Resolve all 125 markdownlint violations across all documents +- Refactor BootstrapManager, JsBridge, MainActivity for reduced complexity +- Convert A&&B||C patterns to if/then/else in install.sh, install-tools.sh +- Bump app version to v0.4.0 (versionCode 9) +- Bump script version to v1.0.12 + +## [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/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..73c2298 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,151 @@ +# 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 automatically runs before every commit: + +- **Kotlin**: ktlint (formatting) + detekt (static analysis) +- **Shell scripts**: shellcheck (requires `shellcheck` installed) +- **Markdown**: markdownlint (requires `markdownlint-cli2` installed) +- **WebView**: ESLint on TypeScript/React files in `android/www/` +- **Sync check**: Verifies `post-setup.sh` root and app assets are identical + +## 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/assets/www/openclaw.svg b/android/app/src/main/assets/www/openclaw.svg new file mode 100644 index 0000000..49746df --- /dev/null +++ b/android/app/src/main/assets/www/openclaw.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/android/app/src/main/java/com/openclaw/android/AppLogger.kt b/android/app/src/main/java/com/openclaw/android/AppLogger.kt new file mode 100644 index 0000000..7cbc2b9 --- /dev/null +++ b/android/app/src/main/java/com/openclaw/android/AppLogger.kt @@ -0,0 +1,48 @@ +package com.openclaw.android + +import android.util.Log + +/** + * Centralized logging wrapper. + * All app code should use AppLogger instead of android.util.Log directly. + * detekt ForbiddenMethodCall enforces this rule for new code. + */ +@Suppress("ForbiddenMethodCall") +object AppLogger { + fun v( + tag: String, + message: String, + ) = Log.v(tag, message) + + fun d( + tag: String, + message: String, + ) = Log.d(tag, message) + + fun i( + tag: String, + message: String, + ) = Log.i(tag, message) + + fun w( + tag: String, + message: String, + ) = Log.w(tag, message) + + fun w( + tag: String, + message: String, + throwable: Throwable, + ) = Log.w(tag, message, throwable) + + fun e( + tag: String, + message: String, + ) = Log.e(tag, message) + + fun e( + tag: String, + message: String, + throwable: Throwable, + ) = Log.e(tag, message, throwable) +} 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..810ea02 --- /dev/null +++ b/android/app/src/main/java/com/openclaw/android/BootReceiver.kt @@ -0,0 +1,30 @@ +package com.openclaw.android + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent + +/** + * 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) { + AppLogger.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..118513e --- /dev/null +++ b/android/app/src/main/java/com/openclaw/android/BootstrapManager.kt @@ -0,0 +1,468 @@ +package com.openclaw.android + +import android.content.Context +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" + private const val PROGRESS_PREPARING = 0.05f + private const val PROGRESS_DOWNLOADING = 0.10f + private const val PROGRESS_EXTRACTING = 0.30f + private const val PROGRESS_CONFIGURING = 0.60f + private const val ELF_MAGIC_SIZE = 4 + private val ELF_SIGNATURE = byteArrayOf(0x7f, 'E'.code.toByte(), 'L'.code.toByte(), 'F'.code.toByte()) + private const val SYMLINK_SEPARATOR = "←" + private const val SYMLINK_PARTS_COUNT = 2 + } + + 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) { + // Clean up any incomplete previous attempt before starting + if (stagingDir.exists()) { + AppLogger.i(TAG, "Removing incomplete staging dir from previous attempt") + stagingDir.deleteRecursively() + } + if (isInstalled()) { + // Bootstrap exists but setup is incomplete — wipe and reinstall + AppLogger.i(TAG, "Incomplete bootstrap detected, reinstalling...") + prefixDir.deleteRecursively() + } + + // Step 1: Download or extract bootstrap + onProgress(PROGRESS_PREPARING, "Preparing bootstrap...") + val zipStream = getBootstrapStream(onProgress) + + // Step 2: Extract bootstrap + onProgress(PROGRESS_EXTRACTING, "Extracting bootstrap...") + extractBootstrap(zipStream) + + // Step 3: Fix paths and configure + onProgress(PROGRESS_CONFIGURING, "Configuring environment...") + fixTermuxPaths(stagingDir) + configureApt(stagingDir) + + // Step 4: Atomic rename + stagingDir.renameTo(prefixDir) + setupDirectories() + copyAssetScripts() + syncWwwFromAssets() + 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(PROGRESS_DOWNLOADING, "Downloading bootstrap...") + val url = UrlResolver(context).getBootstrapUrl() + return URL(url).openStream() + } + + // --- Extraction --- + + private fun extractBootstrap(inputStream: InputStream) { + stagingDir.deleteRecursively() + stagingDir.mkdirs() + + ZipInputStream(inputStream).use { zip -> + var entry = zip.nextEntry + while (entry != null) { + processZipEntry(zip, entry) + zip.closeEntry() + entry = zip.nextEntry + } + } + } + + private fun processZipEntry( + zip: ZipInputStream, + entry: java.util.zip.ZipEntry, + ) { + 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) } + markExecutableIfNeeded(file, entry.name) + } + } + + private fun markExecutableIfNeeded( + file: File, + name: String, + ) { + 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() > ELF_MAGIC_SIZE && isElfBinary(file)) { + file.setExecutable(true) + } + } + + private fun isElfBinary(file: File): Boolean = + try { + file.inputStream().use { fis -> + val magic = ByteArray(ELF_MAGIC_SIZE) + fis.read(magic) == ELF_MAGIC_SIZE && + magic.contentEquals(ELF_SIGNATURE) + } + } catch (_: Exception) { + false + } + + /** + * 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 + content + .lines() + .filter { it.isNotBlank() } + .mapNotNull { line -> + val parts = line.split(SYMLINK_SEPARATOR) + if (parts.size == SYMLINK_PARTS_COUNT) parts else null + }.forEach { parts -> + val 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) { + AppLogger.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) { + AppLogger.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. + AppLogger.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.readText().contains("export PATH"))) { + if (!dpkgReal.exists()) dpkgBin.renameTo(dpkgReal) + val d = "$" // dollar sign for shell script + val realPath = dpkgReal.absolutePath + val wrapperContent = """#!/bin/bash +# dpkg wrapper: set PATH so dpkg can find sh, tar, rm, dpkg-deb etc. +# Also suppresses confdir errors from hardcoded com.termux paths. +export PATH="$realPath/../:$realPath/../applets:${d}PATH" +"$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 to home dir. + * post-setup.sh: try GitHub first, fall back to bundled asset. + * glibc-compat.js: always use bundled asset. + */ + private fun copyAssetScripts() { + val ocaDir = File(homeDir, ".openclaw-android") + ocaDir.mkdirs() + File(ocaDir, "patches").mkdirs() + + val postSetup = File(ocaDir, "post-setup.sh") + copyPostSetupScript(postSetup) + copyBundledAsset("glibc-compat.js", File(ocaDir, "patches/glibc-compat.js")) + } + + private fun copyPostSetupScript(target: File) { + val url = "https://raw.githubusercontent.com/AidanPark/openclaw-android/main/post-setup.sh" + try { + java.net.URL(url).openStream().use { input -> + target.outputStream().use { output -> input.copyTo(output) } + } + target.setExecutable(true) + AppLogger.i(TAG, "post-setup.sh downloaded from GitHub") + return + } catch (e: Exception) { + AppLogger.w(TAG, "Failed to download post-setup.sh, using bundled fallback", e) + } + try { + context.assets.open("post-setup.sh").use { input -> + target.outputStream().use { output -> input.copyTo(output) } + } + target.setExecutable(true) + AppLogger.i(TAG, "post-setup.sh copied from bundled assets") + } catch (e: Exception) { + AppLogger.w(TAG, "Failed to copy bundled post-setup.sh", e) + } + } + + private fun copyBundledAsset( + assetName: String, + target: File, + ) { + try { + context.assets.open(assetName).use { input -> + target.outputStream().use { output -> input.copyTo(output) } + } + AppLogger.i(TAG, "$assetName copied from bundled assets") + } catch (e: Exception) { + AppLogger.w(TAG, "Failed to copy $assetName", e) + } + } + + // Runtime packages are installed by post-setup.sh in the terminal + + /** + * Apply script update on APK version upgrade: + * - Overwrites post-setup.sh and glibc-compat.js from latest assets + * - Installs/updates oa CLI from GitHub so users can run `oa --update` + */ + fun applyScriptUpdate() { + if (!isInstalled()) return + copyAssetScripts() + syncWwwFromAssets() + installOaCli() + AppLogger.i(TAG, "Script update applied") + } + + /** + * Copy bundled assets/www into wwwDir, overwriting any existing files. + * Called on first install and on APK version upgrade to ensure the UI is always current. + */ + fun syncWwwFromAssets() { + try { + wwwDir.mkdirs() + copyAssetDir("www", wwwDir) + AppLogger.i(TAG, "www synced from assets to ${wwwDir.absolutePath}") + } catch (e: Exception) { + AppLogger.w(TAG, "Failed to sync www from assets", e) + } + } + + private fun copyAssetDir( + assetPath: String, + targetDir: File, + ) { + val entries = context.assets.list(assetPath) ?: return + targetDir.mkdirs() + for (entry in entries) { + copyAssetEntry("$assetPath/$entry", File(targetDir, entry)) + } + } + + private fun copyAssetEntry( + assetPath: String, + targetFile: File, + ) { + val children = context.assets.list(assetPath) + if (!children.isNullOrEmpty()) { + copyAssetDir(assetPath, targetFile) + } else { + context.assets.open(assetPath).use { input -> + targetFile.outputStream().use { output -> input.copyTo(output) } + } + } + } + + fun installOaCli() { + val oaBin = File(prefixDir, "bin/oa") + val oaUrl = "https://raw.githubusercontent.com/AidanPark/openclaw-android/main/oa.sh" + try { + java.net.URL(oaUrl).openStream().use { input -> + oaBin.outputStream().use { output -> input.copyTo(output) } + } + oaBin.setExecutable(true) + AppLogger.i(TAG, "oa CLI installed at ${oaBin.absolutePath}") + } catch (e: Exception) { + AppLogger.w(TAG, "Failed to install oa CLI", e) + } + } +} + +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..7bfea9d --- /dev/null +++ b/android/app/src/main/java/com/openclaw/android/CommandRunner.kt @@ -0,0 +1,78 @@ +package com.openclaw.android + +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 = + 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..275cbc2 --- /dev/null +++ b/android/app/src/main/java/com/openclaw/android/EnvironmentBuilder.kt @@ -0,0 +1,83 @@ +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 = build(context.filesDir) + + fun build(filesDir: File): Map { + 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) + val nodeBin = "${home.absolutePath}/.openclaw-android/node/bin" + val localBin = "${home.absolutePath}/.local/bin" + val prefixBin = "${prefix.absolutePath}/bin" + put("PATH", "$nodeBin:$localBin:$prefixBin:$prefixBin/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..7a30f2c --- /dev/null +++ b/android/app/src/main/java/com/openclaw/android/EventBridge.kt @@ -0,0 +1,34 @@ +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..9c97d6c --- /dev/null +++ b/android/app/src/main/java/com/openclaw/android/JsBridge.kt @@ -0,0 +1,739 @@ +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 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). + */ +@Suppress("TooManyFunctions", "LargeClass") // WebView bridge — single facade by design +class JsBridge( + private val activity: MainActivity, + private val sessionManager: TerminalSessionManager, + private val bootstrapManager: BootstrapManager, + private val eventBridge: EventBridge, +) { + private val gson = Gson() + + companion object { + private const val TAG = "JsBridge" + private const val SHELL_INIT_DELAY_MS = 500L + private const val COMMAND_TIMEOUT_MS = 5_000L + private const val PLATFORM_LIST_TIMEOUT_MS = 10_000L + private const val API_TIMEOUT_MS = 5000 + private const val PROGRESS_START = 0f + private const val PROGRESS_HALF = 0.5f + private const val PROGRESS_DONE = 1f + private const val PROGRESS_DOWNLOAD = 0.2f + private const val PROGRESS_EXTRACT = 0.6f + private const val PROGRESS_APPLY = 0.9f + private const val PROGRESS_BOOTSTRAP_START = 0.1f + } + + /** + * 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 -> + AppLogger.e(TAG, "Coroutine error [$errorEventType]: ${throwable.message}", throwable) + eventBridge.emit( + errorEventType, + errorContext + + mapOf( + "error" to (throwable.message ?: "Unknown error"), + "progress" to PROGRESS_START, + "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") + }, SHELL_INIT_DELAY_MS) + } + } + 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 = 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) + }, SHELL_INIT_DELAY_MS) + } + + // ═══════════════════════════════════════════ + // Setup domain + // ═══════════════════════════════════════════ + + @JavascriptInterface + fun getSetupStatus(): String = gson.toJson(bootstrapManager.getStatus()) + + @JavascriptInterface + fun getBootstrapStatus(): String = + gson.toJson( + mapOf( + "installed" to bootstrapManager.isInstalled(), + "prefixPath" to bootstrapManager.prefixDir.absolutePath, + ), + ) + + @JavascriptInterface + fun startSetup() { + launchWithErrorHandling( + errorEventType = "setup_progress", + errorContext = mapOf("progress" to PROGRESS_START), + ) { + 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 "/openclaw.svg", + "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 = PLATFORM_LIST_TIMEOUT_MS, + ) + 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 PROGRESS_START, "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 PROGRESS_HALF, "message" to output), + ) + } + eventBridge.emit( + "install_progress", + mapOf("target" to id, "progress" to PROGRESS_DONE, "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 binary file in node bin + val nodeBin = "${bootstrapManager.homeDir.absolutePath}/.openclaw-android/node/bin" + val npmBinChecks = + mapOf( + "claude-code" to "$nodeBin/claude", + "gemini-cli" to "$nodeBin/gemini", + "codex-cli" to "$nodeBin/codex", + "opencode" to "$nodeBin/opencode", + ) + for ((id, path) in npmBinChecks) { + if (java.io.File(path).exists()) { + 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 prefix = bootstrapManager.prefixDir.absolutePath + val aptGet = + "DEBIAN_FRONTEND=noninteractive $prefix/bin/apt-get" + + " -y -o Acquire::AllowInsecureRepositories=true" + + " -o APT::Get::AllowUnauthenticated=true" + val cmd = + when (id) { + // Termux packages (apt-get) + "tmux", "ttyd", "dufs", "openssh-server", "android-tools" -> + "$aptGet install ${if (id == "openssh-server") "openssh" else id}" + // Chromium (from x11-repo) + "chromium" -> + "$aptGet install 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 @mmmbuto/codex-cli-termux" + // OpenCode (Bun-based) — requires proot + ld.so concatenation + "opencode" -> + "curl -fsSL https://raw.githubusercontent.com/" + + "AidanPark/openclaw-android/main/scripts/install-opencode.sh | bash" + else -> "echo 'Unknown tool: $id'" + } + eventBridge.emit( + "install_progress", + mapOf("target" to id, "progress" to PROGRESS_START, "message" to "Installing $id..."), + ) + CommandRunner.runStreaming(cmd, env, bootstrapManager.homeDir) { output -> + eventBridge.emit( + "install_progress", + mapOf("target" to id, "progress" to PROGRESS_HALF, "message" to output), + ) + } + eventBridge.emit( + "install_progress", + mapOf("target" to id, "progress" to PROGRESS_DONE, "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" -> { + val pkg = if (id == "openssh-server") "openssh" else id + "${bootstrapManager.prefixDir.absolutePath}/bin/apt-get remove -y $pkg" + } + "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 @mmmbuto/codex-cli-termux" + "opencode" -> + "rm -f \$PREFIX/bin/opencode" + + " \$HOME/.openclaw-android/bin/ld.so.opencode" + + " \$PREFIX/tmp/ld.so.opencode" + + " && rm -rf \$HOME/.config/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" -> { + val bin = if (id == "android-tools") "adb" else id + java.io.File("$prefix/bin/$bin").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 = COMMAND_TIMEOUT_MS, + ) + 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 getApkUpdateInfo(): String { + return try { + val url = java.net.URL("https://api.github.com/repos/AidanPark/openclaw-android/releases/latest") + val conn = url.openConnection() as java.net.HttpURLConnection + conn.connectTimeout = API_TIMEOUT_MS + conn.readTimeout = API_TIMEOUT_MS + conn.setRequestProperty("Accept", "application/vnd.github+json") + val body = conn.inputStream.bufferedReader().readText() + conn.disconnect() + val release = gson.fromJson(body, Map::class.java) as? Map<*, *> + val tagName = + release?.get("tagName") as? String + ?: release?.get("tag_name") as? String + ?: return gson.toJson(mapOf("error" to "no tag")) + val latestVersion = tagName.trimStart('v') + val currentVersion = + activity.packageManager + .getPackageInfo(activity.packageName, 0) + .versionName ?: "0.0.0" + gson.toJson( + mapOf( + "currentVersion" to currentVersion, + "latestVersion" to latestVersion, + "updateAvailable" to (compareVersions(latestVersion, currentVersion) > 0), + ), + ) + } catch (e: Exception) { + gson.toJson(mapOf("error" to e.message)) + } + } + + @JavascriptInterface + fun applyUpdate(component: String) { + launchWithErrorHandling( + errorEventType = "install_progress", + errorContext = mapOf("target" to component), + ) { + emitProgress(component, PROGRESS_START, "Updating $component...") + when (component) { + "www" -> updateWww() + "bootstrap" -> updateBootstrap() + "scripts" -> emitProgress("scripts", PROGRESS_HALF, "Scripts are updated with bootstrap") + } + emitProgress(component, PROGRESS_DONE, "$component updated") + } + } + + private fun emitProgress( + target: String, + progress: Float, + message: String, + ) { + eventBridge.emit( + "install_progress", + mapOf( + "target" to target, + "progress" to progress, + "message" to message, + ), + ) + } + + private suspend fun updateWww() { + try { + val url = UrlResolver(activity).getWwwUrl() + val stagingWww = java.io.File(activity.cacheDir, "www-staging") + stagingWww.deleteRecursively() + stagingWww.mkdirs() + + emitProgress("www", PROGRESS_DOWNLOAD, "Downloading...") + val zipFile = java.io.File(activity.cacheDir, "www.zip") + java.net.URL(url).openStream().use { input -> + zipFile.outputStream().use { output -> input.copyTo(output) } + } + + emitProgress("www", PROGRESS_EXTRACT, "Extracting...") + extractZipToDir(zipFile, stagingWww) + zipFile.delete() + + emitProgress("www", PROGRESS_APPLY, "Applying...") + val wwwDir = bootstrapManager.wwwDir + wwwDir.deleteRecursively() + wwwDir.parentFile?.mkdirs() + stagingWww.renameTo(wwwDir) + + activity.runOnUiThread { activity.reloadWebView() } + } catch (e: Exception) { + emitProgress("www", PROGRESS_START, "Update failed: ${e.message}") + } + } + + private suspend fun updateBootstrap() { + try { + emitProgress("bootstrap", PROGRESS_BOOTSTRAP_START, "Downloading bootstrap...") + bootstrapManager.startSetup { progress, message -> + emitProgress("bootstrap", progress, message) + } + } catch (e: Exception) { + emitProgress("bootstrap", PROGRESS_START, "Update failed: ${e.message}") + } + } + + private fun extractZipToDir( + zipFile: java.io.File, + targetDir: java.io.File, + ) { + java.util.zip.ZipInputStream(zipFile.inputStream()).use { zis -> + var entry = zis.nextEntry + while (entry != null) { + extractZipEntry(zis, entry, targetDir) + entry = zis.nextEntry + } + } + } + + private fun extractZipEntry( + zis: java.util.zip.ZipInputStream, + entry: java.util.zip.ZipEntry, + targetDir: java.io.File, + ) { + val destFile = java.io.File(targetDir, entry.name) + if (entry.isDirectory) { + destFile.mkdirs() + } else { + destFile.parentFile?.mkdirs() + destFile.outputStream().use { out -> zis.copyTo(out) } + } + } + + // ═══════════════════════════════════════════ + // 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() + } + + @JavascriptInterface + fun openUrl(url: String) { + val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url)) + intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) + activity.startActivity(intent) + } + + /** Returns positive if a > b, negative if a < b, 0 if equal (semver: major.minor.patch) */ + private fun compareVersions( + a: String, + b: String, + ): Int { + val aParts = a.split(".").map { it.toIntOrNull() ?: 0 } + val bParts = b.split(".").map { it.toIntOrNull() ?: 0 } + val len = maxOf(aParts.size, bParts.size) + for (i in 0 until len) { + val diff = (aParts.getOrElse(i) { 0 }) - (bParts.getOrElse(i) { 0 }) + if (diff != 0) return diff + } + return 0 + } +} 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..319b38d --- /dev/null +++ b/android/app/src/main/java/com/openclaw/android/MainActivity.kt @@ -0,0 +1,719 @@ +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.content.res.ColorStateList +import android.graphics.Typeface +import android.os.Bundle +import android.view.Gravity +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.View +import android.view.inputmethod.InputMethodManager +import android.webkit.WebChromeClient +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.Button +import android.widget.LinearLayout +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import com.openclaw.android.databinding.ActivityMainBinding +import com.termux.terminal.TerminalSession +import com.termux.terminal.TerminalSessionClient +import com.termux.view.TerminalViewClient + +class MainActivity : AppCompatActivity() { + companion object { + private const val TAG = "MainActivity" + private const val DEFAULT_TEXT_SIZE = 32 + private const val MIN_TEXT_SIZE = 8 + private const val MAX_TEXT_SIZE = 32 + private const val KEYBOARD_SHOW_DELAY_MS = 200L + private const val TAB_NAME_TEXT_SIZE = 12f + private const val TAB_CLOSE_TEXT_SIZE = 14f + private const val TAB_ADD_TEXT_SIZE = 18f + private const val TAB_MARGIN_DP = 2 + private const val TAB_HPAD_DP = 10 + private const val TAB_VPAD_DP = 4 + private const val TAB_CLOSE_PAD_DP = 6 + private const val TAB_ADD_PAD_DP = 12 + private const val INDICATOR_HEIGHT_DP = 2 + private const val INPUT_MODE_TYPE_NULL = 1 + } + + 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 = DEFAULT_TEXT_SIZE + 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() + AppLogger.i(TAG, "Bootstrap installed: $isInstalled, needsPostSetup: ${bootstrapManager.needsPostSetup()}") + + // Sync www assets and check for APK version upgrade + if (isInstalled) { + val prefs = getSharedPreferences("openclaw", 0) + val savedVersionCode = prefs.getInt("versionCode", 0) + val currentVersionCode = packageManager.getPackageInfo(packageName, 0).versionCode + // Always sync www from assets to pick up UI updates + bootstrapManager.syncWwwFromAssets() + // Ensure oa CLI is installed (network, run in background) + Thread { bootstrapManager.installOaCli() }.start() + if (currentVersionCode > savedVersionCode) { + AppLogger.i(TAG, "APK version upgrade detected: $savedVersionCode -> $currentVersionCode") + bootstrapManager.applyScriptUpdate() + prefs.edit().putInt("versionCode", currentVersionCode).apply() + } + } + if (isInstalled) { + showTerminal() + val session = sessionManager.createSession() + if (bootstrapManager.needsPostSetup()) { + AppLogger.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" + AppLogger.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 { + clearCache(true) + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.allowFileAccess = true + @Suppress("DEPRECATION") + settings.allowFileAccessFromFileURLs = true + @Suppress("DEPRECATION") + settings.allowUniversalAccessFromFileURLs = true + settings.cacheMode = android.webkit.WebSettings.LOAD_NO_CACHE + addJavascriptInterface(jsBridge, "OpenClaw") + webViewClient = + object : WebViewClient() { + override fun onPageFinished( + view: WebView?, + url: String?, + ) { + super.onPageFinished(view, url) + AppLogger.i(TAG, "WebView page loaded: $url") + // Page loaded successfully + } + } + webChromeClient = + object : WebChromeClient() { + override fun onConsoleMessage(consoleMessage: android.webkit.ConsoleMessage?): Boolean { + consoleMessage?.let { + AppLogger.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" + } + AppLogger.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) + }, KEYBOARD_SHOW_DELAY_MS) + } + } + + 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("|") } + setupExtraKeyTouch(findViewById(R.id.btnPaste)) { + val clipboard = getSystemService(CLIPBOARD_SERVICE) as android.content.ClipboardManager + val text = + clipboard.primaryClip + ?.getItemAt(0) + ?.coerceToText(this) + ?.toString() + if (!text.isNullOrEmpty()) sessionManager.activeSession?.write(text) + } + + // 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 tabWrapper = createSessionTab(info, density) + tabsLayout.addView(tabWrapper) + if (info["active"] as Boolean) { + binding.sessionTabBar.post { + binding.sessionTabBar.smoothScrollTo(tabWrapper.left, 0) + } + } + } + + tabsLayout.addView(createAddButton(density)) + } + + private fun createSessionTab( + info: Map, + density: Float, + ): LinearLayout { + val id = info["id"] as String + val name = info["name"] as String + val active = info["active"] as Boolean + val finished = info["finished"] as Boolean + + val tabWrapper = + LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + layoutParams = + LinearLayout + .LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.MATCH_PARENT, + ).apply { + marginEnd = (TAB_MARGIN_DP * density).toInt() + } + val bgColor = + if (active) { + R.color.tabActiveBackground + } else { + R.color.tabInactiveBackground + } + setBackgroundColor(ContextCompat.getColor(this@MainActivity, bgColor)) + isFocusable = false + isFocusableInTouchMode = false + } + + val tabContent = createTabContent(name, active, finished, id, density) + val indicator = createTabIndicator(active, density) + + tabWrapper.addView(tabContent) + tabWrapper.addView(indicator) + tabWrapper.setOnClickListener { + sessionManager.switchSession(id) + binding.terminalView.requestFocus() + } + + return tabWrapper + } + + private fun createTabContent( + name: String, + active: Boolean, + finished: Boolean, + id: String, + density: Float, + ): LinearLayout { + val tabContent = + LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + val hPad = (TAB_HPAD_DP * density).toInt() + val vPad = (TAB_VPAD_DP * density).toInt() + setPadding(hPad, vPad, (TAB_CLOSE_PAD_DP * density).toInt(), vPad) + layoutParams = + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + 0, + 1f, + ) + isFocusable = false + isFocusableInTouchMode = false + } + + val nameView = + TextView(this).apply { + text = name + textSize = TAB_NAME_TEXT_SIZE + 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, + ) + } + + val closeView = + TextView(this).apply { + text = "\u00D7" + textSize = TAB_CLOSE_TEXT_SIZE + setTextColor(ContextCompat.getColor(this@MainActivity, R.color.tabTextSecondary)) + setPadding((TAB_CLOSE_PAD_DP * density).toInt(), 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) + return tabContent + } + + private fun createTabIndicator( + active: Boolean, + density: Float, + ): View = + View(this).apply { + layoutParams = + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + (INDICATOR_HEIGHT_DP * density).toInt(), + ) + val color = if (active) R.color.tabAccent else android.R.color.transparent + setBackgroundColor(ContextCompat.getColor(this@MainActivity, color)) + } + + private fun createAddButton(density: Float): TextView = + TextView(this).apply { + text = "+" + textSize = TAB_ADD_TEXT_SIZE + setTextColor(ContextCompat.getColor(this@MainActivity, R.color.tabTextSecondary)) + val pad = (TAB_ADD_PAD_DP * 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() + } + } + + 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) = Unit + + override fun onColorsChanged(session: TerminalSession) = Unit + + override fun onTerminalCursorStateChange(state: Boolean) = Unit + + override fun setTerminalShellPid( + session: TerminalSession, + pid: Int, + ) = Unit + + override fun getTerminalCursorStyle(): Int = 0 + + override fun logError( + tag: String, + message: String, + ) { + AppLogger.e(tag, message) + } + + override fun logWarn( + tag: String, + message: String, + ) { + AppLogger.w(tag, message) + } + + override fun logInfo( + tag: String, + message: String, + ) { + AppLogger.i(tag, message) + } + + override fun logDebug( + tag: String, + message: String, + ) { + AppLogger.d(tag, message) + } + + override fun logVerbose( + tag: String, + message: String, + ) { + AppLogger.v(tag, message) + } + + override fun logStackTraceWithMessage( + tag: String, + message: String, + e: Exception, + ) { + AppLogger.e(tag, message, e) + } + + override fun logStackTrace( + tag: String, + e: Exception, + ) { + AppLogger.e(tag, "Exception", e) + } + } + + // --- Terminal view callbacks --- + + @Suppress("TooManyFunctions") // Interface implementation requires all methods + 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(MIN_TEXT_SIZE, MAX_TEXT_SIZE) + 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 = INPUT_MODE_TYPE_NULL + + override fun shouldUseCtrlSpaceWorkaround(): Boolean = false + + override fun isTerminalViewSelected(): Boolean = binding.terminalContainer.visibility == View.VISIBLE + + override fun copyModeChanged(copyMode: Boolean) = Unit + + 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() = Unit + + override fun logError( + tag: String, + message: String, + ) { + AppLogger.e(tag, message) + } + + override fun logWarn( + tag: String, + message: String, + ) { + AppLogger.w(tag, message) + } + + override fun logInfo( + tag: String, + message: String, + ) { + AppLogger.i(tag, message) + } + + override fun logDebug( + tag: String, + message: String, + ) { + AppLogger.d(tag, message) + } + + override fun logVerbose( + tag: String, + message: String, + ) { + AppLogger.v(tag, message) + } + + override fun logStackTraceWithMessage( + tag: String, + message: String, + e: Exception, + ) { + AppLogger.e(tag, message, e) + } + + override fun logStackTrace( + tag: String, + e: Exception, + ) { + AppLogger.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..886dc15 --- /dev/null +++ b/android/app/src/main/java/com/openclaw/android/OpenClawService.kt @@ -0,0 +1,79 @@ +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..0c2e231 --- /dev/null +++ b/android/app/src/main/java/com/openclaw/android/TerminalSessionManager.kt @@ -0,0 +1,162 @@ +package com.openclaw.android + +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() } + + AppLogger.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? = 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() } + AppLogger.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> = + 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..d30078a --- /dev/null +++ b/android/app/src/main/java/com/openclaw/android/UrlResolver.kt @@ -0,0 +1,84 @@ +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, +) { + companion object { + private const val CONFIG_FETCH_TIMEOUT_MS = 5_000L + } + + 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(CONFIG_FETCH_TIMEOUT_MS) { + 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..f43b39d --- /dev/null +++ b/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* Routes */} + + { setSetupDone(true); navigate('/dashboard') }} /> + + + + + + + + + ) +} + +function SettingsRouter() { + const { path } = useRoute() + if (path === '/settings') 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/i18n/en.ts b/android/www/src/i18n/en.ts new file mode 100644 index 0000000..04d6c91 --- /dev/null +++ b/android/www/src/i18n/en.ts @@ -0,0 +1,124 @@ +export const en = { + // App tab bar + tab_terminal: '🖥 Terminal', + tab_dashboard: '📊 Dashboard', + tab_settings: '⚙ Settings', + + // Setup - steps + step_platform: 'Platform', + step_tools: 'Tools', + step_setup: 'Setup', + + // Setup - platform select + setup_choose_platform: 'Choose your platform', + setup_more_platforms: 'More platforms available in Settings.', + + // Setup - tool select + setup_optional_tools: 'Optional Tools', + setup_tools_desc: 'Select tools to install alongside {platform}. You can always add more later in Settings.', + setup_start: 'Start Setup', + + // Setup - installing + setup_setting_up: 'Setting up...', + setup_preparing: 'Preparing setup...', + + // Setup - done + setup_done_title: "You're all set!", + setup_done_desc: 'The terminal will now install runtime components and your selected tools. This takes 3–10 minutes.', + setup_open_terminal: 'Open Terminal', + + // Setup - tips + tip_1: 'You can install multiple AI platforms and switch between them anytime.', + tip_2: 'Setup is a one-time process. Future launches are instant.', + tip_3: 'Once setup is complete, your AI assistant runs at full speed — just like on a computer.', + tip_4: 'All processing happens locally on your device. Your data never leaves your phone.', + + // Setup - tool descriptions + tool_tmux: 'Terminal multiplexer for background sessions', + tool_ttyd: 'Web terminal — access from a browser', + tool_dufs: 'File server (WebDAV)', + tool_code_server: 'VS Code in browser', + tool_claude_code: 'Anthropic AI CLI', + tool_gemini_cli: 'Google AI CLI', + tool_codex_cli: 'OpenAI AI CLI', + + // Dashboard + dash_setup_required: 'Setup Required', + dash_setup_desc: "The runtime environment hasn't been set up yet.", + dash_commands: 'Commands', + dash_runtime: 'Runtime', + dash_management: 'Management', + + // Dashboard - commands + cmd_gateway: 'Start the gateway', + cmd_status: 'Show gateway status', + cmd_onboard: 'Initial setup wizard', + cmd_logs: 'Follow live logs', + cmd_update: 'Update OpenClaw and all components', + cmd_install_tools: 'Add or remove optional tools', + + // Settings + settings_title: 'Settings', + settings_platforms: 'Platforms', + settings_platforms_desc: 'Manage installed platforms', + settings_updates: 'Updates', + settings_updates_desc: 'Check for updates', + settings_keep_alive: 'Keep Alive', + settings_keep_alive_desc: 'Prevent background killing', + settings_storage: 'Storage', + settings_storage_desc: 'Manage disk usage', + settings_about: 'About', + settings_about_desc: 'App info & licenses', + + // Settings - Keep Alive + ka_title: 'Keep Alive', + ka_desc: 'Android may kill background processes after a while. Follow these steps to prevent it.', + ka_battery: '1. Battery Optimization', + ka_status: 'Status', + ka_excluded: '✓ Excluded', + ka_request: 'Request Exclusion', + ka_developer: '2. Developer Options', + ka_developer_desc: '• Enable Developer Options\n• Enable "Stay Awake"', + ka_open_dev: 'Open Developer Options', + ka_phantom: '3. Phantom Process Killer (Android 12+)', + ka_phantom_desc: 'Connect USB and enable ADB debugging, then run this command on your PC:', + ka_copy: 'Copy', + ka_copied: 'Copied!', + ka_charge: '4. Charge Limit (Optional)', + ka_charge_desc: 'Set battery charge limit to 80% for always-on use. This can be configured in your phone\'s battery settings.', + + // Settings - Storage + storage_title: 'Storage', + storage_total: 'Total used: ', + storage_bootstrap: 'Bootstrap (usr/)', + storage_www: 'Web UI (www/)', + storage_free: 'Free Space', + storage_clear: 'Clear Cache', + storage_clearing: 'Clearing...', + storage_loading: 'Loading storage info...', + + // Settings - About + about_title: 'About', + about_version: 'Version', + about_apk: 'APK', + about_update_available: 'Update available', + about_package: 'Package', + about_script: 'Script', + about_runtime: 'Runtime', + about_license: 'License', + about_app_info: 'App Info', + about_made_for: 'Made for Android', + + // Settings - Updates + updates_title: 'Updates', + updates_checking: 'Checking for updates...', + updates_up_to_date: 'Everything is up to date.', + updates_updating: 'Updating {name}...', + updates_update: 'Update', + + // Settings - Platforms + platforms_title: 'Platforms', + platforms_installing: 'Installing {name}...', + platforms_active: 'Active', + platforms_install: 'Install & Switch', +} diff --git a/android/www/src/i18n/index.ts b/android/www/src/i18n/index.ts new file mode 100644 index 0000000..d669162 --- /dev/null +++ b/android/www/src/i18n/index.ts @@ -0,0 +1,64 @@ +import { createContext, useContext } from 'react' +import { en } from './en' +import { ko } from './ko' +import { zh } from './zh' + +export type TranslationKey = keyof typeof en +type Translations = Record + +const locales: Record = { en, ko, zh } + +function detectLocale(): string { + // 1. Check saved preference + try { + const saved = localStorage.getItem('locale') + if (saved && locales[saved]) return saved + } catch { + // localStorage may not be available + } + + // 2. Detect from browser/system language + const lang = navigator.language || '' + if (lang.startsWith('ko')) return 'ko' + if (lang.startsWith('zh')) return 'zh' + return 'en' +} + +let currentLocale = detectLocale() +let currentTranslations = locales[currentLocale] || en + +export function getLocale(): string { + return currentLocale +} + +export function setLocale(locale: string) { + if (locales[locale]) { + currentLocale = locale + currentTranslations = locales[locale] + try { + localStorage.setItem('locale', locale) + } catch { + // ignore + } + } +} + +export function t(key: TranslationKey, vars?: Record): string { + let text = currentTranslations[key] || en[key] || key + if (vars) { + for (const [k, v] of Object.entries(vars)) { + text = text.replace(`{${k}}`, v) + } + } + return text +} + +// Context for triggering re-renders on locale change +export const LocaleContext = createContext(currentLocale) +export const useLocale = () => useContext(LocaleContext) + +export const availableLocales = [ + { code: 'en', label: 'English' }, + { code: 'ko', label: '한국어' }, + { code: 'zh', label: '中文' }, +] diff --git a/android/www/src/i18n/ko.ts b/android/www/src/i18n/ko.ts new file mode 100644 index 0000000..4b83910 --- /dev/null +++ b/android/www/src/i18n/ko.ts @@ -0,0 +1,105 @@ +export const ko = { + tab_terminal: '🖥 터미널', + tab_dashboard: '📊 대시보드', + tab_settings: '⚙ 설정', + + step_platform: '플랫폼', + step_tools: '도구', + step_setup: '설치', + + setup_choose_platform: '플랫폼을 선택하세요', + setup_more_platforms: '더 많은 플랫폼은 설정에서 확인할 수 있습니다.', + setup_optional_tools: '선택 도구', + setup_tools_desc: '{platform}과 함께 설치할 도구를 선택하세요. 나중에 설정에서 추가할 수 있습니다.', + setup_start: '설치 시작', + setup_setting_up: '설치 중...', + setup_preparing: '설치 준비 중...', + setup_done_title: '설치 완료!', + setup_done_desc: '터미널에서 런타임 구성요소와 선택한 도구를 설치합니다. 약 3~10분 소요됩니다.', + setup_open_terminal: '터미널 열기', + + tip_1: '여러 AI 플랫폼을 설치하고 언제든지 전환할 수 있습니다.', + tip_2: '설치는 한 번만 하면 됩니다. 이후 실행은 즉시 됩니다.', + tip_3: '설치가 완료되면 AI 어시스턴트가 PC와 동일한 속도로 작동합니다.', + tip_4: '모든 처리는 기기에서 로컬로 이루어집니다. 데이터가 폰을 떠나지 않습니다.', + + tool_tmux: '백그라운드 세션용 터미널 멀티플렉서', + tool_ttyd: '브라우저에서 접속하는 웹 터미널', + tool_dufs: '파일 서버 (WebDAV)', + tool_code_server: '브라우저용 VS Code', + tool_claude_code: 'Anthropic AI CLI', + tool_gemini_cli: 'Google AI CLI', + tool_codex_cli: 'OpenAI AI CLI', + + dash_setup_required: '설치 필요', + dash_setup_desc: '런타임 환경이 아직 설치되지 않았습니다.', + dash_commands: '명령어', + dash_runtime: '런타임', + dash_management: '관리', + + cmd_gateway: '게이트웨이 시작', + cmd_status: '게이트웨이 상태 확인', + cmd_onboard: '초기 설정 마법사', + cmd_logs: '실시간 로그 확인', + cmd_update: 'OpenClaw 및 전체 구성요소 업데이트', + cmd_install_tools: '선택 도구 추가/제거', + + settings_title: '설정', + settings_platforms: '플랫폼', + settings_platforms_desc: '설치된 플랫폼 관리', + settings_updates: '업데이트', + settings_updates_desc: '업데이트 확인', + settings_keep_alive: '백그라운드 유지', + settings_keep_alive_desc: '백그라운드 종료 방지', + settings_storage: '저장공간', + settings_storage_desc: '디스크 사용량 관리', + settings_about: '정보', + settings_about_desc: '앱 정보 및 라이선스', + + ka_title: '백그라운드 유지', + ka_desc: 'Android는 백그라운드 프로세스를 일정 시간 후 종료할 수 있습니다. 이를 방지하려면 다음 단계를 따르세요.', + ka_battery: '1. 배터리 최적화', + ka_status: '상태', + ka_excluded: '✓ 제외됨', + ka_request: '제외 요청', + ka_developer: '2. 개발자 옵션', + ka_developer_desc: '• 개발자 옵션 활성화\n• "화면 켜짐 유지" 활성화', + ka_open_dev: '개발자 옵션 열기', + ka_phantom: '3. 유령 프로세스 킬러 (Android 12+)', + ka_phantom_desc: 'USB를 연결하고 ADB 디버깅을 활성화한 후, PC에서 다음 명령을 실행하세요:', + ka_copy: '복사', + ka_copied: '복사됨!', + ka_charge: '4. 충전 제한 (선택사항)', + ka_charge_desc: '상시 사용을 위해 배터리 충전 제한을 80%로 설정하세요. 휴대폰의 배터리 설정에서 구성할 수 있습니다.', + + storage_title: '저장공간', + storage_total: '총 사용량: ', + storage_bootstrap: 'Bootstrap (usr/)', + storage_www: '웹 UI (www/)', + storage_free: '여유 공간', + storage_clear: '캐시 삭제', + storage_clearing: '삭제 중...', + storage_loading: '저장공간 정보 로드 중...', + + about_title: '정보', + about_version: '버전', + about_apk: 'APK', + about_update_available: '업데이트 가능', + about_package: '패키지', + about_script: '스크립트', + about_runtime: '런타임', + about_license: '라이선스', + about_app_info: '앱 정보', + about_made_for: 'Made for Android', + + updates_title: '업데이트', + updates_checking: '업데이트 확인 중...', + updates_up_to_date: '모두 최신 버전입니다.', + updates_updating: '{name} 업데이트 중...', + updates_update: '업데이트', + + platforms_title: '플랫폼', + platforms_installing: '{name} 설치 중...', + platforms_active: '활성', + platforms_install: '설치 및 전환', +} diff --git a/android/www/src/i18n/zh.ts b/android/www/src/i18n/zh.ts new file mode 100644 index 0000000..b56162c --- /dev/null +++ b/android/www/src/i18n/zh.ts @@ -0,0 +1,105 @@ +export const zh = { + tab_terminal: '🖥 终端', + tab_dashboard: '📊 仪表盘', + tab_settings: '⚙ 设置', + + step_platform: '平台', + step_tools: '工具', + step_setup: '安装', + + setup_choose_platform: '选择你的平台', + setup_more_platforms: '更多平台可在设置中查看。', + setup_optional_tools: '可选工具', + setup_tools_desc: '选择与 {platform} 一起安装的工具。稍后可以在设置中随时添加。', + setup_start: '开始安装', + setup_setting_up: '安装中...', + setup_preparing: '准备安装...', + setup_done_title: '安装完成!', + setup_done_desc: '终端将安装运行时组件和你选择的工具,大约需要 3-10 分钟。', + setup_open_terminal: '打开终端', + + tip_1: '你可以安装多个 AI 平台,随时切换使用。', + tip_2: '安装只需一次,之后启动即时完成。', + tip_3: '安装完成后,AI 助手将以全速运行——和电脑上一样快。', + tip_4: '所有处理都在设备本地完成,数据不会离开你的手机。', + + tool_tmux: '后台会话终端复用器', + tool_ttyd: '网页终端——通过浏览器访问', + tool_dufs: '文件服务器 (WebDAV)', + tool_code_server: '浏览器版 VS Code', + tool_claude_code: 'Anthropic AI CLI', + tool_gemini_cli: 'Google AI CLI', + tool_codex_cli: 'OpenAI AI CLI', + + dash_setup_required: '需要安装', + dash_setup_desc: '运行时环境尚未安装。', + dash_commands: '命令', + dash_runtime: '运行时', + dash_management: '管理', + + cmd_gateway: '启动网关', + cmd_status: '查看网关状态', + cmd_onboard: '初始配置向导', + cmd_logs: '查看实时日志', + cmd_update: '更新 OpenClaw 及所有组件', + cmd_install_tools: '添加或移除可选工具', + + settings_title: '设置', + settings_platforms: '平台', + settings_platforms_desc: '管理已安装的平台', + settings_updates: '更新', + settings_updates_desc: '检查更新', + settings_keep_alive: '保持运行', + settings_keep_alive_desc: '防止后台被杀', + settings_storage: '存储', + settings_storage_desc: '管理磁盘用量', + settings_about: '关于', + settings_about_desc: '应用信息与许可证', + + ka_title: '保持运行', + ka_desc: 'Android 可能会在一段时间后终止后台进程。按照以下步骤防止此问题。', + ka_battery: '1. 电池优化', + ka_status: '状态', + ka_excluded: '✓ 已排除', + ka_request: '请求排除', + ka_developer: '2. 开发者选项', + ka_developer_desc: '• 启用开发者选项\n• 启用"保持唤醒"', + ka_open_dev: '打开开发者选项', + ka_phantom: '3. 虚拟进程杀手 (Android 12+)', + ka_phantom_desc: '连接 USB 并启用 ADB 调试,然后在电脑上运行以下命令:', + ka_copy: '复制', + ka_copied: '已复制!', + ka_charge: '4. 充电限制(可选)', + ka_charge_desc: '将电池充电限制设为 80% 以便长期使用。可在手机的电池设置中配置。', + + storage_title: '存储', + storage_total: '总使用量:', + storage_bootstrap: 'Bootstrap (usr/)', + storage_www: '网页 UI (www/)', + storage_free: '可用空间', + storage_clear: '清除缓存', + storage_clearing: '清除中...', + storage_loading: '正在加载存储信息...', + + about_title: '关于', + about_version: '版本', + about_apk: 'APK', + about_update_available: '有更新可用', + about_package: '包名', + about_script: '脚本', + about_runtime: '运行时', + about_license: '许可证', + about_app_info: '应用信息', + about_made_for: 'Made for Android', + + updates_title: '更新', + updates_checking: '正在检查更新...', + updates_up_to_date: '一切都是最新的。', + updates_updating: '正在更新 {name}...', + updates_update: '更新', + + platforms_title: '平台', + platforms_installing: '正在安装 {name}...', + platforms_active: '当前使用', + platforms_install: '安装并切换', +} diff --git a/android/www/src/lib/bridge.ts b/android/www/src/lib/bridge.ts new file mode 100644 index 0000000..25ed469 --- /dev/null +++ b/android/www/src/lib/bridge.ts @@ -0,0 +1,81 @@ +/** + * 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 + getApkUpdateInfo(): string + getAppInfo(): string + getBatteryOptimizationStatus(): string + requestBatteryOptimizationExclusion(): void + openSystemSettings(page: string): void + copyToClipboard(text: string): void + getStorageInfo(): string + clearCache(): void + openUrl(url: string): 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..7f04d27 --- /dev/null +++ b/android/www/src/main.tsx @@ -0,0 +1,21 @@ +import { StrictMode, useState } from 'react' +import { createRoot } from 'react-dom/client' +import { Router } from './lib/router' +import { App } from './App' +import { LocaleContext, getLocale } from './i18n' +import './styles/global.css' + +function Root() { + const [locale] = useState(getLocale) + return ( + + + + + + + + ) +} + +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..3742a77 --- /dev/null +++ b/android/www/src/screens/Dashboard.tsx @@ -0,0 +1,140 @@ +import { useState, useEffect } from 'react' +import { bridge } from '../lib/bridge' +import { t } from '../i18n' + +interface BootstrapStatus { + installed: boolean + prefixPath?: string +} + +interface PlatformInfo { + id: string + name: string +} + +function getCommands() { + return [ + { label: 'Gateway', cmd: 'openclaw gateway', desc: t('cmd_gateway') }, + { label: 'Status', cmd: 'openclaw status', desc: t('cmd_status') }, + { label: 'Onboard', cmd: 'openclaw onboard', desc: t('cmd_onboard') }, + { label: 'Logs', cmd: 'openclaw logs --follow', desc: t('cmd_logs') }, + ] +} + +function getManagement() { + return [ + { label: 'Update', cmd: 'oa --update', desc: t('cmd_update') }, + { label: 'Install Tools', cmd: 'oa --install', desc: t('cmd_install_tools') }, + ] +} + +export function Dashboard() { + 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) + + 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 runInTerminal(cmd: string) { + bridge.call('showTerminal') + bridge.call('writeToTerminal', '', cmd) + } + + + + if (!status?.installed) { + return ( +
+
+ OpenClaw +
{t('dash_setup_required')}
+
+ {t('dash_setup_desc')} +
+
+
+ ) + } + + return ( +
+ {/* Platform header */} +
+ OpenClaw +
+
+ {platform?.name || 'OpenClaw'} +
+
+
+ + {/* Commands */} +
{t('dash_commands')}
+
+ {getCommands().map((item, i) => ( +
0 ? '1px solid var(--border)' : 'none', padding: '10px 0' }} + onClick={() => runInTerminal(item.cmd)} + > +
+
{item.label}
+
{item.cmd}
+
+
+
+ ))} +
+ + {/* Runtime info */} +
{t('dash_runtime')}
+
+ {Object.entries(runtimeInfo).map(([key, val]) => ( +
+ {key} + {val} +
+ ))} +
+ + {/* Management */} +
{t('dash_management')}
+
+ {getManagement().map((item, i) => ( +
0 ? '1px solid var(--border)' : 'none', padding: '10px 0' }} + onClick={() => runInTerminal(item.cmd)} + > +
+
{item.label}
+
{item.cmd}
+
+
+
+ ))} +
+
+ ) +} diff --git a/android/www/src/screens/Settings.tsx b/android/www/src/screens/Settings.tsx new file mode 100644 index 0000000..4f1ac09 --- /dev/null +++ b/android/www/src/screens/Settings.tsx @@ -0,0 +1,65 @@ +import { useRoute } from '../lib/router' +import { t, getLocale, setLocale, availableLocales } from '../i18n' + +interface MenuItem { + icon: string + label: string + desc: string + route: string + badge?: boolean +} + +function getMenu(): MenuItem[] { + return [ + { icon: '📱', label: t('settings_platforms'), desc: t('settings_platforms_desc'), route: '/settings/platforms' }, + { icon: '🔄', label: t('settings_updates'), desc: t('settings_updates_desc'), route: '/settings/updates', badge: false }, + { icon: '⚡', label: t('settings_keep_alive'), desc: t('settings_keep_alive_desc'), route: '/settings/keep-alive' }, + { icon: '💾', label: t('settings_storage'), desc: t('settings_storage_desc'), route: '/settings/storage' }, + { icon: 'ℹ️', label: t('settings_about'), desc: t('settings_about_desc'), route: '/settings/about' }, + ] +} + +export function Settings() { + const { navigate } = useRoute() + + return ( +
+
{t('settings_title')}
+ {/* Language selector */} +
+
+ 🌐 +
+
Language
+
+
+ {availableLocales.map(loc => ( + + ))} +
+
+
+ + {getMenu().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..7349de7 --- /dev/null +++ b/android/www/src/screens/SettingsAbout.tsx @@ -0,0 +1,124 @@ +import { useState, useEffect } from 'react' +import { useRoute } from '../lib/router' +import { bridge } from '../lib/bridge' +import { t } from '../i18n' + +interface AppInfo { + versionName: string + versionCode: number + packageName: string +} + +export function SettingsAbout() { + const { navigate } = useRoute() + const [appInfo, setAppInfo] = useState(null) + const [scriptVersion, setScriptVersion] = useState('—') + const [runtimeInfo, setRuntimeInfo] = useState>({}) + + const [apkUpdateAvailable, setApkUpdateAvailable] = useState(false) + + useEffect(() => { + const info = bridge.callJson('getAppInfo') + if (info) setAppInfo(info) + + + + // Check APK update availability (async, non-blocking) + setTimeout(() => { + const apkInfo = bridge.callJson<{ updateAvailable?: boolean }>('getApkUpdateInfo') + if (apkInfo?.updateAvailable) setApkUpdateAvailable(true) + }, 0) + + // 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 oaV = bridge.callJson<{ stdout: string }>('runCommand', 'oa --version 2>/dev/null | head -1') + setScriptVersion(oaV?.stdout?.trim() || '—') + setRuntimeInfo({ + 'Node.js': nodeV?.stdout?.trim() || '—', + 'git': gitV?.stdout?.trim()?.replace('git version ', '') || '—', + }) + }, []) + + return ( +
+
+ +
{t('about_title')}
+
+ +
+ Claw +
Claw
+
+ +
{t('about_version')}
+
+
+ {t('about_apk')} + + {appInfo?.versionName || '\u2014'} + {apkUpdateAvailable && ( + bridge.call('openUrl', 'https://github.com/AidanPark/openclaw-android/releases/latest')} + >{t('about_update_available')} + )} + +
+ +
+ {t('about_package')} + {appInfo?.packageName || '—'} +
+
+ {t('about_script')} + {scriptVersion} +
+
+ +
{t('about_runtime')}
+
+ {Object.entries(runtimeInfo).map(([key, val]) => ( +
+ {key} + {val} +
+ ))} +
+ +
+ +
+
+ {t('about_license')} + GPL v3 +
+
+ +
+ +
+ +
+ {t('about_made_for')} +
+
+ ) +} diff --git a/android/www/src/screens/SettingsKeepAlive.tsx b/android/www/src/screens/SettingsKeepAlive.tsx new file mode 100644 index 0000000..0ad6ff1 --- /dev/null +++ b/android/www/src/screens/SettingsKeepAlive.tsx @@ -0,0 +1,100 @@ +import { useState, useEffect } from 'react' +import { useRoute } from '../lib/router' +import { bridge } from '../lib/bridge' +import { t } from '../i18n' + +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 ( +
+
+ +
{t('ka_title')}
+
+ +
+ {t('ka_desc')} +
+ + {/* 1. Battery Optimization */} +
{t('ka_battery')}
+
+
+
+
{t('ka_status')}
+
+ {batteryExcluded ? ( + {t('ka_excluded')} + ) : ( + + )} +
+
+ + {/* 2. Developer Options */} +
{t('ka_developer')}
+
+
+ {t('ka_developer_desc').split('\n').map((line, i) => ( + {line}{i < t('ka_developer_desc').split('\n').length - 1 &&
}
+ ))} +
+ +
+ + {/* 3. Phantom Process Killer */} +
{t('ka_phantom')}
+
+
+ {t('ka_phantom_desc')} +
+
+ {ppkCommand} + +
+
+ + {/* 4. Charge Limit */} +
{t('ka_charge')}
+
+
+ {t('ka_charge_desc')} +
+
+
+ ) +} diff --git a/android/www/src/screens/SettingsPlatforms.tsx b/android/www/src/screens/SettingsPlatforms.tsx new file mode 100644 index 0000000..5de1270 --- /dev/null +++ b/android/www/src/screens/SettingsPlatforms.tsx @@ -0,0 +1,94 @@ +import { useState, useEffect, useCallback } from 'react' +import { useRoute } from '../lib/router' +import { bridge } from '../lib/bridge' +import { useNativeEvent } from '../lib/useNativeEvent' +import { t } from '../i18n' + +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 ( +
+
+ +
{t('platforms_title')}
+
+ + {installing && ( +
+
{t('platforms_installing', { name: installing })}
+
+
+
+
+ )} + + {available.map(p => { + const isActive = p.id === active + return ( +
+
+ {p.icon.startsWith('/') ? {p.name} : p.icon} +
+
+ {p.name} + {isActive && ( + + {t('platforms_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..366371f --- /dev/null +++ b/android/www/src/screens/SettingsStorage.tsx @@ -0,0 +1,125 @@ +import { useState, useEffect } from 'react' +import { useRoute } from '../lib/router' +import { bridge } from '../lib/bridge' +import { t } from '../i18n' + +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 ( +
+
+ +
{t('storage_title')}
+
+ + {info && ( + <> +
+ {t('storage_total')}{formatBytes(totalUsed)} +
+ +
+
+
+
{t('storage_bootstrap')}
+
{formatBytes(info.bootstrapBytes)}
+
+
+
+
+
+
+ +
+
+
+
{t('storage_www')}
+
{formatBytes(info.wwwBytes)}
+
+
+
+
+
+
+ +
+
+
+
{t('storage_free')}
+
{formatBytes(info.freeBytes)}
+
+
+
+ +
+ +
+ + )} + + {!info && ( +
+ {t('storage_loading')} +
+ )} +
+ ) +} 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..de39ff8 --- /dev/null +++ b/android/www/src/screens/SettingsUpdates.tsx @@ -0,0 +1,91 @@ +import { useState, useEffect, useCallback } from 'react' +import { useRoute } from '../lib/router' +import { bridge } from '../lib/bridge' +import { useNativeEvent } from '../lib/useNativeEvent' +import { t } from '../i18n' + +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 ( +
+
+ +
{t('updates_title')}
+
+ + {checking && ( +
+ {t('updates_checking')} +
+ )} + + {!checking && updates.length === 0 && ( +
+ {t('updates_up_to_date')} +
+ )} + + {updating && ( +
+
{t('updates_updating', { name: 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..e210207 --- /dev/null +++ b/android/www/src/screens/Setup.tsx @@ -0,0 +1,260 @@ +import { useState, useCallback, useEffect, Fragment } from 'react' +import { bridge } from '../lib/bridge' +import { useNativeEvent } from '../lib/useNativeEvent' +import { t } from '../i18n' + +interface Props { + onComplete: () => void +} + +type SetupPhase = 'platform-select' | 'tool-select' | 'installing' | 'done' + +interface Platform { + id: string + name: string + icon: string + desc: string +} + +function getOptionalTools() { + return [ + { id: 'tmux', name: 'tmux', desc: t('tool_tmux') }, + { id: 'ttyd', name: 'ttyd', desc: t('tool_ttyd') }, + { id: 'dufs', name: 'dufs', desc: t('tool_dufs') }, + { id: 'code-server', name: 'code-server', desc: t('tool_code_server') }, + { id: 'claude-code', name: 'Claude Code', desc: t('tool_claude_code') }, + { id: 'gemini-cli', name: 'Gemini CLI', desc: t('tool_gemini_cli') }, + { id: 'codex-cli', name: 'Codex CLI', desc: t('tool_codex_cli') }, + ] +} + +function getTips() { + return [ + t('tip_1'), + t('tip_2'), + t('tip_3'), + t('tip_4'), + ] +} + +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: '/openclaw.svg', 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) % getTips().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 = {} + getOptionalTools().forEach(tool => { + selections[tool.id] = selectedTools.has(tool.id) + }) + bridge.call('saveToolSelections', JSON.stringify(selections)) + + // Start bootstrap setup + setPhase('installing') + setProgress(0) + setMessage(t('setup_preparing')) + setError('') + bridge.call('startSetup') + } + + // --- Stepper --- + const currentStep = phase === 'platform-select' ? 0 + : phase === 'tool-select' ? 1 + : phase === 'installing' ? 2 : 3 + + const STEPS = [t('step_platform'), t('step_tools'), t('step_setup')] + + function renderStepper() { + return ( +
+ {STEPS.map((label, i) => ( + + {i > 0 &&
} +
+ {i < currentStep ? '✓' : i === currentStep ? '●' : '○'} + {label} +
+ + ))} +
+ ) + } + + // --- Platform Select --- + if (phase === 'platform-select') { + return ( +
+ {renderStepper()} +
{t('setup_choose_platform')}
+ + {platforms.map(p => ( +
handleSelectPlatform(p.id)} + > +
+ {p.icon.startsWith('/') ? ( + {p.name} + ) : p.icon} +
+
{p.name}
+
+ {p.desc} +
+
+ ))} + +
{t('setup_more_platforms')}
+
+ ) + } + + // --- Tool Select --- + if (phase === 'tool-select') { + return ( +
+ {renderStepper()} + +
{t('setup_optional_tools')}
+
+ {t('setup_tools_desc', { platform: selectedPlatform })} +
+ +
+ {getOptionalTools().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()} +
{t('setup_setting_up')}
+ +
+
+
+
+
+ {pct}% +
+
+ {message} +
+
+ + {error && ( +
{error}
+ )} + +
💡 {getTips()[tipIndex]}
+
+ ) + } + + // --- Done --- + return ( +
+ {renderStepper()} +
+
{t('setup_done_title')}
+
+ {t('setup_done_desc')} +
+ + +
+ ) +} diff --git a/android/www/src/styles/global.css b/android/www/src/styles/global.css new file mode 100644 index 0000000..920ce79 --- /dev/null +++ b/android/www/src/styles/global.css @@ -0,0 +1,447 @@ +* { + 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-danger { + background: #ef4444; + color: #fff; +} + +.btn-danger:active { + background: #dc2626; +} + +.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..7b3e79b --- /dev/null +++ b/android/www/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + base: './', + build: { + target: 'chrome67', + 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..54e302d Binary files /dev/null and b/android/www/www.zip differ diff --git a/bootstrap.sh b/bootstrap.sh new file mode 100755 index 0000000..67dd227 --- /dev/null +++ b/bootstrap.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# bootstrap.sh - Download and run OpenClaw on Android installer +# Usage: curl -sL https://raw.githubusercontent.com/AidanPark/openclaw-android/main/bootstrap.sh | bash +set -euo pipefail + +REPO_TARBALL="https://github.com/AidanPark/openclaw-android/archive/refs/heads/main.tar.gz" +INSTALL_DIR="$HOME/.openclaw-android/installer" + +RED='\033[0;31m' +BOLD='\033[1m' +NC='\033[0m' + +echo "" +echo -e "${BOLD}OpenClaw on Android - Bootstrap${NC}" +echo "" + +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 "Downloading installer..." +mkdir -p "$INSTALL_DIR" +curl -sfL "$REPO_TARBALL" | tar xz -C "$INSTALL_DIR" --strip-components=1 + +bash "$INSTALL_DIR/install.sh" + +cp "$INSTALL_DIR/uninstall.sh" "$HOME/.openclaw-android/uninstall.sh" +chmod +x "$HOME/.openclaw-android/uninstall.sh" +rm -rf "$INSTALL_DIR" diff --git a/docs/disable-phantom-process-killer.ko.md b/docs/disable-phantom-process-killer.ko.md new file mode 100644 index 0000000..62832e6 --- /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..6919b02 --- /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/openclaw-dashboard.png b/docs/images/openclaw-dashboard.png new file mode 100644 index 0000000..2a0f0d8 Binary files /dev/null and b/docs/images/openclaw-dashboard.png differ diff --git a/docs/images/openclaw-onboard.png b/docs/images/openclaw-onboard.png new file mode 100644 index 0000000..968c7fc Binary files /dev/null and b/docs/images/openclaw-onboard.png differ diff --git a/docs/images/openclaw.svg b/docs/images/openclaw.svg new file mode 100644 index 0000000..49746df --- /dev/null +++ b/docs/images/openclaw.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/images/openclaw_android.jpg b/docs/images/openclaw_android.jpg new file mode 100644 index 0000000..6bd1488 Binary files /dev/null and b/docs/images/openclaw_android.jpg differ diff --git a/docs/images/run_claude.png b/docs/images/run_claude.png new file mode 100644 index 0000000..2152a23 Binary files /dev/null and b/docs/images/run_claude.png differ diff --git a/docs/images/run_codex.png b/docs/images/run_codex.png new file mode 100644 index 0000000..b3851ff Binary files /dev/null and b/docs/images/run_codex.png differ diff --git a/docs/images/run_gemini.png b/docs/images/run_gemini.png new file mode 100644 index 0000000..38d5c53 Binary files /dev/null and b/docs/images/run_gemini.png differ 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/images/termux-ifconfig.png b/docs/images/termux-ifconfig.png new file mode 100644 index 0000000..f9f18ac Binary files /dev/null and b/docs/images/termux-ifconfig.png differ diff --git a/docs/images/termux_menu.png b/docs/images/termux_menu.png new file mode 100644 index 0000000..3095519 Binary files /dev/null and b/docs/images/termux_menu.png differ diff --git a/docs/images/termux_tab_1.png b/docs/images/termux_tab_1.png new file mode 100644 index 0000000..69757e1 Binary files /dev/null and b/docs/images/termux_tab_1.png differ diff --git a/docs/images/termux_tab_2.png b/docs/images/termux_tab_2.png new file mode 100644 index 0000000..7b56b5a Binary files /dev/null and b/docs/images/termux_tab_2.png differ diff --git a/docs/superpowers/plans/2026-03-31-playwright-install-option.md b/docs/superpowers/plans/2026-03-31-playwright-install-option.md new file mode 100644 index 0000000..a92dc03 --- /dev/null +++ b/docs/superpowers/plans/2026-03-31-playwright-install-option.md @@ -0,0 +1,258 @@ +# Playwright Install Option Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add Playwright as an optional tool in `oa --install`, with automatic Chromium dependency resolution, environment variable setup, and usage guide. + +**Architecture:** New `scripts/install-playwright.sh` follows the existing delegated-script pattern (like `install-chromium.sh`). `install-tools.sh` gets a new menu entry right after Chromium. The install script checks Chromium dependency, installs `playwright-core` via npm, sets Playwright environment variables in `.bashrc`, and prints a usage guide. + +**Tech Stack:** Bash, npm (playwright-core), Termux environment + +--- + +### Task 1: Create `scripts/install-playwright.sh` + +**Files:** +- Create: `scripts/install-playwright.sh` + +- [ ] **Step 1: Create the install script** + +```bash +#!/usr/bin/env bash +# install-playwright.sh - Install Playwright for browser automation +# Usage: bash install-playwright.sh [install|update] +# +# What it does: +# 1. Ensure Chromium is installed (dependency) +# 2. Install playwright-core via npm global +# 3. Set Playwright environment variables in .bashrc +# 4. Print usage guide +# +# This script is WARN-level: failure does not abort the parent installer. +set -euo pipefail + +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BOLD='\033[1m' +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 + +if ! command -v npm &>/dev/null; then + fail_warn "npm not found — Node.js is required for Playwright" +fi + +# ── Step 1: Ensure Chromium is installed ────── + +if ! CHROMIUM_BIN=$(detect_chromium_bin); then + echo "Chromium is required for Playwright. Installing..." + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + if [ -f "$SCRIPT_DIR/install-chromium.sh" ]; then + if ! bash "$SCRIPT_DIR/install-chromium.sh" install; then + fail_warn "Chromium installation failed — cannot proceed with Playwright" + fi + else + fail_warn "install-chromium.sh not found — install Chromium first" + fi + + if ! CHROMIUM_BIN=$(detect_chromium_bin); then + fail_warn "Chromium binary not found after installation" + fi +fi + +echo -e "${GREEN}[OK]${NC} Chromium found: $CHROMIUM_BIN" + +# ── Step 2: Install playwright-core ─────────── + +if [ "$MODE" = "install" ]; then + # Check if already installed + if npm list -g playwright-core &>/dev/null; then + echo -e "${GREEN}[SKIP]${NC} playwright-core already installed" + else + echo "Installing playwright-core..." + if ! npm install -g playwright-core; then + fail_warn "Failed to install playwright-core" + fi + echo -e "${GREEN}[OK]${NC} playwright-core installed" + fi +elif [ "$MODE" = "update" ]; then + echo "Updating playwright-core..." + if ! npm install -g playwright-core@latest; then + fail_warn "Failed to update playwright-core" + fi + echo -e "${GREEN}[OK]${NC} playwright-core updated" +fi + +# ── Step 3: Set environment variables ───────── + +BASHRC="$HOME/.bashrc" +PW_MARKER_START="# >>> Playwright >>>" +PW_MARKER_END="# <<< Playwright <<<" + +PW_BLOCK="${PW_MARKER_START} +export PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=\"$CHROMIUM_BIN\" +export PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 +${PW_MARKER_END}" + +touch "$BASHRC" +if grep -qF "$PW_MARKER_START" "$BASHRC"; then + sed -i "/${PW_MARKER_START//\//\\/}/,/${PW_MARKER_END//\//\\/}/d" "$BASHRC" +fi +echo "" >> "$BASHRC" +echo "$PW_BLOCK" >> "$BASHRC" + +echo -e "${GREEN}[OK]${NC} Environment variables set in .bashrc" +echo " PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=$CHROMIUM_BIN" +echo " PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1" + +# ── Step 4: Usage guide ────────────────────── + +echo "" +echo -e "${BOLD} Playwright is ready!${NC}" +echo "" +echo " To use in your project:" +echo "" +echo " npm install playwright-core # add to your project" +echo "" +echo " Example code:" +echo "" +echo " const { chromium } = require('playwright-core');" +echo "" +echo " const browser = await chromium.launch();" +echo " const page = await browser.newPage();" +echo " await page.goto('https://example.com');" +echo " await page.screenshot({ path: 'screenshot.png' });" +echo " await browser.close();" +echo "" +echo -e " ${YELLOW}[NOTE]${NC} Environment variables are set. No need to specify" +echo " executablePath or --no-sandbox manually." +echo "" +echo " To apply environment variables in current session:" +echo " source ~/.bashrc" +echo "" +``` + +Write this to `scripts/install-playwright.sh`. + +- [ ] **Step 2: Make the script executable** + +Run: `chmod +x scripts/install-playwright.sh` + +--- + +### Task 2: Add Playwright to `install-tools.sh` + +**Files:** +- Modify: `install-tools.sh:79` (tool detection) +- Modify: `install-tools.sh:118` (flag declaration) +- Modify: `install-tools.sh:124` (user prompt — after Chromium) +- Modify: `install-tools.sh:135-137` (anything_selected check) +- Modify: `install-tools.sh:152` (NEEDS_TARBALL condition) +- Modify: `install-tools.sh:202-208` (installation phase — after Chromium) + +- [ ] **Step 1: Add Playwright to tool detection** + +After line 79 (`check_tool "Chromium" "chromium-browser"`), detect playwright-core via npm: + +```bash +# Playwright detection — check via npm since it's a global npm package +if npm list -g playwright-core &>/dev/null 2>&1; then + TOOL_STATUS["Playwright"]="installed" + echo -e " ${GREEN}[INSTALLED]${NC} Playwright" +else + TOOL_STATUS["Playwright"]="not_installed" + echo -e " ${YELLOW}[NOT INSTALLED]${NC} Playwright" +fi +``` + +- [ ] **Step 2: Add flag declaration** + +After `INSTALL_CHROMIUM=false` (line 118), add: + +```bash +INSTALL_PLAYWRIGHT=false +``` + +- [ ] **Step 3: Add user prompt after Chromium prompt** + +After the Chromium prompt line (line 124), add: + +```bash +if [ "${TOOL_STATUS[Playwright]}" = "not_installed" ] && ask_yn " Install Playwright (browser automation library, requires Chromium)?"; then INSTALL_PLAYWRIGHT=true; fi +``` + +- [ ] **Step 4: Add to anything_selected check** + +Add `INSTALL_PLAYWRIGHT` to the for loop on line 135-137: + +```bash +for var in INSTALL_TMUX INSTALL_TTYD INSTALL_DUFS INSTALL_ANDROID_TOOLS \ + INSTALL_CHROMIUM INSTALL_PLAYWRIGHT INSTALL_CODE_SERVER INSTALL_OPENCODE INSTALL_CLAUDE_CODE \ + INSTALL_GEMINI_CLI INSTALL_CODEX_CLI; do +``` + +- [ ] **Step 5: Add NEEDS_TARBALL condition** + +On line 152, add `INSTALL_PLAYWRIGHT` to the condition: + +```bash +if [ "$INSTALL_CODE_SERVER" = true ] || [ "$INSTALL_OPENCODE" = true ] || [ "$INSTALL_CHROMIUM" = true ] || [ "$INSTALL_PLAYWRIGHT" = true ]; then +``` + +- [ ] **Step 6: Add installation block after Chromium** + +After the Chromium installation block (after line 208), add: + +```bash +if [ "$INSTALL_PLAYWRIGHT" = true ]; then + if bash "$RELEASE_TMP/scripts/install-playwright.sh" install; then + echo -e "${GREEN}[OK]${NC} Playwright installed" + else + echo -e "${YELLOW}[WARN]${NC} Playwright installation failed (non-critical)" + fi +fi +``` + +--- + +### Task 3: Verify + +- [ ] **Step 1: Syntax check install-playwright.sh** + +Run: `bash -n scripts/install-playwright.sh` +Expected: no output (no syntax errors) + +- [ ] **Step 2: Syntax check install-tools.sh** + +Run: `bash -n install-tools.sh` +Expected: no output (no syntax errors) + +- [ ] **Step 3: Verify Playwright detection logic handles missing npm gracefully** + +Run: `grep -n "npm list -g playwright-core" install-tools.sh` +Expected: the detection line appears in the tool detection section diff --git a/docs/termux-ssh-guide.ko.md b/docs/termux-ssh-guide.ko.md new file mode 100644 index 0000000..ab9868a --- /dev/null +++ b/docs/termux-ssh-guide.ko.md @@ -0,0 +1,77 @@ +# Termux SSH 접속 가이드 + +폰의 Termux에 컴퓨터에서 SSH로 접속하면, 컴퓨터 키보드로 모든 명령어를 입력할 수 있습니다. + +## 준비물 + +- 폰과 컴퓨터가 **같은 Wi-Fi 네트워크**에 연결되어 있어야 합니다 + +## 1단계: openssh 설치 + +폰에서 Termux 앱을 열고 입력합니다: + +```bash +pkg install -y openssh +``` + +설치가 완료될 때까지 기다리세요 (1~2분). + +## 2단계: 비밀번호 설정 + +```bash +passwd +``` + +비밀번호를 입력합니다 (예: `1234`): + +``` +New password: 1234 ← 입력 +Retype new password: 1234 ← 같은 비밀번호 다시 입력 +``` + +> 비밀번호 입력 시 화면에 아무것도 표시되지 않는 것이 정상입니다. 그냥 입력하고 Enter를 누르면 됩니다. + +## 3단계: SSH 서버 시작 + +> **중요**: `sshd`는 SSH가 아닌, 폰의 Termux 앱에서 직접 실행하세요. + +```bash +sshd +``` + +아무 메시지 없이 프롬프트(`$`)가 다시 나오면 정상입니다. + +sshd 실행 화면 + +## 4단계: IP 주소 확인 + +```bash +ifconfig +``` + +`wlan0` 항목을 찾으세요: + +``` +wlan0: flags=4163 mtu 1500 + inet 192.168.45.139 netmask 255.255.255.0 +``` + +`inet` 뒤의 숫자가 폰의 IP 주소입니다 (위 예시에서는 `192.168.45.139`). + +## 5단계: 컴퓨터에서 SSH 접속 + +컴퓨터 터미널(맥: 터미널, 윈도우: PowerShell 또는 명령 프롬프트)을 열고 입력합니다. IP 주소는 4단계에서 확인한 값으로 변경하세요: + +```bash +ssh -p 8022 192.168.45.139 +``` + +- `Are you sure you want to continue connecting?` → `yes` 입력 +- `Password:` → 2단계에서 설정한 비밀번호 입력 (예: `1234`) + +접속 성공하면 Termux의 `$` 프롬프트가 나타납니다. 이제부터 컴퓨터 키보드로 모든 Termux 명령어를 입력할 수 있습니다. + +## 참고 사항 + +- Termux의 SSH 포트는 **8022**입니다 (일반 리눅스의 22가 아님) +- Termux 앱을 종료하면 SSH 서버도 꺼집니다. 다시 접속하려면 폰에서 Termux를 열고 `sshd`를 실행하세요 diff --git a/docs/termux-ssh-guide.md b/docs/termux-ssh-guide.md new file mode 100644 index 0000000..a2600d0 --- /dev/null +++ b/docs/termux-ssh-guide.md @@ -0,0 +1,77 @@ +# Termux SSH Setup Guide + +By connecting to Termux via SSH from your computer, you can type all commands using your computer keyboard. + +## Prerequisites + +- Both phone and computer must be on the **same Wi-Fi network** + +## Step 1: Install openssh + +Open the Termux app on your phone and type: + +```bash +pkg install -y openssh +``` + +Wait for the installation to complete (1-2 minutes). + +## Step 2: Set Password + +```bash +passwd +``` + +Enter a password (e.g., `1234`): + +``` +New password: 1234 ← type +Retype new password: 1234 ← type the same password again +``` + +> It's normal that nothing shows on screen while typing the password. Just type it and press Enter. + +## Step 3: Start SSH Server + +> **Important**: Run `sshd` directly in the Termux app on your phone, not via SSH. + +```bash +sshd +``` + +If the prompt (`$`) returns with no error message, it's working. + +sshd running in Termux + +## Step 4: Find the Phone's IP Address + +```bash +ifconfig +``` + +Look for the `wlan0` section: + +``` +wlan0: flags=4163 mtu 1500 + inet 192.168.45.139 netmask 255.255.255.0 +``` + +The number after `inet` is your phone's IP address (in this example, `192.168.45.139`). + +## Step 5: Connect via SSH from Computer + +Open a terminal on your computer (Mac: Terminal, Windows: PowerShell or Command Prompt) and type. Replace the IP address with the one you found in Step 4: + +```bash +ssh -p 8022 192.168.45.139 +``` + +- `Are you sure you want to continue connecting?` → type `yes` +- `Password:` → enter the password you set in Step 2 (e.g., `1234`) + +Once connected, you'll see the Termux `$` prompt. From now on, you can type all Termux commands using your computer keyboard. + +## Notes + +- Termux uses SSH port **8022** (not the standard Linux port 22) +- If you close the Termux app, the SSH server stops. To reconnect, open Termux on the phone and run `sshd` diff --git a/docs/troubleshooting.ko.md b/docs/troubleshooting.ko.md new file mode 100644 index 0000000..e130f28 --- /dev/null +++ b/docs/troubleshooting.ko.md @@ -0,0 +1,304 @@ +# 트러블슈팅 + +Termux에서 OpenClaw 사용 중 발생할 수 있는 문제와 해결 방법을 정리합니다. + +## 게이트웨이가 시작되지 않음: "gateway already running" 또는 "Port is already in use" + +``` +Gateway failed to start: gateway already running (pid XXXXX); lock timeout after 5000ms +Port 18789 is already in use. +``` + +### 원인 + +이전 게이트웨이 프로세스가 비정상 종료되면서 잠금 파일이 남아있거나, 프로세스가 좀비 상태로 남아있는 경우 발생합니다. 주로 다음 상황에서 일어납니다: + +- SSH 연결이 끊어지면서 게이트웨이 프로세스가 고아(orphan) 상태로 남음 +- `Ctrl+Z`(일시정지)로 중단한 경우 프로세스가 종료되지 않고 백그라운드에 남음 +- Termux가 Android에 의해 강제 종료된 경우 + +> **참고**: 게이트웨이를 종료할 때는 반드시 `Ctrl+C`를 사용하세요. `Ctrl+Z`는 프로세스를 일시정지시킬 뿐 종료하지 않습니다. + +### 해결 방법 + +#### 1단계: 남아있는 프로세스 확인 및 종료 + +```bash +ps aux | grep -E "node|openclaw" | grep -v grep +``` + +프로세스가 보이면 PID를 확인하고 종료: + +```bash +kill -9 +``` + +#### 2단계: 잠금 파일 삭제 + +```bash +rm -rf $PREFIX/tmp/openclaw-* +``` + +#### 3단계: 게이트웨이 재시작 + +```bash +openclaw gateway +``` + +### 그래도 안 되면 + +위 과정으로도 해결되지 않으면 Termux 앱을 완전히 종료했다가 다시 열고 `openclaw gateway`를 실행하세요. 폰을 재시작하면 확실하게 모든 상태가 초기화됩니다. + +## 게이트웨이 연결 끊김: "gateway not connected" + +``` +send failed: Error: gateway not connected +disconnected | error +``` + +### 원인 + +게이트웨이 프로세스가 종료되었거나 SSH 세션이 끊어진 경우 발생합니다. + +### 해결 방법 + +게이트웨이를 실행했던 SSH 세션을 확인하세요. 세션이 끊어졌다면 다시 SSH 접속 후 게이트웨이를 시작합니다: + +```bash +openclaw gateway +``` + +"gateway already running" 에러가 나오면 위의 [게이트웨이가 시작되지 않음](#게이트웨이가-시작되지-않음-gateway-already-running-또는-port-is-already-in-use) 섹션을 참고하세요. + +## SSH 접속 실패: "Connection refused" + +``` +ssh: connect to host 192.168.45.139 port 8022: Connection refused +``` + +### 원인 + +Termux의 SSH 서버(`sshd`)가 실행되지 않은 상태입니다. Termux 앱을 종료하거나 폰을 재시작하면 sshd가 꺼집니다. + +### 해결 방법 + +폰에서 Termux 앱을 열고 `sshd`를 실행하세요. 폰에서 직접 타이핑하거나 adb로 전송: + +```bash +adb shell input text 'sshd' +``` +```bash +adb shell input keyevent 66 +``` + +IP가 변경되었을 수 있으니 확인: + +```bash +adb shell input text 'ifconfig' +``` +```bash +adb shell input keyevent 66 +``` + +> 매번 수동으로 `sshd`를 실행하기 번거로우면 `~/.bashrc` 맨 아래에 `sshd 2>/dev/null`을 추가하면 Termux 시작 시 자동으로 SSH 서버가 켜집니다. + +## `openclaw --version` 실패 + +### 원인 + +환경변수가 로드되지 않은 상태입니다. + +### 해결 방법 + +```bash +source ~/.bashrc +``` + +또는 Termux 앱을 완전히 종료했다가 다시 여세요. + +## "Cannot find module glibc-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"였던 이전 버전에서 업데이트한 경우 발생합니다. + +### 해결 방법 + +업데이터를 실행하면 환경변수 블록이 갱신됩니다: + +```bash +oa --update && source ~/.bashrc +``` + +또는 수동으로 수정: + +```bash +sed -i 's/\.openclaw-lite/\.openclaw-android/g' ~/.bashrc && source ~/.bashrc +``` + +## 업데이트 중 "systemctl --user unavailable: spawn systemctl ENOENT" 에러 + +``` +Gateway service check failed: Error: systemctl --user unavailable: spawn systemctl ENOENT +``` + +### 원인 + +`openclaw update` 실행 후, OpenClaw이 `systemctl`로 게이트웨이 서비스를 재시작하려고 합니다. Termux에는 systemd가 없으므로 `systemctl` 바이너리를 찾을 수 없어 `ENOENT` 에러가 발생합니다. + +### 영향 + +**이 에러는 무해합니다.** 업데이트 자체는 이미 성공적으로 완료되었으며, 자동 서비스 재시작만 실패한 것입니다. OpenClaw은 최신 상태로 업데이트되어 있습니다. + +### 해결 방법 + +수동으로 게이트웨이를 시작하면 됩니다: + +```bash +openclaw gateway +``` + +업데이트 전에 게이트웨이가 실행 중이었다면 기존 프로세스를 먼저 종료해야 할 수 있습니다. 위의 [게이트웨이가 시작되지 않음](#게이트웨이가-시작되지-않음-gateway-already-running-또는-port-is-already-in-use) 섹션을 참고하세요. + +## `openclaw update` 중 sharp 빌드 실패 + +``` +npm error gyp ERR! not ok +Update Result: ERROR +Reason: global update +``` + +### 원인 + +**v1.0.0+(glibc)**: `sharp` 모듈은 프리빌트 바이너리(`@img/sharp-linux-arm64`)를 사용하며 glibc 환경에서 네이티브로 로딩됩니다. 이 에러는 드문 — 주로 프리빌트 바이너리가 누락되거나 손상된 경우입니다. + +**v1.0.0 이전(Bionic)**: `openclaw update`가 npm을 서브프로세스로 실행할 때, Termux 전용 빌드 환경변수(`CXXFLAGS`, `GYP_DEFINES`)가 서브프로세스 환경에서 사용 불가하여 네이티브 모듈 컴파일이 실패합니다. + +### 영향 + +**이 에러는 무해합니다.** OpenClaw 자체는 정상적으로 업데이트되었으며, `sharp` 모듈(이미지 처리용)만 리빌드에 실패한 것입니다. OpenClaw는 sharp 없이도 정상적으로 작동합니다. + +### 해결 방법 + +업데이트 후 아래 스크립트로 sharp를 수동 빌드하세요: + +```bash +bash ~/.openclaw-android/scripts/build-sharp.sh +``` + +또는 `openclaw update` 대신 `oa --update`를 사용하면 sharp를 자동으로 처리합니다: + +```bash +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" 에러 + +``` +Gateway status failed: Error: Gateway service install not supported on android +``` + +> **참고**: 이 문제는 v1.0.0 이전(Bionic) 설치에서만 발생합니다. v1.0.0+(glibc)에서는 Node.js가 `process.platform`을 `'linux'`으로 보고하므로 이 에러가 발생하지 않습니다. + +### 원인 + +**v1.0.0 이전(Bionic)**: `glibc-compat.js`의 `process.platform` 오버라이드가 적용되지 않은 상태입니다. `NODE_OPTIONS`가 설정되지 않았기 때문입니다. + +### 해결 방법 + +어떤 Node.js가 사용되고 있는지 확인: + +```bash +node -e "console.log(process.platform)" +``` + +`android`가 출력되면 glibc node 래퍼가 사용되지 않고 있는 것입니다. 환경변수를 로드하세요: + +```bash +source ~/.bashrc +``` + +여전히 `android`가 출력되면, 최신 버전으로 업데이트하세요 (v1.0.0+는 glibc를 사용하여 이 문제를 영구적으로 해결합니다): + +```bash +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 래퍼가 실행을 처리합니다. + +### 해결 방법 + +조치가 필요 없습니다. OpenCode가 정상 작동하는지 확인: + +```bash +opencode --version +``` diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..e8266ab --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,304 @@ +# Troubleshooting + +Common issues and solutions when using OpenClaw on Termux. + +## Gateway won't start: "gateway already running" or "Port is already in use" + +``` +Gateway failed to start: gateway already running (pid XXXXX); lock timeout after 5000ms +Port 18789 is already in use. +``` + +### Cause + +A previous gateway process was terminated abnormally, leaving behind a lock file or a zombie process. This typically happens when: + +- SSH connection drops, leaving the gateway process orphaned +- `Ctrl+Z` (suspend) was used instead of `Ctrl+C` (terminate), leaving the process alive in the background +- Termux was force-killed by Android + +> **Note**: Always use `Ctrl+C` to stop the gateway. `Ctrl+Z` only suspends the process — it does not terminate it. + +### Solution + +#### Step 1: Find and kill remaining processes + +```bash +ps aux | grep -E "node|openclaw" | grep -v grep +``` + +If processes are listed, note the PID and kill them: + +```bash +kill -9 +``` + +#### Step 2: Remove lock files + +```bash +rm -rf $PREFIX/tmp/openclaw-* +``` + +#### Step 3: Restart the gateway + +```bash +openclaw gateway +``` + +### If it still doesn't work + +If the above steps don't help, fully close and reopen the Termux app, then run `openclaw gateway`. Rebooting the phone will reliably clear all state. + +## Gateway disconnected: "gateway not connected" + +``` +send failed: Error: gateway not connected +disconnected | error +``` + +### Cause + +The gateway process has stopped or the SSH session was disconnected. + +### Solution + +Check the SSH session where the gateway was running. If the session was disconnected, reconnect via SSH and start the gateway: + +```bash +openclaw gateway +``` + +If you get a "gateway already running" error, see the [Gateway won't start](#gateway-wont-start-gateway-already-running-or-port-is-already-in-use) section above. + +## SSH connection failed: "Connection refused" + +``` +ssh: connect to host 192.168.45.139 port 8022: Connection refused +``` + +### Cause + +The Termux SSH server (`sshd`) is not running. Closing the Termux app or rebooting the phone stops sshd. + +### Solution + +Open the Termux app on the phone and run `sshd`. Either type directly on the phone or send via adb: + +```bash +adb shell input text 'sshd' +``` +```bash +adb shell input keyevent 66 +``` + +The IP address may have changed, so verify: + +```bash +adb shell input text 'ifconfig' +``` +```bash +adb shell input keyevent 66 +``` + +> To start sshd automatically, add `sshd 2>/dev/null` to the end of your `~/.bashrc` file so the SSH server starts whenever Termux opens. + +## `openclaw --version` fails + +### Cause + +Environment variables are not loaded. + +### Solution + +```bash +source ~/.bashrc +``` + +Or fully close and reopen the Termux app. + +## "Cannot find module glibc-compat.js" error + +``` +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". + +### Solution + +Run the updater to refresh the environment variable block: + +```bash +oa --update && source ~/.bashrc +``` + +Or manually fix it: + +```bash +sed -i 's/\.openclaw-lite/\.openclaw-android/g' ~/.bashrc && source ~/.bashrc +``` + +## "systemctl --user unavailable: spawn systemctl ENOENT" during update + +``` +Gateway service check failed: Error: systemctl --user unavailable: spawn systemctl ENOENT +``` + +### Cause + +After running `openclaw update`, OpenClaw tries to restart the gateway service using `systemctl`. Since Termux doesn't have systemd, the `systemctl` binary doesn't exist and the command fails with `ENOENT`. + +### Impact + +**This error is harmless.** The update itself has already completed successfully — only the automatic service restart failed. Your OpenClaw installation is up to date. + +### Solution + +Simply start the gateway manually: + +```bash +openclaw gateway +``` + +If the gateway was already running before the update, you may need to stop the old process first. See the [Gateway won't start](#gateway-wont-start-gateway-already-running-or-port-is-already-in-use) section above. + +## sharp build fails during `openclaw update` + +``` +npm error gyp ERR! not ok +Update Result: ERROR +Reason: global update +``` + +### Cause + +**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 + +**This error is non-critical.** OpenClaw itself has been updated successfully — only the `sharp` module (used for image processing) failed to rebuild. OpenClaw works normally without it. + +### Solution + +After the update, manually rebuild `sharp` using the provided script: + +```bash +bash ~/.openclaw-android/scripts/build-sharp.sh +``` + +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 +cd $(npm root -g)/clawdhub && npm install undici +``` + +## "not supported on android" error + +``` +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 + +**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 which Node.js is being used: + +```bash +node -e "console.log(process.platform)" +``` + +If it prints `android`, the glibc node wrapper is not being used. Load the environment: + +```bash +source ~/.bashrc +``` + +If it still prints `android`, update to the latest version (v1.0.0+ uses glibc and resolves this permanently): + +```bash +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 + +No action needed. Verify that OpenCode works: + +```bash +opencode --version +``` diff --git a/install-tools.sh b/install-tools.sh new file mode 100644 index 0000000..9b360c9 --- /dev/null +++ b/install-tools.sh @@ -0,0 +1,251 @@ +#!/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.27" +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 +command -v resolve_npm_registry >/dev/null 2>&1 && resolve_npm_registry || true + +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" +if command -v npm &>/dev/null && npm list -g playwright-core &>/dev/null 2>&1; then + TOOL_STATUS["Playwright"]="installed" + echo -e " ${GREEN}[INSTALLED]${NC} Playwright" +else + TOOL_STATUS["Playwright"]="not_installed" + echo -e " ${YELLOW}[NOT INSTALLED]${NC} Playwright" +fi +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 (Termux)" "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 +INSTALL_PLAYWRIGHT=false + +if [ "${TOOL_STATUS[tmux]}" = "not_installed" ] && ask_yn " Install tmux (terminal multiplexer)?"; then INSTALL_TMUX=true; fi +if [ "${TOOL_STATUS[ttyd]}" = "not_installed" ] && ask_yn " Install ttyd (web terminal)?"; then INSTALL_TTYD=true; fi +if [ "${TOOL_STATUS[dufs]}" = "not_installed" ] && ask_yn " Install dufs (file server)?"; then INSTALL_DUFS=true; fi +if [ "${TOOL_STATUS[android-tools]}" = "not_installed" ] && ask_yn " Install android-tools (adb)?"; then INSTALL_ANDROID_TOOLS=true; fi +if [ "${TOOL_STATUS[Chromium]}" = "not_installed" ] && ask_yn " Install Chromium (browser automation, ~400MB)?"; then INSTALL_CHROMIUM=true; fi +if [ "${TOOL_STATUS[Playwright]}" = "not_installed" ] && ask_yn " Install Playwright (browser automation library, requires Chromium)?"; then INSTALL_PLAYWRIGHT=true; fi +if [ "${TOOL_STATUS[code-server]}" = "not_installed" ] && ask_yn " Install code-server (browser IDE)?"; then INSTALL_CODE_SERVER=true; fi +if [ "$IS_GLIBC" = true ] && [ "${TOOL_STATUS[OpenCode]}" = "not_installed" ]; then + if ask_yn " Install OpenCode (AI coding assistant)?"; then INSTALL_OPENCODE=true; fi +fi +if [ "${TOOL_STATUS[Claude Code]}" = "not_installed" ] && ask_yn " Install Claude Code CLI?"; then INSTALL_CLAUDE_CODE=true; fi +if [ "${TOOL_STATUS[Gemini CLI]}" = "not_installed" ] && ask_yn " Install Gemini CLI?"; then INSTALL_GEMINI_CLI=true; fi +if [ "${TOOL_STATUS[Codex CLI (Termux)]}" = "not_installed" ] && ask_yn " Install Codex CLI (Termux)?"; then INSTALL_CODEX_CLI=true; fi + +# --- Check if anything selected --- +ANYTHING_SELECTED=false +for var in INSTALL_TMUX INSTALL_TTYD INSTALL_DUFS INSTALL_ANDROID_TOOLS \ + INSTALL_CHROMIUM INSTALL_PLAYWRIGHT 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 ] || [ "$INSTALL_PLAYWRIGHT" = 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 "" + +if [ "$INSTALL_TMUX" = true ]; then echo "Installing tmux..."; if pkg install -y tmux; then echo -e "${GREEN}[OK]${NC} tmux installed"; fi; fi +if [ "$INSTALL_TTYD" = true ]; then echo "Installing ttyd..."; if pkg install -y ttyd; then echo -e "${GREEN}[OK]${NC} ttyd installed"; fi; fi +if [ "$INSTALL_DUFS" = true ]; then echo "Installing dufs..."; if pkg install -y dufs; then echo -e "${GREEN}[OK]${NC} dufs installed"; fi; fi +if [ "$INSTALL_ANDROID_TOOLS" = true ]; then echo "Installing android-tools..."; if pkg install -y android-tools; then echo -e "${GREEN}[OK]${NC} android-tools installed"; fi; fi + +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 + +if [ "$INSTALL_PLAYWRIGHT" = true ]; then + if bash "$RELEASE_TMP/scripts/install-playwright.sh" install; then + echo -e "${GREEN}[OK]${NC} Playwright installed" + else + echo -e "${YELLOW}[WARN]${NC} Playwright installation failed (non-critical)" + fi +fi + +if [ "$INSTALL_CLAUDE_CODE" = true ]; then echo "Installing Claude Code..."; if npm install -g @anthropic-ai/claude-code; then echo -e "${GREEN}[OK]${NC} Claude Code installed"; fi; fi +if [ "$INSTALL_GEMINI_CLI" = true ]; then echo "Installing Gemini CLI..."; if npm install -g @google/gemini-cli; then echo -e "${GREEN}[OK]${NC} Gemini CLI installed"; fi; fi +if [ "$INSTALL_CODEX_CLI" = true ]; then + npm uninstall -g @openai/codex 2>/dev/null || true + echo "Installing Codex CLI (Termux)..." + if npm install -g @mmmbuto/codex-cli-termux; then + # Create codex CLI wrapper (DioNanos fork launcher fix) + _codex_bin="$PREFIX/bin/codex" + _codex_pkg="$PREFIX/lib/node_modules/@mmmbuto/codex-cli-termux/bin" + if [ -f "$_codex_pkg/codex.bin" ]; then + [ -L "$_codex_bin" ] && rm -f "$_codex_bin" + printf '#!%s/bin/bash\nPKG_BIN="%s"\nexport LD_LIBRARY_PATH="$PKG_BIN:${LD_LIBRARY_PATH:-}"\nexec "$PKG_BIN/codex.bin" "$@"\n' \ + "$PREFIX" "$_codex_pkg" > "$_codex_bin" + chmod +x "$_codex_bin" + fi + echo -e "${GREEN}[OK]${NC} Codex CLI (Termux) installed" + fi +fi + +command -v fix_npm_global_shebangs >/dev/null 2>&1 && fix_npm_global_shebangs || true + +echo "" +echo -e "${GREEN}${BOLD} Installation Complete!${NC}" +echo "" diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..f06438e --- /dev/null +++ b/install.sh @@ -0,0 +1,152 @@ +#!/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}========================================${NC}" +echo -e "${BOLD} OpenClaw on Android - Installer v${OA_VERSION}${NC}" +echo -e "${BOLD}========================================${NC}" +echo "" +echo "This script installs OpenClaw on Termux with platform-aware architecture." +echo "" + +step() { + echo "" + 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 "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 (Termux)?"; 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 5 "Platform Runtime Dependencies (L2)" +if [ "${PLATFORM_NEEDS_GLIBC:-false}" = true ]; then bash "$SCRIPT_DIR/scripts/install-glibc.sh"; fi +if [ "${PLATFORM_NEEDS_NODEJS:-false}" = true ]; then bash "$SCRIPT_DIR/scripts/install-nodejs.sh"; fi +if [ "${PLATFORM_NEEDS_BUILD_TOOLS:-false}" = true ]; then bash "$SCRIPT_DIR/scripts/install-build-tools.sh"; fi +if [ "${PLATFORM_NEEDS_PROOT:-false}" = true ]; then pkg install -y proot; fi + +# Source environment for current session (needed by platform install) +GLIBC_BIN_DIR="$PROJECT_DIR/bin" +GLIBC_NODE_DIR="$PROJECT_DIR/node" +export PATH="$GLIBC_BIN_DIR:$GLIBC_NODE_DIR/bin:$HOME/.local/bin:$PATH" +export TMPDIR="$PREFIX/tmp" +export TMP="$TMPDIR" +export TEMP="$TMPDIR" +export OA_GLIBC=1 + +# Auto-detect npm registry (session-scoped via NPM_CONFIG_REGISTRY env var). +# Does NOT write to ~/.npmrc — see CHANGELOG v1.0.24. +command -v resolve_npm_registry >/dev/null 2>&1 && resolve_npm_registry || true + +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 + +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" + +cp "$SCRIPT_DIR/uninstall.sh" "$PROJECT_DIR/uninstall.sh" +chmod +x "$PROJECT_DIR/uninstall.sh" + +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" +cp "$SCRIPT_DIR/scripts/backup.sh" "$PROJECT_DIR/scripts/backup.sh" +rm -rf "$PROJECT_DIR/platforms/$SELECTED_PLATFORM" +cp -R "$SCRIPT_DIR/platforms/$SELECTED_PLATFORM" "$PROJECT_DIR/platforms/$SELECTED_PLATFORM" + +step 7 "Install Optional Tools (L3)" +if [ "$INSTALL_TMUX" = true ]; then pkg install -y tmux; fi +if [ "$INSTALL_TTYD" = true ]; then pkg install -y ttyd; fi +if [ "$INSTALL_DUFS" = true ]; then pkg install -y dufs; fi +if [ "$INSTALL_ANDROID_TOOLS" = true ]; then pkg install -y android-tools; fi + +if [ "$INSTALL_CHROMIUM" = true ]; then bash "$SCRIPT_DIR/scripts/install-chromium.sh" install; fi + +if [ "$INSTALL_CODE_SERVER" = true ]; then 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; fi + +if [ "$INSTALL_OPENCODE" = true ]; then bash "$SCRIPT_DIR/scripts/install-opencode.sh" install; fi + +if [ "$INSTALL_CLAUDE_CODE" = true ]; then npm install -g @anthropic-ai/claude-code; fi +if [ "$INSTALL_GEMINI_CLI" = true ]; then npm install -g @google/gemini-cli; fi +if [ "$INSTALL_CODEX_CLI" = true ]; then + npm install -g @mmmbuto/codex-cli-termux + # Create codex CLI wrapper (DioNanos fork launcher fix) + _codex_bin="$PREFIX/bin/codex" + _codex_pkg="$PREFIX/lib/node_modules/@mmmbuto/codex-cli-termux/bin" + if [ -f "$_codex_pkg/codex.bin" ]; then + [ -L "$_codex_bin" ] && rm -f "$_codex_bin" + printf '#!%s/bin/bash\nPKG_BIN="%s"\nexport LD_LIBRARY_PATH="$PKG_BIN:${LD_LIBRARY_PATH:-}"\nexec "$PKG_BIN/codex.bin" "$@"\n' \ + "$PREFIX" "$_codex_pkg" > "$_codex_bin" + chmod +x "$_codex_bin" + fi +fi + +command -v fix_npm_global_shebangs >/dev/null 2>&1 && fix_npm_global_shebangs || 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 " $PLATFORM_NAME $($PLATFORM_VERSION_CMD 2>/dev/null || echo '')" +echo "" +echo "Next step:" +echo " $PLATFORM_POST_INSTALL_MSG" +echo "" diff --git a/oa.sh b/oa.sh new file mode 100644 index 0000000..ae413e0 --- /dev/null +++ b/oa.sh @@ -0,0 +1,248 @@ +#!/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" + # shellcheck source=/dev/null + if [ -f "$HOME/.openclaw-android/scripts/backup.sh" ]; then + source "$HOME/.openclaw-android/scripts/backup.sh" + fi +else + OA_VERSION="1.0.27" + RED='\033[0;31m' + GREEN='\033[0;32m' + YELLOW='\033[1;33m' + BOLD='\033[1m' + NC='\033[0m' + REPO_BASE_ORIGIN="https://raw.githubusercontent.com/AidanPark/openclaw-android/main" + REPO_BASE="$REPO_BASE_ORIGIN" + PLATFORM_MARKER="$PROJECT_DIR/.platform" + + detect_platform() { + if [ -f "$PLATFORM_MARKER" ]; then + cat "$PLATFORM_MARKER" + return 0 + fi + return 1 + } + + resolve_repo_base() { + if curl -sI --connect-timeout 3 "$REPO_BASE_ORIGIN/oa.sh" >/dev/null 2>&1; then + REPO_BASE="$REPO_BASE_ORIGIN"; return 0 + fi + local mirrors=( + "https://ghfast.top/$REPO_BASE_ORIGIN" + "https://ghproxy.net/$REPO_BASE_ORIGIN" + "https://mirror.ghproxy.com/$REPO_BASE_ORIGIN" + ) + for m in "${mirrors[@]}"; do + if curl -sI --connect-timeout 3 "$m/oa.sh" >/dev/null 2>&1; then + echo -e " ${YELLOW}[MIRROR]${NC} Using mirror for GitHub downloads" + REPO_BASE="$m"; return 0 + fi + done + 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 " --backup Create a full backup of OpenClaw data" + echo " --restore Restore from a backup" + 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 "${TMPDIR:-${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}" + exit 0 +} + +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 "${TMPDIR:-${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" +} + +# Resolve mirror before any network operation +case "${1:-}" in + --update|--install) + resolve_repo_base || true + command -v resolve_npm_registry >/dev/null 2>&1 && resolve_npm_registry || true + ;; + --version|-v|--uninstall) + resolve_repo_base || true + ;; +esac + +case "${1:-}" in + --update) + cmd_update + ;; + --install) + cmd_install + ;; + --uninstall) + cmd_uninstall + ;; + --backup) + if declare -f cmd_backup > /dev/null 2>&1; then + cmd_backup "${2:-}" + else + echo -e "${RED}[FAIL]${NC} backup.sh not found. Run: oa --update" + exit 1 + fi + ;; + --restore) + if declare -f cmd_restore > /dev/null 2>&1; then + cmd_restore + else + echo -e "${RED}[FAIL]${NC} backup.sh not found. Run: oa --update" + exit 1 + fi + ;; + --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 new file mode 100755 index 0000000..50d5b29 --- /dev/null +++ b/patches/apply-patches.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# apply-patches.sh - Apply all patches for OpenClaw on Termux (glibc architecture) +set -euo pipefail + +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PATCH_DEST="$HOME/.openclaw-android/patches" +LOG_FILE="$HOME/.openclaw-android/patch.log" + +echo "=== Applying Patches ===" +echo "" + +# Ensure destination exists +mkdir -p "$PATCH_DEST" + +# Start logging +echo "Patch application started: $(date)" > "$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} glibc-compat.js not found in $SCRIPT_DIR" + echo " FAILED: glibc-compat.js not found" >> "$LOG_FILE" + exit 1 +fi + +# 2. Install systemctl stub (Termux has no systemd) +if [ -f "$SCRIPT_DIR/systemctl" ]; then + cp "$SCRIPT_DIR/systemctl" "$PREFIX/bin/systemctl" + chmod +x "$PREFIX/bin/systemctl" + echo -e "${GREEN}[OK]${NC} Installed systemctl stub to $PREFIX/bin/" + echo " Installed systemctl stub" >> "$LOG_FILE" +else + echo -e "${RED}[FAIL]${NC} systemctl stub not found in $SCRIPT_DIR" + echo " FAILED: systemctl not found" >> "$LOG_FILE" + exit 1 +fi + +# 3. Run path patches +echo "" +if [ -f "$SCRIPT_DIR/patch-paths.sh" ]; then + bash "$SCRIPT_DIR/patch-paths.sh" 2>&1 | tee -a "$LOG_FILE" +else + echo -e "${RED}[FAIL]${NC} patch-paths.sh not found in $SCRIPT_DIR" + echo " FAILED: patch-paths.sh not found" >> "$LOG_FILE" + exit 1 +fi + +echo "" +echo "Patch log saved to: $LOG_FILE" +echo -e "${GREEN}All patches applied.${NC}" +echo "Patch application completed: $(date)" >> "$LOG_FILE" 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/glibc-compat.js b/patches/glibc-compat.js new file mode 100644 index 0000000..f8990aa --- /dev/null +++ b/patches/glibc-compat.js @@ -0,0 +1,583 @@ +/** + * 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 = process.env._OA_WRAPPER_PATH || path.join( + process.env.HOME || '/data/data/com.termux/files/home', + '.openclaw-android', 'bin', 'node' +); +try { + if (fs.existsSync(_wrapperPath)) { + Object.defineProperty(process, 'execPath', { + value: _wrapperPath, + writable: true, + configurable: true, + }); + } +} catch {} + + +// ─── LD_PRELOAD cleanup ───────────────────────────────────── +// The node wrapper unsets LD_PRELOAD to prevent bionic libtermux-exec.so +// from loading into the glibc node.real process. +// +// Previously, we restored LD_PRELOAD here so bionic child processes +// (like /bin/sh) would get libtermux-exec.so for path translation +// (e.g., /usr/bin/env → $PREFIX/bin/env in shebang resolution). +// +// However, libtermux-exec.so re-injects LD_PRELOAD into execve() calls +// even after the shell unsets it. This causes glibc processes (ld.so) +// spawned from node to crash with "Could not find a PHDR" errors. +// +// Fix: Do NOT restore LD_PRELOAD. Instead, the spawn/spawnSync wrapper +// below handles shebang resolution (#!/usr/bin/env → PATH lookup) in +// JavaScript, eliminating the need for libtermux-exec.so in child processes. + +delete process.env.LD_PRELOAD; +delete process.env._OA_ORIG_LD_PRELOAD; + + +// ─── 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. +// +// Additionally, Android/Termux typically only exposes the loopback +// interface (`lo`) to Node.js. In that situation, OpenClaw's Bonjour +// advertiser can't send multicast announcements and logs noisy +// "Announcement failed as of socket errors!" repeatedly. +// Auto-disable Bonjour via OPENCLAW_DISABLE_BONJOUR when only +// loopback interfaces are visible. + +const _originalNetworkInterfaces = os.networkInterfaces; + +function _createLoopbackInterfaces() { + 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', + }, + ], + }; +} + +function _hasNonLoopbackInterface(interfaces) { + try { + return Object.values(interfaces).some(entries => + Array.isArray(entries) && entries.some(entry => entry && entry.internal === false) + ); + } catch { + return false; + } +} + +os.networkInterfaces = function networkInterfaces() { + let interfaces; + try { + interfaces = _originalNetworkInterfaces.call(os); + } catch { + interfaces = _createLoopbackInterfaces(); + } + if (!process.env.OPENCLAW_DISABLE_BONJOUR && !_hasNonLoopbackInterface(interfaces)) { + process.env.OPENCLAW_DISABLE_BONJOUR = '1'; + } + return interfaces; +}; + +// ─── Shell override for exec/execSync ──────────────────────── +// Node.js child_process hardcodes /bin/sh as the default shell on Linux. +// On Android: +// - Android 7-8: /bin/sh doesn't exist at all +// - Android 9+: /bin/sh exists (/system/bin/sh) but is minimal (toybox/mksh) +// and lacks Termux PATH, environment, and proper command support +// Always use Termux's shell for exec/execSync to ensure consistent behavior. + +{ + const child_process = require('child_process'); + const termuxSh = (process.env.PREFIX || '/data/data/com.termux/files/usr') + '/bin/sh'; + + if (fs.existsSync(termuxSh)) { + 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); + }; + } +} + +// ─── DNS resolver fix ──────────────────────────────────────── +// glibc's getaddrinfo() reads /data/data/com.termux/files/usr/glibc/etc/resolv.conf +// for DNS servers. This file may be missing or inaccessible: +// - Standalone APK: runs under com.openclaw.android, can't access com.termux paths +// - Termux: resolv-conf package may not be installed +// Without a valid resolv.conf, dns.lookup() fails with EAI_AGAIN errors. +// +// Fix: Override both dns.lookup (callback) and dns.promises.lookup (promise) +// to use c-ares resolver (dns.resolve) which respects dns.setServers(), +// then fall back to getaddrinfo. + +try { + const dns = require('dns'); + + // Read DNS servers from our resolv.conf or use Google DNS as fallback + let dnsServers = ['8.8.8.8', '8.8.4.4']; + try { + const resolvConf = fs.readFileSync( + (process.env.PREFIX || '/data/data/com.termux/files/usr') + '/etc/resolv.conf', + 'utf8' + ); + const parsed = resolvConf.match(/^nameserver\s+(.+)$/gm); + if (parsed && parsed.length > 0) { + dnsServers = parsed.map(l => l.replace(/^nameserver\s+/, '').trim()); + } + } catch {} + + // Set DNS servers for c-ares resolver + try { dns.setServers(dnsServers); } catch {} + + // Override dns.lookup (callback API) to use c-ares resolver + const _originalLookup = dns.lookup; + + // Localhost must never go to external DNS. Android/glibc may lack /etc/hosts, + // causing getaddrinfo to fail or return 0.0.0.0. Short-circuit it here. + const _localhostNames = new Set(['localhost', 'localhost.localdomain', 'ip6-localhost', 'ip6-loopback']); + + dns.lookup = function lookup(hostname, options, callback) { + if (typeof options === 'function') { + callback = options; + options = {}; + } + const originalOptions = options; + const opts = typeof options === 'number' ? { family: options } : (options || {}); + const wantAll = opts.all === true; + const family = opts.family || 0; + + // Short-circuit localhost — never send to external DNS + if (_localhostNames.has(hostname)) { + if (family === 6) { + if (wantAll) return callback(null, [{ address: '::1', family: 6 }]); + return callback(null, '::1', 6); + } + if (wantAll) return callback(null, [{ address: '127.0.0.1', family: 4 }]); + return callback(null, '127.0.0.1', 4); + } + + const resolve = (fam, cb) => { + const fn = fam === 6 ? dns.resolve6 : dns.resolve4; + fn(hostname, cb); + }; + + const tryResolve = (fam) => { + resolve(fam, (err, addresses) => { + if (!err && addresses && addresses.length > 0) { + const resFam = fam === 6 ? 6 : 4; + if (wantAll) { + callback(null, addresses.map(a => ({ address: a, family: resFam }))); + } else { + callback(null, addresses[0], resFam); + } + } else if (family === 0 && fam === 4) { + tryResolve(6); + } else { + _originalLookup.call(dns, hostname, originalOptions, callback); + } + }); + }; + + tryResolve(family === 6 ? 6 : 4); + }; + + // Override dns.promises.lookup (promise API) to use c-ares resolver. + // OpenClaw's SSRF guard uses this API for web_search DNS resolution. + const _originalPromiseLookup = dns.promises.lookup; + dns.promises.lookup = async function lookup(hostname, options) { + const opts = typeof options === 'number' ? { family: options } : (options || {}); + const wantAll = opts.all === true; + const family = opts.family || 0; + + // Short-circuit localhost + if (_localhostNames.has(hostname)) { + if (family === 6) { + return wantAll ? [{ address: '::1', family: 6 }] : { address: '::1', family: 6 }; + } + return wantAll ? [{ address: '127.0.0.1', family: 4 }] : { address: '127.0.0.1', family: 4 }; + } + + const resolve = (fam) => { + return new Promise((res, rej) => { + const fn = fam === 6 ? dns.resolve6 : dns.resolve4; + fn(hostname, (err, addresses) => err ? rej(err) : res(addresses)); + }); + }; + + const tryResolve = async (fam) => { + try { + const addresses = await resolve(fam); + if (addresses && addresses.length > 0) { + const resFam = fam === 6 ? 6 : 4; + if (wantAll) { + return addresses.map(a => ({ address: a, family: resFam })); + } + return { address: addresses[0], family: resFam }; + } + } catch {} + if (family === 0 && fam === 4) return tryResolve(6); + return _originalPromiseLookup.call(dns.promises, hostname, options); + }; + + return tryResolve(family === 6 ? 6 : 4); + }; +} catch {} + +// ─── ELF binary auto-wrapping for spawn/spawnSync ────────── +// npm/npx-installed native binaries (e.g., @zed-industries/codex-acp) +// are standard Linux ELF files whose interpreter is /lib/ld-linux-aarch64.so.1. +// Android lacks this path, so the kernel returns ENOENT on direct execution. +// Intercept child_process spawn APIs to detect ELF binaries and automatically +// route them through the glibc dynamic linker (ld.so). + +const _glibcLdso = (process.env.PREFIX || '/data/data/com.termux/files/usr') + + '/glibc/lib/ld-linux-aarch64.so.1'; +const _glibcLibPath = (process.env.PREFIX || '/data/data/com.termux/files/usr') + + '/glibc/lib'; + +function _needsGlibcWrap(filePath) { + // Read ELF header and check PT_INTERP to distinguish glibc binaries + // from bionic (Android/Termux) binaries. Only glibc binaries need wrapping. + // Bionic binaries use /system/bin/linker64; glibc use /lib/ld-linux-*.so.1 + try { + const fd = fs.openSync(filePath, 'r'); + try { + const ehdr = Buffer.alloc(64); + if (fs.readSync(fd, ehdr, 0, 64, 0) < 64) return false; + // Check ELF magic + if (ehdr[0] !== 0x7f || ehdr[1] !== 0x45 || ehdr[2] !== 0x4c || ehdr[3] !== 0x46) return false; + // ELF64: e_phoff at offset 32 (8 bytes), e_phentsize at 54 (2 bytes), e_phnum at 56 (2 bytes) + const phoff = Number(ehdr.readBigUInt64LE(32)); + const phentsize = ehdr.readUInt16LE(54); + const phnum = ehdr.readUInt16LE(56); + // Scan program headers for PT_INTERP (type = 3) + for (let i = 0; i < phnum; i++) { + const phBuf = Buffer.alloc(phentsize); + if (fs.readSync(fd, phBuf, 0, phentsize, phoff + i * phentsize) < phentsize) continue; + const pType = phBuf.readUInt32LE(0); + if (pType === 3) { // PT_INTERP + const interpOff = Number(phBuf.readBigUInt64LE(8)); + const interpSize = Number(phBuf.readBigUInt64LE(32)); + const interpBuf = Buffer.alloc(Math.min(interpSize, 256)); + fs.readSync(fd, interpBuf, 0, interpBuf.length, interpOff); + const interp = interpBuf.toString('utf8').replace(/\0+$/, ''); + return interp.includes('ld-linux'); + } + } + return false; + } finally { + fs.closeSync(fd); + } + } catch { + return false; + } +} + +function _resolveCommand(command, env) { + if (command.includes('/')) { + try { return fs.realpathSync(command); } catch { return command; } + } + const searchPath = (env && env.PATH) || process.env.PATH || ''; + const dirs = searchPath.split(':'); + for (const dir of dirs) { + const full = path.join(dir, command); + try { + fs.accessSync(full, fs.constants.X_OK); + return full; + } catch {} + } + return null; +} + +function _readShebang(filePath) { + // Read first 256 bytes to extract shebang line from script files. + // Returns null if not a script, or { interpreter, args } if shebang found. + try { + const fd = fs.openSync(filePath, 'r'); + try { + const buf = Buffer.alloc(256); + const n = fs.readSync(fd, buf, 0, 256, 0); + if (n < 2 || buf[0] !== 0x23 || buf[1] !== 0x21) return null; // not #! + const line = buf.toString('utf8', 2, n).split('\n')[0].trim(); + const parts = line.split(/\s+/); + return { interpreter: parts[0], args: parts.slice(1) }; + } finally { + fs.closeSync(fd); + } + } catch { + return null; + } +} + +function _resolveShebang(resolved, spawnEnv) { + // For a resolved file path, check if it has a shebang pointing to a + // non-existent interpreter (e.g. #!/usr/bin/env node on Android). + // Returns { interpPath, interpArgs } or null if no fixup needed. + const shebang = _readShebang(resolved); + if (!shebang) return null; + + let interpPath = shebang.interpreter; + let interpArgs = shebang.args; + + if (interpPath === '/usr/bin/env' && interpArgs.length > 0) { + // #!/usr/bin/env — resolve from PATH + let cmdIdx = 0; + while (cmdIdx < interpArgs.length && interpArgs[cmdIdx].startsWith('-')) cmdIdx++; + if (cmdIdx >= interpArgs.length) return null; + const cmd = interpArgs[cmdIdx]; + const resolvedCmd = _resolveCommand(cmd, spawnEnv); + if (!resolvedCmd) return null; + interpPath = resolvedCmd; + interpArgs = interpArgs.slice(0, cmdIdx).concat(interpArgs.slice(cmdIdx + 1)); + } else if (!fs.existsSync(interpPath)) { + // Interpreter at non-existent absolute path — try resolving by basename from PATH + const basename = path.basename(interpPath); + const resolvedInterp = _resolveCommand(basename, spawnEnv); + if (!resolvedInterp) return null; + interpPath = resolvedInterp; + } else { + // Shebang interpreter exists, kernel can handle it + return null; + } + + return { interpPath, interpArgs }; +} + +// Detect shell invocation patterns: spawn('/path/to/sh', ['-c', 'cmd args...']) +// or spawn('cmd', args, { shell: true }) +function _isShellInvocation(file, args) { + if (!args || args.length < 2 || args[0] !== '-c') return null; + const base = path.basename(file); + if (base !== 'sh' && base !== 'bash' && base !== 'dash' && base !== 'zsh') return null; + return args[1]; // the shell command string +} + +function _tryFixShellCommand(cmdStr, spawnEnv) { + // Extract the command name from a simple shell command string. + // Only handle simple cases: "cmd arg1 arg2..." — no pipes, redirects, etc. + const shellChars = /[|><&;`$(){}]/; + if (shellChars.test(cmdStr)) return null; + + const parts = cmdStr.trim().split(/\s+/); + if (parts.length === 0) return null; + const cmd = parts[0]; + const cmdArgs = parts.slice(1); + + const resolved = _resolveCommand(cmd, spawnEnv); + if (!resolved) return null; + + // glibc ELF binary + if (_needsGlibcWrap(resolved)) { + const env = Object.assign({}, spawnEnv); + delete env.LD_PRELOAD; + return { + file: _glibcLdso, + args: ['--library-path', _glibcLibPath, resolved].concat(cmdArgs), + env: env, + }; + } + + // Script with broken shebang + const shebang = _resolveShebang(resolved, spawnEnv); + if (shebang) { + return { + file: shebang.interpPath, + args: shebang.interpArgs.concat([resolved]).concat(cmdArgs), + }; + } + + return null; +} + +function _tryWrapSpawn(file, args, options) { + const spawnEnv = options && options.env ? options.env : process.env; + + // Detect shell invocations: spawn('sh', ['-c', 'command...']) or spawn(cmd, args, { shell: true }) + // npm/npx uses spawn('/path/to/sh', ['-c', 'cmd args...']) to execute package binaries. + // Without libtermux-exec.so, #!/usr/bin/env shebangs in these scripts fail. + if (options && options.shell) { + // shell: true — Node.js will convert to sh -c 'file args...' + if (!file.includes(' ') && !(/[|><&;`$()]/).test(file)) { + const resolved = _resolveCommand(file, spawnEnv); + if (resolved) { + if (_needsGlibcWrap(resolved)) { + const env = Object.assign({}, spawnEnv); + delete env.LD_PRELOAD; + return { + file: _glibcLdso, + args: ['--library-path', _glibcLibPath, resolved].concat(args || []), + options: Object.assign({}, options, { env: env, shell: false }), + }; + } + const shebang = _resolveShebang(resolved, spawnEnv); + if (shebang) { + return { + file: shebang.interpPath, + args: shebang.interpArgs.concat([resolved]).concat(args || []), + options: Object.assign({}, options, { shell: false }), + }; + } + } + } + return null; + } + + // Direct shell invocation: spawn('/path/to/sh', ['-c', 'cmd args...']) + const shellCmd = _isShellInvocation(file, args); + if (shellCmd) { + const fix = _tryFixShellCommand(shellCmd, spawnEnv); + if (fix) { + return { + file: fix.file, + args: fix.args, + options: fix.env ? Object.assign({}, options, { env: fix.env }) : options, + }; + } + return null; + } + + const resolved = _resolveCommand(file, spawnEnv); + if (!resolved) return null; + if (resolved === _glibcLdso || resolved.endsWith('/ld-linux-aarch64.so.1')) return null; + + // Case 1: glibc ELF binary — wrap with ld.so + if (_needsGlibcWrap(resolved)) { + const env = Object.assign({}, spawnEnv); + delete env.LD_PRELOAD; + return { + file: _glibcLdso, + args: ['--library-path', _glibcLibPath, resolved].concat(args || []), + options: Object.assign({}, options, { env: env }), + }; + } + + // Case 2: Script with shebang pointing to non-existent path + const shebang = _resolveShebang(resolved, spawnEnv); + if (!shebang) return null; + + return { + file: shebang.interpPath, + args: shebang.interpArgs.concat([resolved]).concat(args || []), + options: options, + }; +} + +if (fs.existsSync(_glibcLdso)) { + const _cp = require('child_process'); + + // Normalize optional args parameter: spawn(cmd[, args][, opts]) + function _normalizeArgs(args, options) { + if (args != null && !Array.isArray(args)) { + return { args: [], options: args }; + } + return { args: args || [], options: options }; + } + + const _origSpawn = _cp.spawn; + _cp.spawn = function spawn(command, args, options) { + const n = _normalizeArgs(args, options); + const w = _tryWrapSpawn(command, n.args, n.options); + if (w) return _origSpawn.call(_cp, w.file, w.args, w.options); + return _origSpawn.call(_cp, command, args, options); + }; + + const _origSpawnSync = _cp.spawnSync; + _cp.spawnSync = function spawnSync(command, args, options) { + const n = _normalizeArgs(args, options); + const w = _tryWrapSpawn(command, n.args, n.options); + if (w) return _origSpawnSync.call(_cp, w.file, w.args, w.options); + return _origSpawnSync.call(_cp, command, args, options); + }; + + const _origExecFile = _cp.execFile; + _cp.execFile = function execFile(file, args, options, callback) { + // execFile(file[, args][, options], callback) + if (typeof args === 'function') { + callback = args; args = []; options = {}; + } else if (typeof options === 'function') { + callback = options; + if (Array.isArray(args)) { options = {}; } else { options = args; args = []; } + } + const w = _tryWrapSpawn(file, args, options); + if (w) return _origExecFile.call(_cp, w.file, w.args, w.options, callback); + return _origExecFile.call(_cp, file, args, options, callback); + }; + + const _origExecFileSync = _cp.execFileSync; + _cp.execFileSync = function execFileSync(file, args, options) { + const n = _normalizeArgs(args, options); + const w = _tryWrapSpawn(file, n.args, n.options); + if (w) return _origExecFileSync.call(_cp, w.file, w.args, w.options); + return _origExecFileSync.call(_cp, file, args, options); + }; +} diff --git a/patches/glibc-libs/libcap.so.2.44 b/patches/glibc-libs/libcap.so.2.44 new file mode 100644 index 0000000..1c86358 Binary files /dev/null and b/patches/glibc-libs/libcap.so.2.44 differ diff --git a/patches/patch-paths.sh b/patches/patch-paths.sh new file mode 100755 index 0000000..96b4a1b --- /dev/null +++ b/patches/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/patches/spawn.h b/patches/spawn.h new file mode 100644 index 0000000..555d672 --- /dev/null +++ b/patches/spawn.h @@ -0,0 +1,60 @@ +/* + * spawn.h - POSIX spawn stub header for Termux + * + * Android Bionic supports posix_spawn() since API 28 (Android 9.0), + * but Termux's NDK headers may not include spawn.h. + * This stub provides the necessary declarations so native modules + * (e.g. koffi) can compile and link against the system libc. + * + * Based on Android Open Source Project (Apache-2.0 / BSD) + */ + +#ifndef _SPAWN_H_ +#define _SPAWN_H_ + +#include +#include +#include +#include + +__BEGIN_DECLS + +#define POSIX_SPAWN_RESETIDS 1 +#define POSIX_SPAWN_SETPGROUP 2 +#define POSIX_SPAWN_SETSIGDEF 4 +#define POSIX_SPAWN_SETSIGMASK 8 +#define POSIX_SPAWN_SETSCHEDPARAM 16 +#define POSIX_SPAWN_SETSCHEDULER 32 +#define POSIX_SPAWN_SETSID 128 +#define POSIX_SPAWN_CLOEXEC_DEFAULT 256 + +typedef struct __posix_spawnattr* posix_spawnattr_t; +typedef struct __posix_spawn_file_actions* posix_spawn_file_actions_t; + +int posix_spawn(pid_t* __pid, const char* __path, const posix_spawn_file_actions_t* __actions, const posix_spawnattr_t* __attr, char* const* __argv, char* const* __env); +int posix_spawnp(pid_t* __pid, const char* __file, const posix_spawn_file_actions_t* __actions, const posix_spawnattr_t* __attr, char* const* __argv, char* const* __env); + +int posix_spawnattr_init(posix_spawnattr_t* __attr); +int posix_spawnattr_destroy(posix_spawnattr_t* __attr); +int posix_spawnattr_setflags(posix_spawnattr_t* __attr, short __flags); +int posix_spawnattr_getflags(const posix_spawnattr_t* __attr, short* __flags); +int posix_spawnattr_setpgroup(posix_spawnattr_t* __attr, pid_t __pgroup); +int posix_spawnattr_getpgroup(const posix_spawnattr_t* __attr, pid_t* __pgroup); +int posix_spawnattr_setsigmask(posix_spawnattr_t* __attr, const sigset_t* __mask); +int posix_spawnattr_getsigmask(const posix_spawnattr_t* __attr, sigset_t* __mask); +int posix_spawnattr_setsigdefault(posix_spawnattr_t* __attr, const sigset_t* __mask); +int posix_spawnattr_getsigdefault(const posix_spawnattr_t* __attr, sigset_t* __mask); +int posix_spawnattr_setschedparam(posix_spawnattr_t* __attr, const struct sched_param* __param); +int posix_spawnattr_getschedparam(const posix_spawnattr_t* __attr, struct sched_param* __param); +int posix_spawnattr_setschedpolicy(posix_spawnattr_t* __attr, int __policy); +int posix_spawnattr_getschedpolicy(const posix_spawnattr_t* __attr, int* __policy); + +int posix_spawn_file_actions_init(posix_spawn_file_actions_t* __actions); +int posix_spawn_file_actions_destroy(posix_spawn_file_actions_t* __actions); +int posix_spawn_file_actions_addopen(posix_spawn_file_actions_t* __actions, int __fd, const char* __path, int __flags, mode_t __mode); +int posix_spawn_file_actions_addclose(posix_spawn_file_actions_t* __actions, int __fd); +int posix_spawn_file_actions_adddup2(posix_spawn_file_actions_t* __actions, int __fd, int __new_fd); + +__END_DECLS + +#endif diff --git a/patches/systemctl b/patches/systemctl new file mode 100644 index 0000000..3fd6ec5 --- /dev/null +++ b/patches/systemctl @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# systemctl - Termux stub (systemd unavailable) +# Intercepts restart calls and triggers openclaw reload via config.patch (SIGUSR1) + +for arg in "$@"; do + if [ "$arg" = "restart" ]; then + # Try config.patch reload first (gateway running → SIGUSR1) + if ! openclaw gateway config.patch \ + "{\"meta\":{\"restartNonce\":\"$(date +%s%N)\"}}" \ + 2>/dev/null; then + # Gateway not running — start it + openclaw gateway 2>/dev/null || true + fi + exit 0 + fi +done + +exit 0 diff --git a/patches/termux-compat.h b/patches/termux-compat.h new file mode 100644 index 0000000..d3f5826 --- /dev/null +++ b/patches/termux-compat.h @@ -0,0 +1,27 @@ +/* + * termux-compat.h - Compatibility shim for missing libc declarations in Termux + * + * Android Bionic lacks some glibc/POSIX functions that native modules expect. + * This header provides syscall-based wrappers or stubs for them. + * + * Usage: CXXFLAGS="-include /path/to/termux-compat.h" + * This force-includes the header before every source file. + */ + +#ifndef _TERMUX_COMPAT_H_ +#define _TERMUX_COMPAT_H_ + +#include +#include +#include + +/* renameat2() - available in kernel 3.15+ but Bionic only exposes it in API 30+ */ +#ifndef __RENAME_NOREPLACE_DEFINED +static inline int renameat2(int olddirfd, const char *oldpath, + int newdirfd, const char *newpath, + unsigned int flags) { + return syscall(__NR_renameat2, olddirfd, oldpath, newdirfd, newpath, flags); +} +#endif + +#endif /* _TERMUX_COMPAT_H_ */ 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..4a40a76 --- /dev/null +++ b/platforms/openclaw/install.sh @@ -0,0 +1,73 @@ +#!/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" + +# Restore optional/channel deps that --ignore-scripts skips. +# Uses npm_config_ignore_scripts=true so sharp's native build doesn't block. +OPENCLAW_DIR="$(npm root -g)/openclaw" +if [ -d "$OPENCLAW_DIR" ]; then + echo "Restoring optional dependencies..." + (cd "$OPENCLAW_DIR" && npm_config_ignore_scripts=true node scripts/postinstall-bundled-plugins.mjs 2>/dev/null) || true +fi + +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..0af7ab6 --- /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="$BIN_DIR/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 (Termux)"; 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..d51faf4 --- /dev/null +++ b/platforms/openclaw/uninstall.sh @@ -0,0 +1,127 @@ +#!/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 "$HOME/.openclaw-android/bin/ld.so.opencode" ] && rm -f "$HOME/.openclaw-android/bin/ld.so.opencode" && echo -e "${GREEN}[OK]${NC} Removed ld.so.opencode" + # Clean up legacy tmp location + [ -f "$PREFIX/tmp/ld.so.opencode" ] && rm -f "$PREFIX/tmp/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+=("@mmmbuto/codex-cli-termux") + AI_TOOL_LABELS+=("Codex CLI (Termux)") +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..a20a3a8 --- /dev/null +++ b/platforms/openclaw/update.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" + +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 + +# Reinstall dependencies without --ignore-scripts to restore optional/channel +# deps (e.g. @buape/carbon, grammy) that were skipped above. +# Native build failures (sharp, node-gyp) are non-fatal here (|| true). +# Restore optional/channel deps that --ignore-scripts skips. +# Uses npm_config_ignore_scripts=true so that the internal npm install +# doesn't trigger sharp's native build (which fails on Termux). +# The postinstall-bundled-plugins.mjs installs pure JS channel deps only. +OPENCLAW_DIR="$(npm root -g)/openclaw" +if [ -d "$OPENCLAW_DIR" ]; then + if ! node -e "require('$OPENCLAW_DIR/node_modules/@buape/carbon')" 2>/dev/null; then + echo "Restoring optional dependencies..." + (cd "$OPENCLAW_DIR" && npm_config_ignore_scripts=true node scripts/postinstall-bundled-plugins.mjs 2>/dev/null) || true + 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/post-setup.sh b/post-setup.sh new file mode 100644 index 0000000..5e9a3cd --- /dev/null +++ b/post-setup.sh @@ -0,0 +1,768 @@ +#!/usr/bin/env bash +# OpenClaw Android — Post-Bootstrap Setup +# Runs in the terminal after Termux bootstrap extraction. +# Installs: git, glibc, Node.js, OpenClaw +# +# Strategy: +# - Termux .deb packages: dpkg-deb -x + relocate (bypasses dpkg hardcoded paths) +# - Pacman .pkg.tar.xz packages: tar -xJf + relocate (bypasses pacman entirely) +# - Both have files under data/data/com.termux/files/usr/ which we relocate to $PREFIX +# +# Why not apt-get/dpkg/pacman? +# All three have hardcoded /data/data/com.termux/... paths that libtermux-exec +# cannot rewrite (it only intercepts execve, not open/opendir). + +set -eo pipefail + +# ─── Paths ──────────────────────────────────── +: "${PREFIX:?PREFIX not set}" +: "${HOME:?HOME not set}" +: "${TMPDIR:=$(dirname "$PREFIX")/tmp}" + +OCA_DIR="$HOME/.openclaw-android" +NODE_DIR="$OCA_DIR/node" +BIN_DIR="$OCA_DIR/bin" +NODE_VERSION="22.22.0" +GLIBC_LDSO="$PREFIX/glibc/lib/ld-linux-aarch64.so.1" +MARKER="$OCA_DIR/.post-setup-done" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +# ─── GitHub mirror fallback (for China/restricted networks) ── +REPO_BASE_ORIGIN="https://raw.githubusercontent.com/AidanPark/openclaw-android/main" +REPO_BASE="$REPO_BASE_ORIGIN" +resolve_repo_base() { + if curl -sI --connect-timeout 3 "$REPO_BASE_ORIGIN/oa.sh" >/dev/null 2>&1; then + REPO_BASE="$REPO_BASE_ORIGIN"; return 0 + fi + local mirrors=( + "https://ghfast.top/$REPO_BASE_ORIGIN" + "https://ghproxy.net/$REPO_BASE_ORIGIN" + "https://mirror.ghproxy.com/$REPO_BASE_ORIGIN" + ) + for m in "${mirrors[@]}"; do + if curl -sI --connect-timeout 3 "$m/oa.sh" >/dev/null 2>&1; then + echo -e " ${YELLOW}[MIRROR]${NC} Using mirror for GitHub downloads" + REPO_BASE="$m"; return 0 + fi + done + return 1 +} + +# Kept in sync with scripts/lib.sh resolve_npm_registry() +NPM_REGISTRY_ORIGIN="https://registry.npmjs.org/" +NPM_REGISTRY_MIRROR="https://registry.npmmirror.com/" +resolve_npm_registry() { + local choice + local cache_file="$OCA_DIR/.npm-registry" + local reachable=0 + if curl -sI --connect-timeout 5 "$NPM_REGISTRY_ORIGIN" >/dev/null 2>&1; then + choice="$NPM_REGISTRY_ORIGIN" + reachable=1 + elif curl -sI --connect-timeout 5 "$NPM_REGISTRY_MIRROR" >/dev/null 2>&1; then + echo -e " ${YELLOW}[MIRROR]${NC} Using npm mirror: ${NPM_REGISTRY_MIRROR}" + choice="$NPM_REGISTRY_MIRROR" + reachable=1 + else + choice="$NPM_REGISTRY_ORIGIN" + fi + mkdir -p "$(dirname "$cache_file")" + printf '%s' "$choice" > "$cache_file.tmp" && mv "$cache_file.tmp" "$cache_file" + export NPM_CONFIG_REGISTRY="$choice" + if [ "$reachable" -eq 1 ]; then + return 0 + fi + return 1 +} + +# SSL cert for curl (bootstrap curl looks at hardcoded com.termux path) +export CURL_CA_BUNDLE="$PREFIX/etc/tls/cert.pem" +export SSL_CERT_FILE="$PREFIX/etc/tls/cert.pem" +export GIT_SSL_CAINFO="$PREFIX/etc/tls/cert.pem" + +# Git system config has hardcoded com.termux path — skip it +export GIT_CONFIG_NOSYSTEM=1 + +# Git exec path (git looks for helpers like git-remote-https here) +export GIT_EXEC_PATH="$PREFIX/libexec/git-core" + +# Git template dir (hardcoded /data/data/com.termux path workaround) +export GIT_TEMPLATE_DIR="$PREFIX/share/git-core/templates" + +if [ -f "$MARKER" ]; then + echo -e "${GREEN}Post-setup already completed.${NC}" + exit 0 +fi + +echo "" +echo "══════════════════════════════════════════════" +echo " OpenClaw Android — Installing components" +echo "══════════════════════════════════════════════" +echo "" + +mkdir -p "$OCA_DIR" "$OCA_DIR/patches" "$TMPDIR" + +TERMUX_DEB_REPO="https://packages-cf.termux.dev/apt/termux-main" +PACMAN_PKG_REPO="https://service.termux-pacman.dev/gpkg/aarch64" +TERMUX_INNER="data/data/com.termux/files/usr" +DEB_DIR="$TMPDIR/debs" +PKG_DIR="$TMPDIR/pkgs" +EXTRACT_DIR="$TMPDIR/pkg-extract" + +# ─── Helper: install_deb ────────────────────── +# Downloads a .deb from Termux repo and extracts into $PREFIX +install_deb() { + local filename="$1" + local name + name=$(basename "$filename" | sed 's/_[0-9].*//') + local url="${TERMUX_DEB_REPO}/${filename}" + local deb_file="${DEB_DIR}/$(basename "$filename")" + + if [ -f "$deb_file" ]; then + echo " (cached) $name" + else + echo " downloading $name..." + curl -fsSL --max-time 120 -o "$deb_file" "$url" + fi + + rm -rf "$EXTRACT_DIR" + mkdir -p "$EXTRACT_DIR" + dpkg-deb -x "$deb_file" "$EXTRACT_DIR" 2>/dev/null + + # Relocate: data/data/com.termux/files/usr/* → $PREFIX/ + if [ -d "$EXTRACT_DIR/$TERMUX_INNER" ]; then + cp -a "$EXTRACT_DIR/$TERMUX_INNER/"* "$PREFIX/" 2>/dev/null || true + fi + rm -rf "$EXTRACT_DIR" +} + +# ─── Helper: install_pacman_pkg ─────────────── +# Downloads a .pkg.tar.xz from pacman repo and extracts into target dir +install_pacman_pkg() { + local filename="$1" + local target="$2" # e.g., $PREFIX/glibc + local name + name=${filename%%-[0-9]*} + local url="${PACMAN_PKG_REPO}/${filename}" + local pkg_file="${PKG_DIR}/${filename}" + + if [ -f "$pkg_file" ]; then + echo " (cached) $name" + else + echo " downloading $name..." + curl -fsSL --max-time 300 -o "$pkg_file" "$url" + fi + + rm -rf "$EXTRACT_DIR" + mkdir -p "$EXTRACT_DIR" + tar -xJf "$pkg_file" -C "$EXTRACT_DIR" 2>/dev/null + + # Pacman packages also extract under data/data/com.termux/files/usr/... + local inner="$EXTRACT_DIR/$TERMUX_INNER" + if [ -d "$inner/glibc" ]; then + # glibc packages go under $PREFIX/glibc/ + cp -a "$inner/glibc/"* "$target/" 2>/dev/null || true + elif [ -d "$inner" ]; then + cp -a "$inner/"* "$target/" 2>/dev/null || true + fi + rm -rf "$EXTRACT_DIR" +} + +# ─── [1/7] Install essential packages ───────── +echo -e "▸ ${YELLOW}[1/7]${NC} Installing essential packages..." +mkdir -p "$DEB_DIR" "$PKG_DIR" + +# Download Packages index to resolve .deb filenames +echo " Fetching package index..." +PACKAGES_FILE="$TMPDIR/Packages" +curl -fsSL --max-time 60 \ + "${TERMUX_DEB_REPO}/dists/stable/main/binary-aarch64/Packages" \ + -o "$PACKAGES_FILE" + +# Resolve package filename from Packages index +get_deb_filename() { + local pkg="$1" + awk -v pkg="$pkg" ' + /^Package: / { found = ($2 == pkg) } + found && /^Filename:/ { print $2; exit } + ' "$PACKAGES_FILE" +} + +# Packages to install via dpkg-deb (dependency order, only those missing from bootstrap) +DEB_PACKAGES=( + libexpat # git dep + pcre2 # git dep + git # for npm/openclaw +) + +TOTAL=${#DEB_PACKAGES[@]} +COUNT=0 +for pkg in "${DEB_PACKAGES[@]}"; do + COUNT=$((COUNT + 1)) + filename=$(get_deb_filename "$pkg") + if [ -z "$filename" ]; then + echo -e " ${RED}✗${NC} Package '$pkg' not found in index" + continue + fi + echo " [$COUNT/$TOTAL] $pkg" + install_deb "$filename" +done + +# Make sure newly extracted binaries are executable +chmod +x "$PREFIX/bin/"* 2>/dev/null || true + +# Verify git +if [ -f "$PREFIX/bin/git" ]; then + echo -e " ${GREEN}✓${NC} git $(git --version 2>/dev/null | head -1)" +else + echo -e " ${RED}✗${NC} git not found after extraction" + exit 1 +fi + +# ─── [2/7] glibc runtime ───────────────────── +echo -e "▸ ${YELLOW}[2/7]${NC} Installing glibc runtime..." + +if [ -x "$GLIBC_LDSO" ]; then + echo -e " ${GREEN}[SKIP]${NC} glibc already installed" +else + mkdir -p "$PREFIX/glibc" + + # Download glibc package directly from pacman repo (no pacman needed) + # The gpkg.db tells us: glibc-2.42-0-aarch64.pkg.tar.xz (~9.7MB) + echo " Downloading glibc (~10MB)..." + install_pacman_pkg "glibc-2.42-0-aarch64.pkg.tar.xz" "$PREFIX/glibc" + + # gcc-libs-glibc provides libstdc++.so.6 needed by Node.js (~24MB) + echo " Downloading gcc-libs (~24MB)..." + install_pacman_pkg "gcc-libs-glibc-14.2.1-1-aarch64.pkg.tar.xz" "$PREFIX/glibc" + + # Verify linker + if [ ! -f "$GLIBC_LDSO" ]; then + echo -e " ${RED}✗${NC} glibc linker not found at $GLIBC_LDSO" + exit 1 + fi + chmod +x "$GLIBC_LDSO" + mkdir -p "$OCA_DIR" + touch "$OCA_DIR/.glibc-arch" + echo -e " ${GREEN}✓${NC} glibc installed" +fi + +# Install supplementary glibc libraries (libcap etc.) +_GLIBC_LIBS_SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/patches/glibc-libs" +if [ -d "$PREFIX/glibc/lib" ] && [ -d "$_GLIBC_LIBS_SRC" ]; then + for _lib in "$_GLIBC_LIBS_SRC"/*.so.*; do + [ -f "$_lib" ] || continue + _fn=$(basename "$_lib") + if [ ! -f "$PREFIX/glibc/lib/$_fn" ]; then + cp "$_lib" "$PREFIX/glibc/lib/$_fn" + _sn=$(echo "$_fn" | sed -E 's/^(lib[^.]+\.so\.[0-9]+)\..*/\1/') + [ "$_sn" != "$_fn" ] && ln -sf "$_fn" "$PREFIX/glibc/lib/$_sn" + echo -e " ${GREEN}✓${NC} Installed $_sn" + fi + done +fi + +# Ensure glibc /etc/hosts exists (localhost resolution) +if [ -d "$PREFIX/glibc/etc" ] && [ ! -f "$PREFIX/glibc/etc/hosts" ]; then + cat > "$PREFIX/glibc/etc/hosts" <<'HOSTS' +127.0.0.1 localhost localhost.localdomain +::1 localhost ip6-localhost ip6-loopback +HOSTS + echo -e " ${GREEN}✓${NC} Created glibc /etc/hosts" +fi +echo -e " Linker: $GLIBC_LDSO" + +# ─── [3/7] Node.js ────────────────────────── +echo -e "▸ ${YELLOW}[3/7]${NC} Installing Node.js v${NODE_VERSION}..." +mkdir -p "$NODE_DIR/bin" + +_NODE_CMD="" +if [ -x "$BIN_DIR/node" ]; then _NODE_CMD="$BIN_DIR/node" +elif [ -f "$NODE_DIR/bin/node.real" ] && [ -x "$NODE_DIR/bin/node" ]; then _NODE_CMD="$NODE_DIR/bin/node" +fi +if [ -n "$_NODE_CMD" ] && "$_NODE_CMD" --version &>/dev/null; then + INSTALLED_VER=$("$_NODE_CMD" --version 2>/dev/null || echo "") + echo -e " ${GREEN}[SKIP]${NC} Node.js already installed ($INSTALLED_VER)" + # Repair wrappers in BIN_DIR (safe from npm overwrites) + mkdir -p "$BIN_DIR" + if [ -f "$NODE_DIR/lib/node_modules/npm/bin/npm-cli.js" ]; then + cat > "$BIN_DIR/npm" << NPMWRAP +#!$PREFIX/bin/bash +"$BIN_DIR/node" "$NODE_DIR/lib/node_modules/npm/bin/npm-cli.js" "\$@" +_npm_exit=\$? +case "\$*" in *-g*openclaw*|*--global*openclaw*|*openclaw*-g*|*openclaw*--global*) + _oc_bin="$PREFIX/bin/openclaw" + _oc_mjs="$PREFIX/lib/node_modules/openclaw/openclaw.mjs" + if [ -f "\$_oc_mjs" ]; then + [ -L "\$_oc_bin" ] && rm -f "\$_oc_bin" + printf '#!$PREFIX/bin/bash\nexec "$BIN_DIR/node" "%s" "\$@"\n' "\$_oc_mjs" > "\$_oc_bin" + chmod +x "\$_oc_bin" + fi + ;; +esac +# Re-patch codex CLI wrapper after global install/update (DioNanos fork launcher fix) +case "\$*" in *codex-cli-termux*) + _codex_bin="$PREFIX/bin/codex" + _codex_pkg="$PREFIX/lib/node_modules/@mmmbuto/codex-cli-termux/bin" + if [ -f "\$_codex_pkg/codex.bin" ]; then + [ -L "\$_codex_bin" ] && rm -f "\$_codex_bin" + printf '#!$PREFIX/bin/bash\nPKG_BIN="%s"\nexport LD_LIBRARY_PATH="\$PKG_BIN:\${LD_LIBRARY_PATH:-}"\nexec "\$PKG_BIN/codex.bin" "\$@"\n' "\$_codex_pkg" > "\$_codex_bin" + chmod +x "\$_codex_bin" + fi + ;; +esac +# Fix shebangs in npm global CLI entry points after global install +case "\$*" in *-g*|*--global*) + for _js in $PREFIX/lib/node_modules/*/bin/*.js \ + $PREFIX/lib/node_modules/@*/*/bin/*.js; do + [ -f "\$_js" ] || continue + head -1 "\$_js" | grep -q '^#!/usr/bin/env node\$' || continue + sed -i "1s|#!/usr/bin/env node|#!$BIN_DIR/node|" "\$_js" + done + ;; +esac +exit \$_npm_exit +NPMWRAP + chmod +x "$BIN_DIR/npm" + fi + if [ -f "$NODE_DIR/lib/node_modules/npm/bin/npx-cli.js" ]; then + cat > "$BIN_DIR/npx" << NPXWRAP +#!$PREFIX/bin/bash +exec "$BIN_DIR/node" "$NODE_DIR/lib/node_modules/npm/bin/npx-cli.js" "\$@" +NPXWRAP + chmod +x "$BIN_DIR/npx" + fi + if [ -f "$NODE_DIR/bin/corepack" ] && head -1 "$NODE_DIR/bin/corepack" 2>/dev/null | grep -q '#!/usr/bin/env node'; then + sed -i "1s|#!/usr/bin/env node|#!$BIN_DIR/node|" "$NODE_DIR/bin/corepack" + fi +else + NODE_TAR="node-v${NODE_VERSION}-linux-arm64" + echo " Downloading Node.js v${NODE_VERSION} (~25MB)..." + curl -fSL --max-time 300 \ + "https://nodejs.org/dist/v${NODE_VERSION}/${NODE_TAR}.tar.xz" \ + -o "$TMPDIR/${NODE_TAR}.tar.xz" + + echo " Extracting..." + tar -xJf "$TMPDIR/${NODE_TAR}.tar.xz" -C "$NODE_DIR" --strip-components=1 + + # Move original binary → 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 + + rm -f "$TMPDIR/${NODE_TAR}.tar.xz" + + # Create grun-style node wrapper in BIN_DIR (safe from npm overwrites) + mkdir -p "$BIN_DIR" + cat > "$BIN_DIR/node" << WRAPPER +#!${PREFIX}/bin/bash +[ -n "\$LD_PRELOAD" ] && export _OA_ORIG_LD_PRELOAD="\$LD_PRELOAD" +unset LD_PRELOAD +export _OA_WRAPPER_PATH="$BIN_DIR/node" +_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 +_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 "$GLIBC_LDSO" --library-path "$PREFIX/glibc/lib" "$NODE_DIR/bin/node.real" "\$@" +WRAPPER + chmod +x "$BIN_DIR/node" + + # Create npm/npx wrappers in BIN_DIR + if [ -f "$NODE_DIR/lib/node_modules/npm/bin/npm-cli.js" ]; then + cat > "$BIN_DIR/npm" << NPMWRAP +#!$PREFIX/bin/bash +"$BIN_DIR/node" "$NODE_DIR/lib/node_modules/npm/bin/npm-cli.js" "\$@" +_npm_exit=\$? +case "\$*" in *-g*openclaw*|*--global*openclaw*|*openclaw*-g*|*openclaw*--global*) + _oc_bin="$PREFIX/bin/openclaw" + _oc_mjs="$PREFIX/lib/node_modules/openclaw/openclaw.mjs" + if [ -f "\$_oc_mjs" ]; then + [ -L "\$_oc_bin" ] && rm -f "\$_oc_bin" + printf '#!$PREFIX/bin/bash\nexec "$BIN_DIR/node" "%s" "\$@"\n' "\$_oc_mjs" > "\$_oc_bin" + chmod +x "\$_oc_bin" + fi + ;; +esac +# Re-patch codex CLI wrapper after global install/update (DioNanos fork launcher fix) +case "\$*" in *codex-cli-termux*) + _codex_bin="$PREFIX/bin/codex" + _codex_pkg="$PREFIX/lib/node_modules/@mmmbuto/codex-cli-termux/bin" + if [ -f "\$_codex_pkg/codex.bin" ]; then + [ -L "\$_codex_bin" ] && rm -f "\$_codex_bin" + printf '#!$PREFIX/bin/bash\nPKG_BIN="%s"\nexport LD_LIBRARY_PATH="\$PKG_BIN:\${LD_LIBRARY_PATH:-}"\nexec "\$PKG_BIN/codex.bin" "\$@"\n' "\$_codex_pkg" > "\$_codex_bin" + chmod +x "\$_codex_bin" + fi + ;; +esac +# Fix shebangs in npm global CLI entry points after global install +case "\$*" in *-g*|*--global*) + for _js in $PREFIX/lib/node_modules/*/bin/*.js \ + $PREFIX/lib/node_modules/@*/*/bin/*.js; do + [ -f "\$_js" ] || continue + head -1 "\$_js" | grep -q '^#!/usr/bin/env node\$' || continue + sed -i "1s|#!/usr/bin/env node|#!$BIN_DIR/node|" "\$_js" + done + ;; +esac +exit \$_npm_exit +NPMWRAP + chmod +x "$BIN_DIR/npm" + fi + if [ -f "$NODE_DIR/lib/node_modules/npm/bin/npx-cli.js" ]; then + cat > "$BIN_DIR/npx" << NPXWRAP +#!$PREFIX/bin/bash +exec "$BIN_DIR/node" "$NODE_DIR/lib/node_modules/npm/bin/npx-cli.js" "\$@" +NPXWRAP + chmod +x "$BIN_DIR/npx" + fi + # corepack: shebang patch only + if [ -f "$NODE_DIR/bin/corepack" ] && head -1 "$NODE_DIR/bin/corepack" 2>/dev/null | grep -q '#!/usr/bin/env node'; then + sed -i "1s|#!/usr/bin/env node|#!$BIN_DIR/node|" "$NODE_DIR/bin/corepack" + fi + + # Configure npm + export PATH="$BIN_DIR:$NODE_DIR/bin:$PATH" + "$BIN_DIR/npm" config set script-shell "$PREFIX/bin/sh" 2>/dev/null || true + + # Verify + NODE_VER=$("$BIN_DIR/node" --version 2>/dev/null) || { + echo -e " ${RED}✗${NC} Node.js verification failed" + exit 1 + } + echo -e " ${GREEN}✓${NC} Node.js $NODE_VER (glibc)" +fi + +# ─── [4/7] OpenClaw ───────────────────────── +echo -e "▸ ${YELLOW}[4/7]${NC} Installing OpenClaw..." +export PATH="$BIN_DIR:$NODE_DIR/bin:$PATH" + +# Auto-detect GitHub mirror for restricted networks +resolve_repo_base + +# Auto-detect npm registry (session-scoped via NPM_CONFIG_REGISTRY env var). +# Does NOT write to ~/.npmrc — see CHANGELOG v1.0.24. +resolve_npm_registry || true + +# Force git to use HTTPS instead of SSH (no SSH client available). +# Preserve any existing user .gitconfig (name, email, aliases); only set our keys. +touch "$HOME/.gitconfig" +git config --global http.sslCAInfo "$PREFIX/etc/tls/cert.pem" +git config --global --unset-all url."https://github.com/".insteadOf 2>/dev/null || true +git config --global --add url."https://github.com/".insteadOf "ssh://git@github.com/" +git config --global --add url."https://github.com/".insteadOf "git@github.com:" + +# Git wrapper: replace $PREFIX/bin/git with a wrapper that: +# 1. Strips --recurse-submodules (triggers open() on hardcoded com.termux path) +# 2. Cleans existing target dirs before clone (npm's withTempDir creates dir first) +# npm caches git path at module load via which.sync('git'), so we must replace the binary. +# $PREFIX/bin/git is a symlink -> ../libexec/git-core/git (the real ELF binary). +REAL_GIT="$PREFIX/libexec/git-core/git" +if [ -f "$REAL_GIT" ] && [ ! -f "$PREFIX/bin/git.wrapper-installed" ]; then + echo " Installing git wrapper (strips --recurse-submodules)..." + rm -f "$PREFIX/bin/git" + # Write shebang with absolute path (no LD_PRELOAD = no /bin/bash rewrite) + echo "#!${PREFIX}/bin/bash" > "$PREFIX/bin/git" + cat >> "$PREFIX/bin/git" << 'ENDWRAP' +filtered=() +is_clone=false +for a in "$@"; do + case "$a" in + --recurse-submodules) ;; + clone) is_clone=true; filtered+=("$a") ;; + *) filtered+=("$a") ;; + esac +done +if $is_clone; then + for a in "${filtered[@]}"; do + case "$a" in + clone|--*|-*|http*|ssh*|git*|[0-9]) ;; + *) [ -d "$a" ] && rm -rf "$a" ;; + esac + done +fi +ENDWRAP + echo "exec \"$REAL_GIT\" \"\${filtered[@]}\"" >> "$PREFIX/bin/git" + chmod +x "$PREFIX/bin/git" + touch "$PREFIX/bin/git.wrapper-installed" + echo -e " ${GREEN}\u2713${NC} git wrapper installed" +else + if [ -f "$PREFIX/bin/git.wrapper-installed" ]; then + echo -e " ${GREEN}[SKIP]${NC} git wrapper already installed" + else + echo -e " ${RED}\u2717${NC} Real git not found at $REAL_GIT" + exit 1 + fi +fi + +if command -v openclaw &>/dev/null 2>&1; then + OC_VER=$(openclaw --version 2>/dev/null || echo "unknown") + echo -e " ${GREEN}[SKIP]${NC} OpenClaw already installed ($OC_VER)" +else + # Clean npm cache tmp dir (leftover from previous failed installs) + rm -rf "$HOME/.npm/_cacache/tmp" 2>/dev/null || true + npm install -g openclaw@latest --ignore-scripts 2>&1 + OC_VER=$(openclaw --version 2>/dev/null || echo "installed") + echo -e " ${GREEN}✓${NC} OpenClaw $OC_VER" +fi + +# Restore optional/channel deps that --ignore-scripts skips. +# Uses npm_config_ignore_scripts=true so sharp's native build doesn't block. +OPENCLAW_DIR="$(npm root -g)/openclaw" +if [ -d "$OPENCLAW_DIR" ]; then + echo " Restoring optional dependencies..." + (cd "$OPENCLAW_DIR" && npm_config_ignore_scripts=true node scripts/postinstall-bundled-plugins.mjs 2>/dev/null) || true +fi + +# Install clawdhub (skill manager) +echo " Installing clawdhub..." +if npm install -g clawdhub --no-fund --no-audit; then + echo -e " ${GREEN}✓${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..." + (cd "$CLAWHUB_DIR" && npm install undici --no-fund --no-audit) || true + fi +else + echo -e " ${YELLOW}[WARN]${NC} clawdhub installation failed (non-critical)" +fi + +# PyYAML (for .skill packaging) +command -v python &>/dev/null && { python -c "import yaml" 2>/dev/null || pip install pyyaml -q || true; } + +# Run openclaw update (builds native modules like sharp) +echo " Running: openclaw update (this may take 5-10 minutes)..." +openclaw update || true + +# ─── [5/7] Patches ────────────────────────── +echo -e "▸ ${YELLOW}[5/7]${NC} Applying patches..." + +# Copy glibc-compat.js from project (bundled alongside this script) +COMPAT_SRC="$(dirname "$0")/glibc-compat.js" +if [ -f "$COMPAT_SRC" ]; then + cp "$COMPAT_SRC" "$OCA_DIR/patches/glibc-compat.js" +else + # Fallback: download from repo + curl -fsSL "$REPO_BASE/patches/glibc-compat.js" \ + -o "$OCA_DIR/patches/glibc-compat.js" 2>/dev/null || true +fi + +# systemctl stub +printf '#!%s/bin/bash\nexit 0\n' "$PREFIX" > "$PREFIX/bin/systemctl" +chmod +x "$PREFIX/bin/systemctl" + +# sharp WASM fallback (prebuilt native binaries don't load on Android) +if [ -d "$OPENCLAW_DIR/node_modules/sharp" ]; then + if ! node -e "require('$OPENCLAW_DIR/node_modules/sharp')" 2>/dev/null; then + echo " Installing sharp WebAssembly runtime..." + (cd "$OPENCLAW_DIR" && npm install @img/sharp-wasm32 --force --no-audit --no-fund 2>&1 | tail -3) || true + fi +fi + +echo -e " ${GREEN}✓${NC} Patches applied" + +# ─── [6/7] Environment ────────────────────── +echo -e "▸ ${YELLOW}[6/7]${NC} Configuring environment..." + +cat > "$HOME/.bashrc" << BASHRC +# OpenClaw Android environment +export PREFIX="$PREFIX" +export HOME="$HOME" +export TMPDIR="$TMPDIR" +export PATH="$BIN_DIR:$NODE_DIR/bin:\$PREFIX/bin:\$PATH" +export LD_LIBRARY_PATH="$PREFIX/lib" +export LD_PRELOAD="$PREFIX/lib/libtermux-exec.so" +export TERMUX__PREFIX="$PREFIX" +export TERMUX_PREFIX="$PREFIX" +export LANG=en_US.UTF-8 +export TERM=xterm-256color +export OA_GLIBC=1 +export CONTAINER=1 +export SSL_CERT_FILE="$PREFIX/etc/tls/cert.pem" +export CURL_CA_BUNDLE="$PREFIX/etc/tls/cert.pem" +export GIT_SSL_CAINFO="$PREFIX/etc/tls/cert.pem" +export GIT_CONFIG_NOSYSTEM=1 +export GIT_EXEC_PATH="$PREFIX/libexec/git-core" +export GIT_TEMPLATE_DIR="$PREFIX/share/git-core/templates" +export CLAWDHUB_WORKDIR="$HOME/.openclaw/workspace" +export CPATH="$PREFIX/include/glib-2.0:$PREFIX/lib/glib-2.0/include" +# npm registry (auto-detected by OpenClaw Android, safe to override manually) +[ -z "\${NPM_CONFIG_REGISTRY:-}" ] && [ -s "\$HOME/.openclaw-android/.npm-registry" ] && \\ + export NPM_CONFIG_REGISTRY="\$(cat "\$HOME/.openclaw-android/.npm-registry")" +BASHRC + +echo -e " ${GREEN}✓${NC} ~/.bashrc configured" + +# oa CLI (enables oa --update, oa --backup, etc.) +if curl -fsSL "$REPO_BASE/oa.sh" \ + -o "$PREFIX/bin/oa" 2>/dev/null; then + chmod +x "$PREFIX/bin/oa" + echo -e " ${GREEN}✓${NC} oa CLI installed" +else + echo -e " ${YELLOW}[WARN]${NC} oa CLI installation failed (non-critical)" +fi + +# ─── [7/7] Optional Tools ────────────────── +TOOL_CONF="$OCA_DIR/tool-selections.conf" +if [ -f "$TOOL_CONF" ]; then + # shellcheck source=/dev/null + source "$TOOL_CONF" + + HAS_TOOLS=false + for var in INSTALL_TMUX INSTALL_TTYD INSTALL_DUFS INSTALL_CODE_SERVER INSTALL_PLAYWRIGHT INSTALL_CLAUDE_CODE INSTALL_GEMINI_CLI INSTALL_CODEX_CLI; do + eval "val=\${$var:-false}" + # shellcheck disable=SC2154 + [ "$val" = "true" ] && HAS_TOOLS=true && break + done + + if $HAS_TOOLS; then + echo -e "▸ ${YELLOW}[7/7]${NC} Installing optional tools..." + + # Helper: install .deb with direct dependencies + install_with_deps() { + local pkg="$1" + local deps + deps=$(awk -v pkg="$pkg" ' + /^Package: / { found = ($2 == pkg) } + found && /^Depends:/ { + gsub(/^Depends: /, "") + gsub(/ *\([^)]*\)/, "") + gsub(/, /, "\n") + print; exit + } + ' "$PACKAGES_FILE") + while IFS= read -r dep; do + dep=$(echo "$dep" | tr -d ' ') + [ -z "$dep" ] && continue + local dep_file + dep_file=$(get_deb_filename "$dep") + if [ -n "$dep_file" ]; then install_deb "$dep_file" 2>/dev/null || true; fi + done <<< "$deps" + local filename + filename=$(get_deb_filename "$pkg") + [ -n "$filename" ] && install_deb "$filename" + } + + # Termux packages + [ "${INSTALL_TMUX:-false}" = "true" ] && { + echo " Installing tmux..." + install_with_deps tmux + echo -e " ${GREEN}✓${NC} tmux" + } + [ "${INSTALL_TTYD:-false}" = "true" ] && { + echo " Installing ttyd..." + install_with_deps ttyd + echo -e " ${GREEN}✓${NC} ttyd" + } + [ "${INSTALL_DUFS:-false}" = "true" ] && { + echo " Installing dufs..." + install_with_deps dufs + echo -e " ${GREEN}✓${NC} dufs" + } + + # npm packages + [ "${INSTALL_CODE_SERVER:-false}" = "true" ] && { + echo " Installing code-server (this may take a while)..." + npm install -g code-server 2>&1 || true + echo -e " ${GREEN}✓${NC} code-server" + } + [ "${INSTALL_PLAYWRIGHT:-false}" = "true" ] && { + echo " Installing Playwright (playwright-core)..." + npm install -g playwright-core 2>&1 || true + # Set Playwright environment variables if Chromium is available + CHROMIUM_BIN="" + for bin in "$PREFIX/bin/chromium-browser" "$PREFIX/bin/chromium"; do + [ -x "$bin" ] && CHROMIUM_BIN="$bin" && break + done + if [ -n "$CHROMIUM_BIN" ]; then + PW_MARKER_START="# >>> Playwright >>>" + PW_MARKER_END="# <<< Playwright <<<" + if ! grep -qF "$PW_MARKER_START" "$HOME/.bashrc"; then + cat >> "$HOME/.bashrc" << PWENV + +${PW_MARKER_START} +export PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH="$CHROMIUM_BIN" +export PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 +${PW_MARKER_END} +PWENV + fi + echo -e " ${GREEN}✓${NC} Playwright (env: PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=$CHROMIUM_BIN)" + else + echo -e " ${GREEN}✓${NC} Playwright (install Chromium later via 'oa --install' for full setup)" + fi + } + [ "${INSTALL_CLAUDE_CODE:-false}" = "true" ] && { + echo " Installing Claude Code..." + npm install -g @anthropic-ai/claude-code 2>&1 || true + echo -e " ${GREEN}✓${NC} Claude Code" + } + [ "${INSTALL_GEMINI_CLI:-false}" = "true" ] && { + echo " Installing Gemini CLI..." + npm install -g @google/gemini-cli 2>&1 || true + echo -e " ${GREEN}✓${NC} Gemini CLI" + } + [ "${INSTALL_CODEX_CLI:-false}" = "true" ] && { + echo " Installing Codex CLI (Termux)..." + npm install -g @mmmbuto/codex-cli-termux 2>&1 || true + # Create codex CLI wrapper (DioNanos fork launcher fix) + _codex_bin="$PREFIX/bin/codex" + _codex_pkg="$PREFIX/lib/node_modules/@mmmbuto/codex-cli-termux/bin" + if [ -f "$_codex_pkg/codex.bin" ]; then + [ -L "$_codex_bin" ] && rm -f "$_codex_bin" + printf '#!%s/bin/bash\nPKG_BIN="%s"\nexport LD_LIBRARY_PATH="$PKG_BIN:${LD_LIBRARY_PATH:-}"\nexec "$PKG_BIN/codex.bin" "$@"\n' \ + "$PREFIX" "$_codex_pkg" > "$_codex_bin" + chmod +x "$_codex_bin" + fi + echo -e " ${GREEN}✓${NC} Codex CLI (Termux)" + } + + # Fix shebangs in npm global CLIs (kept in sync with scripts/lib.sh fix_npm_global_shebangs()) + for _js in "$PREFIX/lib/node_modules"/*/bin/*.js \ + "$PREFIX/lib/node_modules"/@*/*/bin/*.js; do + [ -f "$_js" ] || continue + head -1 "$_js" | grep -q '^#!/usr/bin/env node$' || continue + sed -i "1s|#!/usr/bin/env node|#!$BIN_DIR/node|" "$_js" + done + else + echo -e "▸ ${YELLOW}[7/7]${NC} No optional tools selected" + fi +else + echo -e "▸ ${YELLOW}[7/7]${NC} No optional tools selected" +fi + +# ─── Cleanup ──────────────────────────────── +rm -rf "$DEB_DIR" "$PKG_DIR" "$PACKAGES_FILE" "$TMPDIR/gpkg.db" 2>/dev/null || true + +# ─── Done ──────────────────────────────────── +touch "$MARKER" + +echo "" +echo "══════════════════════════════════════════════" +echo -e " ${GREEN}✓ Installation complete!${NC}" +echo "══════════════════════════════════════════════" +echo "" +echo " Loading environment..." +source "$HOME/.bashrc" +echo "" +echo " Starting OpenClaw onboard..." +echo "" +openclaw onboard diff --git a/scripts/backup.sh b/scripts/backup.sh new file mode 100644 index 0000000..f6f701b --- /dev/null +++ b/scripts/backup.sh @@ -0,0 +1,404 @@ +#!/usr/bin/env bash +# backup.sh — oa --backup / oa --restore implementation +# Sourced by oa.sh after lib.sh is loaded. + +# ── Constants ── +BACKUP_DIR="$PROJECT_DIR/backup" +BACKUP_SCHEMA_VERSION=1 + +# ── Helpers ── + +# Build an ISO-8601 timestamp matching OpenClaw's naming rule: +# colons replaced with dashes, e.g. 2026-03-14T00-00-00.000Z +_backup_timestamp() { + date -u +"%Y-%m-%dT%H-%M-%S.000Z" +} + +# Return the archive-root name embedded inside the tarball. +# OpenClaw uses the bare filename (without .tar.gz) as archiveRoot. +_backup_archive_root() { + local basename="$1" # e.g. 2026-…-openclaw-backup + echo "$basename" +} + +# Collect all paths that exist under a given data dir and return them +# as a bash array (by name). Skips missing paths silently. +# Usage: _collect_assets +_collect_assets() { + local data_dir="$1" + local -n _arr="$2" # nameref — bash 4.3+ + + local candidates=( + "openclaw.json5" + "openclaw.json" + ".env" + "secrets.json" + "credentials" + "identity" + "auth" + "sessions" + "workspace" + ) + + # Agents directory — dynamic enumeration + if [ -d "$data_dir/agents" ]; then + while IFS= read -r agent_path; do + local rel="${agent_path#"$data_dir/"}" + candidates+=("$rel") + done < <(find "$data_dir/agents" -mindepth 1 -maxdepth 2 -name "agent" 2>/dev/null) + fi + + _arr=() + for rel in "${candidates[@]}"; do + local full="$data_dir/$rel" + if [ -e "$full" ]; then + _arr+=("$rel") + fi + done +} + +# Detect which runtime owns a backup by inspecting its manifest.json. +# Echoes the platform name (e.g. "openclaw"), or "" on failure. +_detect_backup_platform() { + local archive="$1" + + # Extract manifest.json from the tarball without unpacking everything + local manifest + manifest=$(gzip -dc "$archive" 2>/dev/null | tar -xf - --wildcards "*/manifest.json" -O 2>/dev/null | head -c 65536) + + if [ -z "$manifest" ]; then + echo "" + return 1 + fi + + # Quick heuristic: look for known platform fingerprints in sourcePath values + if echo "$manifest" | grep -q '"\.openclaw"'; then + echo "openclaw" + return 0 + fi + if echo "$manifest" | grep -q '\.openclaw'; then + echo "openclaw" + return 0 + fi + + echo "" + return 1 +} + +# Return the restore root directory for a given platform name. +_restore_root_for_platform() { + local platform="$1" + case "$platform" in + openclaw) + echo "$HOME/.openclaw" + ;; + *) + # Future platforms: extend here + echo "" + ;; + esac +} + +# ── cmd_backup ────────────────────────────────────────────────────────────── + +cmd_backup() { + if ! command -v gzip &>/dev/null; then + echo " Installing gzip..." + pkg install -y gzip 2>/dev/null || { echo -e "${RED}[FAIL]${NC} gzip not found and could not be installed"; exit 1; } + fi + + local output_dir="${1:-}" + + # Resolve output directory + if [ -z "$output_dir" ]; then + output_dir="$BACKUP_DIR" + fi + + echo "" + echo -e "${BOLD}OpenClaw on Android — Backup${NC}" + echo -e "────────────────────────────────────────" + + # Load platform config to get PLATFORM_DATA_DIR + local platform + platform=$(detect_platform 2>/dev/null) || platform="" + + if [ -z "$platform" ]; then + echo -e "${RED}[FAIL]${NC} Could not detect installed platform." + exit 1 + fi + + load_platform_config "$platform" "$PROJECT_DIR" 2>/dev/null || { + echo -e "${RED}[FAIL]${NC} Could not load platform config for: $platform" + exit 1 + } + + local data_dir="$PLATFORM_DATA_DIR" + + if [ ! -d "$data_dir" ]; then + echo -e "${RED}[FAIL]${NC} Platform data directory not found: $data_dir" + exit 1 + fi + + # Collect assets + local assets=() + _collect_assets "$data_dir" assets + + if [ ${#assets[@]} -eq 0 ]; then + echo -e "${YELLOW}[WARN]${NC} No backup targets found in $data_dir" + exit 1 + fi + + echo -e " Platform: $platform" + echo -e " Source: $data_dir" + echo -e " Destination: $output_dir" + echo -e " Assets: ${#assets[@]} item(s)" + echo "" + + # Create output directory + mkdir -p "$output_dir" + + # Build filename — OpenClaw naming rule + local ts + ts=$(_backup_timestamp) + local basename="${ts}-openclaw-backup" + local archive_filename="${basename}.tar.gz" + local archive_path="$output_dir/$archive_filename" + local archive_root + archive_root=$(_backup_archive_root "$basename") + + # Build manifest.json in a temp dir, then pack everything + local tmpdir + tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/oa-backup.XXXXXX") + trap 'rm -rf "'"$tmpdir"'"' EXIT + + local staging="$tmpdir/$archive_root" + local payload_dir="$staging/payload" + mkdir -p "$payload_dir" + + # Copy each asset into payload/, preserving relative structure + echo -e "Collecting files…" + local manifest_assets_json="" + local sep="" + for rel in "${assets[@]}"; do + local src="$data_dir/$rel" + local dst="$payload_dir/$rel" + + # Determine kind + local kind="state" + case "$rel" in + openclaw.json5|openclaw.json) kind="config" ;; + .env|secrets.json|credentials|identity|auth) kind="config" ;; + workspace*) kind="workspace" ;; + agents*) kind="workspace" ;; + sessions*) kind="state" ;; + esac + + local archive_path_rel="$archive_root/payload/$rel" + + if [ -d "$src" ]; then + mkdir -p "$dst" + cp -a "$src/." "$dst/" + else + mkdir -p "$(dirname "$dst")" + cp -a "$src" "$dst" + fi + + # Append to manifest assets JSON array + manifest_assets_json+="${sep}" + manifest_assets_json+=$(printf ' {\n "kind": "%s",\n "sourcePath": "%s",\n "archivePath": "%s"\n }' \ + "$kind" "$src" "$archive_path_rel") + sep=$',\n' + done + + # Generate manifest.json + local node_version runtime_version + node_version=$(node --version 2>/dev/null || echo "unknown") + runtime_version=$(openclaw --version 2>/dev/null | head -1 || echo "unknown") + + cat > "$staging/manifest.json" < "$archive_path"; then + echo -e "${RED}[FAIL]${NC} Failed to create archive: $archive_path" + exit 1 + fi + + echo -e "${GREEN}[OK]${NC} Archive created: $archive_path" + echo "" + + # ── Integrity verification ── + echo -e "Verifying integrity…" + + # Try openclaw backup verify first (preferred — full manifest check) + if command -v openclaw &>/dev/null && openclaw backup verify "$archive_path" &>/dev/null 2>&1; then + echo -e "${GREEN}[OK]${NC} Integrity check passed (openclaw backup verify)" + else + # Fallback: tar -tzf structural check + local file_count + file_count=$(gzip -dc "$archive_path" 2>/dev/null | tar -tf - 2>/dev/null | wc -l) + if [ "$file_count" -gt 0 ]; then + echo -e "${GREEN}[OK]${NC} Integrity check passed (tar structural, $file_count entries)" + else + echo -e "${RED}[FAIL]${NC} Integrity check failed — archive may be corrupt" + exit 1 + fi + fi + + echo "" + echo -e "${GREEN}Backup complete.${NC}" + echo -e " File: $archive_path" + echo -e " Size: $(du -sh "$archive_path" | cut -f1)" + echo "" +} + +# ── cmd_restore ───────────────────────────────────────────────────────────── + +cmd_restore() { + if ! command -v gzip &>/dev/null; then + echo " Installing gzip..." + pkg install -y gzip 2>/dev/null || { echo -e "${RED}[FAIL]${NC} gzip not found and could not be installed"; exit 1; } + fi + + echo "" + echo -e "${BOLD}OpenClaw on Android — Restore${NC}" + echo -e "────────────────────────────────────────" + + # Collect backup files + if [ ! -d "$BACKUP_DIR" ]; then + echo -e "${RED}[FAIL]${NC} Backup directory not found: $BACKUP_DIR" + echo -e " Run ${BOLD}oa --backup${NC} first." + exit 1 + fi + + local -a backups=() + while IFS= read -r f; do + backups+=("$f") + done < <(ls -t "$BACKUP_DIR"/*.tar.gz 2>/dev/null) + + if [ ${#backups[@]} -eq 0 ]; then + echo -e "${RED}[FAIL]${NC} No backup files found in $BACKUP_DIR" + echo -e " Run ${BOLD}oa --backup${NC} first." + exit 1 + fi + + # Display numbered list + echo -e "Available backups:" + echo "" + local idx=1 + for f in "${backups[@]}"; do + local fname size + fname=$(basename "$f") + size=$(du -sh "$f" 2>/dev/null | cut -f1) + printf " ${BOLD}[%d]${NC} %s ${YELLOW}(%s)${NC}\n" "$idx" "$fname" "$size" + idx=$((idx + 1)) + done + + echo "" + local choice + if (echo -n "" > /dev/tty) 2>/dev/null; then + read -rp "Select backup to restore [1-${#backups[@]}]: " choice < /dev/tty + else + read -rp "Select backup to restore [1-${#backups[@]}]: " choice + fi + + # Validate input + if ! [[ "$choice" =~ ^[0-9]+$ ]] || [ "$choice" -lt 1 ] || [ "$choice" -gt "${#backups[@]}" ]; then + echo -e "${RED}[FAIL]${NC} Invalid selection: $choice" + exit 1 + fi + + local selected="${backups[$((choice - 1))]}" + echo "" + echo -e " Selected: ${BOLD}$(basename "$selected")${NC}" + + # Detect platform from manifest + echo -e " Detecting platform…" + local platform + platform=$(_detect_backup_platform "$selected") + + if [ -z "$platform" ]; then + echo -e "${RED}[FAIL]${NC} Could not determine backup platform from manifest." + exit 1 + fi + + local restore_root + restore_root=$(_restore_root_for_platform "$platform") + + if [ -z "$restore_root" ]; then + echo -e "${RED}[FAIL]${NC} Unsupported platform in backup: $platform" + exit 1 + fi + + echo -e " Platform: $platform" + echo -e " Restore to: $restore_root" + echo "" + + # ── Warning ── + echo -e "${YELLOW}┌─────────────────────────────────────────────────┐${NC}" + echo -e "${YELLOW}│ WARNING: This will overwrite your current │${NC}" + echo -e "${YELLOW}│ configuration and data in: │${NC}" + echo -e "${YELLOW}│ │${NC}" + echo -e "${YELLOW}│ $restore_root${NC}" + echo -e "${YELLOW}│ │${NC}" + echo -e "${YELLOW}│ Existing files will be replaced. This cannot │${NC}" + echo -e "${YELLOW}│ be undone unless you have another backup. │${NC}" + echo -e "${YELLOW}└─────────────────────────────────────────────────┘${NC}" + echo "" + + if ! ask_yn "Continue with restore?"; then + echo -e "Restore cancelled." + exit 0 + fi + + echo "" + echo -e "Restoring…" + + # Extract archive: only the payload/ contents go to restore_root + # The archive structure is: /payload/ + # We strip the first two components (/payload) and restore to restore_root + + # Get archiveRoot from manifest + local archive_root + archive_root=$(gzip -dc "$selected" 2>/dev/null | tar -xf - --wildcards "*/manifest.json" -O 2>/dev/null \ + | grep '"archiveRoot"' \ + | sed 's/.*"archiveRoot"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/') + + if [ -z "$archive_root" ]; then + echo -e "${RED}[FAIL]${NC} Could not read archiveRoot from manifest." + exit 1 + fi + + mkdir -p "$restore_root" + + # Extract payload files, stripping /payload/ prefix + if ! gzip -dc "$selected" 2>/dev/null | tar -xf - \ + --strip-components=2 \ + --exclude="${archive_root}/manifest.json" \ + -C "$restore_root" \ + "${archive_root}/payload/" 2>/dev/null; then + echo -e "${RED}[FAIL]${NC} Extraction failed." + exit 1 + fi + + echo -e "${GREEN}[OK]${NC} Restore complete." + echo "" + echo -e " Restored to: $restore_root" + echo "" + echo -e "${YELLOW}[NOTE]${NC} Restart the OpenClaw gateway for changes to take effect." + echo "" +} diff --git a/scripts/build-sharp.sh b/scripts/build-sharp.sh new file mode 100755 index 0000000..56d5a1c --- /dev/null +++ b/scripts/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/scripts/check-env.sh b/scripts/check-env.sh new file mode 100755 index 0000000..e94c4ec --- /dev/null +++ b/scripts/check-env.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib.sh" + +ERRORS=0 + +echo "=== OpenClaw on Android - Environment Check ===" +echo "" + +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." + exit 1 +else + echo -e "${GREEN}[OK]${NC} Termux detected (PREFIX=$PREFIX)" +fi + +ARCH=$(uname -m) +echo -n " Architecture: $ARCH" +if [ "$ARCH" = "aarch64" ]; then + echo -e " ${GREEN}(recommended)${NC}" +elif [ "$ARCH" = "armv7l" ] || [ "$ARCH" = "arm" ]; then + echo -e " ${YELLOW}(supported, but aarch64 recommended)${NC}" +elif [ "$ARCH" = "x86_64" ] || [ "$ARCH" = "i686" ]; then + echo -e " ${YELLOW}(emulator detected)${NC}" +else + echo -e " ${YELLOW}(unknown, may not work)${NC}" +fi + +AVAILABLE_MB=$(df "$PREFIX" 2>/dev/null | awk 'NR==2 {print int($4/1024)}') +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 + +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" + NODE_MAJOR="${NODE_VER%%.*}" + NODE_MAJOR="${NODE_MAJOR#v}" + if [ "$NODE_MAJOR" -lt 22 ] 2>/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 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 "" +if [ "$ERRORS" -gt 0 ]; then + echo -e "${RED}Environment check failed with $ERRORS error(s).${NC}" + exit 1 +else + echo -e "${GREEN}Environment check passed.${NC}" +fi 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..c76ed43 --- /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 may include i18next ad text before the version line. + # Use regex to extract X.Y.Z directly, ignoring any noise lines. + CURRENT_VERSION=$("$BIN_DIR/code-server" --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -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-glibc.sh b/scripts/install-glibc.sh new file mode 100755 index 0000000..6fa171c --- /dev/null +++ b/scripts/install-glibc.sh @@ -0,0 +1,157 @@ +#!/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 + +# ── Install supplementary glibc libraries (always runs) ── +# glibc-runner provides core libraries but not all libraries that +# third-party binaries may need (e.g., libcap.so.2 for codex-acp). +# This runs on both fresh install and update to ensure libraries are current. + +GLIBC_LIB_DIR="$PREFIX/glibc/lib" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +GLIBC_LIBS_SRC="$SCRIPT_DIR/../patches/glibc-libs" + +if [ -d "$GLIBC_LIB_DIR" ] && [ -d "$GLIBC_LIBS_SRC" ]; then + for lib in "$GLIBC_LIBS_SRC"/*.so.*; do + [ -f "$lib" ] || continue + filename=$(basename "$lib") + soname=$(echo "$filename" | sed -E 's/^(lib[^.]+\.so\.[0-9]+)\..*/\1/') + if [ ! -f "$GLIBC_LIB_DIR/$filename" ]; then + cp "$lib" "$GLIBC_LIB_DIR/$filename" + [ "$soname" != "$filename" ] && ln -sf "$filename" "$GLIBC_LIB_DIR/$soname" + echo -e "${GREEN}[OK]${NC} Installed $soname" + fi + done +fi + +# ── Ensure glibc /etc/hosts exists (always runs) ── +# glibc's getaddrinfo reads $PREFIX/glibc/etc/hosts for localhost resolution. +# Neither glibc nor glibc-runner packages include this file; it comes from +# resolv-conf (via openssl-glibc) which may not be installed. +# Without it, dns.lookup('localhost') can return 0.0.0.0 → gateway bind failure. + +GLIBC_ETC="$PREFIX/glibc/etc" +if [ -d "$GLIBC_ETC" ] && [ ! -f "$GLIBC_ETC/hosts" ]; then + cat > "$GLIBC_ETC/hosts" <<'HOSTS' +127.0.0.1 localhost localhost.localdomain +::1 localhost ip6-localhost ip6-loopback +HOSTS + echo -e "${GREEN}[OK]${NC} Created glibc /etc/hosts" +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..d0ff1a2 --- /dev/null +++ b/scripts/install-nodejs.sh @@ -0,0 +1,352 @@ +#!/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" +BIN_DIR="$OPENCLAW_DIR/bin" +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 (check BIN_DIR wrapper first, fall back to NODE_DIR) +_NODE_CMD="" +if [ -x "$BIN_DIR/node" ]; then + _NODE_CMD="$BIN_DIR/node" +elif [ -x "$NODE_DIR/bin/node" ]; then + _NODE_CMD="$NODE_DIR/bin/node" +fi +if [ -n "$_NODE_CMD" ]; then + if "$_NODE_CMD" --version &>/dev/null; then + INSTALLED_VER=$("$_NODE_CMD" --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})" + # Repair wrappers — ensure they exist in BIN_DIR (npm-safe location) + mkdir -p "$BIN_DIR" + _any_fixed=false + # Ensure node wrapper exists in BIN_DIR (may be missing for pre-v1.0.16 installs) + if [ ! -x "$BIN_DIR/node" ]; then + # Ensure node.real exists + if [ -f "$NODE_DIR/bin/node" ] && [ ! -L "$NODE_DIR/bin/node" ] && file "$NODE_DIR/bin/node" 2>/dev/null | grep -q ELF; then + mv "$NODE_DIR/bin/node" "$NODE_DIR/bin/node.real" + fi + if [ -f "$NODE_DIR/bin/node.real" ]; then + cat > "$BIN_DIR/node" << NODEWRAP +#!${PREFIX}/bin/bash +[ -n "\$LD_PRELOAD" ] && export _OA_ORIG_LD_PRELOAD="\$LD_PRELOAD" +unset LD_PRELOAD +export _OA_WRAPPER_PATH="$BIN_DIR/node" +_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 +_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 "$GLIBC_LDSO" --library-path "$PREFIX/glibc/lib" "$NODE_DIR/bin/node.real" "\$@" +NODEWRAP + chmod +x "$BIN_DIR/node" + _any_fixed=true + fi + fi + if [ -f "$NODE_DIR/lib/node_modules/npm/bin/npm-cli.js" ]; then + cat > "$BIN_DIR/npm" << 'NPMWRAP' +#!__PREFIX__/bin/bash +"__BIN_DIR__/node" "__NODE_DIR__/lib/node_modules/npm/bin/npm-cli.js" "$@" +_npm_exit=$? +case "$*" in *-g*openclaw*|*--global*openclaw*|*openclaw*-g*|*openclaw*--global*) + _oc_bin="__PREFIX__/bin/openclaw" + _oc_mjs="__PREFIX__/lib/node_modules/openclaw/openclaw.mjs" + if [ -f "$_oc_mjs" ]; then + [ -L "$_oc_bin" ] && rm -f "$_oc_bin" + printf '#!__PREFIX__/bin/bash\nexec "__BIN_DIR__/node" "%s" "$@"\n' "$_oc_mjs" > "$_oc_bin" + chmod +x "$_oc_bin" + fi + ;; +esac +# Re-patch codex CLI wrapper after global install/update (DioNanos fork launcher fix) +case "$*" in *codex-cli-termux*) + _codex_bin="__PREFIX__/bin/codex" + _codex_pkg="__PREFIX__/lib/node_modules/@mmmbuto/codex-cli-termux/bin" + if [ -f "$_codex_pkg/codex.bin" ]; then + [ -L "$_codex_bin" ] && rm -f "$_codex_bin" + printf '#!__PREFIX__/bin/bash\nPKG_BIN="%s"\nexport LD_LIBRARY_PATH="$PKG_BIN:${LD_LIBRARY_PATH:-}"\nexec "$PKG_BIN/codex.bin" "$@"\n' "$_codex_pkg" > "$_codex_bin" + chmod +x "$_codex_bin" + fi + ;; +esac +# Fix shebangs in npm global CLI entry points after global install +case "$*" in *-g*|*--global*) + for _js in __PREFIX__/lib/node_modules/*/bin/*.js \ + __PREFIX__/lib/node_modules/@*/*/bin/*.js; do + [ -f "$_js" ] || continue + head -1 "$_js" | grep -q '^#!/usr/bin/env node$' || continue + sed -i "1s|#!/usr/bin/env node|#!__BIN_DIR__/node|" "$_js" + done + ;; +esac +exit $_npm_exit +NPMWRAP + sed -i "s|__PREFIX__|$PREFIX|g; s|__BIN_DIR__|$BIN_DIR|g; s|__NODE_DIR__|$NODE_DIR|g" "$BIN_DIR/npm" + chmod +x "$BIN_DIR/npm" + _any_fixed=true + fi + if [ -f "$NODE_DIR/lib/node_modules/npm/bin/npx-cli.js" ]; then + cat > "$BIN_DIR/npx" << 'NPXWRAP' +#!__PREFIX__/bin/bash +exec "__BIN_DIR__/node" "__NODE_DIR__/lib/node_modules/npm/bin/npx-cli.js" "$@" +NPXWRAP + sed -i "s|__PREFIX__|$PREFIX|g; s|__BIN_DIR__|$BIN_DIR|g; s|__NODE_DIR__|$NODE_DIR|g" "$BIN_DIR/npx" + chmod +x "$BIN_DIR/npx" + _any_fixed=true + fi + if [ -f "$NODE_DIR/bin/corepack" ] && head -1 "$NODE_DIR/bin/corepack" 2>/dev/null | grep -q '#!/usr/bin/env node'; then + sed -i "1s|#!/usr/bin/env node|#!$BIN_DIR/node|" "$NODE_DIR/bin/corepack" + _any_fixed=true + fi + if [ "$_any_fixed" = true ]; then + echo -e "${YELLOW}[FIX]${NC} wrappers repaired in $BIN_DIR" + fi + 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 ──────────── +# +# Wrappers are placed in BIN_DIR (~/.openclaw-android/bin/), separate from +# NODE_DIR/bin/ which npm manages. This prevents npm from overwriting our +# wrappers when users run 'npm install -g npm' or similar commands. + +echo "" +echo "Creating wrapper scripts (grun-style, no patchelf)..." +mkdir -p "$BIN_DIR" + +# 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 in BIN_DIR +# 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 > "$BIN_DIR/node" << WRAPPER +#!${PREFIX}/bin/bash +[ -n "\$LD_PRELOAD" ] && export _OA_ORIG_LD_PRELOAD="\$LD_PRELOAD" +unset LD_PRELOAD +export _OA_WRAPPER_PATH="$BIN_DIR/node" +_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 +_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 "$GLIBC_LDSO" --library-path "$PREFIX/glibc/lib" "$NODE_DIR/bin/node.real" "\$@" +WRAPPER +chmod +x "$BIN_DIR/node" +echo -e "${GREEN}[OK]${NC} node wrapper created ($BIN_DIR/node)" + +# ── Step 2.5: Create npm/npx wrapper scripts in BIN_DIR ── +# +# npm/npx wrappers go in BIN_DIR (not NODE_DIR/bin/) so npm can't overwrite them. +echo "Creating npm/npx wrapper scripts..." +if [ -f "$NODE_DIR/lib/node_modules/npm/bin/npm-cli.js" ]; then + cat > "$BIN_DIR/npm" << 'NPMWRAP' +#!__PREFIX__/bin/bash +"__BIN_DIR__/node" "__NODE_DIR__/lib/node_modules/npm/bin/npm-cli.js" "$@" +_npm_exit=$? +# Re-patch openclaw CLI wrapper after global install/update +case "$*" in *-g*openclaw*|*--global*openclaw*|*openclaw*-g*|*openclaw*--global*) + _oc_bin="__PREFIX__/bin/openclaw" + _oc_mjs="__PREFIX__/lib/node_modules/openclaw/openclaw.mjs" + if [ -f "$_oc_mjs" ]; then + [ -L "$_oc_bin" ] && rm -f "$_oc_bin" + printf '#!__PREFIX__/bin/bash\nexec "__BIN_DIR__/node" "%s" "$@"\n' "$_oc_mjs" > "$_oc_bin" + chmod +x "$_oc_bin" + fi + ;; +esac +# Re-patch codex CLI wrapper after global install/update (DioNanos fork launcher fix) +case "$*" in *codex-cli-termux*) + _codex_bin="__PREFIX__/bin/codex" + _codex_pkg="__PREFIX__/lib/node_modules/@mmmbuto/codex-cli-termux/bin" + if [ -f "$_codex_pkg/codex.bin" ]; then + [ -L "$_codex_bin" ] && rm -f "$_codex_bin" + printf '#!__PREFIX__/bin/bash\nPKG_BIN="%s"\nexport LD_LIBRARY_PATH="$PKG_BIN:${LD_LIBRARY_PATH:-}"\nexec "$PKG_BIN/codex.bin" "$@"\n' "$_codex_pkg" > "$_codex_bin" + chmod +x "$_codex_bin" + fi + ;; +esac +# Fix shebangs in npm global CLI entry points after global install +case "$*" in *-g*|*--global*) + for _js in __PREFIX__/lib/node_modules/*/bin/*.js \ + __PREFIX__/lib/node_modules/@*/*/bin/*.js; do + [ -f "$_js" ] || continue + head -1 "$_js" | grep -q '^#!/usr/bin/env node$' || continue + sed -i "1s|#!/usr/bin/env node|#!__BIN_DIR__/node|" "$_js" + done + ;; +esac +exit $_npm_exit +NPMWRAP + sed -i "s|__PREFIX__|$PREFIX|g; s|__BIN_DIR__|$BIN_DIR|g; s|__NODE_DIR__|$NODE_DIR|g" "$BIN_DIR/npm" + chmod +x "$BIN_DIR/npm" + echo -e "${GREEN}[OK]${NC} npm wrapper created ($BIN_DIR/npm)" +fi +if [ -f "$NODE_DIR/lib/node_modules/npm/bin/npx-cli.js" ]; then + cat > "$BIN_DIR/npx" << 'NPXWRAP' +#!__PREFIX__/bin/bash +exec "__BIN_DIR__/node" "__NODE_DIR__/lib/node_modules/npm/bin/npx-cli.js" "$@" +NPXWRAP + sed -i "s|__PREFIX__|$PREFIX|g; s|__BIN_DIR__|$BIN_DIR|g; s|__NODE_DIR__|$NODE_DIR|g" "$BIN_DIR/npx" + chmod +x "$BIN_DIR/npx" + echo -e "${GREEN}[OK]${NC} npx wrapper created ($BIN_DIR/npx)" +fi +# corepack uses a different structure — shebang patch is sufficient +if [ -f "$NODE_DIR/bin/corepack" ] && head -1 "$NODE_DIR/bin/corepack" 2>/dev/null | grep -q '#!/usr/bin/env node'; then + sed -i "1s|#!/usr/bin/env node|#!$BIN_DIR/node|" "$NODE_DIR/bin/corepack" + echo -e "${GREEN}[OK]${NC} corepack shebang patched" +fi + +# ── 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="$BIN_DIR:$NODE_DIR/bin:$PATH" +"$BIN_DIR/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=$("$BIN_DIR/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=$("$BIN_DIR/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=$("$BIN_DIR/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 ($BIN_DIR/node)" diff --git a/scripts/install-opencode.sh b/scripts/install-opencode.sh new file mode 100644 index 0000000..bec7c1a --- /dev/null +++ b/scripts/install-opencode.sh @@ -0,0 +1,237 @@ +#!/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" + +# Remove bun's global shim — it tries to spawnSync the native binary directly, +# which fails on Termux (missing /lib/ld-linux-aarch64.so.1). +# Our proot wrapper at $PREFIX/bin/opencode will be used instead. +rm -f "$HOME/.bun/bin/opencode" + +# 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 + # shellcheck disable=SC2012,SC2086 + 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 +mkdir -p "$OPENCLAW_DIR/bin" +LDSO_OPENCODE="$OPENCLAW_DIR/bin/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/install-playwright.sh b/scripts/install-playwright.sh new file mode 100755 index 0000000..4424bd9 --- /dev/null +++ b/scripts/install-playwright.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +# install-playwright.sh - Install Playwright for browser automation +# Usage: bash install-playwright.sh [install|update] +# +# What it does: +# 1. Ensure Chromium is installed (dependency) +# 2. Install playwright-core via npm global +# 3. Set Playwright environment variables in .bashrc +# 4. Print usage guide +# +# This script is WARN-level: failure does not abort the parent installer. +set -euo pipefail + +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BOLD='\033[1m' +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 + +if ! command -v npm &>/dev/null; then + fail_warn "npm not found — Node.js is required for Playwright" +fi + +# ── Step 1: Ensure Chromium is installed ────── + +if ! CHROMIUM_BIN=$(detect_chromium_bin); then + echo "Chromium is required for Playwright. Installing..." + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + if [ -f "$SCRIPT_DIR/install-chromium.sh" ]; then + if ! bash "$SCRIPT_DIR/install-chromium.sh" install; then + fail_warn "Chromium installation failed — cannot proceed with Playwright" + fi + else + fail_warn "install-chromium.sh not found — install Chromium first" + fi + + if ! CHROMIUM_BIN=$(detect_chromium_bin); then + fail_warn "Chromium binary not found after installation" + fi +fi + +echo -e "${GREEN}[OK]${NC} Chromium found: $CHROMIUM_BIN" + +# ── Step 2: Install playwright-core ─────────── + +if [ "$MODE" = "install" ]; then + if npm list -g playwright-core &>/dev/null; then + echo -e "${GREEN}[SKIP]${NC} playwright-core already installed" + else + echo "Installing playwright-core..." + if ! npm install -g playwright-core; then + fail_warn "Failed to install playwright-core" + fi + echo -e "${GREEN}[OK]${NC} playwright-core installed" + fi +elif [ "$MODE" = "update" ]; then + echo "Updating playwright-core..." + if ! npm install -g playwright-core@latest; then + fail_warn "Failed to update playwright-core" + fi + echo -e "${GREEN}[OK]${NC} playwright-core updated" +fi + +# ── Step 3: Set environment variables ───────── + +BASHRC="$HOME/.bashrc" +PW_MARKER_START="# >>> Playwright >>>" +PW_MARKER_END="# <<< Playwright <<<" + +PW_BLOCK="${PW_MARKER_START} +export PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=\"$CHROMIUM_BIN\" +export PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 +${PW_MARKER_END}" + +touch "$BASHRC" +if grep -qF "$PW_MARKER_START" "$BASHRC"; then + sed -i "/${PW_MARKER_START//\//\\/}/,/${PW_MARKER_END//\//\\/}/d" "$BASHRC" +fi +echo "" >> "$BASHRC" +echo "$PW_BLOCK" >> "$BASHRC" + +echo -e "${GREEN}[OK]${NC} Environment variables set in .bashrc" +echo " PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=$CHROMIUM_BIN" +echo " PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1" + +# ── Step 4: Usage guide ────────────────────── + +echo "" +echo -e "${BOLD} Playwright is ready!${NC}" +echo "" +echo " To use in your project:" +echo "" +echo " npm install playwright-core # add to your project" +echo "" +echo " Example code:" +echo "" +echo " const { chromium } = require('playwright-core');" +echo "" +echo " const browser = await chromium.launch();" +echo " const page = await browser.newPage();" +echo " await page.goto('https://example.com');" +echo " await page.screenshot({ path: 'screenshot.png' });" +echo " await browser.close();" +echo "" +echo -e " ${YELLOW}[NOTE]${NC} Environment variables are set. No need to specify" +echo " executablePath or --no-sandbox manually." +echo "" +echo " To apply environment variables in current session:" +echo " source ~/.bashrc" +echo "" diff --git a/scripts/lib.sh b/scripts/lib.sh new file mode 100755 index 0000000..fbfdfe7 --- /dev/null +++ b/scripts/lib.sh @@ -0,0 +1,152 @@ +#!/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" +BIN_DIR="$PROJECT_DIR/bin" +PLATFORM_MARKER="$PROJECT_DIR/.platform" +REPO_BASE_ORIGIN="https://raw.githubusercontent.com/AidanPark/openclaw-android/main" +REPO_BASE_MIRRORS=( + "https://ghfast.top/https://raw.githubusercontent.com/AidanPark/openclaw-android/main" + "https://ghproxy.net/https://raw.githubusercontent.com/AidanPark/openclaw-android/main" + "https://mirror.ghproxy.com/https://raw.githubusercontent.com/AidanPark/openclaw-android/main" +) +NPM_REGISTRY_ORIGIN="https://registry.npmjs.org/" +NPM_REGISTRY_MIRROR="https://registry.npmmirror.com/" +NPM_REGISTRY_CACHE="$PROJECT_DIR/.npm-registry" + +# Detect reachable REPO_BASE (origin first, then mirrors) +resolve_repo_base() { + if curl -sI --connect-timeout 3 "$REPO_BASE_ORIGIN/oa.sh" >/dev/null 2>&1; then + REPO_BASE="$REPO_BASE_ORIGIN" + return 0 + fi + for mirror in "${REPO_BASE_MIRRORS[@]}"; do + if curl -sI --connect-timeout 3 "$mirror/oa.sh" >/dev/null 2>&1; then + echo -e " ${YELLOW}[MIRROR]${NC} Using mirror: ${mirror%%/oa.sh*}" + REPO_BASE="$mirror" + return 0 + fi + done + # Fallback to origin even if unreachable + REPO_BASE="$REPO_BASE_ORIGIN" + return 1 +} + +# Detect reachable npm registry and export NPM_CONFIG_REGISTRY (origin first, then mirror) +resolve_npm_registry() { + local choice + local cache_file="$NPM_REGISTRY_CACHE" + local reachable=0 + if curl -sI --connect-timeout 5 "$NPM_REGISTRY_ORIGIN" >/dev/null 2>&1; then + choice="$NPM_REGISTRY_ORIGIN" + reachable=1 + elif curl -sI --connect-timeout 5 "$NPM_REGISTRY_MIRROR" >/dev/null 2>&1; then + echo -e " ${YELLOW}[MIRROR]${NC} Using npm mirror: ${NPM_REGISTRY_MIRROR}" + choice="$NPM_REGISTRY_MIRROR" + reachable=1 + else + choice="$NPM_REGISTRY_ORIGIN" + fi + mkdir -p "$(dirname "$cache_file")" + printf '%s' "$choice" > "$cache_file.tmp" && mv "$cache_file.tmp" "$cache_file" + export NPM_CONFIG_REGISTRY="$choice" + if [ "$reachable" -eq 1 ]; then + return 0 + fi + return 1 +} + +# Fix shebangs in npm globally-installed CLI entry points +# Rewrites #!/usr/bin/env node → #!$BIN_DIR/node so CLIs work on Android +fix_npm_global_shebangs() { + local _js + for _js in "$PREFIX/lib/node_modules"/*/bin/*.js \ + "$PREFIX/lib/node_modules"/@*/*/bin/*.js; do + [ -f "$_js" ] || continue + head -1 "$_js" | grep -q '^#!/usr/bin/env node$' || continue + sed -i "1s|#!/usr/bin/env node|#!$BIN_DIR/node|" "$_js" + done +} + +# Initialize REPO_BASE +REPO_BASE="$REPO_BASE_ORIGIN" + +BASHRC_MARKER_START="# >>> OpenClaw on Android >>>" +BASHRC_MARKER_END="# <<< OpenClaw on Android <<<" +OA_VERSION="1.0.27" + +# ── 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. +ask_yn() { + local prompt="$1" + local reply + if (echo -n "" > /dev/tty) 2>/dev/null; then + read -rp "$prompt [Y/n] " reply < /dev/tty + else + read -rp "$prompt [Y/n] " reply + fi + [[ "${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 + # shellcheck source=/dev/null + source "$config_path" + return 0 +} diff --git a/scripts/setup-env.sh b/scripts/setup-env.sh new file mode 100755 index 0000000..f9795f5 --- /dev/null +++ b/scripts/setup-env.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/lib.sh" + +BASHRC="$HOME/.bashrc" +PLATFORM=$(detect_platform) || true + +INFRA_VARS="export TMPDIR=\"\$PREFIX/tmp\" +export TMP=\"\$TMPDIR\" +export TEMP=\"\$TMPDIR\" +export OA_GLIBC=1" + +# npm registry re-injection — reads cache file written by resolve_npm_registry. +# Literal \$HOME, \${NPM_CONFIG_REGISTRY:-}, and \$(cat ...) are preserved for +# runtime expansion in each new shell. -z guard lets users override manually. +NPM_REGISTRY_INJECT="# npm registry (auto-detected by OpenClaw Android, safe to override manually) +[ -z \"\${NPM_CONFIG_REGISTRY:-}\" ] && [ -s \"\$HOME/.openclaw-android/.npm-registry\" ] && \\ + export NPM_CONFIG_REGISTRY=\"\$(cat \"\$HOME/.openclaw-android/.npm-registry\")\"" + +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/bin:\$HOME/.openclaw-android/node/bin:\$HOME/.local/bin:\$PATH\"" + fi +fi + +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 + +ENV_BLOCK="${BASHRC_MARKER_START} +# platform: ${PLATFORM:-none} +${PATH_LINE} +${INFRA_VARS}" + +if [ -n "$PLATFORM_VARS" ]; then + ENV_BLOCK="${ENV_BLOCK} +${PLATFORM_VARS}" +fi + +ENV_BLOCK="${ENV_BLOCK} +${NPM_REGISTRY_INJECT} +${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" + +if [ ! -e "$PREFIX/bin/ar" ] && [ -x "$PREFIX/bin/llvm-ar" ]; then + ln -s "$PREFIX/bin/llvm-ar" "$PREFIX/bin/ar" +fi diff --git a/scripts/setup-paths.sh b/scripts/setup-paths.sh new file mode 100755 index 0000000..86d8b93 --- /dev/null +++ b/scripts/setup-paths.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/lib.sh" + +echo "=== Setting Up Paths ===" +echo "" + +mkdir -p "$PREFIX/tmp" +echo -e "${GREEN}[OK]${NC} Created $PREFIX/tmp" + +mkdir -p "$PROJECT_DIR/patches" +echo -e "${GREEN}[OK]${NC} Created $PROJECT_DIR/patches" + +echo "" +echo "Standard path mappings (via \$PREFIX):" +echo " /bin/sh -> $PREFIX/bin/sh" +echo " /usr/bin/env -> $PREFIX/bin/env" +echo " /tmp -> $PREFIX/tmp" + +echo "" +echo -e "${GREEN}Path setup complete.${NC}" diff --git a/tests/verify-compat.sh b/tests/verify-compat.sh new file mode 100644 index 0000000..2025d33 --- /dev/null +++ b/tests/verify-compat.sh @@ -0,0 +1,215 @@ +#!/usr/bin/env bash +# verify-compat.sh — Compatibility constraint harness +# +# Tests all known mutual-exclusion constraints simultaneously. +# A fix for one axis must not break another. Run after every +# glibc-compat.js or wrapper change. +# +# Usage: +# bash tests/verify-compat.sh (on device) +# ssh device 'bash ~/verify-compat.sh' (remote) + +set -uo pipefail + +PASS=0; FAIL=0; TOTAL=0 +RED='\033[0;31m'; GREEN='\033[0;32m'; NC='\033[0m' + +pass() { echo -e "${GREEN}[PASS]${NC} $1"; PASS=$((PASS+1)); TOTAL=$((TOTAL+1)); } +fail() { echo -e "${RED}[FAIL]${NC} $1"; FAIL=$((FAIL+1)); TOTAL=$((TOTAL+1)); } + +check() { + local desc="$1"; shift + if "$@" >/dev/null 2>&1; then pass "$desc"; else fail "$desc"; fi +} + +node_check() { + local desc="$1" code="$2" + if node -e "$code" 2>/dev/null; then pass "$desc"; else fail "$desc"; fi +} + +echo "=== Compatibility Constraint Harness ===" +echo "" + +# ───────────────────────────────────────────────── +# AXIS 1: LD_PRELOAD lifecycle +# Constraint A: node.real must load WITHOUT bionic LD_PRELOAD +# Constraint B: child bionic processes must HAVE LD_PRELOAD +# ───────────────────────────────────────────────── +echo "--- Axis 1: LD_PRELOAD lifecycle ---" + +# 1a: node.real itself must not have libtermux-exec loaded in its own process +# (if it did, glibc/bionic mismatch would crash — the fact that we're +# running means it didn't, but verify the wrapper structure) +WRAPPER=$(node -e "process.stdout.write(process.env._OA_WRAPPER_PATH || '')" 2>/dev/null) +if [ -n "$WRAPPER" ] && [ -f "$WRAPPER" ] && grep -q "unset LD_PRELOAD" "$WRAPPER"; then + pass "1a: node wrapper unsets LD_PRELOAD before exec" +else + fail "1a: node wrapper missing or missing 'unset LD_PRELOAD' (path: ${WRAPPER:-not set})" +fi + +# 1b: LD_PRELOAD must be restored in node's environment (for child inheritance) +node_check "1b: LD_PRELOAD restored in node env" \ + "if (!process.env.LD_PRELOAD) process.exit(1)" + +# 1c: child /bin/sh inherits LD_PRELOAD +CHILD_LP=$(node -e " +const { execSync } = require('child_process'); +process.stdout.write(execSync('echo \$LD_PRELOAD', {encoding:'utf8'}).trim()); +" 2>/dev/null) +if [ -n "$CHILD_LP" ] && echo "$CHILD_LP" | grep -q "libtermux-exec"; then + pass "1c: child sh inherits LD_PRELOAD (libtermux-exec)" +else + fail "1c: child sh missing LD_PRELOAD (got: ${CHILD_LP:-empty})" +fi + +# ───────────────────────────────────────────────── +# AXIS 2: Shebang resolution +# Constraint A: #!/usr/bin/env must resolve in child processes +# Constraint B: our own wrappers must NOT use #!/usr/bin/env +# ───────────────────────────────────────────────── +echo "--- Axis 2: Shebang resolution ---" + +# 2a: shebang with /usr/bin/env works from node child process +TMPSCRIPT="$(mktemp "${TMPDIR:-/tmp}/compat-test.XXXXXX")" +echo '#!/usr/bin/env sh' > "$TMPSCRIPT" +echo 'echo shebang-ok' >> "$TMPSCRIPT" +chmod +x "$TMPSCRIPT" +SHEBANG_OUT=$(node -e " +const { execSync } = require('child_process'); +process.stdout.write(execSync('$TMPSCRIPT', {encoding:'utf8'}).trim()); +" 2>/dev/null) +if [ "$SHEBANG_OUT" = "shebang-ok" ]; then + pass "2a: #!/usr/bin/env sh shebang resolves from node" +else + fail "2a: #!/usr/bin/env sh shebang failed (got: ${SHEBANG_OUT:-empty})" +fi +rm -f "$TMPSCRIPT" + +# 2b: our wrappers do NOT use #!/usr/bin/env +OUR_WRAPPERS_OK=true +WRAPPER_DIR=$(dirname "$WRAPPER" 2>/dev/null) +for f in "$WRAPPER_DIR/node" "$WRAPPER_DIR/npm" "$WRAPPER_DIR/npx"; do + if [ -f "$f" ] && head -1 "$f" | grep -q "/usr/bin/env"; then + fail "2b: $f uses #!/usr/bin/env (will break)" + OUR_WRAPPERS_OK=false + fi +done +$OUR_WRAPPERS_OK && pass "2b: our wrappers avoid #!/usr/bin/env" + +# ───────────────────────────────────────────────── +# AXIS 3: process identity +# Constraint A: process.platform must be 'linux' +# Constraint B: process.execPath must point to wrapper, not ld.so +# ───────────────────────────────────────────────── +echo "--- Axis 3: process identity ---" + +node_check "3a: process.platform === 'linux'" \ + "if (process.platform !== 'linux') process.exit(1)" + +EXEC_PATH=$(node -e "process.stdout.write(process.execPath)" 2>/dev/null) +if [ -x "$EXEC_PATH" ] && ! file "$EXEC_PATH" 2>/dev/null | grep -q ELF; then + pass "3b: process.execPath → wrapper script (not ld.so)" +else + fail "3b: process.execPath is not a wrapper script: $EXEC_PATH" +fi + +# ───────────────────────────────────────────────── +# AXIS 4: OS API shims +# Constraint: patched APIs must return valid data +# ───────────────────────────────────────────────── +echo "--- Axis 4: OS API shims ---" + +node_check "4a: os.cpus().length > 0" \ + "if (require('os').cpus().length === 0) process.exit(1)" + +node_check "4b: os.networkInterfaces() does not throw" \ + "require('os').networkInterfaces()" + +# ───────────────────────────────────────────────── +# AXIS 5: DNS resolution +# Constraint: dns.lookup must work without resolv.conf +# ───────────────────────────────────────────────── +echo "--- Axis 5: DNS resolution ---" + +DNS_OK=$(node -e " +const dns = require('dns'); +dns.lookup('github.com', (err, addr) => { + if (err) process.exit(1); + process.stdout.write(addr); + process.exit(0); +}); +" 2>/dev/null) +if [ -n "$DNS_OK" ]; then + pass "5a: dns.lookup('github.com') → $DNS_OK" +else + fail "5a: dns.lookup('github.com') failed" +fi + +# ───────────────────────────────────────────────── +# AXIS 6: child_process shell +# Constraint A: /bin/sh must work (or shim must be active) +# Constraint B: exec/execSync must default to valid shell +# ───────────────────────────────────────────────── +echo "--- Axis 6: child_process shell ---" + +node_check "6a: child_process.execSync works" \ + "require('child_process').execSync('echo ok', {encoding:'utf8'})" + +CHILD_PLATFORM=$(node -e " +const { execSync } = require('child_process'); +process.stdout.write(execSync('node -e \"process.stdout.write(process.platform)\"', {encoding:'utf8'})); +" 2>/dev/null) +if [ "$CHILD_PLATFORM" = "linux" ]; then + pass "6b: child node also reports platform=linux" +else + fail "6b: child node platform=$CHILD_PLATFORM (expected linux)" +fi + +# ───────────────────────────────────────────────── +# AXIS 7: npm lifecycle +# Constraint: npm install with lifecycle scripts must succeed +# ───────────────────────────────────────────────── +echo "--- Axis 7: npm lifecycle ---" + +NPM_SCRIPT_SHELL=$(npm config get script-shell 2>/dev/null) +if [ -n "$NPM_SCRIPT_SHELL" ] && [ -x "$NPM_SCRIPT_SHELL" ]; then + pass "7a: npm script-shell=$NPM_SCRIPT_SHELL (executable)" +else + fail "7a: npm script-shell not set or not executable ($NPM_SCRIPT_SHELL)" +fi + +# ───────────────────────────────────────────────── +# AXIS 8: glibc-compat.js integrity +# Constraint: all shims must be loaded +# ───────────────────────────────────────────────── +echo "--- Axis 8: glibc-compat.js integrity ---" + +COMPAT="$HOME/.openclaw-android/patches/glibc-compat.js" +if [ -f "$COMPAT" ]; then + pass "8a: glibc-compat.js exists" +else + fail "8a: glibc-compat.js missing" +fi + +NODE_OPTS=$(node -e "process.stdout.write(process.env.NODE_OPTIONS||'')" 2>/dev/null) +if echo "$NODE_OPTS" | grep -q "glibc-compat.js"; then + pass "8b: NODE_OPTIONS includes glibc-compat.js" +else + fail "8b: glibc-compat.js not in NODE_OPTIONS ($NODE_OPTS)" +fi + +# ───────────────────────────────────────────────── +# Summary +# ───────────────────────────────────────────────── +echo "" +echo "===============================" +echo -e " Results: ${GREEN}$PASS passed${NC} / ${RED}$FAIL failed${NC} / $TOTAL total" +echo "===============================" +echo "" + +if [ "$FAIL" -gt 0 ]; then + echo -e "${RED}COMPAT CHECK FAILED${NC} — fix failures before committing." + exit 1 +else + echo -e "${GREEN}ALL CONSTRAINTS SATISFIED${NC}" +fi diff --git a/tests/verify-install.sh b/tests/verify-install.sh new file mode 100755 index 0000000..a86054e --- /dev/null +++ b/tests/verify-install.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../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 on Android - Installation Verification ===" +echo "" + +if command -v node &>/dev/null; then + NODE_VER=$(node -v) + NODE_MAJOR="${NODE_VER%%.*}" + NODE_MAJOR="${NODE_MAJOR#v}" + if [ "$NODE_MAJOR" -ge 22 ] 2>/dev/null; then + check_pass "Node.js $NODE_VER (>= 22)" + else + check_fail "Node.js $NODE_VER (need >= 22)" + fi +else + check_fail "Node.js not found" +fi + +if command -v npm &>/dev/null; then + check_pass "npm $(npm -v)" +else + check_fail "npm not found" +fi + +if [ -n "${TMPDIR:-}" ]; then + check_pass "TMPDIR=$TMPDIR" +else + check_fail "TMPDIR not set" +fi + +if [ "${OA_GLIBC:-}" = "1" ]; then + check_pass "OA_GLIBC=1 (glibc architecture)" +else + check_fail "OA_GLIBC not set" +fi + +COMPAT_FILE="$PROJECT_DIR/patches/glibc-compat.js" +if [ -f "$COMPAT_FILE" ]; then + check_pass "glibc-compat.js exists" +else + check_fail "glibc-compat.js not found at $COMPAT_FILE" +fi + +GLIBC_MARKER="$PROJECT_DIR/.glibc-arch" +if [ -f "$GLIBC_MARKER" ]; then + check_pass "glibc architecture marker (.glibc-arch)" +else + check_fail "glibc architecture marker not found" +fi + +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 "glibc dynamic linker not found at $GLIBC_LDSO" +fi + +NODE_WRAPPER="$BIN_DIR/node" +if [ -f "$NODE_WRAPPER" ] && head -1 "$NODE_WRAPPER" 2>/dev/null | grep -q "bash"; then + check_pass "glibc node wrapper script" +else + check_fail "glibc node wrapper not found or not a wrapper script" +fi + +for DIR in "$PROJECT_DIR" "$PREFIX/tmp"; do + if [ -d "$DIR" ]; then + check_pass "Directory $DIR exists" + else + check_fail "Directory $DIR missing" + fi +done + +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 + +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}" +echo "===============================" +echo "" + +if [ "$FAIL" -gt 0 ]; then + echo -e "${RED}Installation verification FAILED.${NC}" + echo "Please check the errors above and re-run install.sh" + exit 1 +else + echo -e "${GREEN}Installation verification PASSED!${NC}" +fi diff --git a/uninstall.sh b/uninstall.sh new file mode 100644 index 0000000..00be220 --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,148 @@ +#!/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 + 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}" +echo -e "${BOLD} OpenClaw on Android - Uninstaller${NC}" +echo -e "${BOLD}========================================${NC}" +echo "" + +reply="" +read -rp "This will remove the installation. Continue? [y/N] " reply < /dev/tty +if [[ ! "$reply" =~ ^[Yy]$ ]]; then + echo "Aborted." + exit 0 +fi + +step() { + echo "" + echo -e "${BOLD}[$1/7] $2${NC}" + echo "----------------------------------------" +} + +step 1 "Platform uninstall" +PLATFORM=$(detect_platform 2>/dev/null || true) +if [ -z "$PLATFORM" ]; then + echo -e "${YELLOW}[SKIP]${NC} Platform not detected" +else + 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 + +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} code-server not found in ~/.local/lib" +fi + +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} ~/.local/bin/code-server not found" +fi + +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 + +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 + +step 7 "installation directory" + +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 $PROJECT_DIR" + fi +else + echo -e "${YELLOW}[SKIP]${NC} $PROJECT_DIR not found" +fi + +echo "" +echo -e "${GREEN}${BOLD}Uninstall complete.${NC}" +echo "Restart your Termux session to clear environment variables." +echo "" diff --git a/update-core.sh b/update-core.sh new file mode 100755 index 0000000..ee2f8d7 --- /dev/null +++ b/update-core.sh @@ -0,0 +1,340 @@ +#!/usr/bin/env bash +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.27" + +echo "" +echo -e "${BOLD}========================================${NC}" +echo -e "${BOLD} OpenClaw on Android - Updater v${OA_VERSION}${NC}" +echo -e "${BOLD}========================================${NC}" +echo "" + +step() { + echo "" + echo -e "${BOLD}[$1/5] $2${NC}" + echo "----------------------------------------" +} + +step 1 "Pre-flight Check" + +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" + +if ! command -v curl &>/dev/null; then + echo -e "${RED}[FAIL]${NC} curl not found. Install it with: pkg install curl" + exit 1 +fi + +OLD_DIR="$HOME/.openclaw-lite" +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 $PROJECT_DIR" +else + mkdir -p "$PROJECT_DIR" +fi + +if [ -f "$PROJECT_DIR/scripts/lib.sh" ]; then + source "$PROJECT_DIR/scripts/lib.sh" +fi +command -v resolve_npm_registry >/dev/null 2>&1 && resolve_npm_registry || true + +# 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 + +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" + +IS_GLIBC=false +if [ -f "$PROJECT_DIR/.glibc-arch" ]; then + IS_GLIBC=true + echo -e "${GREEN}[OK]${NC} Architecture: glibc" +else + 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 2 "Download Latest Release (tarball)" + +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 +} +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 release" + exit 1 +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" + +source "$RELEASE_TMP/scripts/lib.sh" + +step 3 "Update Core Infrastructure" + +mkdir -p "$PROJECT_DIR/platforms" "$PROJECT_DIR/scripts" "$PROJECT_DIR/patches" + +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" +if [ -f "$RELEASE_TMP/scripts/backup.sh" ]; then + cp "$RELEASE_TMP/scripts/backup.sh" "$PROJECT_DIR/scripts/backup.sh" +fi + +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" + +# Deploy supplementary glibc libraries (e.g., libcap.so.2 for native binary support) +if [ -d "$RELEASE_TMP/patches/glibc-libs" ] && [ -d "$PREFIX/glibc/lib" ]; then + for _lib in "$RELEASE_TMP/patches/glibc-libs"/*.so.*; do + [ -f "$_lib" ] || continue + _fn=$(basename "$_lib") + if [ ! -f "$PREFIX/glibc/lib/$_fn" ]; then + cp "$_lib" "$PREFIX/glibc/lib/$_fn" + _sn=$(echo "$_fn" | sed -E 's/^(lib[^.]+\.so\.[0-9]+)\..*/\1/') + [ "$_sn" != "$_fn" ] && ln -sf "$_fn" "$PREFIX/glibc/lib/$_sn" + echo -e "${GREEN}[OK]${NC} Installed glibc lib: $_sn" + fi + done +fi + +# Ensure glibc /etc/hosts exists (localhost resolution) +if [ -d "$PREFIX/glibc/etc" ] && [ ! -f "$PREFIX/glibc/etc/hosts" ]; then + cat > "$PREFIX/glibc/etc/hosts" <<'HOSTS' +127.0.0.1 localhost localhost.localdomain +::1 localhost ip6-localhost ip6-loopback +HOSTS + echo -e "${GREEN}[OK]${NC} Created glibc /etc/hosts" +fi + +cp "$RELEASE_TMP/oa.sh" "$PREFIX/bin/oa" +chmod +x "$PREFIX/bin/oa" + +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} glibc migration failed (non-critical)" + fi +fi + +# Update Node.js if a newer version is available +if [ "$IS_GLIBC" = true ]; then + bash "$RELEASE_TMP/scripts/install-nodejs.sh" || true +fi + +bash "$RELEASE_TMP/scripts/setup-env.sh" + +GLIBC_NODE_DIR="$PROJECT_DIR/node" +GLIBC_BIN_DIR="$PROJECT_DIR/bin" +if [ "$IS_GLIBC" = true ]; then + # Migrate wrappers from node/bin/ to bin/ (safe from npm overwrites) + if [ ! -d "$GLIBC_BIN_DIR" ] || [ ! -x "$GLIBC_BIN_DIR/node" ]; then + echo "" + echo -e "${BOLD}[MIGRATE] Moving wrappers to $GLIBC_BIN_DIR${NC}" + bash "$RELEASE_TMP/scripts/install-nodejs.sh" || true + echo -e "${GREEN}[OK]${NC} Wrapper migration complete" + fi + export PATH="$GLIBC_BIN_DIR:$GLIBC_NODE_DIR/bin:$HOME/.local/bin:$PATH" + export OA_GLIBC=1 +fi +export TMPDIR="$PREFIX/tmp" +export TMP="$TMPDIR" +export TEMP="$TMPDIR" +# 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 + +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 + +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}[SKIP]${NC} code-server not installed" +fi + +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 [ "$IS_GLIBC" = false ]; then + echo -e "${YELLOW}[SKIP]${NC} OpenCode requires glibc architecture" +else + 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 + +update_ai_tool() { + local cmd="$1" + local pkg="$2" + local label="$3" + + if ! command -v "$cmd" &>/dev/null; then + return 1 + fi + + 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 +# Migrate codex from upstream (static musl, broken DNS on Android) to Termux fork (dynamic Bionic) +if npm list -g @openai/codex &>/dev/null 2>&1; then + echo -e " ${YELLOW}[MIGRATE]${NC} Replacing @openai/codex with Termux-optimized @mmmbuto/codex-cli-termux..." + npm uninstall -g @openai/codex 2>/dev/null || true +fi +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" "@mmmbuto/codex-cli-termux" "Codex CLI (Termux)" && AI_FOUND=true +# Create/refresh codex CLI wrapper (DioNanos fork launcher fix) +_codex_bin="$PREFIX/bin/codex" +_codex_pkg="$PREFIX/lib/node_modules/@mmmbuto/codex-cli-termux/bin" +if [ -f "$_codex_pkg/codex.bin" ]; then + [ -L "$_codex_bin" ] && rm -f "$_codex_bin" + printf '#!%s/bin/bash\nPKG_BIN="%s"\nexport LD_LIBRARY_PATH="$PKG_BIN:${LD_LIBRARY_PATH:-}"\nexec "$PKG_BIN/codex.bin" "$@"\n' \ + "$PREFIX" "$_codex_pkg" > "$_codex_bin" + chmod +x "$_codex_bin" +fi +if [ "$AI_FOUND" = false ]; then + echo -e "${YELLOW}[SKIP]${NC} No AI CLI tools installed" +fi + +command -v fix_npm_global_shebangs >/dev/null 2>&1 && fix_npm_global_shebangs || true + +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" diff --git a/update.sh b/update.sh new file mode 100755 index 0000000..44f1402 --- /dev/null +++ b/update.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# update.sh - Thin wrapper that downloads and runs update-core.sh +# Usage: curl -sL https://raw.githubusercontent.com/AidanPark/openclaw-android/main/update.sh | bash +# or: oaupdate (after initial install) +set -euo pipefail + +RED='\033[0;31m' +YELLOW='\033[1;33m' +NC='\033[0m' + +REPO_BASE_ORIGIN="https://raw.githubusercontent.com/AidanPark/openclaw-android/main" +REPO_BASE="$REPO_BASE_ORIGIN" +LOGFILE="$HOME/.openclaw-android/update.log" + +# 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 + +# GitHub mirror fallback for restricted networks +resolve_repo_base() { + if curl -sI --connect-timeout 3 "$REPO_BASE_ORIGIN/oa.sh" >/dev/null 2>&1; then + REPO_BASE="$REPO_BASE_ORIGIN"; return 0 + fi + local mirrors=( + "https://ghfast.top/$REPO_BASE_ORIGIN" + "https://ghproxy.net/$REPO_BASE_ORIGIN" + "https://mirror.ghproxy.com/$REPO_BASE_ORIGIN" + ) + for m in "${mirrors[@]}"; do + if curl -sI --connect-timeout 3 "$m/oa.sh" >/dev/null 2>&1; then + echo -e "${YELLOW}[MIRROR]${NC} Using mirror for GitHub downloads" + REPO_BASE="$m"; return 0 + fi + done + return 1 +} +resolve_repo_base + +# Prepare log directory +mkdir -p "$HOME/.openclaw-android" + +# Download update-core.sh +TMPFILE=$(mktemp "${PREFIX:-/tmp}/tmp/update-core.XXXXXX.sh" 2>/dev/null) || TMPFILE=$(mktemp /tmp/update-core.XXXXXX.sh) +trap 'rm -f "$TMPFILE"' EXIT + +if ! curl -sfL "$REPO_BASE/update-core.sh" -o "$TMPFILE"; then + echo -e "${RED}[FAIL]${NC} Failed to download update-core.sh" + exit 1 +fi + +# Execute and save output to log +bash "$TMPFILE" 2>&1 | tee "$LOGFILE" + +echo "" +echo -e "${YELLOW}Log saved to $LOGFILE${NC}"