diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml new file mode 100644 index 0000000..d41cbae --- /dev/null +++ b/.github/workflows/build-web.yml @@ -0,0 +1,288 @@ +name: Build HarmonyOS Web APP (ArkWeb + on-device Node.js) + +# Web variant of the app — no electron-harmony runtime: +# ArkWeb (Web component) + our shared libnode.so running the electerm-web +# backend as a native child process (childProcessManager.startNativeChildProcess). +# +# Triggers on dev2 pushes. Uploads the signed .app as an artifact. + +on: + push: + branches: + - dev2 + - build + workflow_dispatch: + +# Cancel previous runs on the same branch +concurrency: + group: build-web-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + # Our own shared libnode.so release used as the on-device Node.js runtime. + # MUST stay in sync with the default in scripts/prepare-node.sh: the script + # derives the release tag as "ohos-node-shared-v${NODE_VERSION}", so a + # mismatch turns into a 404 on the asset download. + # 24.19.0 was the last hqzing/ohos-node release; 24.2.0 is the first + # self-built --shared libnode (a real shared library, not a PIE). + NODE_VERSION: '24.2.0' + +jobs: + build: + # HarmonyOS Command Line Tools are x64-only. + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + # ── Checkout ────────────────────────────────────────────────────────── + - name: Checkout electerm-harmony + uses: actions/checkout@v4 + + # ── Setup Node.js (for building the web app) ───────────────────────── + - name: Setup Node.js 24 + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: npm + + # ── Install system deps ─────────────────────────────────────────────── + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + build-essential \ + unzip \ + jq \ + xz-utils \ + python3 \ + make \ + g++ + + # ── Setup JDK (for hap-sign-tool.jar) ──────────────────────────────── + - name: Setup JDK 21 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '21' + + # ── Step 1: Cache / download HarmonyOS Command Line Tools (~2 GB) ──── + - name: Compute Command Line Tools cache key + id: cmdkey + env: + OHOS_CMDLINE_TOOLS_URL: ${{ secrets.OHOS_CMDLINE_TOOLS_URL }} + run: | + if [ -z "${OHOS_CMDLINE_TOOLS_URL}" ]; then + echo "::error::OHOS_CMDLINE_TOOLS_URL secret is not set." + exit 1 + fi + HASH="$(echo -n "${OHOS_CMDLINE_TOOLS_URL}" | md5sum | cut -d' ' -f1)" + echo "key=cmdline-tools-${HASH}" >> "$GITHUB_OUTPUT" + + - name: Restore HarmonyOS Command Line Tools cache + id: cmdline_cache + uses: actions/cache/restore@v4 + with: + path: .cache/commandline-tools + key: ${{ steps.cmdkey.outputs.key }} + + - name: Download & extract HarmonyOS Command Line Tools + if: steps.cmdline_cache.outputs.cache-hit != 'true' + env: + OHOS_CMDLINE_TOOLS_URL: ${{ secrets.OHOS_CMDLINE_TOOLS_URL }} + run: | + set -euo pipefail + mkdir -p .cache + ZIP=".cache/commandline-tools.zip" + echo "Cache miss — downloading HarmonyOS Command Line Tools (~2 GB) ..." + curl -L --retry 10 --retry-all-errors --retry-delay 5 -C - \ + -A "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36" \ + -o "$ZIP" "${OHOS_CMDLINE_TOOLS_URL}" + echo "Verifying archive integrity ..." + if ! unzip -t "$ZIP" >/dev/null 2>&1; then + echo "::error::Downloaded archive is corrupt; retrying once without resume." + curl -L --retry 10 --retry-all-errors --retry-delay 5 \ + -A "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36" \ + -o "$ZIP" "${OHOS_CMDLINE_TOOLS_URL}" + unzip -t "$ZIP" >/dev/null 2>&1 || { echo "::error::Still corrupt after retry"; exit 1; } + fi + rm -rf .cache/commandline-tools + mkdir -p .cache/commandline-tools + unzip -o -q "$ZIP" -d .cache/commandline-tools + rm -f "$ZIP" + echo "Downloaded and extracted HarmonyOS Command Line Tools" + + - name: Save HarmonyOS Command Line Tools cache + if: steps.cmdline_cache.outputs.cache-hit != 'true' && always() + uses: actions/cache/save@v4 + with: + path: .cache/commandline-tools + key: ${{ steps.cmdkey.outputs.key }} + + - name: Configure Command Line Tools environment + run: | + set -euo pipefail + COMMANDLINE_TOOLS="$(pwd)/.cache/commandline-tools/command-line-tools" + if [ ! -d "$COMMANDLINE_TOOLS" ]; then + COMMANDLINE_TOOLS="$(cd "$(dirname "$(find .cache/commandline-tools -name ohpm -type f | head -1)")/.." && pwd)" + fi + # Fix: Project root package.json has "type": "module", which makes + # Node.js treat hvigorw.js as an ES module (breaks with "require is + # not defined"). Adding a CommonJS package.json to the tools dirs + # prevents Node from traversing up to the project root. + echo '{"type":"commonjs"}' > "$COMMANDLINE_TOOLS/hvigor/package.json" 2>/dev/null || true + echo '{"type":"commonjs"}' > "$COMMANDLINE_TOOLS/package.json" + echo "COMMANDLINE_TOOLS=$COMMANDLINE_TOOLS" >> "$GITHUB_ENV" + echo "OHOS_SDK_HOME=$COMMANDLINE_TOOLS/sdk" >> "$GITHUB_ENV" + echo "DEVECO_NODE_HOME=$COMMANDLINE_TOOLS/tool/node" >> "$GITHUB_ENV" + echo "DEVECO_SDK_HOME=$COMMANDLINE_TOOLS/sdk" >> "$GITHUB_ENV" + echo "$COMMANDLINE_TOOLS/bin" >> "$GITHUB_PATH" + echo "$COMMANDLINE_TOOLS/hvigor/bin" >> "$GITHUB_PATH" + echo "HarmonyOS Command Line Tools ready at $COMMANDLINE_TOOLS" + + - name: Configure ohpm registry + run: | + ohpm config set registry https://ohpm.openharmony.cn/ohpm/ || true + ohpm --version || true + + - name: Restore ohpm modules cache + id: ohpm_cache + uses: actions/cache/restore@v4 + with: + path: | + oh_modules + entry/oh_modules + ~/.ohpm + key: ohpm-web-${{ hashFiles('**/oh-package.json5', '**/oh-package.json') }} + restore-keys: | + ohpm-web- + + # ── Step 2: Prepare the OpenHarmony Node.js runtime ─────────────────── + # shared libnode.so release → entry/libs/arm64-v8a/libnode.so + - name: Restore Node runtime cache + id: node_cache + uses: actions/cache/restore@v4 + with: + path: .cache/node-runtime + key: ohos-node-${{ env.NODE_VERSION }} + + - name: Prepare Node.js runtime (shared libnode.so) + run: ./scripts/prepare-node.sh + + - name: Save Node runtime cache + if: steps.node_cache.outputs.cache-hit != 'true' && always() + uses: actions/cache/save@v4 + with: + path: .cache/node-runtime + key: ohos-node-${{ env.NODE_VERSION }} + + - name: Inject safe-storage secret + env: + STORAGE_SECRET: ${{ secrets.OHOS_SERVER_SECRET }} + run: node scripts/inject-safe-storage-secret.mjs + + # ── Step 3: Build web app (frontend + backend bundle → resfile) ────── + - name: Prepare web app + run: ./scripts/prepare-web.sh + env: + SERVER_SECRET: ${{ secrets.OHOS_SERVER_SECRET }} + + # ── Step 4: Decode signing materials ──────────────────────────────── + - name: Decode signing materials + env: + OHOS_KEYSTORE_B64: ${{ secrets.OHOS_KEYSTORE_B64 }} + OHOS_CERT_B64: ${{ secrets.OHOS_CERT_B64 }} + OHOS_PROFILE_B64: ${{ secrets.OHOS_PROFILE_B64 }} + run: | + mkdir -p signing + if [ -z "${OHOS_KEYSTORE_B64}" ] || [ -z "${OHOS_CERT_B64}" ] || [ -z "${OHOS_PROFILE_B64}" ]; then + echo "One or more signing material secrets are not set" + exit 1 + fi + echo "${OHOS_KEYSTORE_B64}" | base64 -d > signing/electerm.p12 + echo "${OHOS_CERT_B64}" | base64 -d > signing/electerm_publish.cer + echo "${OHOS_PROFILE_B64}" | base64 -d > signing/electermRelease.p7b + + for f in signing/electerm.p12 signing/electerm_publish.cer signing/electermRelease.p7b; do + if [ ! -s "${f}" ]; then + echo "::error::Failed to decode ${f} — check GitHub Secrets." + exit 1 + fi + echo " ✓ $(basename ${f}): $(du -h ${f} | cut -f1)" + done + + # ── Step 5: Set bundle name from secret ────────────────────────────── + - name: Configure bundle name + env: + BUNDLE_NAME: ${{ secrets.OHOS_BUNDLE_NAME }} + run: | + if [ -n "${BUNDLE_NAME}" ]; then + sed -i "s/\"bundleName\": \".*\"/\"bundleName\": \"${BUNDLE_NAME}\"/" \ + AppScope/app.json5 + echo "Bundle name set to: ${BUNDLE_NAME}" + else + echo "Using default bundle name from app.json5" + fi + cat AppScope/app.json5 + + # ── Step 6: Build & sign the APP ───────────────────────────────────── + - name: Build HarmonyOS web app + run: ./scripts/build-web-app.sh --release + env: + COMMANDLINE_TOOLS: ${{ env.COMMANDLINE_TOOLS }} + OHOS_SDK_HOME: ${{ env.OHOS_SDK_HOME }} + KEYSTORE_PASSWORD: ${{ secrets.OHOS_KEYSTORE_PASSWORD }} + KEY_PASSWORD: ${{ secrets.OHOS_KEY_PASSWORD }} + KEY_ALIAS: ${{ secrets.OHOS_KEY_ALIAS }} + + - name: Save ohpm modules cache + if: steps.ohpm_cache.outputs.cache-hit != 'true' && always() + uses: actions/cache/save@v4 + with: + path: | + oh_modules + entry/oh_modules + ~/.ohpm + key: ohpm-web-${{ hashFiles('**/oh-package.json5', '**/oh-package.json') }} + + # ── Step 7: Upload artifact ────────────────────────────────────────── + - name: Find APP file + id: find_app + run: | + APP_FILE=$(find build/outputs -name "*.app" -type f | head -1) + if [ -z "${APP_FILE}" ]; then + echo "::error::No .app file found!" + exit 1 + fi + APP_NAME=$(basename "${APP_FILE}") + echo "app_path=${APP_FILE}" >> $GITHUB_OUTPUT + echo "app_name=${APP_NAME}" >> $GITHUB_OUTPUT + echo "artifact_name=${APP_NAME}" >> $GITHUB_OUTPUT + echo "Found APP: ${APP_FILE} ($(du -h ${APP_FILE} | cut -f1))" + + - name: Upload APP artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.find_app.outputs.artifact_name }} + path: ${{ steps.find_app.outputs.app_path }} + retention-days: 30 + + # ── Summary ────────────────────────────────────────────────────────── + - name: Build summary + if: always() + run: | + echo "## Build Summary (web)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Item | Value |" >> $GITHUB_STEP_SUMMARY + echo "|------|-------|" >> $GITHUB_STEP_SUMMARY + echo "| Branch | \`${{ github.ref_name }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Commit | \`${{ github.sha }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Runtime | \`shared libnode.so v${{ env.NODE_VERSION }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Web app | \`electerm-web backend + ArkWeb frontend\` |" >> $GITHUB_STEP_SUMMARY + echo "| App version | \`$(python3 -c "import json; print(json.load(open('package.json'))['version'])" 2>/dev/null || echo unknown)\` |" >> $GITHUB_STEP_SUMMARY + if [ -f "${{ steps.find_app.outputs.app_path }}" ]; then + echo "| APP file | \`${{ steps.find_app.outputs.app_name }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| APP size | \`$(du -h ${{ steps.find_app.outputs.app_path }} | cut -f1)\` |" >> $GITHUB_STEP_SUMMARY + fi diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 971e1fa..01d7a50 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,6 +6,9 @@ on: - build - dev - dev1 + # NOTE: dev2 intentionally excluded — that branch only builds the + # web variant (build-web.yml). Keeping it here would fire the full + # HarmonyOS APP build on every dev2 push, which we don't want. # Cancel previous runs on the same branch/tag concurrency: @@ -15,9 +18,10 @@ concurrency: permissions: contents: read -# Note: The Electron harmony OS runtime tarball URL is set via the -# ELECTRON_RUNTIME_URL secret in the "Prepare Electron runtime" step below, -# to avoid exposing the private address in the workflow file. +# Note: The Node.js runtime (libnode.so) is downloaded from the +# electerm/electerm-harmony release published manually via +# temp/bak/publish-node-release.sh — see the "Prepare Node.js runtime (arm64)" +# step below. jobs: build: @@ -56,15 +60,20 @@ jobs: distribution: 'temurin' java-version: '21' - # ── Step 1: Prepare Electron 鸿蒙 runtime ────────────────────────────── - # Downloads the pre-built tarball from the ELECTRON_RUNTIME_URL secret. - # The tarball contains: - # - web_engine/ (HAR module: ArkTS API + resfile resources) - # - electron/libs/arm64-v8a/*.so (native libraries) - - name: Prepare Electron runtime + # ── Step 1: Prepare Node.js runtime ──────────────────────────────────── + # Downloads our own real shared libnode.so (built with --shared via + # scripts/build-node-ohos.sh, archived in temp/bak/, published manually + # via temp/bak/publish-node-release.sh) from the electerm/electerm-harmony + # release. arm64-v8a is the device ABI for phones/tablets/2in1; this is + # the half that used to be a dlopen-crashing PIE executable + # (hqzing/ohos-node). + - name: Prepare Node.js runtime (arm64) + run: ./scripts/prepare-node.sh arm64 + + - name: Inject safe-storage secret env: - ELECTRON_RUNTIME_URL: ${{ secrets.ELECTRON_RUNTIME_URL }} - run: ./scripts/prepare-electron-runtime.sh + STORAGE_SECRET: ${{ secrets.OHOS_SERVER_SECRET }} + run: node scripts/inject-safe-storage-secret.mjs # ── Step 2: Build web app (frontend + backend bundle) ─────────── - name: Prepare web app @@ -205,11 +214,15 @@ jobs: cat AppScope/app.json5 # ── Step 6: Build & sign the APP ───────────────────────────────────── + # APP_ARCH=arm64: the device ABI (phones/tablets/2in1). The entry module + # abiFilters cover arm64-v8a + x86_64; build-app.sh selects + # entry/libs// by APP_ARCH. - name: Build HarmonyOS app run: ./scripts/build-app.sh --${{ github.event.inputs.build_mode || 'release' }} env: COMMANDLINE_TOOLS: ${{ env.COMMANDLINE_TOOLS }} OHOS_SDK_HOME: ${{ env.OHOS_SDK_HOME }} + APP_ARCH: arm64 KEYSTORE_PASSWORD: ${{ secrets.OHOS_KEYSTORE_PASSWORD }} KEY_PASSWORD: ${{ secrets.OHOS_KEY_PASSWORD }} KEY_ALIAS: ${{ secrets.OHOS_KEY_ALIAS }} @@ -244,6 +257,9 @@ jobs: # build-app.sh already verifies HAP contents, but this step provides # a clear pass/fail signal in the CI log and adds the results to the # GitHub Step Summary for quick inspection. + # Layout is the dev2 (ArkWeb + Node.js backend) one: the electerm web + # app lives at resources/resfile/electerm/ and the runtime .so files at + # libs//. - name: Verify APP contents run: | set -euo pipefail @@ -255,30 +271,38 @@ jobs: HAP_FILE=$(find "${TMPDIR}" -name "*.hap" -type f | head -1) HAP_DIR="${TMPDIR}/hap" unzip -q "${HAP_FILE}" -d "${HAP_DIR}" - APP_DIR="${HAP_DIR}/resources/resfile/resources/app" + APP_DIR="${HAP_DIR}/resources/resfile/electerm" ERRORS="" for f in \ - "assets/index.html" \ - "bootstrap.js" \ - "app.js" \ + "index.js" \ + "app.bundle.mjs" \ "package.json" \ - "server/server.js" \ - "lib/file-server.js"; do + "views/index.pug"; do if [ ! -f "${APP_DIR}/${f}" ]; then ERRORS="${ERRORS}\n ✗ MISSING: ${f}" else echo " ✓ ${f}" fi done - JS_COUNT=$(find "${APP_DIR}/assets/js" -name "*.js" 2>/dev/null | wc -l) - CSS_COUNT=$(find "${APP_DIR}/assets/css" -name "*.css" 2>/dev/null | wc -l) - CHUNK_COUNT=$(find "${APP_DIR}/assets/chunk" -name "*.js" 2>/dev/null | wc -l) + JS_COUNT=$(find "${APP_DIR}/dist/assets/js" -name "*.js" 2>/dev/null | wc -l) + CSS_COUNT=$(find "${APP_DIR}/dist/assets/css" -name "*.css" 2>/dev/null | wc -l) + CHUNK_COUNT=$(find "${APP_DIR}/dist/assets/chunk" -name "*.js" 2>/dev/null | wc -l) echo " ✓ assets/js: ${JS_COUNT} files" echo " ✓ assets/css: ${CSS_COUNT} files" echo " ✓ assets/chunk: ${CHUNK_COUNT} files" - if [ "${JS_COUNT}" -eq 0 ]; then ERRORS="${ERRORS}\n ✗ No JS files in assets/js/"; fi - if [ "${CSS_COUNT}" -eq 0 ]; then ERRORS="${ERRORS}\n ✗ No CSS files in assets/css/"; fi - if [ "${CHUNK_COUNT}" -eq 0 ]; then ERRORS="${ERRORS}\n ✗ No chunk files in assets/chunk/"; fi + if [ "${JS_COUNT}" -eq 0 ]; then ERRORS="${ERRORS}\n ✗ No JS files in dist/assets/js/"; fi + if [ "${CSS_COUNT}" -eq 0 ]; then ERRORS="${ERRORS}\n ✗ No CSS files in dist/assets/css/"; fi + if [ "${CHUNK_COUNT}" -eq 0 ]; then ERRORS="${ERRORS}\n ✗ No chunk files in dist/assets/chunk/"; fi + # Native runtime libs (real shared libnode.so — see prepare-node.sh) + LIB_NODE_COUNT=$(find "${HAP_DIR}/libs" -name "libnode.so" 2>/dev/null | wc -l) + LIB_CTL_COUNT=$(find "${HAP_DIR}/libs" -name "libnode_ctl.so" 2>/dev/null | wc -l) + LIB_LAUNCHER_COUNT=$(find "${HAP_DIR}/libs" -name "libnode_launcher.so" 2>/dev/null | wc -l) + echo " ✓ libs/libnode.so: ${LIB_NODE_COUNT} arch(s)" + echo " ✓ libs/libnode_ctl.so: ${LIB_CTL_COUNT} arch(s)" + echo " ✓ libs/libnode_launcher.so: ${LIB_LAUNCHER_COUNT} arch(s)" + if [ "${LIB_NODE_COUNT}" -eq 0 ]; then ERRORS="${ERRORS}\n ✗ libnode.so missing from libs/"; fi + if [ "${LIB_CTL_COUNT}" -eq 0 ]; then ERRORS="${ERRORS}\n ✗ libnode_ctl.so missing from libs/"; fi + if [ "${LIB_LAUNCHER_COUNT}" -eq 0 ]; then ERRORS="${ERRORS}\n ✗ libnode_launcher.so missing from libs/"; fi if [ -n "${ERRORS}" ]; then echo -e "::error::APP content verification failed:${ERRORS}" exit 1 @@ -306,7 +330,7 @@ jobs: echo "|------|-------|" >> $GITHUB_STEP_SUMMARY echo "| Branch/Tag | \`${{ github.ref_name }}\` |" >> $GITHUB_STEP_SUMMARY echo "| Commit | \`${{ github.sha }}\` |" >> $GITHUB_STEP_SUMMARY - echo "| Runtime | \`Electron 鸿蒙 (libelectron.so)\` |" >> $GITHUB_STEP_SUMMARY + echo "| Runtime | \`Node.js 24 (--shared libnode.so) + ArkWeb\` |" >> $GITHUB_STEP_SUMMARY echo "| Web app | \`electerm source (direct run)\` |" >> $GITHUB_STEP_SUMMARY echo "| App version | \`${{ env.APP_VERSION || 'unknown' }}\` |" >> $GITHUB_STEP_SUMMARY echo "| Build mode | \`${{ github.event.inputs.build_mode || 'release' }}\` |" >> $GITHUB_STEP_SUMMARY diff --git a/.gitignore b/.gitignore index 7593fb6..00fd44d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ # Note: /build/ contains committed source (build scripts, vite config). # Only ignore HarmonyOS build outputs and electerm-web build artifacts. /build/outputs/ +/build/tools/ /build/intermediates/ /oh_modules/ /entry/build/ @@ -51,7 +52,18 @@ Thumbs.db /build-download/ build/harmony/rawfile src/client/electerm-react/ -entry/src/main/resources/rawfile /src/client/electerm-react/ /data -.workbuddy \ No newline at end of file +.workbuddy +# --- Web (ArkWeb) build outputs --- +# electerm-web app bundled into the entry module resfile by build/web/build.mjs +/entry/src/main/resources/resfile/ +# node runtime download cache +/.cache/ +# hvigor native (CMake/Ninja) build dir & ohpm installs +entry/.cxx/ +entry/oh_modules/ +# generated local SDK paths (written by scripts/build-web-app.sh) +local.properties +build/.verify-tmp +/src \ No newline at end of file diff --git a/AppScope/app.json5 b/AppScope/app.json5 index 6928c34..6251eb3 100644 --- a/AppScope/app.json5 +++ b/AppScope/app.json5 @@ -2,8 +2,8 @@ "app": { "bundleName": "org.electerm.electerm", "vendor": "electerm", - "versionCode": 31500167, - "versionName": "5.3.15", + "versionCode": 50300016, + "versionName": "5.3.16", "icon": "$media:app_icon", "label": "$string:app_name" } diff --git a/README.md b/README.md index ab04bd1..89434df 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,21 @@ **electerm** is a free and open-sourced ssh/sftp/telnet/RDP/VNC/Spice/ftp client (linux, mac, win, HarmonyOS, Android, iOS). -This project brings electerm to **HarmonyOS** using the [Electron Harmony OS runtime](https://gitcode.com/openharmony-sig/electron) (Chromium + Node.js). +This project brings electerm to **HarmonyOS** using a lightweight on-device runtime — **no Electron**: + +- **ArkWeb** ([`@kit.ArkWeb`](https://developer.huawei.com/consumer/en/doc/harmonyos-guides-V5/arkweb-V5)) `Web` component renders the electerm-web frontend UI. +- An on-device **Node.js** backend serves the UI and runs the SSH/SFTP/telnet/ftp/RDP/VNC/Spice protocols. The Node.js runtime is a shared library (`libnode.so`) from [electerm/ohos-node-shared](https://github.com/electerm/ohos-node-shared), embedded in the HAP's native `libs` directory. + +The backend runs **in-process** in the main app process (the `libnode_ctl.so` NAPI module `dlopen`s `libnode.so` and calls `node::Start`), with a fallback to a **native child process** (`libnode_launcher.so`, launched via `childProcessManager.startNativeChildProcess`). + +``` +ArkWeb (frontend) ── http://127.0.0.1:5577 ──► Node.js backend (libnode.so) + Web component loads UI serves UI + SSH/SFTP/telnet/ftp/RDP/VNC/Spice +``` + +The electerm app (frontend + backend bundle) is packaged in the HAP `resfile/electerm` and read directly by the node process; the Node.js shared library is packaged as `libs/arm64-v8a/libnode.so`. On-device boot diagnostics land in `/electerm-data/node-boot.log`. + +> **Branch note.** This branch (`dev2`) is the Node.js + ArkWeb build. Other branches in this repo (`main`/`dev`/`dev1`) used the [Electron 鸿蒙 runtime](https://gitcode.com/openharmony-sig/electron); this branch does not. CI for this branch: `.github/workflows/build-web.yml` (push to `dev2`). --- diff --git a/README.zh-CN.md b/README.zh-CN.md index fc25758..903a3d0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -35,7 +35,21 @@ **electerm** 是一个免费开源的 ssh/sftp/telnet/RDP/VNC/Spice/ftp 客户端(支持 Linux、Mac、Windows、HarmonyOS、Android、iOS)。 -本项目使用 [Electron 鸿蒙运行时](https://gitcode.com/openharmony-sig/electron)(Chromium + Node.js)将 electerm 移植到 **HarmonyOS** 平台。 +本项目使用一种轻量级的端侧运行时将 electerm 移植到 **HarmonyOS** —— **不再使用 Electron**: + +- **ArkWeb**(`@kit.ArkWeb` 的 `Web` 组件)负责渲染 electerm-web 前端界面。 +- 端侧 **Node.js** 后端负责提供界面并运行 SSH/SFTP/telnet/ftp/RDP/VNC/Spice 协议。Node.js 运行时(共享库 `libnode.so`)来自 [electerm/ohos-node-shared](https://github.com/electerm/ohos-node-shared),打包进 HAP 的原生 `libs` 目录。 + +后端默认在**主应用进程内**运行:由 `libnode_ctl.so` 这个 NAPI 模块 `dlopen` `libnode.so` 并调用 `node::Start`;若进程内启动失败,则回退为**原生子进程**(`libnode_launcher.so`,通过 `childProcessManager.startNativeChildProcess` 启动)。 + +``` +ArkWeb(前端)── http://127.0.0.1:5577 ──► Node.js 后端(libnode.so) + Web 组件加载界面 提供界面 + SSH/SFTP/telnet/ftp/RDP/VNC/Spice +``` + +electerm 应用(前端 + 后端打包产物)打包在 HAP 的 `resfile/electerm` 中,由 node 进程直接读取;Node.js 共享库打包为 `libs/arm64-v8a/libnode.so`。端侧启动诊断信息位于 `/electerm-data/node-boot.log`。 + +> **分支说明:** 本分支(`dev2`)为 Node.js + ArkWeb 构建。本仓库的其他分支(`main`/`dev`/`dev1`)曾使用 [Electron 鸿蒙运行时](https://gitcode.com/openharmony-sig/electron);本分支不再使用。本分支 CI:`.github/workflows/build-web.yml`(推送 `dev2` 触发)。 --- diff --git a/build/bin/.yarnclean b/build/bin/.yarnclean index bf77825..f7b5e72 100644 --- a/build/bin/.yarnclean +++ b/build/bin/.yarnclean @@ -19,19 +19,10 @@ CONTRIBUTORS .yarn-integrity *.md *.ts +*.jst *.js.map *.ts.map -*.jst *.coffee -*.d.cts -*.d.mts -tsconfig.json -.nycrc -.nycrc.json -opslevel.yml -package-support.json -bench.js -tests.js # folders __tests__ @@ -47,24 +38,8 @@ example examples coverage .nyc_output -dist/esm + +# ignores +!*.d.ts zmodem2/dist/cjs -zmodem2/dist/browser -zmodem2/dist/esm -trzsz2/dist/cjs -trzsz2/dist/esm -package-lock.json -.github -.circleci -scripts -samplejson -flash -third_party -tools -bench -benchmarks -spec -specs -fixture -fixtures -umd \ No newline at end of file +zmodem2/dist/browser \ No newline at end of file diff --git a/build/bin/app.js b/build/bin/app.js deleted file mode 100644 index 26d4f5c..0000000 --- a/build/bin/app.js +++ /dev/null @@ -1,13 +0,0 @@ -const { exec } = require('shelljs') -const os = require('os') -const platform = os.platform() -console.log('platform:', platform) - -// Clear ELECTRON_RUN_AS_NODE so electron runs in full Electron mode -// (not pure Node.js mode where require('electron').app is undefined) -delete process.env.ELECTRON_RUN_AS_NODE - -const cmd = platform.startsWith('win') - ? 'node_modules\\.bin\\cross-env NODE_ENV=development node_modules\\.bin\\electron -r dotenv/config src\\app\\app' - : 'node_modules/.bin/cross-env NODE_ENV=development node_modules/.bin/electron -r dotenv/config src/app/app' -exec(cmd, { env: process.env }) diff --git a/build/bin/build-common.js b/build/bin/build-common.js new file mode 100644 index 0000000..e5b24bb --- /dev/null +++ b/build/bin/build-common.js @@ -0,0 +1,18 @@ +/** + * common functions for build + */ + +import { exec } from 'child_process' + +export const run = function (cmd) { + return new Promise((resolve, reject) => { + exec(cmd, (err, stdout, stderr) => { + if (err || stderr) { + return reject(err || stderr) + } + resolve(stdout) + }) + }).then(console.log).catch(console.error) +} + +export const cwd = process.cwd() diff --git a/build/bin/build.js b/build/bin/build.js index 28f708a..eefd28b 100644 --- a/build/bin/build.js +++ b/build/bin/build.js @@ -1,22 +1,19 @@ /** * build */ - -const { exec, echo } = require('shelljs') +import pkg from 'shelljs' +const { exec, echo } = pkg echo('start build') -const timeStart = +new Date() +const timeStart = Date.now() // echo('clean') // exec('npm run clean') -echo('version file') echo('js/css file') exec('npm run vite-build') echo('copy file') exec('node ./build/bin/copy.js') -echo('html file') -exec('node ./build/bin/pug.js') -const endTime = +new Date() +const endTime = Date.now() echo(`done build in ${(endTime - timeStart) / 1000} s`) diff --git a/build/bin/clean-empty-folders.js b/build/bin/clean-empty-folders.js deleted file mode 100644 index 1f94ebb..0000000 --- a/build/bin/clean-empty-folders.js +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env node - -const fs = require('fs') -const path = require('path') - -/** - * Clean empty folders recursively in a given directory - * @param {string} dirPath - The directory path to clean - * @returns {number} - Number of empty folders removed - */ -function cleanEmptyFolders (dirPath) { - let removedCount = 0 - - if (!fs.existsSync(dirPath)) { - console.log('Directory does not exist:', dirPath) - return removedCount - } - - try { - const items = fs.readdirSync(dirPath) - - // First, recursively clean subdirectories - for (const item of items) { - const itemPath = path.join(dirPath, item) - const stats = fs.statSync(itemPath) - - if (stats.isDirectory()) { - removedCount += cleanEmptyFolders(itemPath) - } - } - - // After cleaning subdirectories, check if current directory is now empty - const remainingItems = fs.readdirSync(dirPath) - if (remainingItems.length === 0) { - // Don't remove the root node_modules directory itself - const nodeModulesPath = path.resolve(process.cwd(), 'work/app/node_modules') - if (path.resolve(dirPath) !== nodeModulesPath) { - console.log('Removing empty directory:', dirPath) - fs.rmdirSync(dirPath) - removedCount++ - } - } - } catch (error) { - console.error('Error processing directory ' + dirPath + ':', error.message) - } - - return removedCount -} - -/** - * Main function to clean empty folders in work/app/node_modules - */ -function main () { - const targetDir = path.resolve(process.cwd(), 'work/app/node_modules') - - console.log('Starting cleanup of empty folders in:', targetDir) - console.log('='.repeat(60)) - - if (!fs.existsSync(targetDir)) { - console.log('Target directory does not exist:', targetDir) - return - } - - const startTime = Date.now() - const removedCount = cleanEmptyFolders(targetDir) - const endTime = Date.now() - - console.log('='.repeat(60)) - console.log('Cleanup completed!') - console.log('Empty folders removed:', removedCount) - console.log('Time taken:', endTime - startTime + 'ms') - - if (removedCount === 0) { - console.log('No empty folders found.') - } -} - -// Run the script if called directly -if (require.main === module) { - main() -} - -module.exports = { cleanEmptyFolders, main } diff --git a/build/bin/clean.js b/build/bin/clean.js index 0319c06..7824396 100644 --- a/build/bin/clean.js +++ b/build/bin/clean.js @@ -1,5 +1,7 @@ -const { rm } = require('shelljs') +import pkg from 'shelljs' + +const { rm } = pkg rm('-rf', [ - 'work' + 'dist' ]) diff --git a/build/bin/copy.js b/build/bin/copy.js index 39fd6fb..b75b6b6 100644 --- a/build/bin/copy.js +++ b/build/bin/copy.js @@ -1,27 +1,44 @@ -const { resolve } = require('path') -const { cp } = require('shelljs') -const from = resolve( - __dirname, - '../../node_modules/@electerm/electerm-resource/tray-icons/*' +import { resolve } from 'path' +import pkg from 'shelljs' +import { cwd } from './build-common.js' + +const { cp } = pkg + +const f1 = resolve( + cwd, + 'src/client/statics/*' ) const from0 = resolve( - __dirname, - '../../node_modules/electerm-icons/icons' + cwd, + 'node_modules/electerm-icons/icons' +) +const from1 = resolve( + cwd, + 'src/app/views' +) + +const t1 = resolve( + cwd, + 'dist/assets/' ) const to1 = resolve( - __dirname, - '../../work/app/assets/images/' + cwd, + 'dist' ) const to2 = resolve( - __dirname, - '../../work/app/assets/icons' + cwd, + 'dist/assets/icons' ) const arr = [ { - from, - to: to1, - file: true - }, { + from: f1, + to: t1 + }, + { + from: from1, + to: to1 + }, + { from: from0, to: to2 } diff --git a/build/bin/gen-logo.py b/build/bin/gen-logo.py new file mode 100644 index 0000000..35ef99e --- /dev/null +++ b/build/bin/gen-logo.py @@ -0,0 +1,425 @@ +#!/usr/bin/env python3 +""" +Generate ALL Android launcher icons, splash assets, and related XML from +two source images: + + build/electerm-logo-square.png (2160x2160 square logo -> all icons) + build/electerm.png (766x266 wordmark -> splash screen) + +Usage: + npm run logo + +The square logo may have a solid background — it is auto-removed by +detecting the corner colour (with anti-aliased edge handling). +Already-transparent PNGs are used as-is. + +After updating either source image, just run `npm run logo` to regenerate +everything in build/android/res-overlay. +""" +import os +import sys +from PIL import Image, ImageChops, ImageDraw + +# --------------------------------------------------------------------------- +# Paths +# --------------------------------------------------------------------------- +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +LOGO_SRC = os.path.join(ROOT, "build", "electerm-logo-square.png") +WORDMARK_SRC = os.path.join(ROOT, "build", "electerm.png") +RES = os.path.join(ROOT, "build", "android", "res-overlay") + +# --------------------------------------------------------------------------- +# Brand colours +# --------------------------------------------------------------------------- +BG = (21, 23, 26, 255) # #15171a — electerm dark slate (splash background) +BG_HEX = "#15171a" + +# Launcher icon background — matches the solid brown background of +# build/electerm-logo-square.png (#534741), so the rendered icon +# reproduces the source square logo instead of showing a black/ +# dark-slate background. +ICON_BG = (83, 71, 65, 255) # #534741 +ICON_BG_HEX = "#534741" + +# --------------------------------------------------------------------------- +# Density maps (108dp canvas for adaptive, standard sizes for legacy) +# --------------------------------------------------------------------------- +FOREGROUND_DENSITIES = { + "drawable-mdpi": 108, # 108dp @ 1x + "drawable-hdpi": 162, # 108dp @ 1.5x + "drawable-xhdpi": 216, # 108dp @ 2x + "drawable-xxhdpi": 324, # 108dp @ 3x + "drawable-xxxhdpi": 432, # 108dp @ 4x +} + +LEGACY_DENSITIES = { + "mipmap-mdpi": 48, + "mipmap-hdpi": 72, + "mipmap-xhdpi": 96, + "mipmap-xxhdpi": 144, + "mipmap-xxxhdpi": 192, +} + +# Logo size as a fraction of the icon canvas. +# Adaptive icon safe zone = 66dp / 108dp ~ 61%. +# 60% keeps the logo comfortably inside the safe zone on all launchers. +LOGO_FRACTION = 0.60 + +# Splash wordmark height (pixels). +SPLASH_LOGO_HEIGHT = 200 + +# Background-removal parameters. +# BG_TOL: pixels within this Chebyshev distance of the corner +# colour are fully transparent. +# BG_GRADIENT: distance at which alpha reaches 255. Between BG_TOL +# and BG_GRADIENT alpha is linearly interpolated, which +# preserves smooth anti-aliased edges. +BG_TOL = 30 +BG_GRADIENT = 100 + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +_logo_cache = None + + +def ensure_dir(p): + os.makedirs(p, exist_ok=True) + + +def paste_centered(canvas, img): + """Paste *img* onto *canvas* centred, respecting alpha.""" + cw, ch = canvas.size + iw, ih = img.size + left = (cw - iw) // 2 + top = (ch - ih) // 2 + canvas.paste(img, (left, top), img) + + +def make_circular_bg(size, color): + """Circular background with anti-aliased edges (4x supersampled).""" + scale = 4 + big = size * scale + canvas = Image.new("RGBA", (big, big), (0, 0, 0, 0)) + draw = ImageDraw.Draw(canvas) + draw.ellipse((0, 0, big - 1, big - 1), fill=color) + return canvas.resize((size, size), Image.LANCZOS) + + +# --------------------------------------------------------------------------- +# Source loading +# --------------------------------------------------------------------------- +def remove_background(im): + """ + Detect the solid background colour from the four corners and make it + transparent, with a smooth gradient at anti-aliased edges. + + Uses PIL ImageChops (C-level operations) for speed — no per-pixel + Python loops over the 4.7M-pixel source. + """ + w, h = im.size + + # --- detect background colour from corners --- + corners = [ + im.getpixel((0, 0)), + im.getpixel((w - 1, 0)), + im.getpixel((0, h - 1)), + im.getpixel((w - 1, h - 1)), + ] + bg = tuple(sum(c[i] for c in corners) // len(corners) for i in range(3)) + + # --- compute Chebyshev distance from background --- + # ImageChops.difference gives |im - bg| per channel. + # ImageChops.lighter gives pixel-wise max => max(r, g, b) distance. + bg_img = Image.new("RGBA", (w, h), bg + (255,)) + diff = ImageChops.difference(im, bg_img) + r_d, g_d, b_d = diff.split()[:3] + max_diff = ImageChops.lighter(ImageChops.lighter(r_d, g_d), b_d) + + # --- map distance -> alpha via LUT (fast C-level point op) --- + table = [] + for d in range(256): + if d <= BG_TOL: + table.append(0) + elif d < BG_GRADIENT: + table.append(int(255 * (d - BG_TOL) / (BG_GRADIENT - BG_TOL))) + else: + table.append(255) + alpha = max_diff.point(table, mode="L") + + # --- replace alpha channel --- + r, g, b = im.split()[:3] + im = Image.merge("RGBA", (r, g, b, alpha)) + + # --- crop to content --- + bbox = im.getbbox() + if bbox: + im = im.crop(bbox) + return im + + +def load_square_logo(): + """ + Load build/electerm-logo-square.png. + + If the image already has transparency it is used as-is (just cropped + to its bounding box). Otherwise the solid background is auto- + detected and removed. + """ + global _logo_cache + if _logo_cache is not None: + return _logo_cache + + if not os.path.exists(LOGO_SRC): + sys.exit("ERROR: square logo not found: " + LOGO_SRC) + + im = Image.open(LOGO_SRC).convert("RGBA") + print(" Loaded square logo:", im.size, im.mode) + + # Detect whether the image already has meaningful transparency. + extrema = im.getextrema() # [(r_min,r_max), …, (a_min,a_max)] + has_alpha = len(extrema) > 3 and extrema[3][0] < 255 + + if has_alpha: + print(" Image already transparent — using as-is") + bbox = im.getbbox() + if bbox: + im = im.crop(bbox) + else: + print(" Removing solid background…") + im = remove_background(im) + + print(" Final logo size:", im.size) + _logo_cache = im + return im + + +def get_logo(max_size=None): + """Return a (optionally scaled) copy of the processed square logo.""" + im = load_square_logo().copy() + if max_size: + im.thumbnail((max_size, max_size), Image.LANCZOS) + return im + + +def load_wordmark(height): + """Load build/electerm.png and scale to *height* pixels.""" + if not os.path.exists(WORDMARK_SRC): + sys.exit("ERROR: wordmark not found: " + WORDMARK_SRC) + im = Image.open(WORDMARK_SRC).convert("RGBA") + w, h = im.size + new_w = int(round(w * height / h)) + return im.resize((new_w, height), Image.LANCZOS) + + +# --------------------------------------------------------------------------- +# Generators +# --------------------------------------------------------------------------- + +def gen_foreground(): + """ + Adaptive icon foreground (108dp canvas, logo inside the 66dp safe + zone). Generated at every standard density so Android never upscales. + + Also removes the old single-density foreground that used to live in + drawable/ (432px in drawable/ was treated as mdpi = 432dp, 4x too + large for the 108dp adaptive-icon canvas). + """ + old_fg = os.path.join(RES, "drawable", "ic_launcher_foreground.png") + if os.path.exists(old_fg): + os.remove(old_fg) + print(" Removed old single-density foreground:", old_fg) + + for folder, size in FOREGROUND_DENSITIES.items(): + out = os.path.join(RES, folder) + ensure_dir(out) + canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + logo = get_logo(max_size=int(size * LOGO_FRACTION)) + paste_centered(canvas, logo) + canvas.save(os.path.join(out, "ic_launcher_foreground.png")) + + +def gen_legacy(): + """ + Legacy (pre-26) launcher icons. + + Both ic_launcher.png and ic_launcher_round.png use a CIRCULAR brand + background with transparent corners, so the icon looks round even on + launchers that don't mask adaptive icons. + """ + for folder, size in LEGACY_DENSITIES.items(): + out = os.path.join(RES, folder) + ensure_dir(out) + bg = make_circular_bg(size, ICON_BG) + canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + canvas.paste(bg, (0, 0), bg) + logo = get_logo(max_size=int(size * LOGO_FRACTION)) + paste_centered(canvas, logo) + canvas.save(os.path.join(out, "ic_launcher.png")) + canvas.save(os.path.join(out, "ic_launcher_round.png")) + + +# Adaptive icon XML — both square and round reference the same +# foreground/background; the launcher's own mask is applied on top. +ADAPTIVE_XML = """ + + + + +""" + + +def gen_adaptive_xml(): + out = os.path.join(RES, "mipmap-anydpi-v26") + ensure_dir(out) + with open(os.path.join(out, "ic_launcher.xml"), "w") as f: + f.write(ADAPTIVE_XML) + with open(os.path.join(out, "ic_launcher_round.xml"), "w") as f: + f.write(ADAPTIVE_XML) + + +def gen_splash(): + """Splash: brand background + centred wordmark.""" + drawable = os.path.join(RES, "drawable") + ensure_dir(drawable) + logo = load_wordmark(height=SPLASH_LOGO_HEIGHT) + logo.save(os.path.join(drawable, "splash_logo.png")) + + with open(os.path.join(drawable, "splash.xml"), "w") as f: + f.write( + """ + + + + + + + + +""" + ) + + +def gen_values(): + """ + Colours + styles + network security config. + + Written as SEPARATE files (colors-electerm.xml / splash-styles.xml) + so they merge with Capacitor's generated resources instead of + overwriting them. + """ + v = os.path.join(RES, "values") + ensure_dir(v) + with open(os.path.join(v, "colors-electerm.xml"), "w") as f: + f.write( + """ + + """ + BG_HEX + """ + """ + ICON_BG_HEX + """ + +""" + ) + with open(os.path.join(v, "splash-styles.xml"), "w") as f: + f.write( + """ + + + +""" + ) + xml = os.path.join(RES, "xml") + ensure_dir(xml) + with open(os.path.join(xml, "network_security_config.xml"), "w") as f: + f.write( + """ + + + 127.0.0.1 + localhost + + + +""" + ) + + +def gen_manifest(): + """ + AndroidManifest.xml overlay (full file; copied over the generated + one). Includes android:roundIcon so tablet launchers that look for + a round icon get the electerm round icon. + """ + with open(os.path.join(RES, "AndroidManifest.xml"), "w") as f: + f.write( + """ + + + + + + + + + + + + +""" + ) + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +if __name__ == "__main__": + print("=" * 60) + print(" electerm Android — logo & splash asset generator") + print("=" * 60) + print(" Square logo source:", LOGO_SRC) + print(" Wordmark source: ", WORDMARK_SRC) + print(" Output directory: ", RES) + print() + + load_square_logo() + print() + + print("[1/6] Adaptive icon foregrounds …") + gen_foreground() + print("[2/6] Legacy launcher icons …") + gen_legacy() + print("[3/6] Adaptive icon XML …") + gen_adaptive_xml() + print("[4/6] Splash screen …") + gen_splash() + print("[5/6] Colours, styles & security config …") + gen_values() + print("[6/6] AndroidManifest.xml …") + gen_manifest() + + print() + print("Done! All assets generated in:") + print(" " + RES) + print() + print("To apply them to the native project, run:") + print(" cd build/android && npx cap sync android && npm run overlay") diff --git a/build/bin/gen_logos.py b/build/bin/gen_logos.py deleted file mode 100755 index da1979e..0000000 --- a/build/bin/gen_logos.py +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env python3 -""" -Generate HarmonyOS app icons (entry/src/main/resources/base/media/*) - from the source logos in build/logos. - -Currently the square logo is resized to 1024x1024 RGBA and written as - both `app_icon.png` and `start_icon.png`, which are the icons referenced - by entry/src/main/module.json5 (`$media:app_icon`). - -Requirements: - - Python 3.7+ - - Pillow (pip install Pillow) - -Usage: - python3 build/bin/gen_logos.py -""" - -import sys -from pathlib import Path - -try: - from PIL import Image -except ImportError: - sys.exit( - 'Pillow is required. Install it with: pip install Pillow' - ) - -# Project root is two levels up from this script (build/bin -> build -> root) -ROOT = Path(__file__).resolve().parent.parent.parent - -# Source logo (square, high-resolution) -SOURCE = ROOT / 'build' / 'logos' / 'electerm-logo-square.png' - -# Output directory for HarmonyOS media resources -MEDIA_DIR = ROOT / 'entry' / 'src' / 'main' / 'resources' / 'base' / 'media' - -# Target icon size (HarmonyOS expects 1024x1024 app icons) -ICON_SIZE = (1024, 1024) - -# Output file names generated from the square logo -OUTPUTS = ['app_icon.png', 'start_icon.png'] - - -def main() -> int: - if not SOURCE.exists(): - print(f'error: source logo not found: {SOURCE}', file=sys.stderr) - return 1 - - MEDIA_DIR.mkdir(parents=True, exist_ok=True) - - print(f'gen_logos: opening {SOURCE.relative_to(ROOT)}') - with Image.open(SOURCE) as img: - print(f' source size: {img.size} mode: {img.mode}') - - # Resize to the target icon size with high-quality resampling - resized = img.resize(ICON_SIZE, Image.LANCZOS) - - # HarmonyOS icons are expected to be RGBA - if resized.mode != 'RGBA': - resized = resized.convert('RGBA') - - for name in OUTPUTS: - out_path = MEDIA_DIR / name - resized.save(out_path, 'PNG') - print(f' wrote {out_path.relative_to(ROOT)} ' - f'{resized.size} {resized.mode}') - - print('gen_logos: done') - return 0 - - -if __name__ == '__main__': - raise SystemExit(main()) diff --git a/build/bin/install.js b/build/bin/install.js index bd211da..eadb0c6 100644 --- a/build/bin/install.js +++ b/build/bin/install.js @@ -1,9 +1,109 @@ -const pkg = require('shelljs') +/** + * install.js + * + * Runs automatically on `npm install` (npm "install" lifecycle script). + * + * electerm-harmony reuses 100 % of the source code from electerm-android. + * Instead of keeping a duplicate copy in this repo, we download the latest + * source archive from https://github.com/electerm/electerm-android and copy + * its `src/` directory into ours. This repo only keeps its own package.json, + * build scripts and HarmonyOS-specific build configuration. + * + * After the source sync we also copy the @electerm/electerm-react client + * from node_modules (same as the original install step). + */ +import { copyFile, readdir, writeFile, mkdir } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { resolve } from 'node:path' +import pkg from 'shelljs' +import { x as tarX } from 'tar' -const { echo, rm, cp } = pkg +const { echo, rm: shellRm, cp } = pkg + +const REPO = 'electerm/electerm-android' +const BRANCH = 'main' +const URL = `https://codeload.github.com/${REPO}/tar.gz/refs/heads/${BRANCH}` +const TMP = resolve('temp/electerm-android-src') +const TMP_FILE = resolve(TMP, 'electerm-android.tar.gz') +const REPLACE_DIR = resolve('build/replace') echo('install required modules') -rm('-rf', 'src/client/electerm-react') +async function copyReplacements (from, to) { + await mkdir(to, { recursive: true }) + for (const entry of await readdir(from, { withFileTypes: true })) { + const source = resolve(from, entry.name) + const destination = resolve(to, entry.name) + if (entry.isDirectory()) { + await copyReplacements(source, destination) + } else { + await copyFile(source, destination) + } + } +} + +// --------------------------------------------------------------------------- +// 1. Download the latest electerm-android source archive +// --------------------------------------------------------------------------- +echo(`downloading latest ${REPO} (${BRANCH} branch)…`) + +shellRm('-rf', TMP) +await mkdir(TMP, { recursive: true }) + +let downloaded = false +try { + const res = await fetch(URL) + if (!res.ok) { + throw new Error(`HTTP ${res.status} ${res.statusText}`) + } + const buf = Buffer.from(await res.arrayBuffer()) + await writeFile(TMP_FILE, buf) + echo('download complete') + downloaded = true +} catch (e) { + echo(`WARNING: failed to download source — ${e.message}`) + if (existsSync('src')) { + echo('keeping existing src/ folder') + } else { + echo('ERROR: src/ does not exist and download failed — cannot continue') + process.exit(1) + } +} + +// --------------------------------------------------------------------------- +// 2. Extract archive and replace src/ +// --------------------------------------------------------------------------- +if (downloaded) { + echo('extracting…') + await tarX({ + file: TMP_FILE, + cwd: TMP, + strip: 1 // remove the top-level "electerm-android-main/" directory + }) + + echo('syncing src/ from electerm-android…') + shellRm('-rf', 'src') + cp('-r', resolve(TMP, 'src'), resolve('src')) +} + +// --------------------------------------------------------------------------- +// 3. Apply tracked HarmonyOS source replacements +// --------------------------------------------------------------------------- +if (existsSync(REPLACE_DIR)) { + echo('applying HarmonyOS source replacements…') + await copyReplacements(REPLACE_DIR, resolve('src')) +} + +// --------------------------------------------------------------------------- +// 4. Copy @electerm/electerm-react client from node_modules +// --------------------------------------------------------------------------- +echo('installing electerm-react module') +shellRm('-rf', 'src/client/electerm-react') cp('-r', 'node_modules/@electerm/electerm-react/client', 'src/client/electerm-react') + +// --------------------------------------------------------------------------- +// 5. Cleanup temp files +// --------------------------------------------------------------------------- +shellRm('-rf', TMP) + echo('done install required modules') diff --git a/build/bin/pre-push b/build/bin/pre-push new file mode 100755 index 0000000..eddf067 --- /dev/null +++ b/build/bin/pre-push @@ -0,0 +1,4 @@ +#!/bin/bash +cd `dirname $0` +cd ../../ +npm run lint \ No newline at end of file diff --git a/build/bin/prepare.js b/build/bin/prepare.js deleted file mode 100644 index f436459..0000000 --- a/build/bin/prepare.js +++ /dev/null @@ -1,111 +0,0 @@ -/** - * prepare the files to be packed - */ - -const pack = require('../../package.json') -const os = require('os') -const { resolve } = require('path') -const { version } = pack -const { mkdir, rm, exec, echo, cp } = require('shelljs') -const dir = 'dist/v' + version -const cwd = process.cwd() - -const platform = os.platform() -const isWin = platform === 'win32' - -pack.main = 'app.js' -delete pack.scripts -delete pack.standard -delete pack.files -delete pack.engines -delete pack.preferGlobal - -if (isWin) { - delete pack.dependencies['node-bash'] -} else { - delete pack.dependencies['node-powershell'] -} - -echo('start pack prepare') -// echo('install test deps') -// exec(`PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm i -D -E playwright@1.28.1 --no-save && npm i -D -E @playwright/test@1.28.1 --no-save`) -const timeStart = +new Date() -rm('-rf', dir) -rm('-rf', 'dist/latest') - -mkdir('-p', dir) -mkdir('-p', 'dist/latest') -cp('-r', 'src/app', 'work/') -rm('-rf', 'work/app/user-config.json') -rm('-rf', 'work/app/localstorage.json') -rm('-rf', 'work/app/nohup.out') -rm('-rf', 'work/app/assets/js/index*') -rm('-rf', 'work/app/assets/js/*.txt') -rm('-rf', 'node_modules/cpu-features') - -require('fs').writeFileSync( - resolve(__dirname, '../../work/app/package.json'), - JSON.stringify( - pack, null, 2 - ) -) - -exec(`cd work/app && npm i --omit=dev && cd ${cwd}`) -rm('-rf', 'work/app/node_modules/.bin') -// Remove axios browser/ESM builds and unnecessary files (keep only lib/ and node CJS) -rm('-rf', 'work/app/node_modules/axios/dist/esm') -rm('-rf', 'work/app/node_modules/axios/dist/browser') -rm('-rf', 'work/app/node_modules/axios/dist/*.js') -rm('-rf', 'work/app/node_modules/axios/dist/*.map') -rm('-rf', 'work/app/node_modules/axios/dist/node/*.map') -rm('-rf', 'work/app/node_modules/axios/index.d.cts') -rm('-rf', 'work/app/node_modules/axios/lib') - -// Remove cpu-features after npm prune to prevent rebuild issues -rm('-rf', 'node_modules/cpu-features') -rm('-rf', 'work/app/node_modules/cpu-features') - -// Clean up node-pty platform-specific files to reduce bundle size -if (isWin) { - // On Windows, remove Unix-specific files - rm('-rf', 'work/app/node_modules/node-pty/lib/unixTerminal.js') - rm('-rf', 'work/app/node_modules/node-pty/lib/unixTerminal.js.map') - rm('-rf', 'work/app/node_modules/node-pty/lib/unixTerminal.test.js') - rm('-rf', 'work/app/node_modules/node-pty/lib/unixTerminal.test.js.map') - rm('-rf', 'work/app/node_modules/node-pty/build/pty.target.mk') - rm('-rf', 'work/app/node_modules/node-pty/build/spawn-helper.target.mk') - rm('-rf', 'work/app/node_modules/node-pty/build/binding.Makefile') - rm('-rf', 'work/app/node_modules/node-pty/build/gyp-mac-tool') -} else { - // On Linux/Mac, remove Windows-specific files - rm('-rf', 'work/app/node_modules/node-pty/lib/conpty_console_list_agent.js') - rm('-rf', 'work/app/node_modules/node-pty/lib/conpty_console_list_agent.js.map') - rm('-rf', 'work/app/node_modules/node-pty/lib/windowsConoutConnection.js') - rm('-rf', 'work/app/node_modules/node-pty/lib/windowsConoutConnection.js.map') - rm('-rf', 'work/app/node_modules/node-pty/lib/windowsPtyAgent.js') - rm('-rf', 'work/app/node_modules/node-pty/lib/windowsPtyAgent.js.map') - rm('-rf', 'work/app/node_modules/node-pty/lib/windowsPtyAgent.test.js') - rm('-rf', 'work/app/node_modules/node-pty/lib/windowsPtyAgent.test.js.map') - rm('-rf', 'work/app/node_modules/node-pty/lib/windowsTerminal.js') - rm('-rf', 'work/app/node_modules/node-pty/lib/windowsTerminal.js.map') - rm('-rf', 'work/app/node_modules/node-pty/lib/windowsTerminal.test.js') - rm('-rf', 'work/app/node_modules/node-pty/lib/windowsTerminal.test.js.map') - rm('-rf', 'work/app/node_modules/node-pty/deps/winpty') -} - -// Remove all test files from node-pty to reduce bundle size -rm('-rf', 'work/app/node_modules/node-pty/lib/*.test.js') -rm('-rf', 'work/app/node_modules/node-pty/lib/*.test.js.map') -rm('-rf', 'work/app/node_modules/node-pty/lib/testUtils.test.js') -rm('-rf', 'work/app/node_modules/node-pty/lib/testUtils.test.js.map') - -// yarn auto clean -cp('-r', 'build/bin/.yarnclean', 'work/app/') -exec(`cd work/app && yarn generate-lock-entry > yarn.lock && yarn autoclean --force && cd ${cwd}`) -rm('-rf', 'work/app/.yarnclean') -rm('-rf', 'work/app/package-lock.json') -rm('-rf', 'work/app/yarn.lock') -require('./clean-empty-folders').main() - -const endTime = +new Date() -echo(`done pack prepare in ${(endTime - timeStart) / 1000} s`) diff --git a/build/bin/pug.js b/build/bin/pug.js index 6c56a67..b58b756 100644 --- a/build/bin/pug.js +++ b/build/bin/pug.js @@ -1,20 +1,27 @@ // build html /** * build common files with react module in it + * + * Generates a static dist/index.html from the pug template, injecting the + * same data the runtime server (src/app/lib/view.js) provides to the client. + * Mirrors upstream electerm's build/bin/pug.js, ported to ESM for this project. */ -const fs = require('fs') -const pug = require('pug') -const { resolve } = require('path') -const pack = require('../../package.json') -const deepCopy = require('json-deep-copy') +import fs from 'fs' +import pug from 'pug' +import { resolve } from 'path' +import deepCopy from 'json-deep-copy' + +const pack = JSON.parse( + fs.readFileSync(resolve(__dirname, '../../package.json'), 'utf8') +) const entryPug = resolve( __dirname, - '../../src/client/views/index.pug' + '../../src/app/views/index.pug' ) const targetFilePath = resolve( __dirname, - '../../work/app/assets/index.html' + '../../dist/index.html' ) const pugContent = fs.readFileSync(entryPug, 'utf-8') const defaultAIPreset = { @@ -25,21 +32,26 @@ const defaultAIPreset = { id: 'ai.electerm.org', nameAI: 'ai.electerm.org(default free)' } - -// const AIDisclamer = 'AI-generated terminal commands can be inaccurate or unsafe, be careful' - +const supportSessionTypes = [ + 'ssh', + 'telnet', + 'web', + 'rdp', + 'vnc', + 'ftp', + 'spice' +] const data = { version: pack.version, siteName: pack.name, isDev: false, - disableUpgradeCheck: true, - hideLocalTerminal: true, + cdn: '', + tokenElecterm: '', defaultAIPreset, - disableAIFeature: false, - AIDisclamer: '本内容由 AI 生成,仅供参考', - supportSessionTypes: ['ssh', 'telnet', 'rdp', 'vnc', 'ftp', 'spice'] + downloadUpgradeFromBrowser: true, + versionFile: 'version-android.html', + supportSessionTypes } - const htmlContent = pug.render(pugContent, { filename: entryPug, ...data, diff --git a/build/bin/run-prod.sh b/build/bin/run-prod.sh new file mode 100755 index 0000000..6ad994b --- /dev/null +++ b/build/bin/run-prod.sh @@ -0,0 +1,4 @@ +#!/bin/bash +cd `dirname $0` +cd ../.. +NODE_ENV=production node ./src/app/app.js \ No newline at end of file diff --git a/build/bin/start.js b/build/bin/start.js deleted file mode 100644 index b49311b..0000000 --- a/build/bin/start.js +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash -const { exec, cd } = require('shelljs') -const { resolve } = require('path') -const p = resolve(__dirname, '../vite') -cd(p) -exec('npm start') diff --git a/build/bin/vite-build.js b/build/bin/vite-build.js deleted file mode 100755 index fefd460..0000000 --- a/build/bin/vite-build.js +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash -const { exec, cd } = require('shelljs') -const { resolve } = require('path') -const p = resolve(__dirname, '../vite') -cd(p) - -exec('npm run build') diff --git a/build/logos/electerm-logo-square.png b/build/electerm-logo-square.png similarity index 100% rename from build/logos/electerm-logo-square.png rename to build/electerm-logo-square.png diff --git a/build/logos/electerm-banner-logo.png b/build/electerm.png similarity index 100% rename from build/logos/electerm-banner-logo.png rename to build/electerm.png diff --git a/build/harmony/build.js b/build/harmony/build.js deleted file mode 100644 index 55130b9..0000000 --- a/build/harmony/build.js +++ /dev/null @@ -1,349 +0,0 @@ -/** - * Build the electerm HarmonyOS app. - * - * Step 0: Copy client source from @electerm/electerm-react npm package - * → src/client/ (gitignored, not in repo) - * Step 1: Run complete electerm build (npm run b) - * clean → compile (vite + copy + pug) → prepare-file (deps install + cleanup) - * Step 2: Apply HarmonyOS delta (main → bootstrap.js, remove native modules) - * Step 3: Copy work/app → web_engine resfile - * Step 4: Verify critical files - * - * This is a CJS file to stay consistent with build/bin/*.js. - */ -const { exec, cp, echo } = require('shelljs') -const { resolve, join, dirname } = require('path') -const fs = require('fs') -const pack = require('../../package.json') - -// Ensure we run from project root (build/bin/*.js rely on cwd) -process.chdir(resolve(__dirname, '../..')) -const ROOT = process.cwd() - -// Load .env so SERVER_SECRET is available for build-time injection -// (called after chdir to ensure .env is found in project root) -try { - require('dotenv').config() -} catch (_) { - // dotenv may not be installed yet during very early runs -} -const WORK_APP = resolve(ROOT, 'work/app') -const OUTPUT_DIR = resolve(ROOT, 'web_engine/src/main/resources/resfile/resources/app') - -const timeStart = Date.now() - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function rmrf (p) { - if (fs.existsSync(p)) { - fs.rmSync(p, { recursive: true, force: true }) - } -} - -function getDirSize (dir) { - let size = 0 - try { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const p = join(dir, entry.name) - if (entry.isDirectory()) size += getDirSize(p) - else size += fs.statSync(p).size - } - } catch {} - return size -} - -function formatBytes (bytes) { - if (bytes < 1024) return bytes + ' B' - if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB' - return (bytes / (1024 * 1024)).toFixed(1) + ' MB' -} - -// --------------------------------------------------------------------------- -// Step 0: Copy client source from @electerm/electerm-react -// --------------------------------------------------------------------------- -// Layout of src/client/: -// - electerm-react/ ← gitignored, vendored from the npm package at build -// - entry/, harmony/, views/ ← tracked in git (HarmonyOS-specific overrides) -// Vite config and pug.js reference src/client/entry/*.jsx and -// src/client/views/index.pug (tracked), while entry/basic.js and -// harmony/main.jsx import the vendored source via ../electerm-react/... -// So we only need to populate src/client/electerm-react/ before `npm run b`, -// and must NOT overwrite or remove the tracked overrides. -// --------------------------------------------------------------------------- -function prepareClientSource () { - echo('[harmony] step 0: prepare client source from @electerm/electerm-react') - const pkgClient = resolve(ROOT, 'node_modules/@electerm/electerm-react/client') - const srcClient = resolve(ROOT, 'src/client') - const vendored = resolve(srcClient, 'electerm-react') - - if (!fs.existsSync(pkgClient)) { - throw new Error( - 'node_modules/@electerm/electerm-react/client not found. ' + - 'Run npm install first.' - ) - } - - // Skip when the vendored source is already present (local dev keeps a - // real checkout here). Do NOT test src/client/entry/electerm.jsx — that - // file is tracked in git, so it always exists in CI and would wrongly - // short-circuit the copy, leaving ../electerm-react/... unresolved. - if (fs.existsSync(resolve(vendored, 'components'))) { - echo(' ✓ src/client/electerm-react/ already populated, skip copy') - return - } - - // Copy client/ from the npm package into src/client/electerm-react/. - // The tracked overrides under src/client/{entry,harmony,views}/ are - // intentionally left untouched. - fs.mkdirSync(srcClient, { recursive: true }) - cp('-r', pkgClient, vendored) - echo(' ✓ copied @electerm/electerm-react/client → src/client/electerm-react') - - // Verify critical vendored files referenced by the tracked overrides - const required = [ - 'components/main/index.jsx', - 'common/pre.js', - 'css/basic.styl' - ] - for (const f of required) { - if (!fs.existsSync(resolve(vendored, f))) { - throw new Error(`Missing required vendored file: src/client/electerm-react/${f}`) - } - } - echo(' ✓ client source verified') -} - -// --------------------------------------------------------------------------- -// Step 1: Run complete electerm build (npm run b) -// --------------------------------------------------------------------------- -// npm run b = npm run clean && npm run compile && npm run prepare-file -// clean → removes work/ -// compile → vite-build + copy icons + pug → work/app/assets/ -// prepare-file → cp src/app → work/app, create package.json, npm install, -// cleanup (axios, node-pty, cpu-features, yarn autoclean, -// clean-empty-folders) -// --------------------------------------------------------------------------- -function buildElecterm () { - echo('[harmony] step 1: run complete electerm build (npm run b)') - const result = exec('npm run b') - if (result.code !== 0) { - throw new Error(`npm run b failed with exit code ${result.code}`) - } - echo(' ✓ electerm build complete') -} - -// --------------------------------------------------------------------------- -// Step 2: Apply HarmonyOS-specific delta -// --------------------------------------------------------------------------- -// electerm's prepare.js produces work/app with: -// - main: 'app.js' → harmony needs 'bootstrap.js' -// - node-pty, serialport, cpu-features installed → harmony excludes them -// (source has try/catch guards for missing native modules) -// --------------------------------------------------------------------------- -function applyHarmonyDelta () { - echo('[harmony] step 2: apply HarmonyOS delta') - - // 2a. Rewrite package.json for HarmonyOS - const workPkg = JSON.parse( - fs.readFileSync(resolve(WORK_APP, 'package.json'), 'utf8') - ) - workPkg.main = 'bootstrap.js' - delete workPkg.dependencies['node-pty'] - delete workPkg.dependencies.serialport - delete workPkg.dependencies['cpu-features'] - fs.writeFileSync( - resolve(WORK_APP, 'package.json'), - JSON.stringify(workPkg, null, 2) - ) - echo(' ✓ package.json: main = bootstrap.js, native modules excluded') - - // 2b. Remove native module directories (not usable on HarmonyOS) - const nativeModules = ['node-pty', 'serialport', 'cpu-features'] - for (const mod of nativeModules) { - const modPath = resolve(WORK_APP, 'node_modules', mod) - if (fs.existsSync(modPath)) { - rmrf(modPath) - echo(` ✓ removed node_modules/${mod}`) - } - } - - // 2c. Inject SERVER_SECRET into safe-storage.js - // The backend code is copied as-is (not bundled by vite), so - // process.env.SERVER_SECRET is NOT available at runtime. - // We replace the default placeholder at build time so the - // production secret is baked into the output. - // JSON.stringify ensures the value is safely escaped for JS. - const safeStoragePath = resolve(WORK_APP, 'lib/safe-storage.js') - if (fs.existsSync(safeStoragePath) && process.env.SERVER_SECRET) { - let safeSrc = fs.readFileSync(safeStoragePath, 'utf8') - const escaped = JSON.stringify(process.env.SERVER_SECRET) - safeSrc = safeSrc.replace( - "'static-secret-string-safe-storage'", - escaped - ) - fs.writeFileSync(safeStoragePath, safeSrc, 'utf8') - echo(' ✓ safe-storage.js: SERVER_SECRET injected') - } else { - echo(' ⚠ safe-storage.js: using default secret (SERVER_SECRET not set)') - } - - // 2d. Remove .env (not needed in the packed app) - rmrf(resolve(WORK_APP, '.env')) - rmrf(resolve(WORK_APP, '.env.bak')) - - echo(' ✓ HarmonyOS delta applied') -} - -// --------------------------------------------------------------------------- -// Step 3: Copy work/app → web_engine resfile -// --------------------------------------------------------------------------- -function copyToResfile () { - echo('[harmony] step 3: copy work/app → web_engine resfile') - - const webEngineDir = resolve(ROOT, 'web_engine') - if (!fs.existsSync(webEngineDir)) { - throw new Error( - 'web_engine/ not found. Run ./scripts/prepare-electron-runtime.sh first.' - ) - } - - rmrf(OUTPUT_DIR) - const parentDir = dirname(OUTPUT_DIR) - fs.mkdirSync(parentDir, { recursive: true }) - cp('-r', WORK_APP, parentDir) - - echo(` ✓ copied to ${OUTPUT_DIR}`) - echo(` ✓ bundled size: ${formatBytes(getDirSize(OUTPUT_DIR))}`) -} - -// --------------------------------------------------------------------------- -// Step 4: Verify critical files -// --------------------------------------------------------------------------- -function verify (label, dir) { - echo(`[harmony] verify: ${label}`) - - const checks = [ - { path: 'assets/index.html', desc: 'index.html' }, - { path: 'bootstrap.js', desc: 'bootstrap.js' }, - { path: 'app.js', desc: 'app.js' }, - { path: 'package.json', desc: 'package.json' }, - { path: 'server/server.js', desc: 'server.js' }, - { path: 'lib/file-server.js', desc: 'file-server.js' } - ] - - let failed = false - for (const check of checks) { - const fullPath = resolve(dir, check.path) - if (!fs.existsSync(fullPath)) { - echo(` ✗ MISSING: ${check.path}`) - failed = true - } else { - echo(` ✓ ${check.desc}`) - } - } - - // Check assets/js/ has JS files - const jsDir = resolve(dir, 'assets/js') - if (!fs.existsSync(jsDir)) { - echo(' ✗ MISSING: assets/js/ directory') - failed = true - } else { - const jsFiles = fs.readdirSync(jsDir).filter(f => f.endsWith('.js')) - if (jsFiles.length === 0) { - echo(' ✗ MISSING: no .js files in assets/js/') - failed = true - } else { - echo(` ✓ assets/js/ (${jsFiles.length} files: ${jsFiles.join(', ')})`) - } - } - - // Check assets/css/ has CSS files - const cssDir = resolve(dir, 'assets/css') - if (!fs.existsSync(cssDir)) { - echo(' ✗ MISSING: assets/css/ directory') - failed = true - } else { - const cssFiles = fs.readdirSync(cssDir).filter(f => f.endsWith('.css')) - if (cssFiles.length === 0) { - echo(' ✗ MISSING: no .css files in assets/css/') - failed = true - } else { - echo(` ✓ assets/css/ (${cssFiles.length} files: ${cssFiles.join(', ')})`) - } - } - - // Check assets/chunk/ has chunk files - const chunkDir = resolve(dir, 'assets/chunk') - if (!fs.existsSync(chunkDir)) { - echo(' ✗ MISSING: assets/chunk/ directory') - failed = true - } else { - const chunkFiles = fs.readdirSync(chunkDir) - echo(` ✓ assets/chunk/ (${chunkFiles.length} files)`) - } - - // Check package.json has main: bootstrap.js - const pkgPath = resolve(dir, 'package.json') - if (fs.existsSync(pkgPath)) { - const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) - if (pkg.main !== 'bootstrap.js') { - echo(` ✗ package.json main should be "bootstrap.js", got "${pkg.main}"`) - failed = true - } else { - echo(' ✓ package.json main = bootstrap.js') - } - } - - // Check node_modules exists - if (!fs.existsSync(resolve(dir, 'node_modules'))) { - echo(' ✗ MISSING: node_modules/') - failed = true - } else { - echo(' ✓ node_modules/ exists') - } - - if (failed) { - echo(`\n[harmony] VERIFICATION FAILED for ${label}!`) - throw new Error(`Verification failed for ${label}`) - } - - echo(` ✓ ${label} verification passed`) -} - -// --------------------------------------------------------------------------- -// Main -// --------------------------------------------------------------------------- -function main () { - echo('[harmony] building electerm HarmonyOS app...') - echo(`[harmony] version: ${pack.version}`) - echo('[harmony] mode: reuse electerm build (npm run b) + harmony delta') - echo('') - - // Step 0: Prepare client source from npm package - prepareClientSource() - - // Step 1: Complete electerm build - buildElecterm() - - // Step 2: Apply HarmonyOS-specific changes - applyHarmonyDelta() - - // Verify work/app before copying - verify('work/app', WORK_APP) - - // Step 3: Copy to resfile - copyToResfile() - - // Verify resfile after copying - verify('resfile', OUTPUT_DIR) - - const elapsed = ((Date.now() - timeStart) / 1000).toFixed(1) - echo('') - echo(`[harmony] build complete in ${elapsed}s`) - echo(`[harmony] output: ${OUTPUT_DIR}`) - echo(`[harmony] total size: ${formatBytes(getDirSize(OUTPUT_DIR))}`) -} - -main() diff --git a/build/replace/app/lib/db.js b/build/replace/app/lib/db.js new file mode 100644 index 0000000..66db87e --- /dev/null +++ b/build/replace/app/lib/db.js @@ -0,0 +1,17 @@ +/** + * db loader + */ + +let dbModule = null + +async function getDbModule () { + if (!dbModule) { + dbModule = await import('./nedb.js') + } + return dbModule +} + +export async function dbAction (...args) { + const db = await getDbModule() + return db.dbAction ? db.dbAction(...args) : db.default.dbAction(...args) +} diff --git a/build/replace/app/lib/install-src.js b/build/replace/app/lib/install-src.js new file mode 100644 index 0000000..6a827dd --- /dev/null +++ b/build/replace/app/lib/install-src.js @@ -0,0 +1,22 @@ +// install-src.js (HarmonyOS replacement) +// Determines the HarmonyOS release asset architecture identifier at runtime. +// Used to match the correct release asset when checking/downloading upgrades +// (see download-upgrade.js: `r.name.includes(installSrc)`). +// +// scripts/build-app.sh names release artifacts +// `electerm-harmony-${APP_ARCH}-${version}.app` with APP_ARCH being either +// `arm64` (arm64-v8a libs, real devices) or `x86_64` (emulator). We resolve +// at runtime from os.arch() so the same bundled code works for both without +// a build-time injection step. + +import os from 'os' + +const archMap = { + arm64: 'arm64', + x64: 'x86_64' +} + +const arch = os.arch() +const installSrc = 'electerm-harmony-' + (archMap[arch] || 'arm64') + +export default installSrc diff --git a/build/replace/app/lib/nedb.js b/build/replace/app/lib/nedb.js new file mode 100644 index 0000000..9531e2a --- /dev/null +++ b/build/replace/app/lib/nedb.js @@ -0,0 +1,122 @@ +/** + * NeDB API wrapper compatible with legacy electerm user data. + */ + +import fs from 'fs' +import { resolve } from 'path' +import Datastore from '@electerm/nedb' +import nedbStorage from '@electerm/nedb/lib/storage.js' +import { cwd, defaultUserName } from '../common/runtime-constants.js' +import { safeDecrypt, safeEncrypt } from './safe-storage.js' + +const originalFlush = nedbStorage.flushToStorage +const encryptedTables = new Set(['bookmarks', 'profiles', 'data', 'history', 'terminalCommandHistory', 'aiChatHistory']) +const encryptedDataId = 'userConfig' +const encryptedPrefix = 'enc:' + +nedbStorage.flushToStorage = function (options, callback) { + originalFlush.call(nedbStorage, options, () => callback(null)) +} + +export const tables = [ + 'bookmarks', + 'bookmarkGroups', + 'addressBookmarks', + 'terminalThemes', + 'lastStates', + 'data', + 'quickCommands', + 'log', + 'dbUpgradeLog', + 'profiles', + 'workspaces', + 'history', + 'terminalCommandHistory', + 'aiChatHistory', + 'autoRunWidgets' +] + +const dbPath = process.env.DB_PATH || process.env.DATA_PATH || resolve(cwd, 'data') +const dbDir = resolve(dbPath, 'users', defaultUserName) +fs.mkdirSync(dbDir, { recursive: true }) + +const db = Object.fromEntries(tables.map(table => [ + table, + new Datastore({ + filename: resolve(dbDir, `electerm.${table}.nedb`), + autoload: true, + onload: (err) => { + if (err && !db[table].executor.ready) { + db[table].executor.processBuffer() + } + } + }) +])) + +function needsEncryption (dbName, id) { + return dbName === 'data' + ? id === encryptedDataId + : encryptedTables.has(dbName) +} + +function encryptDoc (dbName, doc) { + if (!needsEncryption(dbName, doc._id)) return doc + const { _id, ...payload } = doc + return { + ...(_id === undefined ? {} : { _id }), + _encdata: encryptedPrefix + safeEncrypt(JSON.stringify(payload)) + } +} + +function decryptDoc (dbName, doc) { + if (!doc || !needsEncryption(dbName, doc._id) || !doc._encdata) return doc + try { + const decrypted = doc._encdata.startsWith(encryptedPrefix) + ? safeDecrypt(doc._encdata.slice(encryptedPrefix.length)) + : doc._encdata + const { _encdata, ...rest } = doc + return { ...rest, ...JSON.parse(decrypted) } + } catch { + return doc + } +} + +export function dbAction (dbName, op, ...args) { + if (!db[dbName]) { + throw new Error(`Table ${dbName} does not exist`) + } + if (op === 'compactDatafile') { + db[dbName].persistence.compactDatafile() + return + } + return new Promise((resolve, reject) => { + const callback = (err, result) => { + if (err) return reject(err) + if (op === 'find') return resolve((result || []).map(doc => decryptDoc(dbName, doc))) + if (op === 'findOne') return resolve(decryptDoc(dbName, result)) + resolve(result) + } + if (op === 'insert') { + const original = args[0] + const encrypted = Array.isArray(original) + ? original.map(doc => encryptDoc(dbName, doc)) + : encryptDoc(dbName, original) + db[dbName].insert(encrypted, (err, inserted) => { + if (err) return reject(err) + if (Array.isArray(original)) { + return resolve(inserted.map((doc, index) => ({ ...original[index], _id: doc._id }))) + } + resolve({ ...original, _id: inserted._id }) + }) + return + } + if (op === 'update' && needsEncryption(dbName, args[0]._id || args[0].id)) { + const [query, update, options] = args + const payload = update.$set || update + const encrypted = encryptDoc(dbName, { _id: query._id || query.id, ...payload }) + db[dbName].update(query, update.$set ? { $set: encrypted } : encrypted, options || {}, callback) + return + } + db[dbName][op](...args, callback) + }) +} diff --git a/build/replace/app/lib/safe-storage.js b/build/replace/app/lib/safe-storage.js new file mode 100644 index 0000000..cb6f73e --- /dev/null +++ b/build/replace/app/lib/safe-storage.js @@ -0,0 +1,48 @@ +/** + * Safe storage compatible with legacy HarmonyOS NeDB records. + */ + +import crypto from 'crypto' + +const SAFE_PREFIX = 'v2:safe:' +const ALGORITHM = 'aes-256-gcm' +const IV_LENGTH = 12 +const STORAGE_SECRET = process.env.STORAGE_SECRET || 'static-secret-string-safe-storage' + +function getKey () { + return crypto.createHash('sha256').update(STORAGE_SECRET).digest() +} + +export function safeEncrypt (value) { + if (typeof value !== 'string' || !value) return value + try { + const iv = crypto.randomBytes(IV_LENGTH) + const cipher = crypto.createCipheriv(ALGORITHM, getKey(), iv) + const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]) + return SAFE_PREFIX + [ + iv.toString('base64'), + encrypted.toString('base64'), + cipher.getAuthTag().toString('base64') + ].join(':') + } catch (err) { + console.error('[safe-storage] encrypt error:', err.message) + return value + } +} + +export function safeDecrypt (value) { + if (typeof value !== 'string' || !value || !value.startsWith(SAFE_PREFIX)) return value + try { + const [iv, encrypted, authTag] = value.slice(SAFE_PREFIX.length).split(':') + if (!iv || !encrypted || !authTag) return value + const decipher = crypto.createDecipheriv(ALGORITHM, getKey(), Buffer.from(iv, 'base64')) + decipher.setAuthTag(Buffer.from(authTag, 'base64')) + return Buffer.concat([ + decipher.update(Buffer.from(encrypted, 'base64')), + decipher.final() + ]).toString('utf8') + } catch (err) { + console.error('[safe-storage] decrypt error:', err.message) + return value + } +} diff --git a/build/replace/client/entry-web/electerm.jsx b/build/replace/client/entry-web/electerm.jsx new file mode 100644 index 0000000..2ce7657 --- /dev/null +++ b/build/replace/client/entry-web/electerm.jsx @@ -0,0 +1,14 @@ +import { createRoot } from 'react-dom/client' +import '../../../node_modules/antd/dist/reset.css' +import '@fontsource/maple-mono/index.css' +import LanguageSelect from '../harmony/language-select.jsx' +import Main from '../web-components/web-main' + +const rootElement = document.getElementById('container') +const root = createRoot(rootElement) + +root.render( + +
+ +) diff --git a/build/replace/client/harmony/language-select.jsx b/build/replace/client/harmony/language-select.jsx new file mode 100644 index 0000000..f1a9fcd --- /dev/null +++ b/build/replace/client/harmony/language-select.jsx @@ -0,0 +1,56 @@ +import { useEffect, useState } from 'react' +import { GlobalOutlined } from '@ant-design/icons' +import './language-select.styl' + +const STORAGE_KEY = 'locale' + +export default function LanguageSelect ({ children }) { + const [langs, setLangs] = useState(() => window.et?.langs || []) + const [loaded, setLoaded] = useState(() => !!window.et?.langs?.length) + const selected = !!window.localStorage.getItem(STORAGE_KEY) + + useEffect(() => { + if (selected || langs.length) return + window.pre.runGlobalAsync('init') + .then(({ langMap, langs }) => { + window.langMap = langMap + window.et.langs = langs + setLangs(langs || []) + }) + .catch(err => console.error('[language-select] load languages failed', err)) + .finally(() => setLoaded(true)) + }, [langs.length, selected]) + + const choose = async langId => { + window.localStorage.setItem(STORAGE_KEY, langId) + try { + await window.pre.runGlobalAsync('saveUserConfig', { language: langId }) + } catch (err) { + console.error('[language-select] saveUserConfig failed', err) + } + window.location.reload() + } + + if (selected || (loaded && !langs.length)) return children + + return ( +
+
+ +
Select language / 选择语言
+
+ {langs.map(lang => ( + + ))} +
+
+
+ ) +} diff --git a/src/client/harmony/language-select.styl b/build/replace/client/harmony/language-select.styl similarity index 91% rename from src/client/harmony/language-select.styl rename to build/replace/client/harmony/language-select.styl index 3d1a1f3..854a0be 100644 --- a/src/client/harmony/language-select.styl +++ b/build/replace/client/harmony/language-select.styl @@ -1,14 +1,11 @@ .language-select-wrap position fixed - left 0 - top 0 - width 100% - height 100% + inset 0 z-index 9999 - background #fff display flex align-items center justify-content center + background #fff .language-select-card width 420px @@ -47,4 +44,4 @@ &:hover color #fff background #08c - border-color #08c + border-color #08c \ No newline at end of file diff --git a/build/vite/.sample.env b/build/vite/.sample.env deleted file mode 100644 index 006af8a..0000000 --- a/build/vite/.sample.env +++ /dev/null @@ -1,5 +0,0 @@ -# run `cp .sample.env .env` to create your local env - -## development server config -DEV_HOST=127.0.0.1 -DEV_PORT=5570 diff --git a/build/vite/common.js b/build/vite/common.js index 79ae145..4bee2ab 100644 --- a/build/vite/common.js +++ b/build/vite/common.js @@ -7,25 +7,27 @@ conf() export const cwd = process.cwd() export const env = process.env export const isProd = env.NODE_ENV === 'production' -const packPath = resolve(cwd, '../../package.json') +export const isMac = env.PLATFORM === 'darwin' +export const isWin = env.PLATFORM === 'win32' +const packPath = resolve(cwd, 'package.json') export const pack = JSON.parse(readFileSync(packPath).toString()) export const version = pack.version -export const viewPath = resolve(cwd, '../../src/client/views') +export const viewPath = resolve(cwd, 'src/app/views') export const staticPaths = [ { - dir: resolve(cwd, '../../node_modules/electerm-icons/icons'), + dir: resolve(cwd, 'node_modules/electerm-icons/icons'), path: '/icons' }, { - dir: resolve(cwd, '../../node_modules/@electerm/electerm-resource/tray-icons'), + dir: resolve(cwd, 'node_modules/@electerm/electerm-resource/tray-icons'), path: '/images' }, { - dir: resolve(cwd, '../../node_modules/@electerm/electerm-resource/res/imgs'), + dir: resolve(cwd, 'node_modules/@electerm/electerm-resource/res/imgs'), path: '/images' }, { - dir: resolve(cwd, '../../src/client/entry'), + dir: resolve(cwd, 'src/client/statics'), path: '/' } ] diff --git a/build/vite/conf.js b/build/vite/conf.js index 6cce64d..a5154c4 100644 --- a/build/vite/conf.js +++ b/build/vite/conf.js @@ -1,76 +1,58 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' -// import htmlPurge from 'vite-plugin-purgecss' import { cwd, version } from './common.js' import { resolve } from 'path' import def from './def.js' function buildInput () { return { - electerm: resolve(cwd, '../../src/client/entry/electerm.jsx'), - basic: resolve(cwd, '../../src/client/entry/basic.js'), - worker: resolve(cwd, '../../src/client/entry/worker.js') - } -} - -// Custom plugin to replace window.et.isWebApp with false -function replaceWebAppPlugin () { - return { - name: 'replace-webapp', - renderChunk (code, chunk) { - // Replace window.et.isWebApp with false in the bundled code - const newCode = code.replace(/window\.et\.isWebApp/g, 'false') - if (newCode !== code) { - return { - code: newCode, - map: null - } - } - return null - } + electerm: resolve(cwd, 'src/client/entry-web/electerm.jsx'), + basic: resolve(cwd, 'src/client/entry-web/basic.js'), + worker: resolve(cwd, 'src/client/entry-web/worker.js') } } // https://vitejs.dev/config/ export default defineConfig({ plugins: [ - react({ include: /\.(mdx|js|jsx|ts|tsx|mjs)$/ }), - replaceWebAppPlugin() + // commonjs(), + react({ include: /\.(mdx|js|jsx|ts|tsx|mjs)$/ }) ], + define: def, + publicDir: false, + legacy: { + inconsistentCjsInterop: true + }, resolve: { alias: { - 'ironrdp-wasm': resolve(cwd, '../../node_modules/ironrdp-wasm/pkg/rdp_client.js'), + 'ironrdp-wasm': resolve(cwd, 'node_modules/ironrdp-wasm/pkg/rdp_client.js'), + '@novnc/novnc/core/rfb': resolve(cwd, 'node_modules/@novnc/novnc/core/rfb.js'), // @xterm/addon-ligatures bundles lru-cache@11, which calls // channel()/tracingChannel() from node:diagnostics_channel at import time. // In the renderer (browser) context Vite stubs Node builtins and the call // throws. lru-cache only uses it for optional metrics, so a no-op stub is // safe. Covers both bare `diagnostics_channel` and the `node:` prefix. - 'node:diagnostics_channel': resolve(cwd, './diagnostics-channel-stub.js'), - diagnostics_channel: resolve(cwd, './diagnostics-channel-stub.js') + 'node:diagnostics_channel': resolve(cwd, 'build/vite/diagnostics-channel-stub.js'), + diagnostics_channel: resolve(cwd, 'build/vite/diagnostics-channel-stub.js') } }, optimizeDeps: { exclude: ['ironrdp-wasm'] }, - define: def, - publicDir: false, - legacy: { - inconsistentCjsInterop: true - }, - root: resolve(cwd, '../..'), + // assetsInclude: ['**/*.wasm'], + root: resolve(cwd), build: { target: 'esnext', cssCodeSplit: false, codeSplitting: false, emptyOutDir: false, - outDir: resolve(cwd, '../../work/app/assets'), + outDir: resolve(cwd, 'dist/assets'), rollupOptions: { input: buildInput(), output: { format: 'esm', entryFileNames: `js/[name]-${version}.js`, chunkFileNames: `chunk/[name]-${version}-[hash].js`, - dir: resolve(cwd, '../../work/app/assets'), assetFileNames: chunkInfo => { const { name } = chunkInfo if (/\.(png|jpe?g|gif|svg|webp|ico|bmp)$/i.test(name)) { diff --git a/build/vite/def.js b/build/vite/def.js index 9be6654..7565975 100644 --- a/build/vite/def.js +++ b/build/vite/def.js @@ -1,6 +1,5 @@ import { version } from './common.js' export default { - 'process.env.VER': JSON.stringify(version), - __DEFINES__: JSON.stringify('some value') + 'process.env.VER': JSON.stringify(version) } diff --git a/build/vite/dev-server.js b/build/vite/dev-server.js index 51340b4..bc85ee3 100644 --- a/build/vite/dev-server.js +++ b/build/vite/dev-server.js @@ -1,17 +1,34 @@ import logger from 'morgan' -import { viewPath, env, staticPaths, pack, isProd, cwd } from './common.js' +import { + viewPath, + env, + staticPaths, + pack, + isProd, + cwd, + isWin, + isMac +} from './common.js' import express from 'express' import { createServer as createViteServer } from 'vite' import conf from './conf.js' +import os from 'os' import copy from 'json-deep-copy' +import proxy from 'express-http-proxy' +import fsFunctions from '../../src/app/common/fs-functions.js' +import { createToken } from '../../src/app/lib/jwt.js' +import { logDir } from '../../src/app/server/session-log.js' +import { resolve } from 'path' import fs from 'fs' -import path from 'path' -import { spawn } from 'child_process' -import multer from 'multer' +import { defaultUserName } from '../../src/app/common/runtime-constants.js' +import { migrationNotice } from '../../src/app/lib/fancy-console.js' const devPort = env.DEV_PORT || 5570 -const host = env.DEV_HOST || '127.0.0.1' -const h = `http://${host}:${devPort}` +const devHost = env.DEV_HOST || '127.0.0.1' +const port = env.PORT || 5572 +const host = env.HOST || '127.0.0.1' +const h = '' +const tar = `http://${host}:${port}` const defaultAIPreset = { baseURLAI: 'https://ai.electerm.org/api/ai', apiPathAI: '/chat/completions', @@ -20,26 +37,74 @@ const defaultAIPreset = { id: 'ai.electerm.org', nameAI: 'ai.electerm.org(default free)' } - -// const AIDisclamer = 'AI-generated terminal commands can be inaccurate or unsafe, be careful' - const base = { version: pack.version, isDev: !isProd, siteName: pack.name, defaultAIPreset, - disableUpgradeCheck: true, - AIDisclamer: '本内容由 AI 生成,仅供参考', - hideLocalTerminal: true, - disableAIFeature: false, - supportSessionTypes: ['ssh', 'telnet', 'rdp', 'vnc', 'ftp', 'spice'] + isWin, + isMac, + fsFunctions, + packInfo: pack, + home: os.homedir(), + versionFile: 'version-android.html', + downloadUpgradeFromBrowser: true, + server: h, + cdn: h, + isWebApp: true, + sessionLogPath: logDir, + tokenElecterm: process.env.ENABLE_AUTH ? '' : createToken() +} +let needMigrate +function checkNeedMigrate () { + if (needMigrate !== undefined) { + return needMigrate + } + + const nedbPath = process.env.DB_PATH || resolve(cwd, 'data/nedb-database') + const nedbUserPath = resolve(nedbPath, 'users', defaultUserName) + + // Check if nedb directory exists and has .nedb files + if (fs.existsSync(nedbUserPath)) { + const nedbFiles = fs.readdirSync(nedbUserPath).filter(file => file.endsWith('.nedb')) + + if (nedbFiles.length > 0) { + needMigrate = true + return needMigrate + } + } + + needMigrate = false + return needMigrate } -function handleIndex (req, res) { +async function checkNodePty () { + return import('node-pty') + .then(() => true) + .catch(() => false) +} + +async function handleIndex (req, res) { + const hasNodePty = await checkNodePty() + const needMigrate = checkNeedMigrate() + if (needMigrate) { + migrationNotice( + 'electerm-web v3', + 'nedb', + 'sqlite', + 'electerm-data-tool --data-path "/path/to/data/nedb-database" export data.json' + ) + } + const data = { + ...base, + query: req.query, + hasNodePty, + needMigrate + } const view = 'index' res.render(view, { - ...base, - _global: copy(base) + ...data, + _global: copy(data) }) } @@ -48,8 +113,8 @@ function redirect (req, res) { name } = req.params const mapper = { - electerm: '/src/client/entry/electerm.jsx', - worker: '/src/client/entry/worker.js' + electerm: '/src/client/entry-web/electerm.jsx', + worker: '/src/client/entry-web/worker.js' } res.redirect(mapper[name]) } @@ -64,14 +129,14 @@ async function createServer () { ...conf, server: { middlewareMode: true, + allowedHosts: ['service.html5beta.com'], hmr: { - port: 30085, - overlay: true + overlay: true, + port: env.DEV_HMR_PORT || 23589 } }, appType: 'custom' }) - app.use( logger('dev') ) @@ -86,60 +151,6 @@ async function createServer () { ) }) - const upload = multer({ dest: 'uploads/' }) - - app.get('/api/download', (req, res) => { - const filePath = req.query.path - if (!filePath) { - return res.status(400).json({ error: 'path is required' }) - } - try { - const stat = fs.statSync(filePath) - if (stat.isFile()) { - const fileName = path.basename(filePath) - res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(fileName)}"`) - res.setHeader('Content-Type', 'application/octet-stream') - fs.createReadStream(filePath).pipe(res) - } else if (stat.isDirectory()) { - const dirName = path.basename(filePath) - const parentDir = path.dirname(filePath) - res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(dirName)}.tar.gz"`) - res.setHeader('Content-Type', 'application/gzip') - const tar = spawn('tar', ['czf', '-', '-C', parentDir, dirName]) - tar.stdout.pipe(res) - tar.stderr.on('data', (data) => { - console.error('tar stderr:', data.toString()) - }) - tar.on('error', (err) => { - console.error('tar error:', err) - if (!res.headersSent) { - res.status(500).json({ error: err.message }) - } - }) - } else { - res.status(400).json({ error: 'path is not a file or directory' }) - } - } catch (err) { - console.error('download error:', err) - res.status(500).json({ error: err.message }) - } - }) - - app.post('/api/upload', upload.single('file'), (req, res) => { - const targetDir = req.body.path - if (!targetDir || !req.file) { - return res.status(400).json({ error: 'path and file are required' }) - } - try { - const destPath = path.join(targetDir, req.file.originalname) - fs.renameSync(req.file.path, destPath) - res.json({ success: true, path: destPath }) - } catch (err) { - console.error('upload error:', err) - res.status(500).json({ error: err.message }) - } - }) - app.set('views', viewPath) app.set('view engine', 'pug') @@ -148,10 +159,42 @@ async function createServer () { app.use(vite.middlewares) app.get(['/', '/index.html'], handleIndex) app.get('/:dir/:name.:ext', redirect) - app.listen(devPort, host, () => { + app.listen(devPort, devHost, () => { console.log('cwd:', cwd) - console.log(`server started at ${h}`) + console.log(`server started at ${h || `http://${devHost}:${devPort}`}`) }) + app.use( + '/api/login', + proxy(tar, { + proxyReqPathResolver: function (req) { + return req.originalUrl + } + }) + ) + app.use( + '/api/get-constants', + proxy(tar, { + proxyReqPathResolver: function (req) { + return req.originalUrl + } + }) + ) + app.use( + '/api/download', + proxy(tar, { + proxyReqPathResolver: function (req) { + return req.originalUrl + } + }) + ) + app.use( + '/api/upload', + proxy(tar, { + proxyReqPathResolver: function (req) { + return '/api/upload' + } + }) + ) } createServer() diff --git a/build/vite/package-lock.json b/build/vite/package-lock.json deleted file mode 100644 index ef8dbda..0000000 --- a/build/vite/package-lock.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "electerm", - "version": "1.29.5", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "electerm", - "version": "1.29.5" - } - } -} diff --git a/build/vite/package.json b/build/vite/package.json deleted file mode 100644 index e7b6715..0000000 --- a/build/vite/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "electerm", - "version": "1.29.5", - "main": "app.js", - "type": "module", - "scripts": { - "start": "npm run c", - "c": "node ./dev-server.js", - "build": "cross-env NODE_ENV=production vite build --config ./conf.js" - } -} diff --git a/build/web/build.mjs b/build/web/build.mjs new file mode 100644 index 0000000..ba48806 --- /dev/null +++ b/build/web/build.mjs @@ -0,0 +1,239 @@ +/** + * Build the electerm HarmonyOS (ArkWeb) web bundle. + * + * Modelled on build/android/build.mjs from electerm-android. Produces the + * Node.js project that runs on-device inside the HarmonyOS app: + * + * entry/src/main/resources/resfile/electerm/ + * ├── index.js entry started by the on-device node binary; sets + * env (HOST/PORT/SERVER_SECRET/data dirs) then + * imports app.bundle.mjs + * ├── app.bundle.mjs esbuild-bundled electerm backend (pure node, + * no electron APIs) + * ├── package.json read by runtime-constants.js via process.cwd() + * ├── views/index.pug server-rendered shell for the UI + * └── dist/assets/ vite-built frontend + static assets + * + * The resfile directory is packaged into the HAP as-is and is directly + * readable by the Node.js child process at + * /data/storage/el1/bundle/entry/resource/resfile/electerm — no runtime + * extraction needed. + * + * Differences vs the Android build: + * - No Capacitor www/ layout; output goes straight into the entry module. + * - No sql.js shim: the on-device runtime is hqzing/ohos-node v24 LTS + * (node:sqlite available without flags). + * - No path-to-regexp regex patch: that worked around nodejs-mobile's + * stripped ICU; ohos-node is a full build. + * - SERVER_SECRET is baked in at build time (from SERVER_SECRET / + * OHOS_SERVER_SECRET env; CI must provide it). + * - User data dir is passed at runtime via ELECTERM_DATA_DIR (the resfile + * install dir is read-only), so nothing about it is baked here. + */ +import { build as viteBuild } from 'vite' +import * as esbuild from 'esbuild' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const ROOT = path.resolve(__dirname, '..', '..') // build/web -> repo root + +process.chdir(ROOT) + +const OUT_DIR = path.resolve(ROOT, 'entry/src/main/resources/resfile/electerm') +const VERSION = JSON.parse( + fs.readFileSync(path.resolve(ROOT, 'package.json'), 'utf8') +).version + +// JWT secret for the on-device server. +// In CI this MUST come from the SERVER_SECRET / OHOS_SERVER_SECRET Action +// secret. Local development falls back to a fixed value. +const LOCAL_DEV_SECRET = 'electerm-harmony-local-dev-secret' +const SERVER_SECRET = process.env.SERVER_SECRET || process.env.OHOS_SERVER_SECRET || LOCAL_DEV_SECRET +if (process.env.CI && SERVER_SECRET === LOCAL_DEV_SECRET) { + console.error( + '[web] FATAL: SERVER_SECRET is not set. Add it to the repository GitHub Actions secrets (gh secret set SERVER_SECRET).' + ) + process.exit(1) +} + +function copyDir (from, to) { + if (!fs.existsSync(from)) { + console.warn('[web] skip missing source:', from) + return + } + fs.mkdirSync(to, { recursive: true }) + for (const entry of fs.readdirSync(from, { withFileTypes: true })) { + const s = path.join(from, entry.name) + const d = path.join(to, entry.name) + if (entry.isDirectory()) copyDir(s, d) + else fs.copyFileSync(s, d) + } +} + +// -------------------------------------------------------------------------- +// 1. Frontend (vite) +// -------------------------------------------------------------------------- +async function runVite () { + console.log('[web] building frontend (vite)…') + await viteBuild({ + configFile: path.resolve(__dirname, 'vite.web.mjs'), + root: ROOT, + logLevel: 'warn' + }) +} + +// -------------------------------------------------------------------------- +// 2. Static assets for the node project +// -------------------------------------------------------------------------- +function copyFrontendAssets () { + console.log('[web] copying static assets into node project…') + const assets = path.resolve(OUT_DIR, 'dist/assets') + + copyDir(path.resolve(ROOT, 'src/client/statics'), assets) + copyDir( + path.resolve(ROOT, 'node_modules/electerm-icons/icons'), + path.resolve(assets, 'icons') + ) + copyDir( + path.resolve(ROOT, 'node_modules/@electerm/electerm-resource/res/imgs'), + path.resolve(assets, 'images') + ) + copyDir( + path.resolve(ROOT, 'node_modules/@electerm/electerm-resource/tray-icons'), + path.resolve(assets, 'images') + ) + + fs.mkdirSync(path.resolve(OUT_DIR, 'views'), { recursive: true }) + fs.copyFileSync( + path.resolve(ROOT, 'src/app/views/index.pug'), + path.resolve(OUT_DIR, 'views/index.pug') + ) +} + +// -------------------------------------------------------------------------- +// 3. Backend (esbuild) +// -------------------------------------------------------------------------- + +// Mark all .node native-addon files external: the native binaries are not +// built for HarmonyOS and the libraries that use them have pure-JS fallbacks +// guarded by try/catch (see DISABLE_LOCAL_TERMINAL below). +const nativeNodePlugin = { + name: 'native-node-files', + setup (build) { + build.onResolve({ filter: /\.node$/ }, (args) => ({ + path: args.path, + external: true + })) + } +} + +async function bundleBackend () { + console.log('[web] bundling backend (esbuild)…') + await esbuild.build({ + entryPoints: [path.resolve(ROOT, 'src/app/app.js')], + bundle: true, + format: 'esm', + platform: 'node', + // hqzing/ohos-node v24 LTS runs on device + target: 'node22', + outfile: path.resolve(OUT_DIR, 'app.bundle.mjs'), + // Native modules that cannot be built for HarmonyOS. Kept external so + // esbuild never resolves them; guarded imports fall back at runtime. + external: [ + 'node-pty', + 'serialport', + 'node-bash', + 'font-list' + ], + banner: { + js: "import { createRequire } from 'module'; import { fileURLToPath as __etu } from 'url'; const require = createRequire(import.meta.url); const __filename = __etu(import.meta.url); const __dirname = __etu(new URL('.', import.meta.url));" + }, + plugins: [nativeNodePlugin], + // keep node built-ins external; everything else is bundled + logLevel: 'info' + }) +} + +// -------------------------------------------------------------------------- +// 4. Entry script + package.json +// -------------------------------------------------------------------------- + +function writeNodeEntry () { + const entry = `import { resolve } from 'node:path' +import { mkdirSync, appendFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' + +const __d = fileURLToPath(new URL('.', import.meta.url)) + +// Boot milestones are appended DIRECTLY to the launcher's node-boot.log — +// file writes bypass stdout, so ArkWeb/chromium logging (which shares this +// process's fd 1/2 with node) can never bury them. This is the primary +// diagnostics channel on device. +const __bootLog = resolve(process.env.ELECTERM_DATA_DIR || __d, 'node-boot.log') +const boot = (msg) => { + try { appendFileSync(__bootLog, \`[backend] \${msg}\\n\`) } catch {} +} +process.on('uncaughtException', (e) => boot(\`uncaughtException: \${(e && e.stack) || e}\`)) +process.on('unhandledRejection', (e) => boot(\`unhandledRejection: \${(e && e.stack) || e}\`)) +process.on('exit', (code) => boot(\`node process exit, code=\${code}\`)) +boot('entry.js running') + +// The node binary is exec'd by the native launcher with cwd inherited from +// the app process; electerm's runtime-constants.js reads "package.json" via +// resolve(process.cwd(), 'package.json'), so switch cwd to this directory +// before loading the backend bundle. NOTE: this directory (resfile inside the +// HAP install tree) is READ-ONLY — all writes go to ELECTERM_DATA_DIR. +process.chdir(__d) + +process.env.NODE_ENV = 'production' +process.env.HOST = '127.0.0.1' +process.env.PORT = '5577' +// JWT secret baked in at build time. The web UI auto-logs-in because +// ENABLE_AUTH is not set. +process.env.SERVER_SECRET = ${JSON.stringify(SERVER_SECRET)} +// No pty on HarmonyOS -> disable the local terminal feature. +process.env.DISABLE_LOCAL_TERMINAL = '1' +// Where the pug views live (cwd is this directory, set above). +process.env.VIEW_FOLDER = resolve(__d, 'views') + +// Writable user-data directory, created by the ArkTS side and passed in via +// ELECTERM_DATA_DIR (the app sandbox filesDir). This is where the database, +// ssh keys and logs live. Falls back to a sibling of this (read-only) dir — +// which will fail on writes, but keeps local dev on desktop working. +const userDataDir = process.env.ELECTERM_DATA_DIR || resolve(__d, 'data') +mkdirSync(userDataDir, { recursive: true }) +process.env.DB_PATH = userDataDir +process.env.HOME = userDataDir + +// SSH keys live under /.ssh +mkdirSync(resolve(userDataDir, '.ssh'), { recursive: true }) + +await import('./app.bundle.mjs') +` + fs.writeFileSync(path.resolve(OUT_DIR, 'index.js'), entry) + + fs.writeFileSync( + path.resolve(OUT_DIR, 'package.json'), + JSON.stringify({ name: 'electerm-web', version: VERSION, private: true, type: 'module' }, null, 2) + ) + console.log('[web] wrote index.js + package.json') +} + +// -------------------------------------------------------------------------- +// main +// -------------------------------------------------------------------------- + +fs.rmSync(OUT_DIR, { recursive: true, force: true }) +fs.mkdirSync(OUT_DIR, { recursive: true }) + +await runVite() +copyFrontendAssets() +await bundleBackend() +writeNodeEntry() + +const outFiles = fs.readdirSync(OUT_DIR) +console.log('[web] done →', OUT_DIR) +console.log('[web] top-level:', outFiles.join(', ')) diff --git a/build/web/vite.web.mjs b/build/web/vite.web.mjs new file mode 100644 index 0000000..743053a --- /dev/null +++ b/build/web/vite.web.mjs @@ -0,0 +1,68 @@ +// Vite config used to build the electerm *frontend* for the HarmonyOS +// (ArkWeb) app. Identical to build/android/vite.android.mjs except the +// output goes into the entry module's resfile Node.js project. +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import { resolve } from 'path' +import { cwd, version } from '../vite/common.js' +import def from '../vite/def.js' + +function buildInput () { + return { + electerm: resolve(cwd, 'src/client/entry-web/electerm.jsx'), + basic: resolve(cwd, 'src/client/entry-web/basic.js'), + worker: resolve(cwd, 'src/client/entry-web/worker.js') + } +} + +export default defineConfig({ + plugins: [ + react({ include: /\.(mdx|js|jsx|ts|tsx|mjs)$/ }) + ], + define: def, + publicDir: false, + legacy: { + inconsistentCjsInterop: true + }, + resolve: { + alias: { + 'ironrdp-wasm': resolve(cwd, 'node_modules/ironrdp-wasm/pkg/rdp_client.js'), + '@novnc/novnc/core/rfb': resolve(cwd, 'node_modules/@novnc/novnc/core/rfb.js'), + // @xterm/addon-ligatures pulls in lru-cache which touches + // node:diagnostics_channel at import time; stub it for the browser. + 'node:diagnostics_channel': resolve(cwd, 'build/vite/diagnostics-channel-stub.js'), + diagnostics_channel: resolve(cwd, 'build/vite/diagnostics-channel-stub.js') + } + }, + optimizeDeps: { + exclude: ['ironrdp-wasm'] + }, + root: resolve(cwd), + build: { + target: 'esnext', + cssCodeSplit: false, + codeSplitting: false, + emptyOutDir: false, + // Output the built frontend *inside* the resfile Node.js project so the + // backend (which serves `dist/assets`) finds it at runtime on device. + outDir: resolve(cwd, 'entry/src/main/resources/resfile/electerm/dist/assets'), + rollupOptions: { + input: buildInput(), + output: { + format: 'esm', + entryFileNames: `js/[name]-${version}.js`, + chunkFileNames: `chunk/[name]-${version}-[hash].js`, + assetFileNames: chunkInfo => { + const { name } = chunkInfo + if (/\.(png|jpe?g|gif|svg|webp|ico|bmp)$/i.test(name)) { + return `images/${name}` + } else if (name && name.endsWith('.css')) { + return `css/style-${version}[extname]` + } else { + return 'assets/[name]-[hash][extname]' + } + } + } + } + } +}) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6ed72b1..42812d9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,177 +1,178 @@ # Architecture — electerm-harmony -## 1. Overview +> **This branch (`dev2`) uses Node.js + ArkWeb. No Electron runtime is involved.** +> (Other branches in this repo — `main`/`dev`/`dev1` — used the Electron 鸿蒙 +> runtime. That does not apply here.) -electerm-harmony brings the [electerm-web](https://github.com/electerm/electerm-web) ssh/sftp/telnet/RDP/VNC/Spice/ftp client to HarmonyOS using the [Electron 鸿蒙 runtime](https://gitcode.com/openharmony-sig/electron). +## 1. Overview -The Electron 鸿蒙 runtime provides: -- **Node.js** — runs the electerm-web Express backend -- **Chromium** — renders the web UI via BrowserWindow -- **web_engine HAR module** — ArkTS API (WebAbility, WebWindow, JsBindingUtils) that bridges HarmonyOS UI with the Electron runtime +electerm-harmony brings the [electerm-web](https://github.com/electerm/electerm-web) ssh/sftp/telnet/RDP/VNC/Spice/ftp client to HarmonyOS using a lightweight on-device runtime: -This eliminates the need for custom process spawning, child_process shims, or HTTP polling between native and web layers. +- **ArkWeb** (`@kit.ArkWeb`) — the `Web` component renders the electerm-web frontend UI. +- **Node.js** (a shared library `libnode.so`) — runs the electerm-web Express/Node backend, which serves the UI and the SSH/SFTP/telnet/ftp/RDP/VNC/Spice protocol logic, all on `http://127.0.0.1:5577`. +- **Native glue** — small NAPI/C modules bridge ArkTS and the Node.js runtime. ``` -┌─────────────────── HarmonyOS App ───────────────────┐ -│ │ -│ ┌───────────────┐ ┌────────────────────────────┐ │ -│ │ ArkUI Shell │ │ Electron Runtime │ │ -│ │ (WebWindow) │ │ (libelectron.so) │ │ -│ │ │ │ │ │ -│ │ WebWindow │───►│ Node.js (main.js) │ │ -│ │ from │ │ ├── Express backend │ │ -│ │ web_engine │ │ │ (app.bundle.cjs) │ │ -│ │ │ │ └── BrowserWindow │ │ -│ │ │ │ (Chromium WebView) │ │ -│ └───────────────┘ └────────────────────────────┘ │ -│ │ -│ libadapter.so — bridges ArkTS ↔ Electron │ -│ (provided by web_engine HAR module) │ -└──────────────────────────────────────────────────────┘ +┌──────────────────────── HarmonyOS App ────────────────────────┐ +│ │ +│ ┌──────────────────┐ ┌─────────────────────────────┐ │ +│ │ ArkUI / ArkTS │ │ Native (same app process) │ │ +│ │ │ │ │ │ +│ │ pages/Index.ets │ │ libnode_ctl.so (NAPI) │ │ +│ │ ├─ Web (ArkWeb)│──http──│ startBackend() │ │ +│ │ │ src= │ 5577 │ └─ bootstrap thread │ │ +│ │ │ ://127.0.0.1│◄───────│ └─ dlopen(libnode.so)│ │ +│ │ │ :5577 │ │ └─ node::Start │ │ +│ │ └─ Boot overlay │ │ │ │ +│ │ │ │ libnode_launcher.so │ │ +│ │ │ │ (fallback child process) │ │ +│ └──────────────────┘ └─────────────────────────────┘ │ +│ │ +│ resfile/electerm/ ── read-only app bundle (frontend + │ +│ app.bundle.mjs backend) used by node │ +│ /electerm-data/ ── writable data dir (db, keys, │ +│ node-boot.log) │ +└────────────────────────────────────────────────────────────────┘ ``` +Key design points: + +- **No Electron, no Chromium-on-the-side.** The UI is a plain ArkWeb `Web` component; the protocol engine is Node.js running in-process. There is no BrowserWindow / web_engine HAR / libelectron.so. +- **In-process Node.js is the primary path.** The Electron-style pattern (Node core shipped as `.so` inside the app process) avoids the stricter seccomp filter that a spawned native child runs under — node's libuv dies in the child on event-loop syscalls. So node lives in the main process. +- **Native child process is a fallback only.** If in-process boot fails, the app falls back to `libnode_launcher.so:Main` via `childProcessManager.startNativeChildProcess`. + ## 2. Components -### 2.1 Electron 鸿蒙 Runtime (`openharmony-sig/electron`) +### 2.1 Node.js runtime — `libnode.so` (electerm/ohos-node-shared) -- **Repo**: -- **What it is**: A port of Electron (Chromium + Node.js) for HarmonyOS -- **Distribution**: Pre-built tarball (e.g. `electron40_hap_electron_v40.0.0_20260629.tar.gz`) -- **Tarball contents**: - - `web_engine/` — Complete HAR module (ArkTS source + resfile resources + libadapter.so type definitions) - - `electron/libs/arm64-v8a/*.so` — Native libraries: - - `libelectron.so` — Chromium + Node.js + V8 (the main runtime, ~175 MB) - - `libadapter.so` — ArkTS ↔ Electron bridge - - `libffmpeg.so` — Media codec support - - `libvk_swiftshader.so` — Vulkan software renderer - - `libc++_shared.so` — C++ standard library - - `vscode-sqlite3.node` — SQLite native module -- **In this project**: - - `web_engine/` is extracted to the project root (gitignored, downloaded at build time) - - `.so` files are extracted to `entry/libs/arm64-v8a/` (gitignored, downloaded at build time) +- **Repo**: +- **What it is**: a *real* shared library build of Node.js for OpenHarmony (built with `--shared`, so it is a PIC `.so` with dynamic TLS and a `libnode.so.` SONAME). It must **not** be the PIE executable form that some third-party builds ship — that form aliases the host TLS and crashes V8. +- **Version**: `24.2.0` (configured in `scripts/prepare-node.sh` and `.github/workflows/build-web.yml` as `NODE_VERSION`). The release tag is `ohos-node-shared-v${NODE_VERSION}`; the asset is `libnode-${arch}.so`. +- **Placement**: downloaded into `entry/libs//libnode.so` (e.g. `entry/libs/arm64-v8a/libnode.so`). hvigor packages it into the HAP's native libs dir, and the app `dlopen`s it from there at runtime. +- **Launch flags**: node is started with `--jitless --no-verify-heap`. `--jitless` avoids V8's runtime `PROT_EXEC` mapping, which the OpenHarmony W^X policy rejects (the old `# Check failed: 12 == (*__errno_location())` SIGTRAP in `node::Start`). `--no-verify-heap` is defensive against allocation checks under the constrained runtime. +- **io_uring**: node's libuv unconditionally probes `io_uring_setup` (425) at loop init; the sandbox seccomp-traps it. A SIGSYS shim in `libnode_ctl.c` (and `node_launcher.c`) converts the trap to a logged `-1` (exactly `-1`, not `-ENOSYS`) so libuv's guard passes. `UV_USE_IO_URING=0` is also set (though the getenv guard in this libuv build is not actually consulted). -### 2.2 web_engine HAR Module +### 2.2 Native glue -The `web_engine/` module is the core integration layer between HarmonyOS and Electron. It provides: +- **`entry/src/main/cpp/node_ctl.c` → `libnode_ctl.so`** (NAPI module for the main process): + - `startBackend(entryParams)` — spawns a detached bootstrap thread that `dlopen`s `libnode.so` and calls `node::Start`. `dlopen`/`dlsym`/`node::Start` are deliberately **not** on the ArkTS UI thread (doing them inline blocked the UI thread long enough to trip the `APP_INPUT_BLOCK` ANR watchdog). + - `getBackendStatus()` — returns `idle | launching: | running: | failed:` so the ArkTS page can detect a hard bootstrap failure in milliseconds and fall back to the child process. + - `killNode(pid)` — terminate the native-child-process backend. + - Also installs: a crash-marker signal handler (writes a backtrace + fault PC to `node-boot.log`/hilog and parks the node thread instead of dying), and the SIGSYS seccomp shim described above. + - Redirects fd 1/2 onto a 1 MB pipe drained by a reader thread into `node-boot.log` (filtering ArkWeb/Chromium noise) — keeping the pipe drained is what prevents Chromium's IO thread from blocking and wedging the `Web` component. +- **`entry/src/main/cpp/node_launcher.c` → `libnode_launcher.so`** (native child process entry, `Main`): + - Started by `childProcessManager.startNativeChildProcess('libnode_launcher.so:Main', { entryParams })`. Parses `key=value` params, locates `libnode.so`, redirects stdio to the boot log, and `execv`s node with the electerm entry script. Falls back to a `memfd` + `execveat` path if the lib dir is `noexec`. -- **`WebAbilityStage`** — Base class for `AbilityStage`, initializes the Electron native context -- **`WebAbility`** — Base class for `EntryAbility`, handles window creation and XComponent setup -- **`WebWindow`** — ArkUI component that hosts the XComponent surface for Electron -- **`JsBindingUtils`** — Utility class for managing native Electron contexts -- **resfile resources** — Chromium runtime resources (`icudtl.dat`, `.pak` files, `v8_context_snapshot.bin`, `locales/`) +### 2.3 ArkTS layer (`entry` module) -This module is **not modified** by this project — it's used as-is from the tarball. +- **`AbilityStage.ets`** — standard `AbilityStage` (no Electron `WebAbilityStage`). +- **`entryability/EntryAbility.ets`** — standard `UIAbility`; on `onDestroy()` it calls `BackendManager.killBackend()`. +- **`pages/Index.ets`** — the boot orchestrator: + 1. resolves the writable data dir — **el2 only** (`/data/storage/el2/base/files/electerm-data`, else `/data/storage/el2/base/files`, else `filesDir` and its `electerm-data` sub-dir). A candidate that already holds `users/` wins, so an in-place upgrade keeps the previous build's db. `bundleCodeDir` (`el1/bundle`) is the read-only HAP install dir and must never be used for data — `mkdir` there fails with `13900012`; + 2. calls `startBackend()` (in-process primary, native child fallback); + 3. polls `http://127.0.0.1:5577` with plain HTTP until it answers; + 4. once ready, `controller.loadUrl(SERVER_URL)` swaps the `Web` component from the local `loading.html` to the backend. + - While booting or on failure, a native overlay (`Stack` over the `Web`) shows the status / last boot-log lines, so a stuck boot is diagnosable from the screen alone. +- **`BackendManager.ets`** — tracks how the backend runs (`inProcess` vs `pid`) for clean shutdown. -### 2.3 Web app source (bundled in project root) +### 2.4 Web app source (bundled in the HAP `resfile`) - **Repo**: -- **What it is**: Web-based ssh/sftp/telnet/RDP/VNC/Spice/ftp client -- **Build**: The source is in the project root (`src/`, `build/`) and built with: +- **Source**: in the project root (`src/`, `build/`), built with: - **Vite** — builds the React frontend → `dist/assets/` - - **esbuild** — bundles the Node.js backend → `app.bundle.cjs` (CJS format) -- **In this project**: Built output goes to `web_engine/src/main/resources/resfile/resources/app/` - -### 2.4 Electron Main Process (`main.js`) - -The Electron main process entry point, generated by `build/harmony/build.js`: - -1. Sets environment variables (HOST, PORT, SERVER_SECRET, etc.) -2. Starts the Express backend via `require('./app.bundle.cjs')` -3. Polls `http://127.0.0.1:5577` until the backend is ready -4. Creates a `BrowserWindow` that loads the frontend from the backend's HTTP server - -### 2.5 ArkTS Layer (entry module) - -- **`AbilityStage.ets`** — Extends `WebAbilityStage` from `web_engine`, which initializes the Electron native context -- **`EntryAbility.ets`** — Extends `WebAbility` from `web_engine`, handles UIAbility lifecycle -- **`pages/Index.ets`** — Uses the `WebWindow` component from `web_engine` to host the Electron surface + - **esbuild** — bundles the Node.js backend → `app.bundle.mjs` (ESM) +- **Entry**: `resfile/electerm/index.js` (reads the bundled `app.bundle.mjs`, serves the UI and the protocol API). +- **Placement**: copied to `entry/src/main/resources/resfile/electerm/` and packaged read-only into the HAP. The node process runs from there; it writes its mutable state to `/electerm-data/`. ## 3. Build Flow ``` ┌──────────────────────────────────────────────────────────────────┐ -│ Build Pipeline │ +│ Build Pipeline (web variant — no Electron runtime) │ ├──────────────────────────────────────────────────────────────────┤ │ │ -│ 1. prepare-electron-runtime.sh │ -│ ├── Extract tarball (downloaded or local file) │ -│ ├── Copy web_engine/ → project root │ -│ └── Copy .so files → entry/libs/arm64-v8a/ │ +│ 1. prepare-node.sh │ +│ └── Download libnode-.so from the ohos-node-shared │ +│ release → entry/libs//libnode.so (verified ELF is a │ +│ shared lib, NOT a PIE) │ │ │ │ 2. prepare-web.sh │ -│ ├── npm install (project root) │ +│ ├── npm install (project root) │ │ ├── build/harmony/build.js: │ -│ │ ├── npm run b (complete electerm build): │ -│ │ │ ├── clean (remove work/) │ -│ │ │ ├── compile (vite + copy + pug) → work/app/assets/ │ +│ │ ├── npm run b (complete electerm build): │ +│ │ │ ├── clean / compile (vite + copy + pug) → work/app/ │ │ │ │ └── prepare-file (src copy + deps install + cleanup) │ -│ │ ├── HarmonyOS delta: │ -│ │ │ ├── package.json main → bootstrap.js │ +│ │ ├── HarmonyOS delta: │ +│ │ │ ├── package.json main → index.js │ │ │ │ └── Remove native modules (node-pty, serialport, ...) │ -│ │ ├── Copy work/app → web_engine resfile │ -│ │ └── Verify critical files (index.html, JS, CSS, chunks) │ -│ └── Verify output → web_engine/.../resfile/resources/app/ │ +│ │ └── Copy work/app → resfile/electerm │ +│ └── Verify output → entry/src/main/resources/resfile/electerm │ │ │ -│ 3. build-app.sh │ -│ ├── Generate build-profile.json5 (entry + web_engine modules) │ +│ 3. build-web-app.sh │ +│ ├── Generate build-profile.json5 (entry module only) │ │ ├── ohpm install │ │ ├── hvigorw assembleApp (unsigned) │ -│ └── hap-sign-tool.jar sign-app (signed .app) │ +│ └── hap-sign-tool.jar sign-app → electerm-harmony-arm64-.app │ │ └──────────────────────────────────────────────────────────────────┘ ``` +Prepared artifacts inside the HAP: + +- `libs/arm64-v8a/libnode.so` — the Node.js runtime +- `libs/arm64-v8a/libnode_ctl.so` — NAPI glue (in-process) +- `libs/arm64-v8a/libnode_launcher.so` — native child fallback +- `resources/resfile/electerm/index.js`, `app.bundle.mjs`, `views/index.pug`, `dist/assets/...` — the web app + ## 4. Runtime Flow ``` App Launch │ ▼ -AbilityStage.onCreate() - │ WebAbilityStage initializes Electron native context +AbilityStage.onCreate() / EntryAbility.onWindowStageCreate() │ ▼ -EntryAbility.onWindowStageCreate() - │ WebAbility loads 'pages/Index' +Index.aboutToAppear() → startBackend() + │ try IN-PROCESS first: + │ libnode_ctl.startBackend() → background thread: + │ dlopen(libnode.so) → node::Start (V8 --jitless) + │ serves electerm on http://127.0.0.1:5577 │ - ▼ -Index.ets → WebWindow component - │ XComponent surface ready → Electron runtime starts + │ poll http://127.0.0.1:5577 (getBackendStatus consulted for + │ hard failures → fall back if 'failed:') │ - ▼ -Electron Runtime starts (libelectron.so) - │ - ├── Runs main.js (Node.js) - │ ├── Sets env vars - │ ├── require('./app.bundle.cjs') → starts Express on :5577 - │ └── Polls http://127.0.0.1:5577 + ▼ (if in-process fails) +childProcessManager.startNativeChildProcess('libnode_launcher.so:Main') + │ node runs as a separate native child process │ - └── Creates BrowserWindow (Chromium) - └── Loads http://127.0.0.1:5577 (electerm UI) + ▼ +Web component loadUrl("http://127.0.0.1:5577") ← UI appears ``` ## 5. Key Design Decisions | Decision | Rationale | |----------|-----------| -| Use Electron 鸿蒙 runtime instead of standalone ohos-node | Provides both Node.js and Chromium in one package; no need for custom process spawning or WebView bridging | -| Use web_engine HAR module as-is from tarball | Provides the complete ArkTS ↔ Electron integration layer; no need to maintain custom bridge code | -| CJS format for backend bundle | Electron's main process uses CommonJS `require()` | -| Download runtime at build time, not committed | The tarball is ~200 MB of binary/ArkTS artifacts that don't need modification | -| `resfile/` for app code | Directly accessible by the Electron runtime (no extraction needed, unlike `rawfile/`) | +| Use a shared `libnode.so` (ohos-node-shared) instead of an Electron runtime | Provides Node.js in-process without pulling in Chromium-as-a-second-runtime; the `Web` component already supplies Chromium for the UI | +| Run Node.js **in-process** (dlopen + `node::Start`) | The spawned native child runs under a stricter seccomp filter that kills libuv; the main process already runs libuv-class loops (NETSTACK/curl) | +| Keep a native **child-process fallback** | A hard in-process bootstrap failure (missing script / no libnode.so / dlopen failure) is detected fast and recovered without an ANR | +| `--jitless` Node.js | OpenHarmony W^X policy rejects V8's runtime `PROT_EXEC` mapping; jitless runs node as a pure interpreter | +| SIGSYS shim for `io_uring_setup` | libuv probes it unconditionally and the sandbox traps it; the shim returns exactly `-1` so the guard passes | +| `resfile/electerm` for app code | Directly readable by the node process; writable state goes to `filesDir/electerm-data` | +| Download `libnode.so` at build time, not committed | The binary is ~90–120 MB and is fetched from the ohos-node-shared release | ## 6. What's Committed vs. Downloaded | Path | Committed? | Description | |------|-----------|-------------| -| `entry/src/main/ets/` | Yes | Our ArkTS source (AbilityStage, EntryAbility, Index) | +| `entry/src/main/ets/` | Yes | Our ArkTS source (AbilityStage, EntryAbility, Index, BackendManager) | +| `entry/src/main/cpp/` | Yes | Native glue: `node_ctl.c`, `node_launcher.c`, `CMakeLists.txt` | | `entry/src/main/module.json5` | Yes | Module configuration with permissions | -| `entry/build-profile.json5` | Yes | Module build profile | -| `entry/oh-package.json5` | Yes | Depends on `web_engine` | | `src/`, `build/`, `package.json` | Yes | Web app source code | -| `scripts/` | Yes | Build scripts | +| `scripts/` | Yes | `prepare-node.sh`, `prepare-web.sh`, `build-web-app.sh`, … | | `docs/` | Yes | Documentation | | `AppScope/` | Yes | App-level config | -| `web_engine/` | **No** (downloaded) | HAR module from tarball (gitignored) | -| `entry/libs/` | **No** (downloaded) | .so libraries from tarball (gitignored) | -| `build-profile.json5` | **No** (generated) | Generated by `build-app.sh` (gitignored) | +| `entry/libs/` | **No** (downloaded) | `libnode.so` from the ohos-node-shared release (gitignored) | +| `entry/src/main/resources/resfile/electerm/` | **No** (generated) | Web app build output (gitignored) | +| `build-profile.json5` | **No** (generated) | Generated by `build-web-app.sh` (gitignored) | diff --git a/docs/BUILD.md b/docs/BUILD.md index 0c9a92e..e68b512 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -1,49 +1,43 @@ -# Build Guide — electerm-harmony +# Build Guide — electerm-harmony (web variant: ArkWeb + Node.js) -Complete instructions for building the electerm HarmonyOS app locally and on GitHub Actions. +Complete instructions for building the electerm HarmonyOS app on this branch +(`dev2`) locally and on GitHub Actions. This branch uses **no Electron +runtime** — it runs the electerm-web backend on an on-device Node.js shared +library (`libnode.so`) behind an ArkWeb `Web` component. --- ## 1. Architecture Overview -This project uses the **Electron 鸿蒙 runtime** (`openharmony-sig/electron`) to provide: +This branch uses a lightweight on-device runtime: -1. **Node.js runtime** — runs the electerm-web Express backend -2. **WebView** — Chromium-based `BrowserWindow` for the frontend UI -3. **web_engine HAR module** — ArkTS API layer (WebAbility, WebWindow, JsBindingUtils) +1. **ArkWeb (`Web` component)** — renders the electerm-web frontend UI. +2. **Node.js backend** — `libnode.so` (from `electerm/ohos-node-shared`) runs in-process (primary) or as a native child process (fallback), serving the UI + SSH/SFTP/telnet/ftp/RDP/VNC/Spice on `http://127.0.0.1:5577`. +3. **Native glue** — `libnode_ctl.so` (NAPI) and `libnode_launcher.so` (child fallback). The project structure at build time: ``` electerm-harmony/ ├── entry/ # Our HarmonyOS entry module (committed) -│ ├── libs/arm64-v8a/ # .so libraries (downloaded, gitignored) -│ │ ├── libelectron.so # Chromium + Node.js + V8 -│ │ ├── libadapter.so # HarmonyOS ↔ Electron adapter -│ │ ├── libffmpeg.so # Media codecs -│ │ └── ... +│ ├── libs/arm64-v8a/ # libnode.so (downloaded, gitignored) +│ │ ├── libnode.so # Node.js runtime (shared lib) +│ │ ├── libnode_ctl.so # NAPI: startBackend / getBackendStatus / killNode +│ │ └── libnode_launcher.so # native child-process fallback │ └── src/main/ │ ├── ets/ # ArkTS code (committed) -│ │ ├── AbilityStage.ets # Extends WebAbilityStage -│ │ ├── entryability/ # Extends WebAbility -│ │ └── pages/Index.ets # Uses WebWindow component +│ │ ├── AbilityStage.ets +│ │ ├── entryability/ # standard UIAbility +│ │ ├── BackendManager.ets # tracks in-process vs child-pid +│ │ └── pages/Index.ets # boot orchestrator (Web + backend) +│ ├── cpp/ # native glue (committed) │ ├── module.json5 # Module config with permissions -│ └── resources/base/ # Strings, colors, profiles -├── web_engine/ # HAR module from tarball (downloaded, gitignored) -│ ├── Index.ets # Module exports (WebAbility, WebWindow, etc.) -│ ├── oh-package.json5 -│ ├── build-profile.json5 -│ └── src/main/ -│ ├── ets/ # ArkTS source (WebAbility, adapters, etc.) -│ ├── cpp/types/libadapter/ # libadapter.so type declarations -│ └── resources/resfile/ # Electron runtime + app code -│ ├── icudtl.dat # ICU data -│ ├── *.pak # Chromium resource packs -│ ├── v8_context_snapshot.bin -│ ├── locales/ # Localization resources -│ └── resources/app/ # Electron app (built by prepare-web.sh) -│ ├── main.js # Electron main process -│ ├── app.bundle.cjs # electerm-web backend +│ └── resources/ +│ ├── base/ # Strings, colors, profiles +│ └── resfile/electerm/ # Web app build output (generated, gitignored) +│ ├── index.js # node entry (require app.bundle.mjs) +│ ├── app.bundle.mjs # esbuild-bundled backend (ESM) +│ ├── views/index.pug # Express view template │ └── dist/assets/ # Vite-built frontend ├── src/ # Web app source (committed) ├── build/ # Build scripts (committed) @@ -52,8 +46,11 @@ electerm-harmony/ │ └── vite/ # Vite config ├── package.json # Web app npm package (committed) ├── scripts/ # Build scripts (committed) +│ ├── prepare-node.sh # Download libnode.so +│ ├── prepare-web.sh # Build web app → resfile/electerm +│ └── build-web-app.sh # Build unsigned APP + sign ├── AppScope/app.json5 # App-level config (committed) -└── build-profile.json5 # Generated by build-app.sh (gitignored) +└── build-profile.json5 # Generated by build-web-app.sh (gitignored) ``` --- @@ -69,75 +66,39 @@ electerm-harmony/ | HarmonyOS Command Line Tools | 5.0.5.200+ | Provides `ohpm`, `hvigorw`, SDK, and `hap-sign-tool.jar` | | git | latest | | | Python 3 + make + C++ build tools | | For native node modules | -| **Electron 鸿蒙 runtime tarball** | | Pre-built tarball from openharmony-sig/electron (see §3) | +| **Node.js runtime** | `libnode.so` v24.2.0 | Downloaded automatically by `prepare-node.sh` from `electerm/ohos-node-shared` | ### 2.2 CI (GitHub Actions) - Runner: `ubuntu-latest` (Linux x64 — HarmonyOS Command Line Tools are x64-only) - JDK 21 (Temurin) - Node.js 24 -- `ELECTRON_RUNTIME_URL` secret — see §3.2 for the value to set +- `NODE_VERSION` env (default `24.2.0`) — must match `scripts/prepare-node.sh` +- Secrets listed in §5.1 --- -## 3. Obtaining the Electron 鸿蒙 Runtime - -The Electron 鸿蒙 runtime is built from the [openharmony-sig/electron](https://gitcode.com/openharmony-sig/electron) project. - -### 3.1 What the tarball contains - -The tarball (e.g. `electron40_hap_electron_v40.0.0_20260629.tar.gz`) extracts to a directory containing: - -- `web_engine/` — Complete HAR module with: - - ArkTS source code (WebAbility, WebWindow, JsBindingUtils, adapters) - - `resfile/` resources (icudtl.dat, .pak files, v8_context_snapshot.bin, locales/) - - `cpp/types/libadapter/` type declarations for libadapter.so -- `electron/libs/arm64-v8a/` — Native .so libraries: - - `libelectron.so` (~175 MB) — Chromium + Node.js + V8 - - `libadapter.so` — HarmonyOS ↔ Electron bridge - - `libffmpeg.so` — Media codecs - - `libvk_swiftshader.so` — Vulkan software renderer - - `libc++_shared.so` — C++ standard library - - `vscode-sqlite3.node` — SQLite native module - -### 3.2 For CI (GitHub Actions) - -The runtime tarball URL is stored as a GitHub secret (`ELECTRON_RUNTIME_URL`) to avoid -exposing the private hosting address in the repo. Set this in GitHub repo → **Settings → Secrets and variables → Actions**. +## 3. Obtaining the Node.js runtime (`libnode.so`) -> **Note:** Ask the project maintainer for the URL value — it is not committed to the repo. +The Node.js runtime is **not** committed. `scripts/prepare-node.sh` downloads it +from the [electerm/ohos-node-shared](https://github.com/electerm/ohos-node-shared) +GitHub release and installs it into the entry module's native libs dir. -The CI workflow reads this secret and passes it to `prepare-electron-runtime.sh`. - -### 3.3 Using the tarball for local builds - -Set one of these environment variables and run the prepare script: +- **Release tag**: `ohos-node-shared-v${NODE_VERSION}` (default `ohos-node-shared-v24.2.0`) +- **Asset**: `libnode-${arch}.so` (`arm64` / `x64`), placed as `entry/libs//libnode.so` +- The script **verifies the ELF is a true shared library** (ET_DYN without `PT_INTERP`), rejecting the broken PIE form that crashes V8. ```bash -# Option A: Use a local tarball file -export ELECTRON_RUNTIME_FILE=/path/to/electron40_hap_electron_v40.0.0_20260629.tar.gz -./scripts/prepare-electron-runtime.sh - -# Option B: Use an already-extracted directory -export ELECTRON_RUNTIME_DIR=/path/to/extracted/electron144_ohos_hap -./scripts/prepare-electron-runtime.sh -``` - -The script will: -1. Extract the tarball (if using `ELECTRON_RUNTIME_FILE`) -2. Copy `web_engine/` to the project root -3. Copy `.so` files to `entry/libs/arm64-v8a/` - -### 3.4 Using a custom URL (alternative) - -For local builds or alternative CI setups, you can also use a URL directly: +# Default arch = arm64, version = 24.2.0 +./scripts/prepare-node.sh -```bash -export ELECTRON_RUNTIME_URL=https://your-server.com/electron40_hap_electron_v40.0.0_20260629.tar.gz -./scripts/prepare-electron-runtime.sh +# Optional overrides +NODE_VERSION=24.2.0 ARCH=x64 ./scripts/prepare-node.sh ``` -The script will download the tarball, extract it, and install the files. +No manual URL or secret is required — the release is public. The `NODE_VERSION` +in `prepare-node.sh` and in `.github/workflows/build-web.yml` must stay in sync +(a mismatch → 404 on the asset download). --- @@ -161,23 +122,13 @@ signing/ └── electermRelease.p7b # Release provisioning profile ``` -### Step 3 — Prepare the Electron 鸿蒙 runtime +### Step 3 — Prepare the Node.js runtime ```bash -# Option A: From a local tarball file -export ELECTRON_RUNTIME_FILE=/path/to/electron40_hap_electron_v40.0.0_20260629.tar.gz -./scripts/prepare-electron-runtime.sh - -# Option B: From an extracted directory -export ELECTRON_RUNTIME_DIR=/path/to/extracted/electron144_ohos_hap -./scripts/prepare-electron-runtime.sh - -# Option C: From a URL (ask maintainer for the URL) -export ELECTRON_RUNTIME_URL= -./scripts/prepare-electron-runtime.sh +./scripts/prepare-node.sh ``` -This extracts `web_engine/` to the project root and `.so` libraries to `entry/libs/arm64-v8a/`. +This downloads `libnode.so` (v24.2.0) into `entry/libs/arm64-v8a/`. ### Step 4 — Build the web app @@ -185,12 +136,14 @@ This extracts `web_engine/` to the project root and `.so` libraries to `entry/li ./scripts/prepare-web.sh ``` -This installs dependencies in the project root, builds the frontend (Vite) and backend (esbuild CJS bundle), and copies the output into `web_engine/src/main/resources/resfile/resources/app/`. +This installs dependencies in the project root, builds the frontend (Vite) and +backend (esbuild ESM bundle), and copies the output into +`entry/src/main/resources/resfile/electerm/`. The build produces: -- `main.js` — Electron main process (starts backend, creates BrowserWindow) -- `app.bundle.cjs` — esbuild-bundled backend (CJS format) -- `package.json` — `{ name, version, main: "main.js" }` +- `index.js` — node entry point +- `app.bundle.mjs` — esbuild-bundled backend (ESM) +- `package.json` — `{ name, version, main: "index.js", type: "module" }` - `dist/assets/` — Vite-built frontend (JS, CSS, images) - `views/index.pug` — Express view template @@ -207,17 +160,20 @@ export KEY_ALIAS="electerm_key" # export OHOS_SDK_HOME=$COMMANDLINE_TOOLS/sdk # Build (release mode by default) -./scripts/build-app.sh --release +./scripts/build-web-app.sh --release # Or debug mode: -./scripts/build-app.sh --debug +./scripts/build-web-app.sh --debug ``` The signed APP is at: ``` -build/outputs/default/electerm-arm64-.app +build/outputs/default/electerm-harmony-arm64-.app ``` +> **Note:** the X86_64 build (emulator) uses the same script; the canonical +> shipped filename is always `electerm-harmony-arm64-.app`. + ### Step 6 — Install on device or upload to AGC **Upload to AppGallery Connect:** @@ -227,20 +183,19 @@ Upload the `.app` file directly in the AGC console. **Install on device:** ```bash -hdc install build/outputs/default/electerm-arm64-.app +hdc install build/outputs/default/electerm-harmony-arm64-.app ``` --- ## 5. CI Build (GitHub Actions) -The workflow is defined in [`.github/workflows/build.yml`](../.github/workflows/build.yml). +The workflow is defined in [`.github/workflows/build-web.yml`](../.github/workflows/build-web.yml). It triggers on pushes to `dev2`. ### 5.1 Required GitHub Secrets | Secret | Description | |--------|-------------| -| `ELECTRON_RUNTIME_URL` | Runtime tarball URL (see §3.2 for the value) | | `OHOS_CMDLINE_TOOLS_URL` | URL to download HarmonyOS Command Line Tools (~2 GB) | | `OHOS_KEYSTORE_B64` | Base64-encoded `.p12` keystore | | `OHOS_CERT_B64` | Base64-encoded `.cer` certificate | @@ -251,22 +206,25 @@ The workflow is defined in [`.github/workflows/build.yml`](../.github/workflows/ | `OHOS_BUNDLE_NAME` | App bundle name (must match AGC registration) | | `OHOS_SERVER_SECRET` | (Optional) Server secret for web app backend | +> The Node.js runtime is pulled from the **public** `electerm/ohos-node-shared` +> release (no secret needed). Its version is the `NODE_VERSION` workflow env +> (default `24.2.0`), which must match `scripts/prepare-node.sh`. + ### 5.2 What the workflow does ``` 1. Checkout electerm-harmony repo 2. Setup Node.js 24 + JDK 21 3. Install system dependencies - 4. Prepare Electron 鸿蒙 runtime (download tarball → web_engine/ + entry/libs/) - 5. Build web app (frontend + backend → web_engine/.../resfile/resources/app/) - 6. Download & extract HarmonyOS Command Line Tools (~2 GB) - 7. Configure ohpm registry - 8. Decode signing materials from GitHub Secrets - 9. Configure bundle name from secret -10. Build unsigned APP (hvigorw assembleApp) -11. Sign APP with hap-sign-tool.jar -12. Upload .app as GitHub Actions artifact -13. Write build summary + 4. Download & extract HarmonyOS Command Line Tools (~2 GB, cached) + 5. Prepare Node.js runtime (libnode.so via prepare-node.sh) + 6. Build web app (frontend + backend → resfile/electerm) + 7. Decode signing materials from GitHub Secrets + 8. Configure bundle name from secret + 9. Build unsigned APP (hvigorw assembleApp) +10. Sign APP with hap-sign-tool.jar → electerm-harmony-arm64-.app +11. Upload .app as GitHub Actions artifact +12. Write build summary ``` --- @@ -275,17 +233,17 @@ The workflow is defined in [`.github/workflows/build.yml`](../.github/workflows/ | Script | Purpose | |--------|---------| -| [`scripts/prepare-electron-runtime.sh`](../scripts/prepare-electron-runtime.sh) | Extract tarball → `web_engine/` + `entry/libs/arm64-v8a/` | -| [`scripts/prepare-web.sh`](../scripts/prepare-web.sh) | Build web app (Vite + esbuild) → `web_engine/.../resfile/resources/app/` | -| [`scripts/build-app.sh`](../scripts/build-app.sh) | Build unsigned APP, then sign it with `hap-sign-tool.jar` | +| [`scripts/prepare-node.sh`](../scripts/prepare-node.sh) | Download `libnode.so` (ohos-node-shared release) → `entry/libs//libnode.so` | +| [`scripts/prepare-web.sh`](../scripts/prepare-web.sh) | Build web app (Vite + esbuild) → `entry/.../resfile/electerm/` | +| [`scripts/build-web-app.sh`](../scripts/build-web-app.sh) | Build unsigned APP, then sign it with `hap-sign-tool.jar` | | [`scripts/gen-secrets.sh`](../scripts/gen-secrets.sh) | Generates GitHub Secrets values from `signing/` files | Run them in order for a local build: ```bash -./scripts/prepare-electron-runtime.sh +./scripts/prepare-node.sh ./scripts/prepare-web.sh -./scripts/build-app.sh +./scripts/build-web-app.sh ``` --- @@ -311,13 +269,13 @@ export PATH=$PATH:$COMMANDLINE_TOOLS/bin:$COMMANDLINE_TOOLS/hvigor/bin export OHOS_SDK_HOME=$COMMANDLINE_TOOLS/sdk ``` -### "web_engine/ not found" +### "Missing: entry/libs/arm64-v8a/libnode.so" -The `web_engine/` module is not committed to the repo. Run `./scripts/prepare-electron-runtime.sh` first to extract it from the tarball. +The Node.js runtime is not present. Run `./scripts/prepare-node.sh` first. -### "libelectron.so not found" +### "Missing: entry/src/main/resources/resfile/electerm/index.js" -The `.so` libraries are not committed to the repo. Run `./scripts/prepare-electron-runtime.sh` first to extract them from the tarball. +The web app has not been built. Run `./scripts/prepare-web.sh` first. ### electerm-web build fails with native module errors @@ -332,6 +290,22 @@ fnm install 24 fnm use 24 ``` +### Backend never answers (stuck on boot overlay) + +Pull `node-boot.log` from the device: + +```bash +hdc file recv /data/storage/el2/base/files/electerm-data/node-boot.log ./node-boot.log +``` + +The log shows the launch ladder (candidate → dlopen → dlsym → node::Start) for +both the in-process and child-process paths, plus any SIGSYS/SIGSEGV crash +markers. Watch hilog with: + +```bash +hdc hilog | grep -E 'electerm\.(Index|embed|launcher)' +``` + ### Signing fails: "keystore password was incorrect" This is a JDK version mismatch. See [ENV_SETUP.md §2.7](./ENV_SETUP.md#27-keystore-jdk-compatibility-if-keystore-was-created-with-jdk-22). diff --git a/docs/ENV_SETUP.md b/docs/ENV_SETUP.md index f2f5651..991d3a3 100644 --- a/docs/ENV_SETUP.md +++ b/docs/ENV_SETUP.md @@ -281,33 +281,30 @@ Or use the helper script (reads from `temp/.env` and `signing/`): | `OHOS_CMDLINE_TOOLS_URL` | download URL | HarmonyOS Command Line Tools download link (see section 5 below) | | `OHOS_SERVER_SECRET` | random string | Secret key for web app server (generate with `openssl rand -base64 32`) | -### 4.4 Electron 鸿蒙 Runtime +### 4.4 Node.js Runtime (ohos-node-shared) -The app uses the Electron 鸿蒙 runtime (from `openharmony-sig/electron`) for Node.js + WebView. -The pre-built runtime is distributed as a tarball. The URL is set as a GitHub secret to avoid -exposing the private hosting address in the workflow file. +This branch (`dev2`) runs the electerm-web backend on a **Node.js shared library** +(`libnode.so`) — **not** the Electron 鸿蒙 runtime. The Node.js runtime is +downloaded automatically at build time (locally via `scripts/prepare-node.sh`, +in CI via `build-web.yml`) from the **public** +[`electerm/ohos-node-shared`](https://github.com/electerm/ohos-node-shared) +GitHub release. No secret or private URL is required. -Set this secret in GitHub repo → **Settings → Secrets and variables → Actions**. +- **Release tag**: `ohos-node-shared-v${NODE_VERSION}` (default `v24.2.0`) +- **Asset**: `libnode-${arch}.so` → installed as `entry/libs//libnode.so` +- The version is controlled by the `NODE_VERSION` env in `build-web.yml` and the + default in `scripts/prepare-node.sh` (they must match, or the asset download + 404s). -> **Note:** Ask the project maintainer for the URL value — it is not committed to the repo. - -The tarball contains: -- `web_engine/` — Complete HAR module (ArkTS API + resfile resources) -- `electron/libs/arm64-v8a/*.so` — Native libraries - -| Secret Name | Required | Description | -|-------------|----------|-------------| -| `ELECTRON_RUNTIME_URL` | **Yes** | URL to download the pre-built Electron runtime tarball | - -See [BUILD.md §3](./BUILD.md#3-obtaining-the-electron-鸿蒙-runtime) for details. +See [BUILD.md §3](./BUILD.md#3-obtaining-the-nodejs-runtime-libnodeso) for details. ### 4.5 Workflow Environment Variables (not secrets) -These are defined in `.github/workflows/build.yml` under `env:` and can be changed without touching secrets: +These are defined in `.github/workflows/build-web.yml` under `env:` and can be changed without touching secrets: | Variable | Default | Description | |----------|---------|-------------| -| (none) | | Runtime version is controlled by the URL you provide | +| `NODE_VERSION` | `24.2.0` | Node.js runtime version (must match `scripts/prepare-node.sh`) | --- @@ -369,7 +366,7 @@ Before your first CI build, make sure you have: - [ ] `OHOS_APP_ID` - [ ] `OHOS_CMDLINE_TOOLS_URL` - [ ] `OHOS_SERVER_SECRET` - - [ ] `ELECTRON_RUNTIME_URL` (ask maintainer for the value) +- [ ] `NODE_VERSION` env in `build-web.yml` matches `scripts/prepare-node.sh` (`24.2.0`) - [ ] Workflow enabled under repo → **Actions** tab --- @@ -377,7 +374,7 @@ Before your first CI build, make sure you have: ## 7. Security Notes - **Never commit** `.p12`, `.cer`, `.p7b`, or passwords to the repository -- The `.gitignore` file excludes the `signing/` directory, `web_engine/`, `entry/libs/`, and `temp/` directory +- The `.gitignore` file excludes the `signing/` directory, `entry/libs/` (the downloaded `libnode.so`), `entry/src/main/resources/resfile/electerm/` (web app build output), `build-profile.json5`, and `temp/` directory - GitHub Secrets are encrypted and never exposed in logs - If a signing material is compromised, revoke it on AppGallery Connect and generate new ones - Use **release certificates** only for published builds; use **debug certificates** for testing diff --git a/entry/build-profile.json5 b/entry/build-profile.json5 index 3e96441..61e9c50 100644 --- a/entry/build-profile.json5 +++ b/entry/build-profile.json5 @@ -1,6 +1,12 @@ { "apiType": "stageMode", "buildOption": { + "externalNativeOptions": { + "path": "./src/main/cpp/CMakeLists.txt", + "arguments": "", + "cppFlags": "", + "abiFilters": ["arm64-v8a", "x86_64"] + } }, "buildOptionSet": [ { @@ -11,6 +17,11 @@ "enable": false } } + }, + "nativeLib": { + "debugSymbol": { + "strip": false + } } } ], diff --git a/entry/oh-package-lock.json5 b/entry/oh-package-lock.json5 new file mode 100644 index 0000000..e0424c9 --- /dev/null +++ b/entry/oh-package-lock.json5 @@ -0,0 +1,19 @@ +{ + "meta": { + "stableOrder": true, + "enableUnifiedLockfile": false + }, + "lockfileVersion": 3, + "ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.", + "specifiers": { + "libnode_ctl@src/main/cpp/types/libnode_ctl": "libnode_ctl@src/main/cpp/types/libnode_ctl" + }, + "packages": { + "libnode_ctl@src/main/cpp/types/libnode_ctl": { + "name": "libnode_ctl", + "version": "1.0.0", + "resolved": "src/main/cpp/types/libnode_ctl", + "registryType": "local" + } + } +} \ No newline at end of file diff --git a/entry/oh-package.json5 b/entry/oh-package.json5 index db0697b..c9db268 100644 --- a/entry/oh-package.json5 +++ b/entry/oh-package.json5 @@ -1,10 +1,10 @@ { "name": "entry", "version": "5.3.15", - "description": "Electerm HarmonyOS entry module — provides UI surface and Electron runtime integration", + "description": "Electerm HarmonyOS entry module — ArkWeb shell + on-device Node.js backend", "main": "", "license": "MIT", "dependencies": { - "web_engine": "file:../web_engine" + "libnode_ctl": "file:./src/main/cpp/types/libnode_ctl" } } diff --git a/entry/src/main/cpp/CMakeLists.txt b/entry/src/main/cpp/CMakeLists.txt new file mode 100644 index 0000000..67a5b48 --- /dev/null +++ b/entry/src/main/cpp/CMakeLists.txt @@ -0,0 +1,20 @@ +cmake_minimum_required(VERSION 3.5.0) +project(electerm_web_runtime) + +# Required for mcontext_t / REG_RIP / REG_RAX on x86_64 musl +add_compile_definitions(_GNU_SOURCE) + +# libnode_launcher.so — loaded into the native child process; its Main() +# execv()s the bundled node binary (installed as libnode.so). +add_library(node_launcher SHARED node_launcher.c) +# libhilog_ndk.z.so — native hilog so launcher diagnostics survive release +# builds even when the boot-log file cannot be pulled (cloud debug) +target_link_libraries(node_launcher PUBLIC libchild_process.so libhilog_ndk.z.so) + +# libnode_ctl.so — NAPI module for the main (ArkTS) process: run node +# IN-PROCESS (dlopen libnode.so + node::Start — the electron-harmony / +# nodejs-mobile pattern) and kill the spawned child by pid on app destroy. +# -fno-omit-frame-pointer: backtrace() in the crash marker walks the fp chain. +add_library(node_ctl SHARED node_ctl.c) +target_compile_options(node_ctl PRIVATE -fno-omit-frame-pointer) +target_link_libraries(node_ctl PUBLIC libace_napi.z.so libhilog_ndk.z.so) diff --git a/entry/src/main/cpp/node_ctl.c b/entry/src/main/cpp/node_ctl.c new file mode 100644 index 0000000..5b01f9c --- /dev/null +++ b/entry/src/main/cpp/node_ctl.c @@ -0,0 +1,1040 @@ +/** + * node_ctl.c — NAPI module for the main (ArkTS) process. + * + * Two jobs: + * 1. killNode(pid) — terminate the native-child-process node backend. + * 2. startBackend(entryParams) — run node IN THIS (main app) PROCESS: + * dlopen the bundled libnode.so and call node::Start on a dedicated + * 32MB-stack pthread (the nodejs-mobile / electron-harmony pattern). + * + * Why in-process: the nativespawn CHILD process runs under a stricter + * seccomp filter than the app itself — node's libuv dies there (SIGSYS / + * abort) because event-loop syscalls are fenced off. The MAIN app process + * demonstrably runs libuv-class loops (NETSTACK/curl, the ArkTS runtime), + * so node lives there. electron-harmony works the same way: its node core + * ships as .so libraries inside the app process, never a spawned binary. + * + * Everything is logged to /node-boot.log (same file the child + * launcher writes) and to hilog (tag electerm.embed), so the on-screen + * boot-log overlay shows this path's ladder exactly like the child's. + * + * Returns "ok" synchronously once the node thread is launched; on any + * failure returns "err:" so ArkTS can fall back to the native + * child process. + */ + +#include "napi/native_api.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define LOG_BUF_SIZE 4096 +#define MAX_ENV_VARS 32 +#define MAX_LINE 1024 +#define MAX_CANDIDATES 12 + +/* F_SETPIPE_SZ — present in the OHOS NDK headers on newer SDKs only. */ +#ifndef F_SETPIPE_SZ +#define F_SETPIPE_SZ 1031 +#endif + +typedef struct { + char dataDir[MAX_LINE]; /* writable app data dir (el2 filesDir) */ + char script[MAX_LINE * 2]; /* path to resfile/electerm/index.js */ + char node[MAX_LINE * 2]; /* optional parent-provided libnode.so path */ + char port[16]; + char secret[MAX_LINE]; /* SERVER_SECRET */ +} CtlConfig; + +static int g_logFd = -1; +static char g_logPath[MAX_LINE * 2] = ""; +static int g_started = 0; /* startBackend may only run once per process */ +static int g_pipeOut = -1; /* read end of the stdio→hilog pipe */ + +/* ── Launch status plumbing ────────────────────────────────────────────── + * The expensive work (dlopen of the ~120MB libnode.so with RTLD_NOW, dlsym, + * node::Start) runs on a background thread, so startBackend() returns as + * soon as that thread exists. The ArkTS side polls getBackendStatus() while + * it probes http://127.0.0.1:5577, so a HARD failure (script missing, no + * libnode.so, dlopen/dlsym failed) is detectable in milliseconds instead of + * after the whole boot timeout — and the page can then fall back to the + * native child process instead of sitting on the splash screen. + * ──────────────────────────────────────────────────────────────────────── */ +#define ST_LAUNCHING 1 +#define ST_RUNNING 2 +#define ST_FAILED 3 + +static volatile int g_status = 0; /* 0 = not started */ +static char g_statusDetail[160] = ""; /* written BEFORE g_status is set */ + +static void setStatus(int code, const char *detail) { + if (detail && detail[0]) { + snprintf(g_statusDetail, sizeof(g_statusDetail), "%s", detail); + } else { + g_statusDetail[0] = '\0'; + } + __atomic_store_n(&g_status, code, __ATOMIC_RELEASE); +} + +static int getStatusCode(void) { + return __atomic_load_n(&g_status, __ATOMIC_ACQUIRE); +} + +static unsigned long nowMs(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (unsigned long)ts.tv_sec * 1000UL + (unsigned long)(ts.tv_nsec / 1000000); +} + +static void logWrite(const char *fmt, ...); + +/* ArkWeb/chromium logs to the same process-level stdout/stderr we redirected + * for node — at thousands of lines it buries node's output in node-boot.log + * and in the on-screen overlay. Skip the known framework prefixes so what + * remains (node console output, asserts, stack traces) stays readable. */ +static int isFrameworkNoise(const char *s) { + static const char *pref[] = { + "[nweb", "[render_", "[browser_contents", "[arkweb_", + "[extension_u", "[res_", "[frame_", "[disk_cache", + "[sys_info_u", "[inputmethod", "[chrome", "[content::", + "[media/", "[gpu_", "[vulkan", "[webview", + "[crashpad", "[mojo", "[viz", "[cc::", + "[net::", "[base::", "[ipc_", "[tracing/", + "[skia", "[snapshot", "CefRender", "PRPPreload", + "OnFirstScreenPaint", "[nwebspawn", "[sandbox", "[audio_", + NULL + }; + for (int i = 0; pref[i]; i++) { + if (strncmp(s, pref[i], strlen(pref[i])) == 0) return 1; + } + static const char *frag[] = { + "web render log", "SubmitCompositorFrame", "LocalSurfaceId", + "OnScaleInited", "invokeVisualStateCallback", "OnPageVisible", + "OldPageNoLongerRendered", "SetUseSpecifiedDeadline", "cloud control", + "cloud_control", "safe browsing", "safe_browsing", "ua config", + "version.txt open failed", "SIGSYS needs to be reserved", + "Starting update check", "Finished update check", NULL + }; + for (int i = 0; frag[i]; i++) { + if (strstr(s, frag[i])) return 1; + } + return 0; +} + +/* Drain the app's stdout/stderr (fd 1/2 are dup2'd onto a pipe) — FAST and + * BOUNDED. This is the single most important invariant in the file. + * + * The pipe is fed by EVERYTHING in the process, not just node: ArkWeb / + * Chromium logs thousands of lines per second to the same fd 1/2. Doing any + * per-line work that costs more than a memcmp — an OH_LOG_Print (an IPC to + * hilogd), an snprintf, a formatted write — lets the pipe fill up. The next + * writer then BLOCKS: Chromium's logging thread, node's abort(), or a + * signal handler. With Chromium's IO thread blocked the Web component never + * paints, which is exactly how the app used to sit on its splash screen + * until the ANR watchdog killed it. + * + * So the reader: + * 1. always drains (never lets a writer block) — an 8KB read per loop; + * 2. filters framework noise with cheap strncmp/strstr only; + * 3. writes surviving lines to the boot log with a raw write() (one + * syscall per line, no formatting, no locks); + * 4. hilog's at a hard rate limit with a fixed total budget. + */ +static void *stdioReaderThread(void *p) { + (void)p; + char buf[8192]; + char line[480]; + size_t linelen = 0; + long hilogBudget = 400; /* total hilog lines we will ever emit */ + unsigned long lastHilogMs = 0; + + for (;;) { + ssize_t r = read(g_pipeOut, buf, sizeof(buf)); + if (r < 0 && errno == EINTR) continue; + if (r <= 0) break; + for (ssize_t i = 0; i < r; i++) { + char c = buf[i]; + if (c == '\n' || linelen >= sizeof(line) - 1) { + line[linelen] = '\0'; + if (linelen > 0) { + if (isFrameworkNoise(line)) { + /* dropped on the floor — the only correct thing to do with + * thousands of Chromium lines a second. */ + } else { + /* Raw write: no vsnprintf, no libc stdio locks. */ + if (g_logFd >= 0) { + ssize_t ign = write(g_logFd, line, linelen); + ign = write(g_logFd, "\n", 1); + (void)ign; + } + if (hilogBudget > 0) { + unsigned long now = nowMs(); + if (now - lastHilogMs >= 250) { /* max ~4 hilog IPCs per second */ + lastHilogMs = now; + hilogBudget--; + (void)OH_LOG_Print(LOG_APP, LOG_ERROR, 0xE1EC, + "electerm.embed", "[io] %.470s", line); + } + } + } + } + linelen = 0; + } else if (c != '\r' && c != '\0') { + line[linelen++] = c; + } + } + } + return NULL; +} + +static void logWrite(const char *fmt, ...) { + char buf[LOG_BUF_SIZE]; + va_list ap; + va_start(ap, fmt); + int n = vsnprintf(buf, sizeof(buf) - 1, fmt, ap); + va_end(ap); + if (n < 0) return; + buf[n] = '\0'; + if (g_logFd >= 0) { + ssize_t ignored = write(g_logFd, buf, (size_t)n); + ignored = write(g_logFd, "\n", 1); + (void)ignored; + } + (void)OH_LOG_Print(LOG_APP, LOG_ERROR, 0xE1EC, "electerm.embed", + "%{public}s", buf); +} + +/* ── Crash markers — name the killer signal in the boot log AND hilog (the + * process may die right after; hilog keeps the line). If the signal came + * from node's own thread, PARK that thread instead of dying: the app UI + * process survives, the ArkTS probe times out and the on-screen overlay + * shows the boot-log tail — instead of the app just closing. ── */ +static pid_t g_nodeTid = 0; /* tid of the node thread once launched */ + +static void crashMarkerHandler(int sig, siginfo_t *si, void *ctx) { + int saved = errno; + long tid = (long)syscall(__NR_gettid); + ucontext_t *uc = (ucontext_t *)ctx; + unsigned long pc = 0, sp = 0, lr = 0, arg0 = 0; + +#if defined(__aarch64__) + if (uc) { + pc = uc->uc_mcontext.pc; + sp = uc->uc_mcontext.sp; + lr = uc->uc_mcontext.regs[30]; + arg0 = uc->uc_mcontext.regs[0]; + } +#elif defined(__x86_64__) + if (uc) { + pc = (unsigned long)uc->uc_mcontext.gregs[REG_RIP]; + sp = (unsigned long)uc->uc_mcontext.gregs[REG_RSP]; + arg0 = (unsigned long)uc->uc_mcontext.gregs[REG_RAX]; + } +#endif + + char b[128]; + int n = snprintf(b, sizeof(b), "[embed] fatal: signal %d on tid %ld", + sig, tid); + if (n > 0) { + if (g_logFd >= 0) { + ssize_t ign = write(g_logFd, b, (size_t)n); + ign = write(g_logFd, "\n", 1); + (void)ign; + } + (void)OH_LOG_Print(LOG_APP, LOG_ERROR, 0xE1EC, "electerm.embed", + "%{public}s", b); + } + + /* The fault context. backtrace() on aarch64/musl very often returns ZERO + * frames (it cannot unwind through the signal frame), and when that + * happens the crash used to be completely unlocatable. The raw PC plus + * the faulting address can always be resolved offline against the + * unstripped libnode.so (see .workbuddy/tools/sym.py), so log them + * FIRST — before the backtrace, which can itself abort. */ + logWrite("[embed] fault: sig=%d code=%d addr=0x%lx pc=0x%lx sp=0x%lx lr=0x%lx x0=0x%lx", + sig, si ? si->si_code : -1, + (unsigned long)(si ? (uintptr_t)si->si_addr : 0), + pc, sp, lr, arg0); + + /* Capture the crashing thread's native stack. The abort text (e.g. + * "Assertion failed: fd > STDERR_FILENO ... uv__close") names the dying + * function but never its CALLER — that's the missing piece. libnode.so is + * unstripped, so dladdr often resolves real symbol names. Written to the + * boot log AND hilog (short lines survive hilog's ~140-byte truncation). + * Not strictly async-signal-safe, but the thread is about to park/die — + * a corrupted-stack failure here costs nothing the crash didn't already. */ + { + void *bt[24]; + int frames = backtrace(bt, 24); + /* Log the frame count even when it is 0: "no frames" is itself the + * answer to "why is there no backtrace". */ + logWrite("[embed] backtrace frames=%d", frames); + for (int i = 0; i < frames; i++) { + Dl_info info; + char lb[192]; + int ln; + if (dladdr(bt[i], &info) && info.dli_fname) { + const char *slash = strrchr(info.dli_fname, '/'); + const char *base = slash ? slash + 1 : info.dli_fname; + ln = snprintf(lb, sizeof(lb), "[embed] bt[%d/%d] %s%s%+ld (%.40s)", + i, frames, + info.dli_sname ? info.dli_sname : "", + info.dli_sname ? "+" : "", + (long)((char *)bt[i] - (char *)info.dli_fbase), + base); + } else { + ln = snprintf(lb, sizeof(lb), "[embed] bt[%d/%d] %p", i, frames, + bt[i]); + } + if (ln <= 0) continue; + if (g_logFd >= 0) { + ssize_t ign = write(g_logFd, lb, (size_t)ln); + ign = write(g_logFd, "\n", 1); + (void)ign; + } + (void)OH_LOG_Print(LOG_APP, LOG_ERROR, 0xE1EC, "electerm.embed", + "%{public}s", lb); + } + if (g_logFd >= 0 && frames > 0) { + ssize_t ign = write(g_logFd, "[embed] backtrace symbols:\n", 27); + backtrace_symbols_fd(bt, frames, g_logFd); + (void)ign; + } + } + errno = saved; + if (g_nodeTid > 0 && tid == (long)g_nodeTid) { + /* node's thread crashed — freeze it, keep the app alive. Never returns; + * if the crash corrupted a libc lock the UI may eventually freeze too, + * but the evidence is already on disk and in hilog. */ + char msg[128]; + snprintf(msg, sizeof(msg), "node thread died: signal %d at pc=0x%lx", + sig, pc); + /* Report the failure so the ArkTS probe stops waiting: it would + * otherwise sit on "running" for the whole boot timeout, because + * setStatus(ST_RUNNING) was published before node::Start and nobody + * updated it when the thread died. Failing fast lets the page fall + * back to the native child process immediately. */ + setStatus(ST_FAILED, msg); + logWrite("[embed] %s — failing fast so the page can try the child process", + msg); + for (;;) { + pause(); + } + } + signal(sig, SIG_DFL); + raise(sig); +} + +static void installCrashMarkers(void) { + struct sigaction sa; + memset(&sa, 0, sizeof(sa)); + sa.sa_sigaction = crashMarkerHandler; + sa.sa_flags = SA_SIGINFO; + const int sigs[] = {SIGABRT, SIGSEGV, SIGBUS, SIGILL, SIGFPE}; + for (size_t i = 0; i < sizeof(sigs) / sizeof(sigs[0]); i++) { + if (sigaction(sigs[i], &sa, NULL) != 0) { + logWrite("[embed] sigaction(%d) failed: %s", sigs[i], strerror(errno)); + } + } +} + +/* ── SIGSYS shim — same as the child launcher's: if a seccomp filter in + * THIS process traps a syscall node probes (perf_event_open, membarrier, + * …), convert the kill into a logged ENOSYS. node's ResetSignalHandlers() + * preserves SA_SIGINFO handlers, so this survives into node's lifetime. ── */ +static const char *syscallName(int sc) { + switch (sc) { + case 19: return "eventfd2"; + case 20: return "epoll_create1"; + case 220: return "clone"; + case 221: return "execve"; + case 241: return "perf_event_open"; + case 265: return "open_by_handle_at"; + case 270: return "process_vm_readv"; + case 272: return "kcmp"; + case 277: return "seccomp"; + case 278: return "getrandom"; + case 280: return "bpf"; + case 281: return "execveat"; + case 282: return "userfaultfd"; + case 283: return "membarrier"; + case 288: return "pkey_mprotect"; + case 291: return "statx"; + case 293: return "rseq"; + case 403: return "clock_gettime64"; + case 424: return "pidfd_send_signal"; + case 425: return "io_uring_setup"; + case 434: return "pidfd_open"; + case 435: return "clone3"; + case 436: return "close_range"; + case 437: return "openat2"; + case 439: return "faccessat2"; + case 440: return "process_madvise"; + default: return "?"; + } +} + +/* Async-signal-safe log line for use INSIDE signal handlers. The handler + * must not call printf-family/vsnprintf/OH_LOG_Print: those take libc + * locks, and when the trapped thread already holds one (device-proven + * 2026-08-28: second seccomp trap fired mid-stdio on the node thread → + * strlen SEGV inside the handler's own formatting) the handler crashes. + * Compose with fixed strings + manual decimal only; write(2) is safe. */ +static void safeAppend(char *b, size_t cap, size_t *n, const char *s) { + while (*s && *n < cap) { + b[(*n)++] = *s++; + } +} + +static void safeAppendInt(char *b, size_t cap, size_t *n, int v) { + char tmp[12]; + int len = 0; + if (v < 0 && *n < cap) { + b[(*n)++] = '-'; + v = -v; + } + do { + tmp[len++] = (char)('0' + (v % 10)); + v /= 10; + } while (v > 0 && len < (int)sizeof(tmp)); + while (len > 0 && *n < cap) { + b[(*n)++] = tmp[--len]; + } +} + +/* Same contract as safeAppendInt, for addresses. Hand-rolled because + * snprintf is off-limits in a signal handler (see the note above). */ +static void safeAppendHex(char *b, size_t cap, size_t *n, unsigned long v) { + int started = 0; + for (int shift = 60; shift >= 0; shift -= 4) { + unsigned int d = (unsigned int)((v >> shift) & 0xFu); + if (d == 0 && !started && shift != 0) continue; + started = 1; + if (*n >= cap) return; + b[(*n)++] = (char)(d < 10 ? ('0' + d) : ('a' + (d - 10))); + } + if (!started && *n < cap) { + b[(*n)++] = '0'; + } +} + +/* Is there an aarch64 `svc #0` (encoded 0xd4000001) at `pc`? + * + * Deciding whether to skip the trapped instruction by comparing the signal + * frame's PC with si_addr does NOT work: both values come from the same + * pt_regs, so they agree whether or not the kernel already advanced past + * the syscall. Device-verified 2026-08-31: the delta was 0, which is + * consistent with BOTH "PC on the svc" and "PC already past it". + * Looking at the instruction encoding is the only way to tell. */ +static int pcIsSvcInsn(unsigned long pc) { + if (pc == 0 || (pc & 3U) != 0) { + return 0; + } + unsigned int insn = 0; + memcpy(&insn, (const void *)pc, sizeof(insn)); + return (insn & 0xffe0001fu) == 0xd4000001u; +} + +static void sigsysHandler(int sig, siginfo_t *si, void *ctx) { + static unsigned int seenBits[16]; /* 512 syscall numbers, logged once each */ + + /* Only emulate a real seccomp trap. A SIGSYS delivered by raise()/kill() + * carries no syscall context (si_code <= 0) and rewriting the register + * file for it corrupts whichever thread happened to be running. */ + if (!si || !ctx || si->si_code != 1 /* SYS_SECCOMP */) { + signal(sig, SIG_DFL); + raise(sig); + return; + } + + ucontext_t *uc = (ucontext_t *)ctx; + unsigned long callAddr = (unsigned long)(uintptr_t)si->si_addr; + unsigned long pc = 0; +#if defined(__aarch64__) + pc = uc->uc_mcontext.pc; +#elif defined(__x86_64__) + pc = (unsigned long)uc->uc_mcontext.gregs[REG_RIP]; +#endif + int onSvc = pcIsSvcInsn(pc); + + int sc = si->si_syscall; + if (sc >= 0 && sc < 512) { + unsigned int bit = 1u << (sc & 31); + if (!(seenBits[sc >> 5] & bit)) { + seenBits[sc >> 5] |= bit; + char b[192]; + size_t n = 0; + safeAppend(b, sizeof(b), &n, "[embed] SIGSYS: syscall "); + safeAppendInt(b, sizeof(b), &n, sc); + safeAppend(b, sizeof(b), &n, " ("); + safeAppend(b, sizeof(b), &n, syscallName(sc)); + safeAppend(b, sizeof(b), &n, ") blocked by seccomp -> -1"); + safeAppend(b, sizeof(b), &n, " pc=0x"); + safeAppendHex(b, sizeof(b), &n, pc); + safeAppend(b, sizeof(b), &n, " call=0x"); + safeAppendHex(b, sizeof(b), &n, callAddr); + safeAppend(b, sizeof(b), &n, " d="); + safeAppendInt(b, sizeof(b), &n, (int)(long)(pc - callAddr)); + safeAppend(b, sizeof(b), &n, " onsvc="); + safeAppendInt(b, sizeof(b), &n, onSvc); + safeAppend(b, sizeof(b), &n, "\n"); + /* Raw write() to the boot-log FILE only. + * + * No logWrite() here. logWrite() is vsnprintf + OH_LOG_Print, and + * both take libc locks. Calling them from this handler is + * device-proven to crash the trapped thread: on 2026-08-31 the run + * that logged pc/call_addr via logWrite() died with + * SIGSEGV code=1 addr=0x0 pc= lr= + * immediately after the handler returned. Compose with fixed strings + * and hand-rolled number formatting, then a bare write(2) — that is + * the only thing allowed here. + * + * This also used to write to fd 2, the stdio pipe shared with + * ArkWeb/Chromium. That pipe can be full (Chromium floods it) and + * write() on a full pipe BLOCKS — inside a signal handler, fatal. + * The reader thread picks the line up from the boot log and relays it + * to hilog in normal context, where locks are actually safe. */ + if (g_logFd >= 0) { + ssize_t ign = write(g_logFd, b, n); + (void)ign; + } + } + } +#if defined(__aarch64__) + if (onSvc) { + uc->uc_mcontext.pc += 4; /* skip the 4-byte svc instruction */ + } + uc->uc_mcontext.regs[0] = (unsigned long)-1; +#elif defined(__x86_64__) + /* x86-64 `syscall` is 0f 05. */ + if (pc != 0) { + unsigned char c[2] = {0, 0}; + memcpy(c, (const void *)pc, 2); + if (c[0] == 0x0fu && c[1] == 0x05u) { + uc->uc_mcontext.gregs[REG_RIP] += 2; + } + } + uc->uc_mcontext.gregs[REG_RAX] = (unsigned long)-1; +#endif + /* Return EXACTLY -1, not -ENOSYS: OHOS musl's syscall() passes the raw + * x0 through WITHOUT the __syscall_ret(errno)-translation upstream musl + * does, so -38 leaks to callers as a bogus value. Device-proven: libuv's + * uv__iou_init() got ringfd=-38 from the seccomp-trapped io_uring_setup, + * sailed past its `if (ringfd == -1) return;` guard, failed mmap/epoll_ctl + * on the bogus fd, and its cleanup called uv__close(-38) → the very assert + * (fd > STDERR_FILENO) that killed the backend. */ + errno = ENOSYS; /* TLS store — async-signal-safe; for errno-checking callers */ +} + +static void installSigsysShim(void) { + struct sigaction sa; + memset(&sa, 0, sizeof(sa)); + sa.sa_sigaction = sigsysHandler; + sa.sa_flags = SA_SIGINFO; + if (sigaction(SIGSYS, &sa, NULL) != 0) { + logWrite("[embed] sigaction(SIGSYS) failed: %s", strerror(errno)); + } +} + +/* Parse "key=value\n" lines (same format the child launcher parses). */ +static void parseEntryParams(const char *params, CtlConfig *cfg, + char extraEnv[MAX_ENV_VARS][MAX_LINE], + int *extraEnvCount) { + snprintf(cfg->port, sizeof(cfg->port), "5577"); + cfg->dataDir[0] = '\0'; + cfg->script[0] = '\0'; + cfg->node[0] = '\0'; + cfg->secret[0] = '\0'; + + char line[MAX_LINE]; + const char *p = params; + while (p && *p) { + const char *eol = strchr(p, '\n'); + size_t len = eol ? (size_t)(eol - p) : strlen(p); + if (len >= sizeof(line)) len = sizeof(line) - 1; + memcpy(line, p, len); + line[len] = '\0'; + p = eol ? eol + 1 : NULL; + + char *eq = strchr(line, '='); + if (!eq) continue; + *eq = '\0'; + const char *key = line; + const char *value = eq + 1; + + if (strcmp(key, "dataDir") == 0) { + snprintf(cfg->dataDir, sizeof(cfg->dataDir), "%s", value); + } else if (strcmp(key, "script") == 0) { + snprintf(cfg->script, sizeof(cfg->script), "%s", value); + } else if (strcmp(key, "node") == 0) { + snprintf(cfg->node, sizeof(cfg->node), "%s", value); + } else if (strcmp(key, "port") == 0) { + snprintf(cfg->port, sizeof(cfg->port), "%s", value); + } else if (strcmp(key, "secret") == 0) { + snprintf(cfg->secret, sizeof(cfg->secret), "%s", value); + } else if (*extraEnvCount < MAX_ENV_VARS) { + snprintf(extraEnv[(*extraEnvCount)++], MAX_LINE, "%s=%s", key, value); + } + } +} + +static void addCandidate(char (*candidates)[MAX_LINE * 2], int *n, + const char *dir, const char *tag) { + if (*n >= MAX_CANDIDATES) return; + if (!dir || !dir[0]) return; + char path[MAX_LINE * 2]; + snprintf(path, sizeof(path), "%s/libnode.so", dir); + for (int i = 0; i < *n; i++) { + if (strcmp(candidates[i], path) == 0) return; + } + snprintf(candidates[(*n)], MAX_LINE * 2, "%s", path); + logWrite("[embed] candidate(%s): %s", tag, candidates[(*n)]); + (*n)++; +} + +typedef int (*node_start_fn)(int argc, char *argv[]); + +struct NodeThreadArgs { + node_start_fn start; + char *argv[6]; /* node + up to 4 flags + NULL */ + int rc; +}; + +static struct NodeThreadArgs g_nodeArgs; + +static const char *startEmbeddedNode(const char *params); + +/* ── Bootstrap thread ── + * + * Everything expensive happens HERE and never on the caller's thread: + * dlopen(libnode.so, RTLD_NOW) relocates every symbol of a ~120MB + * library, dlsym, and node::Start (which itself runs the whole server). + * + * pages/Index calls the NAPI startBackend() from aboutToAppear() — i.e. on + * the ArkTS UI thread. Doing the dlopen there blocked the UI thread for as + * long as the relocation took, so the Index page never painted: the window + * stayed on its splash screen until the APP_INPUT_BLOCK watchdog fired and + * the system ANR dialog killed the app. That is the reported symptom. + */ +static void *bootstrapMain(void *arg) { + char *params = (char *)arg; + startEmbeddedNode(params); + free(params); + return NULL; +} + +static const char *startBackendAsync(const char *params) { + static char errBuf[128]; + + if (g_started) { + return "err:already started"; + } + g_started = 1; + + char *copy = strdup(params ? params : ""); + if (!copy) { + snprintf(errBuf, sizeof(errBuf), "err:out of memory"); + setStatus(ST_FAILED, errBuf); + return errBuf; + } + + setStatus(ST_LAUNCHING, "bootstrap thread starting"); + pthread_attr_t attr; + pthread_attr_init(&attr); + /* node::Start wants a big stack (V8 + the deep C++ bootstrap). */ + pthread_attr_setstacksize(&attr, 32 * 1024 * 1024); + pthread_t th; + int prc = pthread_create(&th, &attr, bootstrapMain, copy); + pthread_attr_destroy(&attr); + if (prc != 0) { + free(copy); + logWrite("[embed] bootstrap pthread_create failed: %s", strerror(prc)); + snprintf(errBuf, sizeof(errBuf), "err:pthread_create failed"); + setStatus(ST_FAILED, errBuf); + return errBuf; + } + pthread_detach(th); + return "launching"; +} + +static const char *startEmbeddedNode(const char *params) { + static char errBuf[256]; + + char extraEnv[MAX_ENV_VARS][MAX_LINE]; + int extraEnvCount = 0; + CtlConfig cfg; + parseEntryParams(params, &cfg, extraEnv, &extraEnvCount); + + /* boot log — same file the child launcher uses, so the ArkTS overlay and + * hilog dump cover both paths. Truncated per attempt: the overlay only + * shows the last lines, and hilog keeps the history anyway. el2 junction + * fallback like the child. */ + if (cfg.dataDir[0]) { + char logPath[MAX_LINE * 2]; + snprintf(logPath, sizeof(logPath), "%s/node-boot.log", cfg.dataDir); + g_logFd = open(logPath, O_WRONLY | O_CREAT | O_TRUNC, 0644); + if (g_logFd < 0) { + snprintf(logPath, sizeof(logPath), + "/data/storage/el2/base/files/electerm-data/node-boot.log"); + g_logFd = open(logPath, O_WRONLY | O_CREAT | O_APPEND, 0644); + } + if (g_logFd >= 0) { + snprintf(g_logPath, sizeof(g_logPath), "%s", logPath); + } + } + logWrite("[embed] startBackend: pid=%d params=%s", (int)getpid(), params); + + if (!cfg.script[0] || access(cfg.script, F_OK) != 0) { + logWrite("[embed] FATAL: script missing: %s", cfg.script); + snprintf(errBuf, sizeof(errBuf), "err:script missing"); + setStatus(ST_FAILED, errBuf); + return errBuf; + } + + /* locate libnode.so — parent-provided path, then this .so's own dir + * (libnode_ctl.so and libnode.so sit in the same app libs dir), then the + * el1/bundle junction layouts. */ + char candidates[MAX_CANDIDATES][MAX_LINE * 2]; + int nCand = 0; + if (cfg.node[0]) { + snprintf(candidates[nCand], MAX_LINE * 2, "%s", cfg.node); + logWrite("[embed] candidate(parent): %s", candidates[nCand]); + nCand++; + } + { + Dl_info info; + if (dladdr((void *)&startEmbeddedNode, &info) && info.dli_fname && + info.dli_fname[0]) { + const char *slash = strrchr(info.dli_fname, '/'); + if (slash) { + char dir[MAX_LINE * 2]; + snprintf(dir, sizeof(dir), "%.*s", (int)(slash - info.dli_fname), + info.dli_fname); + addCandidate(candidates, &nCand, dir, "dladdr"); + } + } + } + { + const char *bundleDir = "/data/storage/el1/bundle"; + char dir[MAX_LINE * 2]; + snprintf(dir, sizeof(dir), "%s/entry/libs/arm64-v8a", bundleDir); + addCandidate(candidates, &nCand, dir, "el1"); + snprintf(dir, sizeof(dir), "%s/entry/libs/arm64", bundleDir); + addCandidate(candidates, &nCand, dir, "el1"); + snprintf(dir, sizeof(dir), "%s/entry/libs/x86_64", bundleDir); + addCandidate(candidates, &nCand, dir, "el1"); + snprintf(dir, sizeof(dir), "%s/libs/arm64-v8a", bundleDir); + addCandidate(candidates, &nCand, dir, "el1"); + snprintf(dir, sizeof(dir), "%s/libs/x86_64", bundleDir); + addCandidate(candidates, &nCand, dir, "el1"); + } + + const char *nodePath = NULL; + for (int i = 0; i < nCand; i++) { + if (access(candidates[i], F_OK) == 0) { + nodePath = candidates[i]; + break; + } + logWrite("[embed] candidate not found: %s", candidates[i]); + } + if (!nodePath) { + logWrite("[embed] FATAL: no libnode.so candidate exists (tried %d)", nCand); + snprintf(errBuf, sizeof(errBuf), "err:no libnode.so"); + setStatus(ST_FAILED, errBuf); + return errBuf; + } + logWrite("[embed] node binary: %s", nodePath); + + /* environment */ + setenv("NODE_ENV", "production", 1); + setenv("HOST", "127.0.0.1", 1); + setenv("PORT", cfg.port, 1); + setenv("ELECTERM_DATA_DIR", cfg.dataDir, 1); + if (cfg.secret[0]) { + setenv("SERVER_SECRET", cfg.secret, 1); + } + for (int i = 0; i < extraEnvCount; i++) { + /* setenv() COPIES the value. putenv() would store a pointer into + * `extraEnv`, a stack array of THIS frame — and since node now runs on + * a thread that outlives startBackend, that stack is long gone by the + * time libuv/node reads it. getenv() would then strlen() recycled + * stack memory: a SEGV from inside uv_loop_init. */ + char *eq = strchr(extraEnv[i], '='); + if (!eq) continue; + *eq = '\0'; + setenv(extraEnv[i], eq + 1, 1); + } + + /* Guarantee fds 0/1/2 are open before anything node-related runs. libuv's + * uv__close() asserts fd > STDERR_FILENO — if the app process was spawned + * with a closed std fd, pipe() below hands back fd 0/1, dup2() then + * no-ops (dup2(x,x)) and close() re-closes it, and every later cleanup + * path closes a std fd → assert. /dev/null onto any EBADF fd, and log + * the before/after so the device log shows the real fd layout. */ + { + char fix[96]; + int off = 0; + for (int fd = 0; fd <= 2; fd++) { + struct stat st; + if (fstat(fd, &st) == 0) continue; + int nfd = open("/dev/null", O_RDWR); + if (nfd < 0) { + logWrite("[embed] std fd %d closed, /dev/null open failed: %s", fd, + strerror(errno)); + continue; + } + if (nfd != fd) { + dup2(nfd, fd); + close(nfd); + } + off += snprintf(fix + off, sizeof(fix) - (size_t)off, " fd%d=/dev/null", + fd); + if (off >= (int)sizeof(fix) - 16) break; + } + if (off > 0) { + logWrite("[embed] stdio repair:%s", fix); + } + } + + /* node's stderr must be observable or abort()/assert messages vanish: + * redirect fd 1/2 onto a pipe and stream it line-by-line to the boot log + * AND hilog (hilog truncates long messages, so file-only capture is not + * readable in cloud debug). The reader thread keeps draining so framework + * printf traffic (ArkWeb config spam) can never block a writer. */ + installCrashMarkers(); + installSigsysShim(); + setenv("UV_USE_IO_URING", "0", 1); /* io_uring_setup is seccomp-trapped */ + if (g_logFd > 2) { + int fds[2]; + if (pipe(fds) == 0) { + /* 1MB, up from the default 64KB: ArkWeb/Chromium shares this pipe and + * logs thousands of lines per second. A 64KB buffer fills in + * milliseconds whenever the reader is descheduled, and every writer + * that hits a full pipe blocks. */ + int pipeSz = fcntl(fds[0], F_SETPIPE_SZ, 1024 * 1024); + logWrite("[embed] stdio pipe: read=%d write=%d size=%d", fds[0], fds[1], + pipeSz); + g_pipeOut = fds[0]; + dup2(fds[1], 1); + dup2(fds[1], 2); + close(fds[1]); + pthread_t rd; + pthread_attr_t ra; + pthread_attr_init(&ra); + pthread_attr_setstacksize(&ra, 256 * 1024); + if (pthread_create(&rd, &ra, stdioReaderThread, NULL) == 0) { + pthread_detach(rd); + logWrite("[embed] stdio piped: reader streaming to boot log + hilog"); + } else { + logWrite("[embed] reader thread failed: %s", strerror(errno)); + } + } else { + dup2(g_logFd, 1); + dup2(g_logFd, 2); + logWrite("[embed] stdout/stderr redirected to node-boot.log"); + } + } + + if (cfg.dataDir[0] && chdir(cfg.dataDir) == 0) { + logWrite("[embed] cwd: %s", cfg.dataDir); + } + + logWrite("[embed] in-process(main): dlopen(%s)", nodePath); + void *h = dlopen(nodePath, RTLD_NOW | RTLD_LOCAL); + if (!h) { + /* retry by bare name — the linker namespace search path contains the + * app libs dir even when an absolute-path dlopen is refused */ + const char *e1 = dlerror(); + logWrite("[embed] dlopen(abs) failed: %s — retrying bare name", e1 ? e1 : "?"); + h = dlopen("libnode.so", RTLD_NOW | RTLD_LOCAL); + if (!h) { + const char *e2 = dlerror(); + logWrite("[embed] dlopen failed: %s / %s", e1 ? e1 : "-", e2 ? e2 : "-"); + snprintf(errBuf, sizeof(errBuf), "err:dlopen failed"); + setStatus(ST_FAILED, errBuf); + return errBuf; + } + } + dlerror(); + node_start_fn start = (node_start_fn)dlsym(h, "_ZN4node5StartEiPPc"); + const char *e = dlerror(); + if (!start || (e && e[0])) { + logWrite("[embed] dlsym(node::Start) failed: %s", e ? e : "null sym"); + snprintf(errBuf, sizeof(errBuf), "err:node::Start not found"); + setStatus(ST_FAILED, errBuf); + return errBuf; + } + logWrite("[embed] node::Start resolved at %p", (void *)start); + + /* argv must outlive the thread — static storage. V8 flags: + * - --jitless : THE fix for the OpenHarmony W^X policy. V8 never + * maps executable (PROT_EXEC) pages at runtime, so no + * mprotect(PROT_EXEC)/EPERM, and V8's OS::SetPermissions no longer + * hits `CHECK_EQ(ENOMEM, errno)` -> the "# Check failed: 12 ==" + * "(*__errno_location())" SIGTRAP/abort in node::Start. Node runs as a + * pure interpreter; fine for an on-device backend service. + * - --no-verify-heap : disables V8 heap verification on startup + * (defensive — avoids allocation checks that can fail under the + * constrained runtime). Harmless no-op when the heap is healthy. */ + static char arg0[MAX_LINE * 2]; + static char arg1[] = "--no-verify-heap"; + static char arg2[] = "--jitless"; + static char arg3[MAX_LINE * 2]; + snprintf(arg0, sizeof(arg0), "%s", nodePath); + snprintf(arg3, sizeof(arg3), "%s", cfg.script); + g_nodeArgs.start = start; + g_nodeArgs.argv[0] = arg0; + g_nodeArgs.argv[1] = arg1; + g_nodeArgs.argv[2] = arg2; + g_nodeArgs.argv[3] = arg3; + g_nodeArgs.argv[4] = NULL; + + /* Run node::Start on THIS thread — we are already the detached bootstrap + * thread with a 32MB stack, so there is no reason to hand off again. + * + * node::Start returning is abnormal (the server should run forever); log + * it and let the thread end. NEVER _exit() here: this is the app's own + * process. */ + /* Pre-flight the exact syscall libuv is about to make. + * + * libuv's uv__iou_init() calls io_uring_setup (425) unconditionally: its + * UV_USE_IO_URING getenv check sits behind a `tbz w3,#1` on the flags + * argument, and uv__platform_loop_init passes flags=0, so the env var is + * never consulted and setting it does nothing. The sandbox traps the + * syscall, so the SIGSYS shim has to carry it. + * + * Do the same call here, on a thread we fully control and before node is + * up, so a shim that does not work shows up as a logged marker right + * before the crash instead of an unexplained SIGSEGV inside node::Start. + */ + { + unsigned char iouParams[128]; + memset(iouParams, 0, sizeof(iouParams)); + /* Log BEFORE the call too: if the trap handling kills us, "calling" + * is the marker that proves the syscall is where we died. */ + logWrite("[embed] io_uring preflight: calling syscall(425)"); + errno = 0; + long rc = syscall(425 /* __NR_io_uring_setup */, 8, iouParams); + int preErrno = errno; + logWrite("[embed] io_uring preflight: rc=%ld errno=%d (%s)", + rc, preErrno, rc == -1 ? strerror(preErrno) : "not trapped"); + } + + g_nodeTid = (pid_t)syscall(__NR_gettid); + setStatus(ST_RUNNING, "node::Start"); + logWrite("[embed] bootstrap tid=%ld, calling node::Start", (long)g_nodeTid); + errno = 0; /* clear any stale errno left by the io_uring preflight so a + * later CHECK_EQ(ENOMEM, errno) sees the real failure, not EPERM */ + int rc = start(4, g_nodeArgs.argv); + logWrite("[embed] node::Start returned %d (backend stopped)", rc); + snprintf(errBuf, sizeof(errBuf), "err:node::Start returned %d", rc); + setStatus(ST_FAILED, errBuf); + return errBuf; +} + +/* ── NAPI surface ── */ + +static napi_value StartBackend(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1]; + napi_get_cb_info(env, info, &argc, args, NULL, NULL); + + char params[8192] = ""; + if (argc >= 1) { + size_t copied = 0; + napi_get_value_string_utf8(env, args[0], params, sizeof(params), &copied); + } + const char *result = startBackendAsync(params); + + napi_value napiResult = NULL; + napi_create_string_utf8(env, result, NAPI_AUTO_LENGTH, &napiResult); + return napiResult; +} + +/* Report how the background bootstrap is doing. The ArkTS page polls this + * while probing the HTTP port: + * "idle" — startBackend() was never called + * "launching:" — bootstrap thread alive, still resolving/dlopen-ing + * "running:" — node::Start entered (thread is the backend) + * "failed:" — hard failure, the backend will NEVER answer + */ +static napi_value GetBackendStatus(napi_env env, napi_callback_info info) { + (void)info; + int code = getStatusCode(); + char out[192]; + switch (code) { + case ST_RUNNING: + snprintf(out, sizeof(out), "running:%s", g_statusDetail); + break; + case ST_FAILED: + snprintf(out, sizeof(out), "failed:%s", g_statusDetail); + break; + case ST_LAUNCHING: + snprintf(out, sizeof(out), "launching:%s", g_statusDetail); + break; + default: + snprintf(out, sizeof(out), "idle"); + break; + } + napi_value napiResult = NULL; + napi_create_string_utf8(env, out, NAPI_AUTO_LENGTH, &napiResult); + return napiResult; +} + +static napi_value KillNode(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1]; + napi_get_cb_info(env, info, &argc, args, NULL, NULL); + + int32_t pid = -1; + if (argc >= 1) { + napi_get_value_int32(env, args[0], &pid); + } + int32_t result = -1; + if (pid > 1) { + /* TERM first so express can close listening sockets cleanly; KILL if it + * survives. */ + result = kill((pid_t)pid, SIGTERM); + if (result == 0) { + usleep(200 * 1000); /* 200ms grace */ + kill((pid_t)pid, SIGKILL); + } + } + napi_value napiResult = NULL; + napi_create_int32(env, result, &napiResult); + return napiResult; +} + +EXTERN_C_START +static napi_value Init(napi_env env, napi_value exports) { + napi_property_descriptor desc[] = { + {"killNode", NULL, KillNode, NULL, NULL, NULL, napi_default, NULL}, + {"startBackend", NULL, StartBackend, NULL, NULL, NULL, napi_default, NULL}, + {"getBackendStatus", NULL, GetBackendStatus, NULL, NULL, NULL, napi_default, NULL}}; + napi_define_properties(env, exports, sizeof(desc) / sizeof(desc[0]), desc); + return exports; +} +EXTERN_C_END + +static napi_module demoModule = { + 1, 0, NULL, Init, "node_ctl", NULL, {0}}; + +__attribute__((constructor)) void RegisterModule(void) { + napi_module_register(&demoModule); +} diff --git a/entry/src/main/cpp/node_launcher.c b/entry/src/main/cpp/node_launcher.c new file mode 100644 index 0000000..9fd37c7 --- /dev/null +++ b/entry/src/main/cpp/node_launcher.c @@ -0,0 +1,967 @@ +/** + * node_launcher.c — native child-process entry that becomes the Node.js + * backend on HarmonyOS. + * + * ArkTS cannot exec() a binary. It starts this library as a *native child + * process* via childProcessManager.startNativeChildProcess( + * 'libnode_launcher.so:Main', { entryParams }) — the system forks a child + * (through nativespawn), loads this .so into it and calls Main() below. + * + * Main() then: + * 1. parses the entryParams string ("key=value" lines — plain text, no + * JSON parser needed); recognized keys: dataDir, script, node, port, + * secret (unknown keys are exported as env vars for the node process); + * 2. locates the node binary (installed as libnode.so in the app's native + * lib dir): parent-provided "node=" path, then dladdr() on this very + * function, then every mapped-.so directory from /proc/self/maps, then + * the el1/bundle junction layout; + * 3. redirects stdout/stderr to a boot log for on-device debugging; + * 4. execv()s node with the electerm entry script. + * + * If execv fails (e.g. the lib dir turns out to be noexec or the binary is + * blocked by code-integrity checks) a memfd fallback is attempted: the + * binary is copied into an anonymous executable memory file and execveat()d + * — bypassing mount noexec flags entirely. + * + * Every step is logged BOTH to /node-boot.log AND to hilog + * (tag electerm.launcher, error level so release builds keep it) — so the + * boot sequence is visible even when the log file cannot be pulled. + * + * Exit codes: 40 script missing · 41 node binary not found · 42 in-process + * start failed AND all exec strategies failed · else node's own exit code + * (in-process mode exits from nodeThreadMain). + */ + +#include /* native_child_process.h uses `bool` */ + +#include "AbilityKit/native_child_process.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define LOG_BUF_SIZE 4096 +#define MAX_ENV_VARS 32 +#define MAX_LINE 1024 +#define MAX_CANDIDATES 12 + +typedef struct { + char dataDir[MAX_LINE]; /* writable app data dir (el2 filesDir) */ + char script[MAX_LINE * 2]; /* path to resfile/electerm/index.js */ + char node[MAX_LINE * 2]; /* optional parent-provided libnode.so path */ + char port[16]; + char secret[MAX_LINE]; /* SERVER_SECRET */ +} LauncherConfig; + +static int g_logFd = -1; +static char g_logPath[MAX_LINE * 2] = ""; /* for the stdio rebuild below */ + +/* Forward declaration — dladdr() below takes Main's address. */ +void Main(NativeChildProcess_Args args); + +/* Every message goes to the boot log file AND hilog (error level: release + * builds keep it, and `hdc hilog` shows it under electerm.launcher). */ +static void logWrite(const char *fmt, ...) { + char buf[LOG_BUF_SIZE]; + va_list ap; + va_start(ap, fmt); + int n = vsnprintf(buf, sizeof(buf) - 1, fmt, ap); + va_end(ap); + if (n < 0) return; + buf[n] = '\0'; + if (g_logFd >= 0) { + ssize_t ignored = write(g_logFd, buf, (size_t)n); + ignored = write(g_logFd, "\n", 1); + (void)ignored; + } + (void)OH_LOG_Print(LOG_APP, LOG_ERROR, 0xE1EC, "electerm.launcher", + "%{public}s", buf); +} + +/* ── Crash markers: log which signal killed the child before dying ── + * (node's assert → abort() = SIGABRT; without this the boot log just ends). */ +static void crashMarkerHandler(int sig) { + int saved = errno; + char b[64]; + int n = snprintf(b, sizeof(b), "[launcher] process dying: signal %d\n", sig); + if (n > 0) { + if (g_logFd >= 0) { + ssize_t ign = write(g_logFd, b, (size_t)n); + (void)ign; + } else if (g_logPath[0]) { + int fd = open(g_logPath, O_WRONLY | O_CREAT | O_APPEND, 0644); + if (fd >= 0) { + ssize_t ign = write(fd, b, (size_t)n); + (void)ign; + close(fd); + } + } + } + errno = saved; + signal(sig, SIG_DFL); + raise(sig); +} + +static void installCrashMarkers(void) { + const int sigs[] = {SIGABRT, SIGSEGV, SIGBUS, SIGILL, SIGFPE, SIGSYS}; + for (size_t i = 0; i < sizeof(sigs) / sizeof(sigs[0]); i++) { + signal(sigs[i], crashMarkerHandler); + } +} + +/* ── Deterministic stdio for the embedded node runtime ── + * + * nodejs-mobile lesson + libuv's `assert(fd > STDERR_FILENO)` in uv__close: + * whatever fd state the nativespawn child is born with, node must see + * 0=/dev/null, 1=2=our log — every slot open, no aliasing with higher fds, + * so no uv handle can ever end up on fd 0/1/2 through a closed-then-reused + * slot. Also snapshots the inherited fd table into the boot log (answers + * "what was the child born with" for good). */ +static void setupStdioForNode(void) { + for (int fd = 0; fd <= 9; fd++) { + struct stat st; + if (fstat(fd, &st) == 0) { + const char *tag = "other"; + if (S_ISCHR(st.st_mode)) tag = "chardev"; + else if (S_ISREG(st.st_mode)) tag = "regular"; + else if (S_ISFIFO(st.st_mode)) tag = "fifo/pipe"; + else if (S_ISSOCK(st.st_mode)) tag = "socket"; + logWrite("[launcher] fd %d open at birth: %s", fd, tag); + } + } + + close(0); + close(1); + close(2); + if (g_logFd > 2) { + close(g_logFd); /* reopen below right on slot 1 */ + } + g_logFd = -1; + + int f0 = open("/dev/null", O_RDONLY); /* → 0 */ + int f1 = g_logPath[0] + ? open(g_logPath, O_WRONLY | O_CREAT | O_APPEND, 0644) + : -1; /* → 1 */ + int f2 = dup2(f1 >= 0 ? f1 : f0, 2); /* → 2 */ + g_logFd = 1; + logWrite("[launcher] stdio rebuilt: f0=%d f1=%d f2=%d " + "(0=/dev/null, 1=2=%s)", + f0, f1, f2, g_logPath[0] ? g_logPath : "?"); + (void)f0; + (void)f1; + (void)f2; +} + +/* Parse "key=value\n" lines into the config struct. Unknown keys are + * also exported as environment variables for the node process. */ +static void parseEntryParams(const char *params, LauncherConfig *cfg, + char extraEnv[MAX_ENV_VARS][MAX_LINE], + int *extraEnvCount) { + /* defaults */ + snprintf(cfg->port, sizeof(cfg->port), "5577"); + cfg->dataDir[0] = '\0'; + cfg->script[0] = '\0'; + cfg->node[0] = '\0'; + cfg->secret[0] = '\0'; + + char line[MAX_LINE]; + const char *p = params; + while (p && *p) { + const char *eol = strchr(p, '\n'); + size_t len = eol ? (size_t)(eol - p) : strlen(p); + if (len >= sizeof(line)) len = sizeof(line) - 1; + memcpy(line, p, len); + line[len] = '\0'; + p = eol ? eol + 1 : NULL; + + char *eq = strchr(line, '='); + if (!eq) continue; + *eq = '\0'; + const char *key = line; + const char *value = eq + 1; + + if (strcmp(key, "dataDir") == 0) { + snprintf(cfg->dataDir, sizeof(cfg->dataDir), "%s", value); + } else if (strcmp(key, "script") == 0) { + snprintf(cfg->script, sizeof(cfg->script), "%s", value); + } else if (strcmp(key, "node") == 0) { + snprintf(cfg->node, sizeof(cfg->node), "%s", value); + } else if (strcmp(key, "port") == 0) { + snprintf(cfg->port, sizeof(cfg->port), "%s", value); + } else if (strcmp(key, "secret") == 0) { + snprintf(cfg->secret, sizeof(cfg->secret), "%s", value); + } else if (*extraEnvCount < MAX_ENV_VARS) { + snprintf(extraEnv[(*extraEnvCount)++], MAX_LINE, "%s=%s", key, value); + } + } +} + +static void addCandidate(char (*candidates)[MAX_LINE * 2], int *n, + const char *dir, const char *tag) { + if (*n >= MAX_CANDIDATES) return; + if (!dir || !dir[0]) return; + /* dedupe */ + char path[MAX_LINE * 2]; + snprintf(path, sizeof(path), "%s/libnode.so", dir); + for (int i = 0; i < *n; i++) { + if (strcmp(candidates[i], path) == 0) return; + } + snprintf(candidates[(*n)], MAX_LINE * 2, "%s", path); + logWrite("[launcher] candidate(%s): %s", tag, candidates[(*n)]); + (*n)++; +} + +/* Directory this library was loaded from, via the dynamic linker — the most + * reliable source (works even if /proc is restricted). */ +static void selfDirViaDladdr(char *out, size_t outSize) { + out[0] = '\0'; + Dl_info info; + if (dladdr((void *)&Main, &info) && info.dli_fname && info.dli_fname[0]) { + logWrite("[launcher] dladdr dli_fname: %s", info.dli_fname); + const char *slash = strrchr(info.dli_fname, '/'); + if (slash) { + snprintf(out, outSize, "%.*s", (int)(slash - info.dli_fname), + info.dli_fname); + } + } else { + logWrite("[launcher] dladdr failed: %s", dlerror() ? dlerror() : "?"); + } +} + +/* Collect the directory of every mapped .so that looks like an app native + * lib (path contains "arm64"). The child process loads several .so from the + * app libs dir; any of their directories may hold libnode.so. */ +static int collectMapDirs(char (*candidates)[MAX_LINE * 2], int *n) { + FILE *f = fopen("/proc/self/maps", "r"); + if (!f) { + logWrite("[launcher] cannot open /proc/self/maps: %s", strerror(errno)); + return -1; + } + char line[MAX_LINE]; + int found = 0; + while (fgets(line, sizeof(line), f)) { + char *sp = strchr(line, ' '); + while (sp && *sp == ' ') sp++; + if (!sp) continue; + char *nm = sp; + /* skip perms/offset/dev columns to the pathname */ + for (int col = 0; col < 4 && nm; nm = strchr(nm, ' '), col++) { + if (nm) nm++; + } + if (!nm || *nm != '/') continue; + char *nl = strchr(nm, '\n'); + if (nl) *nl = '\0'; + if (!strstr(nm, ".so")) continue; + if (!strstr(nm, "arm64") && !strstr(nm, "x86_64")) continue; + char *slash = strrchr(nm, '/'); + if (!slash) continue; + *slash = '\0'; + addCandidate(candidates, n, nm, "maps"); + found++; + } + fclose(f); + return found; +} + +static int fileExists(const char *path) { + return access(path, F_OK) == 0; +} + +/* Find the system musl dynamic loader — first from /proc/self/maps (it + * mapped us, so it is definitely present at that path), then well-known + * locations. The path is the LAST whitespace-delimited token of the maps + * line — substring-searching for "ld-musl" and slicing from there drops + * the directory prefix (device round-trip taught us: "ld-musl-…so.1" alone + * execve's to ENOENT). */ +static int findLoader(char *out, size_t outSize) { + FILE *f = fopen("/proc/self/maps", "r"); + if (f) { + char line[MAX_LINE]; + while (fgets(line, sizeof(line), f)) { + char *nl = strchr(line, '\n'); + if (nl) *nl = '\0'; + char *sp = strrchr(line, ' '); + char *path = sp ? sp + 1 : line; + if (strstr(path, "ld-musl") && strstr(path, ".so") && + access(path, F_OK) == 0) { + snprintf(out, outSize, "%s", path); + fclose(f); + return 0; + } + } + fclose(f); + } + const char *fallbacks[] = { + "/lib/ld-musl-aarch64.so.1", + "/system/lib/ld-musl-aarch64.so.1", + "/system/lib64/ld-musl-aarch64.so.1", + NULL + }; + for (int i = 0; fallbacks[i]; i++) { + if (fileExists(fallbacks[i])) { + snprintf(out, outSize, "%s", fallbacks[i]); + return 0; + } + } + return -1; +} + +/* Log the mount that contains `path` (from /proc/self/mountinfo) so noexec + * and other enforcement is visible in the boot log. */ +static void logMountFlagsFor(const char *path) { + /* find the deepest mount point that prefixes path */ + char best[MAX_LINE]; + char bestLine[MAX_LINE * 2]; + best[0] = '\0'; + bestLine[0] = '\0'; + FILE *f = fopen("/proc/self/mountinfo", "r"); + if (!f) { + logWrite("[launcher] cannot open /proc/self/mountinfo: %s", + strerror(errno)); + return; + } + char line[MAX_LINE * 2]; + while (fgets(line, sizeof(line), f)) { + /* format: id parent maj:min root mountpoint options ... */ + unsigned id, parent; + unsigned maj, min; + char root[MAX_LINE], mnt[MAX_LINE], opts[MAX_LINE]; + if (sscanf(line, "%u %u %u:%u %s %s %s", &id, &parent, &maj, &min, root, + mnt, opts) != 7) { + continue; + } + if (strncmp(path, mnt, strlen(mnt)) == 0 && + strlen(mnt) > strlen(best)) { + snprintf(best, sizeof(best), "%s", mnt); + snprintf(bestLine, sizeof(bestLine), "mount %s → options: %s", mnt, opts); + } + } + fclose(f); + if (best[0]) { + logWrite("[launcher] %s (for %s)", bestLine, path); + } else { + logWrite("[launcher] no mountinfo entry prefixes %s", path); + } +} + +/* ── Strategy 1: run node IN-PROCESS (the nodejs-mobile / WineHua pattern) ── + * + * execve of any new image is refused inside an app child process on + * HarmonyOS (errno EACCES — direct, via the system loader, and via memfd, + * even with a code-signed binary: XPM blocks the syscall for app uids). + * But dlopen() of a shared object demonstrably works — nativespawn loaded + * this very library. The bundled node is a dynamic PIE, and OHOS musl's + * loader does not reject executables: no PT_INTERP / DF_1_PIE check in + * load_library(). node exports its embedder entry + * int node::Start(int argc, char *argv[]) (_ZN4node5StartEiPPc) + * so we dlopen the binary, resolve node::Start, and call it on a dedicated + * big-stack thread (node needs a large stack; nativespawn's Main() thread + * cannot be assumed to have one). + * + * Returns only on failure (-1); on success node::Start runs until exit and + * the thread wrapper _exit()s the process with node's exit code. */ + +typedef int (*node_start_fn)(int argc, char *argv[]); + +/* ── SIGSYS: the app sandbox's seccomp filter traps syscalls node/V8 probe + * for at startup (membarrier, pkey_mprotect, perf_event_open, …) and the + * default action kills the thread (device: signo 31, si_code SYS_SECCOMP). + * V8/uv handle ENOSYS gracefully for all of those probes — they are + * optional accelerations. So: catch SIGSYS, log which syscall was trapped + * (async-signal-safe), skip the svc instruction, and return -1 from it. */ + +/* aarch64 (asm-generic) syscall numbers worth naming in the log — the + * suspects an app seccomp policy actually fences off. */ +static const char *syscallName(int sc) { + switch (sc) { + case 19: return "eventfd2"; + case 20: return "epoll_create1"; + case 220: return "clone"; + case 221: return "execve"; + case 241: return "perf_event_open"; + case 265: return "open_by_handle_at"; + case 270: return "process_vm_readv"; + case 272: return "kcmp"; + case 277: return "seccomp"; + case 278: return "getrandom"; + case 280: return "bpf"; + case 281: return "execveat"; + case 282: return "userfaultfd"; + case 283: return "membarrier"; + case 288: return "pkey_mprotect"; + case 291: return "statx"; + case 293: return "rseq"; + case 403: return "clock_gettime64"; + case 424: return "pidfd_send_signal"; + case 425: return "io_uring_setup"; + case 434: return "pidfd_open"; + case 435: return "clone3"; + case 436: return "close_range"; + case 437: return "openat2"; + case 439: return "faccessat2"; + case 440: return "process_madvise"; + default: return "?"; + } +} + +/* Async-signal-safe log line for use INSIDE the signal handler — no + * printf-family/vsnprintf/logWrite: those take libc locks, and a trap that + * fires while the thread already holds one crashes the handler (see the + * node_ctl.c twin for the device-proven details). write(2) is safe. */ +static void safeAppend(char *b, size_t cap, size_t *n, const char *s) { + while (*s && *n < cap) { + b[(*n)++] = *s++; + } +} + +static void safeAppendInt(char *b, size_t cap, size_t *n, int v) { + char tmp[12]; + int len = 0; + if (v < 0 && *n < cap) { + b[(*n)++] = '-'; + v = -v; + } + do { + tmp[len++] = (char)('0' + (v % 10)); + v /= 10; + } while (v > 0 && len < (int)sizeof(tmp)); + while (len > 0 && *n < cap) { + b[(*n)++] = tmp[--len]; + } +} + +/* Is there an aarch64 `svc #0` (encoded 0xd4000001) at `pc`? + * + * Do NOT decide this by comparing the signal frame's PC with si_addr: both + * come from the same pt_regs, so they agree whether or not the kernel has + * already advanced past the trapped instruction, and skipping an + * already-advanced PC resumes mid-stream. Read the encoding instead. + * (Same fix as node_ctl.c — the in-process path hit exactly this.) */ +static int pcIsSvcInsn(unsigned long pc) { + if (pc == 0 || (pc & 3U) != 0) { + return 0; + } + unsigned int insn = 0; + memcpy(&insn, (const void *)pc, sizeof(insn)); + return (insn & 0xffe0001fu) == 0xd4000001u; +} + +static void sigsysHandler(int sig, siginfo_t *si, void *ctx) { + static unsigned int seenBits[16]; /* 512 syscall numbers, logged once each */ + + /* Only emulate a real seccomp trap: a SIGSYS from raise()/kill() has no + * syscall context, and rewriting the register file for it corrupts + * whatever thread happened to be running. */ + if (!si || !ctx || si->si_code != 1 /* SYS_SECCOMP */) { + signal(sig, SIG_DFL); + raise(sig); + return; + } + + int sc = si->si_syscall; /* musl: #define si_syscall __si_fields.__sigsys.si_syscall */ + if (sc >= 0 && sc < 512) { + unsigned int bit = 1u << (sc & 31); + if (!(seenBits[sc >> 5] & bit)) { + seenBits[sc >> 5] |= bit; + char b[160]; + size_t n = 0; + /* safeAppend/safeAppendInt only — NO logWrite(). logWrite() is + * vsnprintf + hilog IPC; both take libc locks, and calling them from + * this handler is device-proven to kill the trapped thread (the + * in-process path died with SIGSEGV addr=0x0 that way). */ + safeAppend(b, sizeof(b), &n, "[launcher] SIGSYS: syscall "); + safeAppendInt(b, sizeof(b), &n, sc); + safeAppend(b, sizeof(b), &n, " ("); + safeAppend(b, sizeof(b), &n, syscallName(sc)); + safeAppend(b, sizeof(b), &n, ") blocked by seccomp -> -1"); +#if defined(__aarch64__) + safeAppend(b, sizeof(b), &n, " onsvc="); + safeAppendInt(b, sizeof(b), &n, + pcIsSvcInsn(((ucontext_t *)ctx)->uc_mcontext.pc)); +#endif + safeAppend(b, sizeof(b), &n, "\n"); + if (g_logFd >= 0) { + ssize_t ign = write(g_logFd, b, n); + (void)ign; + } + /* fd 2 is the boot log here (stdio was rebuilt onto it) — same file, + * shared offset, so this is belt-and-braces. */ + ssize_t ign = write(2, b, n); + (void)ign; + } + } + ucontext_t *uc = (ucontext_t *)ctx; + /* Skip the trapped instruction ONLY if it really is the syscall, and put + * the failure value in x0 (the syscall return register). + * Return EXACTLY -1, not -ENOSYS: OHOS musl's syscall() passes raw x0 + * through without __syscall_ret errno-translation, so -38 leaks out as a + * bogus value — device-proven fatal in libuv uv__iou_init(): ringfd=-38 + * passed its `== -1` guard, mmap/epoll_ctl failed, cleanup called + * uv__close(-38) → assert(fd > STDERR_FILENO) → abort. */ +#if defined(__aarch64__) + if (pcIsSvcInsn(uc->uc_mcontext.pc)) { + uc->uc_mcontext.pc += 4; + } + uc->uc_mcontext.regs[0] = (unsigned long)-1; +#elif defined(__x86_64__) + unsigned long pc = (unsigned long)uc->uc_mcontext.gregs[REG_RIP]; + if (pc != 0) { + unsigned char c[2] = {0, 0}; + memcpy(c, (const void *)pc, 2); + if (c[0] == 0x0fu && c[1] == 0x05u) { + uc->uc_mcontext.gregs[REG_RIP] += 2; /* skip 2-byte syscall */ + } + } + uc->uc_mcontext.gregs[REG_RAX] = (unsigned long)-1; +#endif + errno = ENOSYS; /* TLS store — async-signal-safe; for errno-checking callers */ +} + +static void installSigsysShim(void) { + struct sigaction sa; + memset(&sa, 0, sizeof(sa)); + sa.sa_sigaction = sigsysHandler; + sa.sa_flags = SA_SIGINFO; + if (sigaction(SIGSYS, &sa, NULL) != 0) { + logWrite("[launcher] sigaction(SIGSYS) failed: %s", strerror(errno)); + } +} + +struct NodeThreadArgs { + node_start_fn start; + char *argv[7]; /* node binary, V8 flags (up to 4), script, NULL */ + int rc; +}; + +static void *nodeThreadMain(void *p) { + struct NodeThreadArgs *a = (struct NodeThreadArgs *)p; + a->rc = a->start(5, a->argv); + logWrite("[launcher] node::Start returned %d", a->rc); + _exit(a->rc & 0xff); + return NULL; /* unreachable */ +} + +static int runNodeInProcess(const char *nodePath, const char *script) { + logWrite("[launcher] in-process: dlopen(%s)", nodePath); + void *h = dlopen(nodePath, RTLD_NOW | RTLD_LOCAL); + if (!h) { + const char *e1 = dlerror(); + const char *e2 = dlerror(); + logWrite("[launcher] dlopen failed: %s / %s", e1 ? e1 : "-", + e2 ? e2 : "-"); + return -1; + } + dlerror(); + node_start_fn start = (node_start_fn)dlsym(h, "_ZN4node5StartEiPPc"); + const char *e = dlerror(); + if (!start || (e && e[0])) { + logWrite("[launcher] dlsym(node::Start) failed: %s", e ? e : "null sym"); + return -1; + } + logWrite("[launcher] node::Start resolved at %p", (void *)start); + + /* seccomp shim BEFORE node runs: trapped syscalls become logged ENOSYS + * instead of a SIGSYS thread kill. */ + installSigsysShim(); + + /* argv must outlive the thread — static storage. V8 flags: + * - --jitless : THE fix for the OpenHarmony W^X policy — V8 never + * maps PROT_EXEC pages, so no mprotect(PROT_EXEC)/EPERM and no + * `CHECK_EQ(ENOMEM, errno)` abort in node::Start. + * - --no-snap : skip embedded-snapshot load (our build ships none). + * - --no-verify-heap : disables V8 heap verification on startup + * (defensive; harmless when the heap is healthy). */ + static char arg0[MAX_LINE * 2]; + static char arg1[MAX_LINE * 2]; + static char argFlag1[] = "--no-verify-heap"; + static char argFlag2[] = "--no-snap"; + static char argFlag3[] = "--jitless"; + snprintf(arg0, sizeof(arg0), "%s", nodePath); + snprintf(arg1, sizeof(arg1), "%s", script); + + static struct NodeThreadArgs na; + na.start = start; + na.argv[0] = arg0; + na.argv[1] = argFlag1; + na.argv[2] = argFlag2; + na.argv[3] = argFlag3; + na.argv[4] = arg1; + na.argv[5] = NULL; + na.rc = -1; + + pthread_attr_t attr; + pthread_attr_init(&attr); + pthread_attr_setstacksize(&attr, 32 * 1024 * 1024); /* node wants a big stack */ + pthread_t th; + int prc = pthread_create(&th, &attr, nodeThreadMain, &na); + if (prc != 0) { + logWrite("[launcher] pthread_create failed: %s", strerror(prc)); + return -1; + } + void *ret = NULL; + pthread_join(th, &ret); /* nodeThreadMain _exits, so this returns on error only */ + (void)ret; + logWrite("[launcher] node thread ended without _exit (rc=%d)", na.rc); + return -1; +} + +/* ── Strategy 2: copy the signed node binary into the writable data dir, + * chmod +x there, and exec the copy ───────────────────────────────────────── + * + * Device log finding: the bundled libnode.so installs with mode 0644 (no + * execute bit) on a mount that is NOT noexec — execve then fails with + * EACCES for the plainest Unix reason, and the app cannot chmod a file it + * does not own inside el1/bundle. The el2 files dir IS app-owned: copy the + * (code-signed) binary there once, give it 0755, exec it. */ + +static int copyFile(const char *src, const char *dst) { + int in = open(src, O_RDONLY); + if (in < 0) { + logWrite("[launcher] copy: open(%s) failed: %s", src, strerror(errno)); + return -1; + } + int out = open(dst, O_WRONLY | O_CREAT | O_TRUNC, 0755); + if (out < 0) { + logWrite("[launcher] copy: open(%s) failed: %s", dst, strerror(errno)); + close(in); + return -1; + } + char buf[262144]; + ssize_t r; + while ((r = read(in, buf, sizeof(buf))) > 0) { + ssize_t off = 0; + while (off < r) { + ssize_t w = write(out, buf + off, (size_t)(r - off)); + if (w < 0) { + logWrite("[launcher] copy: write failed: %s", strerror(errno)); + close(in); + close(out); + return -1; + } + off += w; + } + } + int rc = 0; + if (r < 0) { + logWrite("[launcher] copy: read failed: %s", strerror(errno)); + rc = -1; + } + close(in); + close(out); + return rc; +} + +/* Returns only on failure (-1) — like every exec strategy, success never + * returns. */ +static int execFromDataDir(const char *nodePath, const char *dataDir, + const char *script) { + char binDir[MAX_LINE * 2]; + char dest[MAX_LINE * 2]; + char tmp[MAX_LINE * 2]; + snprintf(binDir, sizeof(binDir), "%s/bin", dataDir); + snprintf(dest, sizeof(dest), "%s/bin/node", dataDir); + snprintf(tmp, sizeof(tmp), "%s/bin/node.tmp", dataDir); + + mkdir(binDir, 0755); /* ok if it exists */ + + /* copy only if missing or different size (96MB copy ~ a few seconds) */ + struct stat ss, sd; + int needCopy = 1; + if (stat(dest, &sd) == 0 && stat(nodePath, &ss) == 0 && + sd.st_size == ss.st_size) { + needCopy = 0; + logWrite("[launcher] el2 copy already present: %s", dest); + } + if (needCopy) { + logWrite("[launcher] copying %s → %s (%ld bytes)", nodePath, tmp, + (long)ss.st_size); + if (copyFile(nodePath, tmp) != 0) { + return -1; + } + if (rename(tmp, dest) != 0) { + logWrite("[launcher] rename failed: %s", strerror(errno)); + unlink(tmp); + return -1; + } + logWrite("[launcher] copy complete"); + } + + if (chmod(dest, 0755) != 0) { + logWrite("[launcher] chmod(%s, 0755) failed: %s", dest, strerror(errno)); + } + if (stat(dest, &sd) == 0) { + logWrite("[launcher] el2 node stat: mode=%o size=%ld", sd.st_mode, + (long)sd.st_size); + logMountFlagsFor(dest); + } + char arg0[MAX_LINE * 2]; + snprintf(arg0, sizeof(arg0), "%s", dest); + char *const argv[] = {arg0, (char *)script, NULL}; + logWrite("[launcher] execv(el2): %s %s", arg0, script); + execv(arg0, argv); + logWrite("[launcher] execv(el2) failed: errno=%d (%s)", errno, + strerror(errno)); + return -1; +} + +/* execveat on a memfd copy of the binary — the noexec-bypass fallback. */ +static int execFromMemfd(const char *binaryPath, char *const argv[], + char *const envp[]) { + int src = open(binaryPath, O_RDONLY); + if (src < 0) { + logWrite("[launcher] memfd: open(%s) failed: %s", binaryPath, + strerror(errno)); + return -1; + } + int mfd = (int)syscall(__NR_memfd_create, "node", 0); + if (mfd < 0) { + logWrite("[launcher] memfd_create failed: %s", strerror(errno)); + close(src); + return -1; + } + char buf[65536]; + ssize_t r; + while ((r = read(src, buf, sizeof(buf))) > 0) { + ssize_t off = 0; + while (off < r) { + ssize_t w = write(mfd, buf + off, (size_t)(r - off)); + if (w < 0) { + logWrite("[launcher] memfd write failed: %s", strerror(errno)); + close(src); + close(mfd); + return -1; + } + off += w; + } + } + close(src); + if (r < 0) { + logWrite("[launcher] read failed: %s", strerror(errno)); + close(mfd); + return -1; + } + fchmod(mfd, 0755); + lseek(mfd, 0, SEEK_SET); + logWrite("[launcher] execveat(memfd) ..."); + char *const empty[] = {NULL}; + /* OHOS musl does not export the execveat() wrapper — call the syscall + * directly (__NR_execveat, AT_EMPTY_PATH). */ + (void)syscall(__NR_execveat, mfd, "", argv, envp ? envp : empty, AT_EMPTY_PATH); + logWrite("[launcher] execveat failed: %s", strerror(errno)); + close(mfd); + return -1; +} + +/* The native child-process entry point. + * Signature mandated by OH_Ability_StartNativeChildProcess / + * childProcessManager.startNativeChildProcess. */ +__attribute__((visibility("default"))) void Main(NativeChildProcess_Args args) { + char extraEnv[MAX_ENV_VARS][MAX_LINE]; + int extraEnvCount = 0; + LauncherConfig cfg; + + const char *params = args.entryParams ? args.entryParams : ""; + parseEntryParams(params, &cfg, extraEnv, &extraEnvCount); + + /* 1. Open the boot log inside the writable data dir. The dataDir string + * from the parent process may not be mounted in this child's namespace + * (sandbox paths differ) — retry via the per-process el2 junction, which + * points at the same files dir. */ + if (cfg.dataDir[0]) { + char logPath[MAX_LINE * 2]; + snprintf(logPath, sizeof(logPath), "%s/node-boot.log", cfg.dataDir); + g_logFd = open(logPath, O_WRONLY | O_CREAT | O_APPEND, 0644); + if (g_logFd >= 0) { + snprintf(g_logPath, sizeof(g_logPath), "%s", logPath); + } else { + snprintf(logPath, sizeof(logPath), + "/data/storage/el2/base/files/electerm-data/node-boot.log"); + g_logFd = open(logPath, O_WRONLY | O_CREAT | O_APPEND, 0644); + if (g_logFd >= 0) { + snprintf(g_logPath, sizeof(g_logPath), "%s", logPath); + } + } + } + logWrite("[launcher] Main() entered, pid=%d", (int)getpid()); + logWrite("[launcher] entryParams: %s", params); + + if (!cfg.script[0] || !fileExists(cfg.script)) { + logWrite("[launcher] FATAL: script missing: %s (errno=%d %s)", cfg.script, + errno, strerror(errno)); + _exit(40); + } + + /* 2. Locate node */ + char candidates[MAX_CANDIDATES][MAX_LINE * 2]; + int nCand = 0; + + if (cfg.node[0] && nCand < MAX_CANDIDATES) { + /* parent-provided full path, used verbatim */ + snprintf(candidates[nCand], MAX_LINE * 2, "%s", cfg.node); + logWrite("[launcher] candidate(parent): %s", candidates[nCand]); + nCand++; + } + { + char selfDir[MAX_LINE]; + selfDirViaDladdr(selfDir, sizeof(selfDir)); + addCandidate(candidates, &nCand, selfDir, "dladdr"); + } + collectMapDirs(candidates, &nCand); + { + const char *bundleDir = getenv("ELECTERM_BUNDLE_CODE_DIR"); + if (!bundleDir || !bundleDir[0]) bundleDir = "/data/storage/el1/bundle"; + char dir[MAX_LINE * 2]; + snprintf(dir, sizeof(dir), "%s/entry/libs/arm64-v8a", bundleDir); + addCandidate(candidates, &nCand, dir, "el1"); + snprintf(dir, sizeof(dir), "%s/entry/libs/arm64", bundleDir); + addCandidate(candidates, &nCand, dir, "el1"); + snprintf(dir, sizeof(dir), "%s/entry/libs/x86_64", bundleDir); + addCandidate(candidates, &nCand, dir, "el1"); + snprintf(dir, sizeof(dir), "%s/libs/arm64-v8a", bundleDir); + addCandidate(candidates, &nCand, dir, "el1"); + snprintf(dir, sizeof(dir), "%s/libs/x86_64", bundleDir); + addCandidate(candidates, &nCand, dir, "el1"); + } + + const char *nodePath = NULL; + for (int i = 0; i < nCand; i++) { + if (fileExists(candidates[i])) { + nodePath = candidates[i]; + break; + } + logWrite("[launcher] candidate not found: %s (errno=%d)", candidates[i], + errno); + } + if (!nodePath) { + logWrite("[launcher] FATAL: no libnode.so candidate exists (tried %d)", + nCand); + _exit(41); + } + logWrite("[launcher] node binary: %s", nodePath); + + /* 3. Environment for the node process */ + setenv("NODE_ENV", "production", 1); + setenv("HOST", "127.0.0.1", 1); + setenv("PORT", cfg.port, 1); + setenv("ELECTERM_DATA_DIR", cfg.dataDir, 1); + if (cfg.secret[0]) { + setenv("SERVER_SECRET", cfg.secret, 1); + } + for (int i = 0; i < extraEnvCount; i++) { + /* setenv() COPIES. putenv() stores a pointer into `extraEnv`, a stack + * array of the enclosing frame — fine across an immediate execve (the + * kernel copies the strings) but a use-after-return for anything that + * reads environ later in this process. */ + char *eq = strchr(extraEnv[i], '='); + if (!eq) continue; + *eq = '\0'; + setenv(extraEnv[i], eq + 1, 1); + } + + /* 4. Rebuild stdio deterministically (0=/dev/null, 1=2=boot log) so node's + * libuv can never see a closed/aliased fd 0/1/2 — the exact condition + * behind libuv's `assert(fd > STDERR_FILENO)`. Also installs crash + * markers: the boot log records which signal killed the child. */ + setupStdioForNode(); + installCrashMarkers(); + + /* 5. Log the node file's mode + the mount flags of its directory — + * noexec / code-integrity enforcement shows up here. */ + { + struct stat st; + if (stat(nodePath, &st) == 0) { + logWrite("[launcher] node stat: mode=%o size=%ld", st.st_mode, + (long)st.st_size); + } else { + logWrite("[launcher] node stat failed: %s", strerror(errno)); + } + logMountFlagsFor(nodePath); + } + + /* 5b. node writes relative paths into the data dir — make that the cwd + * (the resfile install dir it runs from is read-only). */ + if (cfg.dataDir[0]) { + if (chdir(cfg.dataDir) == 0) { + logWrite("[launcher] cwd: %s", cfg.dataDir); + } else { + logWrite("[launcher] chdir(%s) failed: %s", cfg.dataDir, + strerror(errno)); + } + } + + /* 6. STRATEGY 1 — in-process node::Start via dlopen. Exec of a new image + * is blocked on device (direct, loader, memfd: all EACCES, even + * code-signed); dlopen is how app code legitimately gets mapped + * executable (nativespawn loaded this very library). Runs until exit + * on success; falls through to the exec ladder only if it cannot start. + */ + if (runNodeInProcess(nodePath, cfg.script) == 0) { + _exit(0); /* unreachable — nodeThreadMain exits the process */ + } + + /* 6b. STRATEGY 2 — the bundled file installs 0644 (no +x) and cannot be + * chmod'd in el1; copy the signed binary into the app-owned el2 data + * dir, chmod +x, exec the copy. */ + if (cfg.dataDir[0]) { + if (execFromDataDir(nodePath, cfg.dataDir, cfg.script) == 0) { + _exit(0); /* unreachable */ + } + } + + /* 7. exec ladder (kept for environments where exec is permitted). */ + if (chmod(nodePath, 0755) != 0) { + logWrite("[launcher] chmod(bundle node) failed: %s", strerror(errno)); + } + + char nodeArg0[MAX_LINE * 2]; + snprintf(nodeArg0, sizeof(nodeArg0), "%s", nodePath); + char *const argv[] = {nodeArg0, cfg.script, NULL}; + + logWrite("[launcher] execv: %s %s", nodeArg0, cfg.script); + execv(nodeArg0, argv); + int execErr = errno; + logWrite("[launcher] execv failed: errno=%d (%s)", execErr, + strerror(execErr)); + + /* 6a. Loader-exec fallback: exec the SYSTEM dynamic loader (signed, on an + * exec mount) with the node binary as its program argument. The loader + * maps the binary itself — the same PROT_EXEC file mapping dlopen() uses, + * which demonstrably works for app .so files in this very process. This + * sidesteps execve() of an unsigned app file entirely. */ + { + char loader[MAX_LINE]; + if (findLoader(loader, sizeof(loader)) == 0) { + char *const largv[] = {loader, nodeArg0, cfg.script, NULL}; + logWrite("[launcher] execv via loader: %s %s %s", loader, nodeArg0, + cfg.script); + execv(loader, largv); + logWrite("[launcher] loader execv failed: errno=%d (%s)", errno, + strerror(errno)); + } else { + logWrite("[launcher] no dynamic loader found for fallback"); + } + } + + /* 6b. memfd fallback (noexec mounts) */ + if (execFromMemfd(nodeArg0, argv, NULL) == 0) { + _exit(0); /* unreachable */ + } + + logWrite("[launcher] FATAL: in-process start failed AND all exec strategies " + "failed (execv errno=%d)", + execErr); + _exit(42); +} diff --git a/entry/src/main/cpp/types/libnode_ctl/index.d.ts b/entry/src/main/cpp/types/libnode_ctl/index.d.ts new file mode 100644 index 0000000..fcc5634 --- /dev/null +++ b/entry/src/main/cpp/types/libnode_ctl/index.d.ts @@ -0,0 +1,3 @@ +export const killNode: (pid: number) => number; +export const startBackend: (params: string) => string; +export const getBackendStatus: () => string; diff --git a/entry/src/main/cpp/types/libnode_ctl/oh-package.json5 b/entry/src/main/cpp/types/libnode_ctl/oh-package.json5 new file mode 100644 index 0000000..71d61b5 --- /dev/null +++ b/entry/src/main/cpp/types/libnode_ctl/oh-package.json5 @@ -0,0 +1,6 @@ +{ + "name": "libnode_ctl", + "types": "./index.d.ts", + "version": "1.0.0", + "description": "Node.js child process control (kill by pid)" +} diff --git a/entry/src/main/ets/AbilityStage.ets b/entry/src/main/ets/AbilityStage.ets index 0173706..6aed7e9 100644 --- a/entry/src/main/ets/AbilityStage.ets +++ b/entry/src/main/ets/AbilityStage.ets @@ -1,45 +1,15 @@ /** * AbilityStage — app-level initialization. * - * Extends WebAbilityStage from the web_engine module, which handles - * initializing the Electron 鸿蒙 native context (libadapter.so / libelectron.so) - * before any ability is created. - * - * Writes the sandbox filesDir path to a marker file BEFORE the Electron - * runtime starts, so bootstrap.js can read it and set process.env.DATA_PATH. - * - * The sandbox filesDir is used as the DATA_PATH for reliable app data - * storage (nedb databases, config, logs). It is always writable and - * does not require runtime permission requests. - * - * For the user-visible home directory (os.homedir()), EntryAbility.ets - * requests READ_WRITE_DOCUMENTS_DIRECTORY and writes a separate marker - * (.electerm-documents-path) that bootstrap.js uses to override os.homedir() - * to point at the Documents folder. + * Minimal for the web app: all backend bootstrapping happens in + * pages/Index (native child process + HTTP polling). Kept as the module + * srcEntry for future app-level hooks. */ -import { WebAbilityStage } from 'web_engine'; -import fs from '@ohos.file.fs'; +import { AbilityStage } from '@kit.AbilityKit'; -const TAG: string = 'ElectermAbilityStage'; - -export default class ElectermAbilityStage extends WebAbilityStage { +export default class ElectermAbilityStage extends AbilityStage { onCreate(): void { - super.onCreate(); - - // super.onCreate() schedules the Electron runtime start via setTimeout(0). - // This synchronous code runs BEFORE that setTimeout fires, so the marker - // files are guaranteed to exist when bootstrap.js loads. - const filesDir: string = this.context.getApplicationContext().filesDir; - - try { - const markerPath: string = `${filesDir}/.electerm-data-path`; - const file = fs.openSync(markerPath, fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE | fs.OpenMode.TRUNC); - fs.writeSync(file.fd, filesDir); - fs.closeSync(file); - console.info(`[${TAG}] wrote data path marker: ${markerPath} → ${filesDir}`); - } catch (e) { - console.error(`[${TAG}] failed to write data path marker: ${JSON.stringify(e)}`); - } + console.info('[ElectermAbilityStage] onCreate'); } } diff --git a/entry/src/main/ets/BackendManager.ets b/entry/src/main/ets/BackendManager.ets new file mode 100644 index 0000000..6b38a2d --- /dev/null +++ b/entry/src/main/ets/BackendManager.ets @@ -0,0 +1,47 @@ +/** + * BackendManager — tracks the Node.js backend for the app lifetime. + * + * pages/Index starts the backend and records how it runs: + * - in-process (node::Start on a thread of the MAIN app process, via + * libnode_ctl.so startBackend — the electron-harmony pattern; dodges + * the nativespawn child's stricter seccomp) → inProcess = true + * - native child process (libnode_launcher.so:Main) → pid + * + * EntryAbility.onDestroy() calls BackendManager.killBackend() so the port + * is freed when the app is terminated — killing is only possible for the + * child variant (in-process node dies with the app itself). + */ + +import { killNode } from 'libnode_ctl.so'; + +export class BackendManager { + static pid: number = -1; + static inProcess: boolean = false; + + static setPid(pid: number): void { + BackendManager.pid = pid; + BackendManager.inProcess = false; + } + + static setInProcess(): void { + BackendManager.inProcess = true; + BackendManager.pid = -1; + } + + static killBackend(): void { + if (BackendManager.inProcess) { + // node runs on a thread of the app process itself — it ends with the + // process; killing "our own pid" would kill the UI too. + return; + } + if (BackendManager.pid > 0) { + try { + killNode(BackendManager.pid); + console.info(`[BackendManager] killed node pid=${BackendManager.pid}`); + } catch (e) { + console.error(`[BackendManager] kill failed: ${JSON.stringify(e)}`); + } + BackendManager.pid = -1; + } + } +} diff --git a/entry/src/main/ets/entryability/EntryAbility.ets b/entry/src/main/ets/entryability/EntryAbility.ets index 284dc68..37a0d8b 100644 --- a/entry/src/main/ets/entryability/EntryAbility.ets +++ b/entry/src/main/ets/entryability/EntryAbility.ets @@ -1,31 +1,23 @@ /** - * EntryAbility — main ability for the electerm-harmony app. + * EntryAbility — main ability for the electerm-harmony web app. * - * Extends WebAbility from the web_engine module, which handles window - * creation, XComponent setup, and Electron runtime startup. + * Plain UIAbility (no electron/web_engine): loads pages/Index, which boots + * the on-device Node.js backend as a native child process and renders the + * UI in an ArkWeb Web component pointed at http://127.0.0.1:5577. * - * Key responsibility: request ALL declared user-grant permissions at - * startup, BEFORE the Electron runtime starts. This is done in - * onWindowStageCreate() before calling super.onWindowStageCreate(), - * because super.onWindowStageCreate() loads the WebWindow page which - * triggers the Electron runtime to load bootstrap.js. - * - * After permissions are granted, writes a second marker file - * (.electerm-documents-path) containing the Documents directory path, - * which bootstrap.js uses to override os.homedir() so that file save - * dialogs and SFTP local paths default to the user-visible Documents - * folder. + * Responsibilities: + * - request user-grant permissions at startup (Documents/Desktop/Download + * directory access for SFTP local paths, pasteboard for copy/paste); + * - on destroy, terminate the Node.js child process so port 5577 is freed. */ import window from '@ohos.window'; import Want from '@ohos.app.ability.Want'; import AbilityConstant from '@ohos.app.ability.AbilityConstant'; import { Configuration } from '@ohos.app.ability.Configuration'; -import { abilityAccessCtrl, common, Permissions, PermissionRequestResult } from '@kit.AbilityKit'; +import { abilityAccessCtrl, common, Permissions, PermissionRequestResult, UIAbility } from '@kit.AbilityKit'; import { Environment } from '@kit.CoreFileKit'; -import fs from '@ohos.file.fs'; - -import { WebAbility } from 'web_engine'; +import { BackendManager } from '../BackendManager'; const TAG: string = 'ElectermEntryAbility'; @@ -33,8 +25,6 @@ const TAG: string = 'ElectermEntryAbility'; // Permissions without "reason"/"usedScene" (INTERNET, GET_NETWORK_INFO, // ACCESS_CERT_MANAGER, PRINT, GYROSCOPE, ACCELEROMETER) are normal or // system-grant permissions that don't need runtime request. -// Removed LOCATION, MICROPHONE, CAMERA, ACCESS_BLUETOOTH — a terminal/SSH -// app has no use for geolocation, audio/video capture, or Bluetooth. const ALL_USER_PERMISSIONS: Permissions[] = [ 'ohos.permission.READ_WRITE_DOWNLOAD_DIRECTORY', 'ohos.permission.READ_WRITE_DOCUMENTS_DIRECTORY', @@ -42,46 +32,48 @@ const ALL_USER_PERMISSIONS: Permissions[] = [ 'ohos.permission.READ_PASTEBOARD' ]; -export default class EntryAbility extends WebAbility { - onConfigurationUpdate(config: Configuration) { - super.onConfigurationUpdate(config); - } - +export default class EntryAbility extends UIAbility { onCreate(want: Want, launchParam: AbilityConstant.LaunchParam) { - super.onCreate(want, launchParam); } - async onPrepareToTerminateAsync(): Promise { - return await super.onPrepareToTerminateAsync(); - } - - async onDestroy(): Promise { - await super.onDestroy(); + onConfigurationUpdate(config: Configuration) { } - async onWindowStageCreate(windowStage: window.WindowStage) { - // Request ALL declared user-grant permissions BEFORE the Electron - // runtime starts. super.onWindowStageCreate() loads the WebWindow - // page which triggers the Electron runtime to load bootstrap.js. + onWindowStageCreate(windowStage: window.WindowStage) { + // loadContent FIRST, permissions AFTER. // - // If a permission is already granted, requestPermissionsFromUser - // returns immediately without showing a dialog for that permission. - await this.requestAllPermissions(); - this.writeDocumentsPathMarker(); + // Awaiting requestPermissionsFromUser() before loadContent() means the + // window shows nothing but its splash until the user answers the dialog. + // On an unattended device (cloud debugging / 云调试, CI smoke runs) nobody + // taps "Allow", so the app sat on the splash screen indefinitely and the + // input-dispatch watchdog eventually killed it as APP_INPUT_BLOCK. + windowStage.loadContent('pages/Index', (err) => { + if (err.code) { + console.error(`[${TAG}] Failed to load content: ${JSON.stringify(err)}`); + return; + } + console.info(`[${TAG}] content loaded`); + }); - super.onWindowStageCreate(windowStage); + // Request permissions in the background. Index requests them again via + // its own context when it needs to write user-visible files; a granted + // permission here just avoids the dialogs appearing later. + this.requestAllPermissions(); } onWindowStageDestroy() { - super.onWindowStageDestroy(); } onForeground() { - super.onForeground(); } onBackground() { - super.onBackground(); + } + + async onDestroy(): Promise { + // Terminate the node child process so port 5577 is freed. If the child + // is already dead (crashed / exec failed) killNode just returns -1. + BackendManager.killBackend(); } /** @@ -105,37 +97,4 @@ export default class EntryAbility extends WebAbility { console.error(`[${TAG}] Failed to request permissions: ${JSON.stringify(e)}`); } } - - /** - * Write the documents-path marker file. - * - * After permissions are granted, Environment.getUserDocumentDir() - * returns the user-visible Documents directory path. This is written - * to a separate marker (.electerm-documents-path) which bootstrap.js - * reads to override os.homedir() — so that file save dialogs, SFTP - * local paths, and other home-directory-based operations default to - * the user-visible Documents folder. - * - * If the permission was denied, the marker is not written and - * bootstrap.js falls back to the original os.homedir() value. - */ - private writeDocumentsPathMarker(): void { - try { - const filesDir: string = this.context.getApplicationContext().filesDir; - - try { - const documentsDir: string = Environment.getUserDocumentDir(); - const markerPath: string = `${filesDir}/.electerm-documents-path`; - const file = fs.openSync(markerPath, - fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE | fs.OpenMode.TRUNC); - fs.writeSync(file.fd, documentsDir); - fs.closeSync(file); - console.info(`[${TAG}] wrote documents path marker: ${markerPath} → ${documentsDir}`); - } catch (e) { - console.warn(`[${TAG}] getUserDocumentDir failed, skipping documents marker: ${JSON.stringify(e)}`); - } - } catch (e) { - console.error(`[${TAG}] Failed to write documents path marker: ${JSON.stringify(e)}`); - } - } } diff --git a/entry/src/main/ets/pages/Index.ets b/entry/src/main/ets/pages/Index.ets index 463d814..d1c16cf 100644 --- a/entry/src/main/ets/pages/Index.ets +++ b/entry/src/main/ets/pages/Index.ets @@ -1,61 +1,569 @@ /** - * Index page — WebWindow host for the Electron 鸿蒙 runtime. + * Index page — ArkWeb host for the electerm web app. * - * Uses the WebWindow component from web_engine, which manages the - * XComponent surface and native context (libadapter.so). The Electron - * runtime (libelectron.so) starts automatically and loads - * resfile/resources/app/main.js. + * Startup sequence: + * 1. resolve the writable el2 data dir — the one the previous build used, + * so an in-place upgrade keeps its bookmarks + * 2. start the Node.js backend — primarily IN-PROCESS in this app + * process (libnode_ctl.so startBackend → dlopen libnode.so → + * node::Start; the electron-harmony pattern), falling back to a + * *native child process* (libnode_launcher.so:Main). The backend + * serves the UI + SSH/SFTP/... API on http://127.0.0.1:5577 + * 3. poll the backend with plain HTTP until it answers + * 4. navigate the Web component from the local loading page to the backend + * + * While the engine is starting (or failed to start) a native overlay is + * shown; the Web component stays loaded with rawfile/loading.html behind it. + * + * All logging goes through hilog (visible in release-build hilog, unlike + * console.info which is filtered out) — `hdc hilog | grep electerm.Index`. */ -import { WebWindow } from 'web_engine'; -import { NativeContext } from 'web_engine/src/main/ets/interface/CommonInterface'; -import JsBindingUtils from 'web_engine/src/main/ets/utils/JsBindingUtils'; -import { ContextType } from 'web_engine/src/main/ets/common/Constants'; -import { uiObserver } from '@kit.ArkUI'; +import { webview } from '@kit.ArkWeb'; +import { common } from '@kit.AbilityKit'; +import { childProcessManager } from '@kit.AbilityKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { hilog } from '@kit.PerformanceAnalysisKit'; +import fs from '@ohos.file.fs'; +import http from '@ohos.net.http'; +import { startBackend, getBackendStatus } from 'libnode_ctl.so'; +import { BackendManager } from '../BackendManager'; -let storage = LocalStorage.getShared(); -@Entry(storage) +const TAG: string = 'electerm.Index'; +const DOMAIN: number = 0xE1EC; +const BACKEND_PORT: number = 5577; +const SERVER_URL: string = `http://127.0.0.1:${BACKEND_PORT}`; +const POLL_INTERVAL_MS: number = 500; +/** + * How long to wait for the backend before giving up on a boot strategy. + * + * This used to be 90s. A dead backend therefore pinned the app on the boot + * overlay for a minute and a half — indistinguishable from "stuck on the + * splash screen", which is exactly the bug report. The in-process path + * reports hard failures (missing script / no libnode.so / dlopen failure) + * within a few hundred ms via getBackendStatus(), so a short timeout loses + * nothing and turns a hang into a readable error. + */ +const BOOT_TIMEOUT_MS: number = 20_000; +/** Never read more than this from node-boot.log — this runs on the UI thread. */ +const MAX_BOOT_LOG_BYTES: number = 256 * 1024; +/** + * The data dir must live under el2 — the app-owned area that an in-place + * upgrade keeps (same bundleName + same signing cert ⇒ same UID ⇒ the previous + * build's el2 tree is still there). + * + * `bundleCodeDir` is the *opposite*: the el1 HAP install dir. It is owned by + * the installer, mounted read-only, signature-verified and re-extracted on + * every install, so mkdir there fails with 13900012 (EPERM) and any data put + * there would be wiped by the next update anyway. + * + * Two el2 candidates are inherited from the previous (Electron) build: + * - app level /data/storage/el2/base/files (AbilityStage marker) + * - HAP level /data/storage/el2/base/haps/entry/files (context.filesDir) + * PickDataDir() prefers whichever already holds data, so an upgrade keeps its + * bookmarks instead of starting from an empty db. + */ +const EL2_APP_FILES: string = '/data/storage/el2/base/files'; +/** Sub-dir used when neither candidate holds data yet (fresh install). */ +const DATA_SUBDIR: string = 'electerm-data'; +/** Marker an older build left behind with the dir it actually used. */ +const DATA_PATH_MARKER: string = '.electerm-data-path'; + +@Entry @Component struct Index { - @LocalStorageLink('xcomponentId') xComponentId: string = ''; - @LocalStorageProp('statusBarHeight') statusBarHeight: number = 0; - @State density: number = 0; - private nativeContext: NativeContext = - JsBindingUtils.getNativeContext(ContextType.kMainProcess); + controller: webview.WebviewController = new webview.WebviewController(); + @State serverReady: boolean = false; + @State bootFailed: boolean = false; + @State statusMessage: string = 'Starting electerm …'; + dataDir: string = ''; + /** Result of the last HTTP probe — shown on the overlay so a screenshot + * reveals whether anything is listening on 127.0.0.1:5577 at all. */ + lastProbeError: string = ''; + /** Last line of node-boot.log, refreshed at 0.5 Hz (file I/O on the UI thread). */ + lastBootLine: string = ''; aboutToAppear(): void { - uiObserver.on('densityUpdate', this.getUIContext(), (info: uiObserver.DensityInfo) => { - this.density = info.density; - }); + // Called directly, NOT via setTimeout: the only thing that used to make + // this slow was the synchronous dlopen of libnode.so, and that now runs + // on a background thread. A timer adds a failure mode we cannot observe + // (if it never fires the overlay sits on its default message forever with + // zero log output) and buys nothing. + this.statusMessage = 'Preparing …'; + this.startBackend(); + } + + /** + * Resolve the backend entry script inside the installed HAP. + * The HAP layout is entry/src/main/resources/resfile/electerm → installed + * at /entry/resources/resfile/electerm (note: "resources", + * plural — matches the packaged HAP contents). bundleCodeDir may or may not + * already include the module segment, so try both shapes and verify. + */ + resolveScriptPath(bundleCodeDir: string): string { + const candidates: string[] = [ + `${bundleCodeDir}/entry/resources/resfile/electerm/index.js`, + `${bundleCodeDir}/resources/resfile/electerm/index.js` + ]; + for (let i = 0; i < candidates.length; i++) { + hilog.info(DOMAIN, TAG, 'script candidate: %{public}s', candidates[i]); + if (this.fileExists(candidates[i])) { + hilog.info(DOMAIN, TAG, 'using script: %{public}s', candidates[i]); + return candidates[i]; + } + hilog.warn(DOMAIN, TAG, 'script candidate missing: %{public}s', candidates[i]); + } + return candidates[0]; // let the launcher report the failure + } + + /** + * Resolve libnode.so in the app's native libs dir and log the dir listing — + * this tells us from the parent side whether the installer actually + * extracted the 92MB node binary. Empty string when not found. + * + * We cover every ABI the HAP is built for: arm64-v8a (phones/tablets/2in1) + * and x86_64 (the emulator). The ABI dir actually installed is the one for + * the device that installed the app; probing both keeps the same code + * working on a phone, a tablet and the x86_64 emulator. + */ + resolveNodePath(bundleCodeDir: string): string { + const libsDirs: string[] = [ + `${bundleCodeDir}/entry/libs/arm64-v8a`, + `${bundleCodeDir}/entry/libs/x86_64`, + `${bundleCodeDir}/libs/arm64-v8a`, + `${bundleCodeDir}/libs/x86_64` + ]; + for (let i = 0; i < libsDirs.length; i++) { + const libsDir: string = libsDirs[i]; + try { + const names: string[] = fs.listFileSync(libsDir); + hilog.info(DOMAIN, TAG, 'libs dir %{public}s → %{public}s', libsDir, names.join(', ')); + const nodePath: string = `${libsDir}/libnode.so`; + if (this.fileExists(nodePath)) { + hilog.info(DOMAIN, TAG, 'using node: %{public}s', nodePath); + return nodePath; + } + } catch (e) { + hilog.error(DOMAIN, TAG, 'libs dir not listable: %{public}s (%{public}s)', + libsDir, JSON.stringify(e)); + } + } + return ''; + } + + /** Create the writable data dir and spawn the node backend. */ + async startBackend(): Promise { + try { + const context = getContext(this) as common.Context; + this.dataDir = this.pickDataDir(context); + const scriptPath: string = this.resolveScriptPath(context.bundleCodeDir); + const nodePath: string = this.resolveNodePath(context.bundleCodeDir); + + // 1. writable data dir (db, ssh keys, logs — the resfile install dir + // the backend itself runs from is read-only) + if (!this.dirExists(this.dataDir)) { + fs.mkdirSync(this.dataDir, true); + } + this.pinDataDir(this.dataDir); + + // 2. start the backend + // entryParams is a plain "key=value\n" string parsed by node_launcher.c + const paramLines: string[] = [ + `dataDir=${this.dataDir}`, + `script=${scriptPath}`, + `port=${BACKEND_PORT}` + ]; + if (nodePath) { + paramLines.push(`node=${nodePath}`); + } + const entryParams: string = paramLines.join('\n'); + + this.statusMessage = 'Preparing data dir …'; + hilog.info(DOMAIN, TAG, 'backend params: %{public}s', entryParams); + hilog.info(DOMAIN, TAG, 'script=%{public}s node=%{public}s', scriptPath, + nodePath !== '' ? nodePath : '(not found)'); + + // Primary path: run node IN the main app process (dlopen libnode.so + + // node::Start via libnode_ctl.so — the electron-harmony / nodejs-mobile + // pattern). The nativespawn child runs under a stricter seccomp filter + // whose event-loop syscall blocks kill libuv there; the main process + // runs libuv-class loops of its own (NETSTACK/curl), so node lives here. + // + // startBackend() RETURNS IMMEDIATELY — the dlopen of the ~120MB + // libnode.so and node::Start happen on a background thread. Doing them + // inline here blocked the UI thread long enough that the Index page + // never painted: the window sat on its splash screen until the + // APP_INPUT_BLOCK watchdog fired and the ANR dialog killed the app. + let launchedInProcess: boolean = false; + try { + const rc: string = startBackend(entryParams); + launchedInProcess = !rc.startsWith('err:'); + hilog.info(DOMAIN, TAG, 'in-process startBackend → %{public}s', rc); + } catch (e) { + hilog.error(DOMAIN, TAG, 'in-process startBackend threw: %{public}s', JSON.stringify(e)); + } + + if (launchedInProcess) { + BackendManager.setInProcess(); + this.statusMessage = 'Loading engine (libnode.so) …'; + hilog.info(DOMAIN, TAG, 'node running in-process (background thread)'); + + // 3. wait for the HTTP server, bailing out early on a hard failure + const ok: boolean = await this.waitForBackend(BOOT_TIMEOUT_MS, true); + if (ok) { + // 4. load the real UI + this.serverReady = true; + this.controller.loadUrl(SERVER_URL); + return; + } + // The in-process node thread is detached — if it is merely slow + // rather than dead, it may still bind the port later; either way the + // next bind attempt just fails and the child takes over. + hilog.error(DOMAIN, TAG, 'in-process backend never answered — trying native child process'); + } + + // Fallback: native child process (libnode_launcher.so:Main). + this.statusMessage = 'In-process engine failed — starting child process …'; + const pid: number = await childProcessManager.startNativeChildProcess( + 'libnode_launcher.so:Main', + { entryParams: entryParams } + ); + BackendManager.setPid(pid); + hilog.info(DOMAIN, TAG, 'node child process started, pid=%{public}d', pid); + + const ok: boolean = await this.waitForBackend(BOOT_TIMEOUT_MS, false); + if (!ok) { + this.bootFailed = true; + const tail: string = this.readBootLogTailLines(14); + this.statusMessage = tail + ? tail + : 'Engine failed to start (no node-boot.log — child never ran?)'; + this.logBootTail(); + return; + } + + this.serverReady = true; + this.controller.loadUrl(SERVER_URL); + } catch (e) { + const err = e as BusinessError; + this.bootFailed = true; + this.statusMessage = `Startup error: [${err.code}] ${err.message}`; + hilog.error(DOMAIN, TAG, 'startBackend failed: %{public}s', JSON.stringify(e)); + } + } + + /** Read the launcher/node boot log (written by the child). + * Size-guarded: this runs on the UI thread on every poll tick, and a + * runaway writer would otherwise turn each read into a UI stall. */ + readBootLog(): string { + if (!this.dataDir) { + return ''; + } + const path: string = `${this.dataDir}/node-boot.log`; + try { + const stat = fs.statSync(path); + if (stat.size > MAX_BOOT_LOG_BYTES) { + return ''; + } + return fs.readTextSync(path); + } catch { + return ''; + } + } + + /** Last non-empty log line — shown in the overlay while booting so the + * failure reason is visible on a plain screen mirror (cloud debug). */ + readBootLogLastLine(): string { + return this.readBootLogTailLines(1); + } + + /** Last `n` non-empty log lines joined by newline — the failure overlay + * shows the whole launcher ladder (stat → dlopen → dlsym → start) so one + * device round-trip is enough to see exactly which step failed. */ + readBootLogTailLines(n: number): string { + const text: string = this.readBootLog(); + if (!text) { + return ''; + } + const lines: string[] = text.split('\n'); + const picked: string[] = []; + for (let i = lines.length - 1; i >= 0 && picked.length < n; i--) { + const line: string = lines[i].trim(); + if (line) { + const clipped: string = line.length > 120 + ? `…${line.substring(line.length - 119)}` + : line; + picked.unshift(clipped); + } + } + return picked.join('\n'); + } + + /** Dump the launcher's boot log tail to hilog — the fastest way to see why + * exec failed when only hilog (no file pull) is available, e.g. cloud debug. */ + logBootTail(): void { + this.logBootTailWindow(2400); } - aboutToDisappear(): void { - uiObserver.off('densityUpdate', this.getUIContext()); + /** Dump the last `n` chars of the boot log to hilog (periodic ring-buffer + * insurance — see waitForBackend). hilog truncates a single message at + * ~140 bytes, so the tail goes out in 110-char chunks, each its own line. */ + logBootTailWindow(n: number): void { + const text: string = this.readBootLog(); + if (!text) { + return; + } + const tail: string = text.length > n + ? text.substring(text.length - n) + : text; + const parts: string[] = []; + for (let i = 0; i < tail.length; i += 110) { + parts.push(tail.substring(i, i + 110)); + } + hilog.error(DOMAIN, TAG, 'node-boot.log tail ▼ %{public}d parts', parts.length.toString()); + for (let i = 0; i < parts.length; i++) { + hilog.error(DOMAIN, TAG, 't%{public}d: %{public}s', i.toString(), parts[i]); + } + } + + fileExists(path: string): boolean { + try { + const stat = fs.statSync(path); + return stat.isFile(); + } catch { + return false; + } + } + + dirExists(path: string): boolean { + try { + const stat = fs.statSync(path); + return stat.isDirectory(); + } catch { + return false; + } + } + + /** el2 candidates, most preferred first. Never anything under el1/bundle. */ + dataDirCandidates(context: common.Context): string[] { + return [ + `${EL2_APP_FILES}/${DATA_SUBDIR}`, + EL2_APP_FILES, + `${context.filesDir}/${DATA_SUBDIR}`, + context.filesDir + ]; + } + + /** True when `dir` already holds an electerm nedb tree + * (/users//electerm.*.nedb) — i.e. an older build used it. */ + hasElectermData(dir: string): boolean { + return this.dirExists(`${dir}/users`); + } + + /** Contents of a marker left by an older build; '' when absent or not el2. */ + readMarker(path: string): string { + try { + const text: string = fs.readTextSync(path).trim(); + return text.startsWith('/data/storage/el2/') ? text : ''; + } catch { + return ''; + } + } + + /** Fail fast on a dir that exists but is not writable (locked el2, etc.). */ + writeProbe(dir: string): void { + const probe: string = `${dir}/.write-test`; + fs.closeSync(fs.openSync(probe, fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE)); + fs.unlinkSync(probe); + } + + /** + * Resolve the writable data dir, el2 only. + * + * An in-place upgrade keeps the app's el2 tree, so the previous build's nedb + * files are still on disk — the only thing that has to match is the + * directory. A dir that already holds `users/` therefore beats everything + * else; the marker is only a hint for layouts we no longer recognise. + */ + pickDataDir(context: common.Context): string { + const candidates: string[] = this.dataDirCandidates(context); + + for (let i = 0; i < candidates.length; i++) { + if (this.hasElectermData(candidates[i])) { + hilog.info(DOMAIN, TAG, 'reusing previous data dir: %{public}s', candidates[i]); + return candidates[i]; + } + } + for (let i = 0; i < candidates.length; i++) { + const marker: string = this.readMarker(`${candidates[i]}/${DATA_PATH_MARKER}`); + if (marker !== '') { + hilog.info(DOMAIN, TAG, 'using marker data dir: %{public}s', marker); + return marker; + } + } + for (let i = 0; i < candidates.length; i++) { + try { + if (!this.dirExists(candidates[i])) { + fs.mkdirSync(candidates[i], true); + } + this.writeProbe(candidates[i]); + hilog.info(DOMAIN, TAG, 'using fresh data dir: %{public}s', candidates[i]); + return candidates[i]; + } catch (e) { + hilog.warn(DOMAIN, TAG, 'data dir unusable: %{public}s (%{public}s)', + candidates[i], JSON.stringify(e)); + } + } + return context.tempDir; + } + + /** Record the dir we settled on so the next version resolves it directly. */ + pinDataDir(dir: string): void { + try { + if (!this.dirExists(EL2_APP_FILES)) { + fs.mkdirSync(EL2_APP_FILES, true); + } + const markerPath: string = `${EL2_APP_FILES}/${DATA_PATH_MARKER}`; + const file = fs.openSync(markerPath, + fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE | fs.OpenMode.TRUNC); + fs.writeSync(file.fd, dir); + fs.closeSync(file); + } catch (e) { + hilog.warn(DOMAIN, TAG, 'could not pin data dir: %{public}s', JSON.stringify(e)); + } + } + + /** Poll http://127.0.0.1:5577 until it answers or `timeoutMs` elapses. + * While waiting, surface the child's latest boot-log line in the overlay so + * a stuck boot is diagnosable from the screen alone. + * + * When `checkStatus` is set (in-process boot only), getBackendStatus() is + * consulted every tick: a hard bootstrap failure is known in milliseconds + * instead of after the whole timeout, so we can fall back to the native + * child process instead of staring at a spinner. */ + async waitForBackend(timeoutMs: number, checkStatus: boolean): Promise { + const startedAt: number = Date.now(); + const deadline: number = startedAt + timeoutMs; + let tick: number = 0; + while (Date.now() < deadline) { + if (await this.probe()) { + return true; + } + let status: string = ''; + if (checkStatus) { + status = getBackendStatus(); + if (status.startsWith('failed:')) { + const reason: string = status.substring('failed:'.length).replace('err:', ''); + hilog.error(DOMAIN, TAG, 'bootstrap failed: %{public}s', reason); + this.bootFailed = true; + this.statusMessage = `Engine failed to start: ${reason}`; + this.logBootTail(); + return false; + } + } + + // Always refresh the overlay, and always log — including when + // node-boot.log is EMPTY. An empty boot log means the native side never + // wrote a line, which is itself the single most useful fact to know, + // and the old code logged nothing at all in that case (logBootTailWindow + // returns early on empty input). hilog is ring-buffered away under + // cloud-debug system noise far faster than it can be exported, so the + // SCREEN is the primary diagnostic channel and must never go static. + const secs: number = Math.round((Date.now() - startedAt) / 1000); + const parts: string[] = [`Starting engine … ${secs}s`, `probe: ${this.lastProbeError}`]; + if (checkStatus) { + parts.push(`backend: ${status !== '' ? status : 'unknown'}`); + } + if (this.lastBootLine) { + parts.push(this.lastBootLine); + } + this.statusMessage = parts.join('\n'); + + if (tick % 4 === 0) { // every ~2s + // Reading the boot log is file I/O on the UI thread — keep it at + // 0.5 Hz and reuse the cached line for the per-tick overlay refresh. + this.lastBootLine = this.readBootLogLastLine(); + hilog.info(DOMAIN, TAG, 'waiting %{public}ds probe=%{public}s status=%{public}s boot=%{public}s', + secs.toString(), this.lastProbeError, status, this.lastBootLine); + this.logBootTailWindow(1100); + } + tick++; + await this.sleep(POLL_INTERVAL_MS); + } + return false; + } + + async probe(): Promise { + const httpClient = http.createHttp(); + try { + const response = await httpClient.request(SERVER_URL, { + method: http.RequestMethod.GET, + connectTimeout: 3000, + readTimeout: 3000, + usingCache: false + }); + this.lastProbeError = `HTTP ${response.responseCode}`; + return response.responseCode >= 200 && response.responseCode < 500; + } catch (e) { + const err = e as BusinessError; + this.lastProbeError = `[${err.code}] ${err.message}`; + return false; + } finally { + httpClient.destroy(); + } + } + + sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(() => resolve(), ms); + }); } onBackPress(): boolean { - // On 2-in-1/PC devices the system maps the ESC key to the back event. - // Only hand off to the last widget when one actually exists — otherwise - // the native adapter falls back to minimizing this window, which is not - // the expected behavior for a plain ESC key press. - if (this.nativeContext.GetLastActiveWidgetId()) { - this.nativeContext.OnBackToLastPage(this.xComponentId); + try { + if (this.controller.accessBackward()) { + this.controller.backward(); + return true; + } + } catch { + // controller not attached yet — fall through } - return true; + return false; } build() { - Row() { - WebWindow() + Stack() { + Web({ src: $rawfile('loading.html'), controller: this.controller }) + .width('100%') + .height('100%') + .javaScriptAccess(true) + .domStorageAccess(true) + + if (!this.serverReady) { + Column({ space: 16 }) { + Text('electerm') + .fontSize(24) + .fontWeight(FontWeight.Bold) + .fontColor('#cfd6e4') + if (!this.bootFailed) { + LoadingProgress() + .width(36) + .height(36) + .color('#4aa3ff') + } + Text(this.statusMessage) + .fontSize(this.bootFailed ? 11 : 13) + .fontColor(this.bootFailed ? '#e5484d' : '#8b93a7') + .textAlign(TextAlign.Center) + .padding({ left: 24, right: 24 }) + } + .width('100%') + .height('100%') + .justifyContent(FlexAlign.Center) + .backgroundColor('#15171a') + } } .width('100%') .height('100%') - .padding({ - top: this.getStatusBarHeight(), - }) - } - - getStatusBarHeight(): number { - return this.getUIContext().px2vp(this.statusBarHeight); } } diff --git a/entry/src/main/ets/pages/NodeHandleWindow.ets b/entry/src/main/ets/pages/NodeHandleWindow.ets deleted file mode 100644 index 3af72f5..0000000 --- a/entry/src/main/ets/pages/NodeHandleWindow.ets +++ /dev/null @@ -1,65 +0,0 @@ -/** - * NodeHandleWindow page — alternative WebWindow host for devices that - * support the NodeHandle rendering path. - * - * Uses the WebNodeHandleWindow component from web_engine, which manages - * the ContentSlot surface and native context (libadapter.so). The Electron - * runtime (libelectron.so) starts automatically and loads - * resfile/resources/app/main.js. - * - * This page is loaded by WebAbility when nativeContext.IsSupportNodeHandleFeature() - * returns true. If the device does not support NodeHandle, pages/Index is used instead. - */ - -import { WebNodeHandleWindow } from 'web_engine'; -import { NativeContext } from 'web_engine/src/main/ets/interface/CommonInterface'; -import JsBindingUtils from 'web_engine/src/main/ets/utils/JsBindingUtils'; -import { ContextType } from 'web_engine/src/main/ets/common/Constants'; -import { uiObserver } from '@kit.ArkUI'; - -let storage = LocalStorage.getShared(); -@Entry(storage) -@Component -struct NodeHandleWindow { - @LocalStorageLink('xcomponentId') xComponentId: string = ''; - @LocalStorageProp('statusBarHeight') statusBarHeight: number = 0; - @State density: number = 0; - private nativeContext: NativeContext = - JsBindingUtils.getNativeContext(ContextType.kMainProcess); - - aboutToAppear(): void { - uiObserver.on('densityUpdate', this.getUIContext(), (info: uiObserver.DensityInfo) => { - this.density = info.density; - }); - } - - aboutToDisappear(): void { - uiObserver.off('densityUpdate', this.getUIContext()); - } - - onBackPress(): boolean { - // On 2-in-1/PC devices the system maps the ESC key to the back event. - // Only hand off to the last widget when one actually exists — otherwise - // the native adapter falls back to minimizing this window, which is not - // the expected behavior for a plain ESC key press. - if (this.nativeContext.GetLastActiveWidgetId()) { - this.nativeContext.OnBackToLastPage(this.xComponentId); - } - return true; - } - - build() { - Row() { - WebNodeHandleWindow() - } - .width('100%') - .height('100%') - .padding({ - top: this.getStatusBarHeight(), - }) - } - - getStatusBarHeight(): number { - return this.getUIContext().px2vp(this.statusBarHeight); - } -} diff --git a/entry/src/main/module.json5 b/entry/src/main/module.json5 index e6ea9b7..d93449c 100644 --- a/entry/src/main/module.json5 +++ b/entry/src/main/module.json5 @@ -6,8 +6,9 @@ "description": "$string:module_desc", "mainElement": "EntryAbility", "deviceTypes": [ - "2in1", - "tablet" + "phone", + "tablet", + "2in1" ], "deliveryWithInstall": true, "installationFree": false, @@ -64,11 +65,6 @@ "name": "ohos.permission.ACCELEROMETER" } ], - "definePermissions": [ - { - "name": "ohos.permission.kernel.ALLOW_WRITABLE_CODE_MEMORY" - } - ], "abilities": [ { "name": "EntryAbility", diff --git a/entry/src/main/resources/base/element/string.json b/entry/src/main/resources/base/element/string.json index 1104194..5eddbb9 100644 --- a/entry/src/main/resources/base/element/string.json +++ b/entry/src/main/resources/base/element/string.json @@ -23,6 +23,10 @@ { "name": "reason", "value": "electerm needs access to the Documents, Download, and Desktop directories to store app data, transfer files, and persist settings reliably across restarts" + }, + { + "name": "access_pasteboard", + "value": "electerm needs pasteboard access to copy and paste commands and output in the terminal" } ] } diff --git a/entry/src/main/resources/base/profile/main_pages.json b/entry/src/main/resources/base/profile/main_pages.json index 7c7c1bd..1898d94 100644 --- a/entry/src/main/resources/base/profile/main_pages.json +++ b/entry/src/main/resources/base/profile/main_pages.json @@ -1,6 +1,5 @@ { "src": [ - "pages/Index", - "pages/NodeHandleWindow" + "pages/Index" ] } diff --git a/entry/src/main/resources/rawfile/loading.html b/entry/src/main/resources/rawfile/loading.html new file mode 100644 index 0000000..59d383a --- /dev/null +++ b/entry/src/main/resources/rawfile/loading.html @@ -0,0 +1,24 @@ + + + + + + electerm + + + + +
+ +
Starting engine…
+
+ + diff --git a/hvigor/hvigor-config.json5 b/hvigor/hvigor-config.json5 index 0464a00..eaf2fcb 100644 --- a/hvigor/hvigor-config.json5 +++ b/hvigor/hvigor-config.json5 @@ -1,13 +1,9 @@ { - "modelVersion": "5.3.15", + "modelVersion": "5.0.0", "dependencies": { - "@ohos/hvigor-ohos-plugin": "5.10.3" + "@ohos/hvigor-ohos-plugin": "file:/Applications/DevEco-Studio.app/Contents/tools/hvigor/hvigor-ohos-plugin" }, "execution": {}, - "logging": { - "level": "info" - }, - "debugging": { - "quiet": false - } + "logging": { "level": "info" }, + "debugging": { "quiet": false } } diff --git a/oh-package-lock.json5 b/oh-package-lock.json5 new file mode 100644 index 0000000..96541d5 --- /dev/null +++ b/oh-package-lock.json5 @@ -0,0 +1,20 @@ +{ + "meta": { + "stableOrder": true, + "enableUnifiedLockfile": false + }, + "lockfileVersion": 3, + "ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.", + "specifiers": { + "@ohos/hypium@1.0.21": "@ohos/hypium@1.0.21" + }, + "packages": { + "@ohos/hypium@1.0.21": { + "name": "@ohos/hypium", + "version": "1.0.21", + "integrity": "sha512-iyKGMXxE+9PpCkqEwu0VykN/7hNpb+QOeIuHwkmZnxOpI+dFZt6yhPB7k89EgV1MiSK/ieV/hMjr5Z2mWwRfMQ==", + "resolved": "https://ohpm.openharmony.cn/ohpm/@ohos/hypium/-/hypium-1.0.21.har", + "registryType": "ohpm" + } + } +} \ No newline at end of file diff --git a/oh-package.json5 b/oh-package.json5 index ce70bbb..1c856ea 100644 --- a/oh-package.json5 +++ b/oh-package.json5 @@ -1,6 +1,6 @@ { "name": "electerm-harmony", - "version": "5.3.15", + "version": "5.3.16", "description": "Free and open-sourced ssh/sftp/telnet/RDP/VNC/Spice/ftp client for HarmonyOS", "main": "", "license": "MIT", diff --git a/package-lock.json b/package-lock.json index 759c774..f24c56c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,60 +1,68 @@ { - "name": "electerm", - "version": "5.3.15", + "name": "electerm-harmony", + "version": "5.3.16", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "electerm", - "version": "5.3.15", + "name": "electerm-harmony", + "version": "5.3.16", "hasInstallScript": true, "license": "MIT", "dependencies": { "@electerm/electerm-locales": "2.3.10", "@electerm/electerm-themes": "^1.0.1", "@electerm/ftp-srv": "1.0.5", - "@electerm/nedb": "2.0.0", + "@electerm/nedb": "^2.0.0", "@electerm/ssh2": "1.22.0", "@xterm/headless": "6.1.0-beta.292", "axios": "1.18.1", "basic-ftp": "6.0.1", - "commander": "12.1.0", + "dayjs": "^1.11.21", "diffie-hellman": "^5.0.3", + "dotenv": "16.3.1", "electerm-sync": "2.0.1", - "electron-log": "4.3.5", - "express": "5.2.1", + "esbuild": "^0.28.1", + "express": "4.22.2", + "express-jwt": "^8.5.1", "express-ws": "5.0.2", "fast-deep-equal": "3.1.3", "find-free-port": "2.0.0", "font-list": "1.5.1", + "gist-wrapper": "1.0.0", + "gitee-client": "1.0.0", + "glob": "^13.0.6", "https-proxy-agent": "7.0.1", - "iconv-lite": "^0.7.2", + "iconv-lite": "0.7.2", "json-deep-copy": "1.3.1", "jsonwebtoken": "^9.0.1", - "nanoid": "3.3.8", + "lodash": "4.18.1", + "morgan": "^1.10.1", + "multer": "^2.2.0", + "nanoid": "^5.1.11", "node-bash": "5.0.1", - "node-forge": "1.4.0", - "os-locale-s": "1.1.3", + "node-pty": "1.2.0-beta.15", + "os-locale-s": "^1.1.3", + "pug": "^3.0.4", + "serialport": "13.0.0", "socks": "2.8.9", "socks-proxy-agent": "8.0.1", "socksv5-server": "^1.0.2", + "sql.js": "^1.12.0", "ssh-config-loader": "1.1.2", "ssh2-scp": "3.2.1", - "tar": "7.5.21", + "tar": "^7.5.21", "trzsz2": "1.2.0", "zmodem2": "1.4.0" }, - "bin": { - "electerm": "npm/electerm" - }, "devDependencies": { - "@ant-design/icons": "6.2.5", + "@ant-design/icons": "^6.2.5", "@electerm/electerm-react": "^5.3.15", "@electerm/electerm-resource": "2.2.1", - "@fontsource/maple-mono": "^5.2.5", - "@novnc/novnc": "1.7.0", - "@types/node": "22.12.0", - "@vitejs/plugin-react": "^5.2.0", + "@fontsource/maple-mono": "^5.2.6", + "@novnc/novnc": "^1.7.0", + "@types/node": "22.9.3", + "@vitejs/plugin-react": "5.2.0", "@xterm/addon-attach": "0.13.0-beta.292", "@xterm/addon-fit": "0.12.0-beta.292", "@xterm/addon-image": "0.10.0-beta.292", @@ -64,34 +72,30 @@ "@xterm/addon-web-links": "0.13.0-beta.292", "@xterm/addon-webgl": "0.20.0-beta.291", "@xterm/xterm": "6.1.0-beta.292", - "antd": "6.5.1", + "antd": "^6.5.1", "classnames": "2.5.1", "cross-env": "7.0.3", - "dotenv": "16.4.5", "electerm-icons": "1.0.1", - "electron": "^39.2.7", + "escape-string-regexp": "^5.0.0", + "express-http-proxy": "^2.1.2", "filesize": "10.1.6", "filesize-parser": "1.5.1", - "glob": "^13.0.6", - "ironrdp-wasm": "1.1.0", - "lodash-es": "^4.17.21", - "manate": "2.0.3", - "morgan": "1.11.0", - "multer": "^2.2.0", - "pug": "3.0.4", - "react": "19.2.7", + "ironrdp-wasm": "^1.1.0", + "lodash-es": "^4.18.1", + "manate": "^2.0.3", + "react": "^19.2.6", "react-diff-viewer-continued": "^4.4.0", - "react-dom": "19.2.7", + "react-dom": "^19.2.6", "react-markdown": "9.0.1", "replace-in-file": "6.3.5", "shelljs": "0.8.5", - "spice-client": "1.2.0", + "spice-client": "^1.2.0", "standard": "^17.1.2", "stylus": "^0.64.0", - "vite": "8.1.0" + "vite": "^8.0.15" }, "engines": { - "node": ">=16.0.0" + "node": ">=24.0.0" } }, "node_modules/@adobe/css-tools": { @@ -158,14 +162,14 @@ } }, "node_modules/@ant-design/icons": { - "version": "6.2.5", - "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-6.2.5.tgz", - "integrity": "sha512-0hKtoKqTjGFOndUyJLJmC9Cg6k4rEO7rLo6xmgbNJH+/ZX1C57RVals2v1j1knHl9n7Q+sBOveTvn931wLOCKw==", + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-6.3.2.tgz", + "integrity": "sha512-B6O5a5XJ4wjtNOfZejXYwHW5zvKV5gYkjGf11dHGLEbKn0ABDGndo41+gfIiXyTFhvESj4XTotuud33mUFid0g==", "dev": true, "license": "MIT", "dependencies": { "@ant-design/colors": "^8.0.1", - "@ant-design/icons-svg": "^4.4.2", + "@ant-design/icons-svg": "^4.5.0", "@rc-component/util": "^1.11.0", "clsx": "^2.1.1" }, @@ -347,7 +351,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -357,7 +360,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -391,7 +393,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.7" @@ -483,7 +484,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.29.7", @@ -540,15 +540,6 @@ "node": ">=16" } }, - "node_modules/@electerm/ftp-srv/node_modules/commander": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", - "license": "MIT", - "engines": { - "node": ">=20" - } - }, "node_modules/@electerm/nedb": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@electerm/nedb/-/nedb-2.0.0.tgz", @@ -573,28 +564,6 @@ "node": ">=10.16.0" } }, - "node_modules/@electron/get": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", - "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.1.1", - "env-paths": "^2.2.0", - "fs-extra": "^8.1.0", - "got": "^11.8.5", - "progress": "^2.0.3", - "semver": "^6.2.0", - "sumchecker": "^3.0.1" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "global-agent": "^3.0.0" - } - }, "node_modules/@emnapi/core": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", @@ -663,6 +632,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@emotion/babel-plugin/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@emotion/babel-plugin/node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -796,34 +778,450 @@ "dev": true, "license": "MIT" }, - "node_modules/@emotion/use-insertion-effect-with-fallbacks": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", - "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", - "dev": true, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], "license": "MIT", - "peerDependencies": { - "react": ">=16.8.0" + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@emotion/utils": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", - "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", - "dev": true, - "license": "MIT" + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@emotion/weak-memoize": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", - "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", - "dev": true, - "license": "MIT" + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", - "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -915,9 +1313,9 @@ } }, "node_modules/@fontsource/maple-mono": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@fontsource/maple-mono/-/maple-mono-5.3.0.tgz", - "integrity": "sha512-8N3FVDWphO/971tZIig9D558j9jf09fWYIXFjgv7DKJEvDjtgfATx1ScWHdTCsf+tufFS5M1bAa4AumVBKPhSQ==", + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/@fontsource/maple-mono/-/maple-mono-5.2.6.tgz", + "integrity": "sha512-+VAD7z8nyTkaiz2/Ww639Z5Kp/YJIL4dNMQxydqSMafNb5nKxFeoRq6W3g9WJ04IcBjdBmn0dKFNk90lyz7K+w==", "dev": true, "license": "OFL-1.1", "funding": { @@ -1951,9 +2349,9 @@ } }, "node_modules/@rc-component/trigger": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-3.10.1.tgz", - "integrity": "sha512-mXlDN0IXdtV8Yqqm8195ECCyrbmfvvfKvwVvSlH0+qvKD6BUF8gRhEjSy0FOcD1+CcDRHgTiX99LoxfQrmh3Cw==", + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-3.10.0.tgz", + "integrity": "sha512-uC3QSG7Ax3qLOE5Q2jLqJCJc4iBtJEHzNTPhqGvlRvRcU8x8CT5moIavRVe24YSQKCp2/D1GSq7y76SCSheuVA==", "dev": true, "license": "MIT", "dependencies": { @@ -2299,30 +2697,252 @@ "dev": true, "license": "MIT" }, - "node_modules/@sindresorhus/is": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", - "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", - "dev": true, + "node_modules/@serialport/binding-mock": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@serialport/binding-mock/-/binding-mock-10.2.2.tgz", + "integrity": "sha512-HAFzGhk9OuFMpuor7aT5G1ChPgn5qSsklTFOTUX72Rl6p0xwcSVsRtG/xaGp6bxpN7fI9D/S8THLBWbBgS6ldw==", "license": "MIT", + "dependencies": { + "@serialport/bindings-interface": "^1.2.1", + "debug": "^4.3.3" + }, "engines": { - "node": ">=10" + "node": ">=12.0.0" + } + }, + "node_modules/@serialport/bindings-cpp": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/bindings-cpp/-/bindings-cpp-13.0.0.tgz", + "integrity": "sha512-r25o4Bk/vaO1LyUfY/ulR6hCg/aWiN6Wo2ljVlb4Pj5bqWGcSRC4Vse4a9AcapuAu/FeBzHCbKMvRQeCuKjzIQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@serialport/bindings-interface": "1.2.2", + "@serialport/parser-readline": "12.0.0", + "debug": "4.4.0", + "node-addon-api": "8.3.0", + "node-gyp-build": "4.8.4" + }, + "engines": { + "node": ">=18.0.0" }, "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" + "url": "https://opencollective.com/serialport/donate" } }, - "node_modules/@szmarczak/http-timer": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", - "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", - "dev": true, + "node_modules/@serialport/bindings-cpp/node_modules/@serialport/parser-delimiter": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-delimiter/-/parser-delimiter-12.0.0.tgz", + "integrity": "sha512-gu26tVt5lQoybhorLTPsH2j2LnX3AOP2x/34+DUSTNaUTzu2fBXw+isVjQJpUBFWu6aeQRZw5bJol5X9Gxjblw==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/bindings-cpp/node_modules/@serialport/parser-readline": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-readline/-/parser-readline-12.0.0.tgz", + "integrity": "sha512-O7cywCWC8PiOMvo/gglEBfAkLjp/SENEML46BXDykfKP5mTPM46XMaX1L0waWU6DXJpBgjaL7+yX6VriVPbN4w==", "license": "MIT", "dependencies": { - "defer-to-connect": "^2.0.0" + "@serialport/parser-delimiter": "12.0.0" }, "engines": { - "node": ">=10" + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/bindings-cpp/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@serialport/bindings-cpp/node_modules/node-addon-api": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.3.0.tgz", + "integrity": "sha512-8VOpLHFrOQlAH+qA0ZzuGRlALRA6/LVh8QJldbrC4DY0hXoMP0l4Acq8TzFC018HztWiRqyCEj2aTWY2UvnJUg==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/@serialport/bindings-interface": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@serialport/bindings-interface/-/bindings-interface-1.2.2.tgz", + "integrity": "sha512-CJaUd5bLvtM9c5dmO9rPBHPXTa9R2UwpkJ0wdh9JCYcbrPWsKz+ErvR0hBLeo7NPeiFdjFO4sonRljiw4d2XiA==", + "license": "MIT", + "engines": { + "node": "^12.22 || ^14.13 || >=16" + } + }, + "node_modules/@serialport/parser-byte-length": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-byte-length/-/parser-byte-length-13.0.0.tgz", + "integrity": "sha512-32yvqeTAqJzAEtX5zCrN1Mej56GJ5h/cVFsCDPbF9S1ZSC9FWjOqNAgtByseHfFTSTs/4ZBQZZcZBpolt8sUng==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-cctalk": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-cctalk/-/parser-cctalk-13.0.0.tgz", + "integrity": "sha512-RErAe57g9gvnlieVYGIn1xymb1bzNXb2QtUQd14FpmbQQYlcrmuRnJwKa1BgTCujoCkhtaTtgHlbBWOxm8U2uA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-delimiter": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-delimiter/-/parser-delimiter-13.0.0.tgz", + "integrity": "sha512-Qqyb0FX1avs3XabQqNaZSivyVbl/yl0jywImp7ePvfZKLwx7jBZjvL+Hawt9wIG6tfq6zbFM24vzCCK7REMUig==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-inter-byte-timeout": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-inter-byte-timeout/-/parser-inter-byte-timeout-13.0.0.tgz", + "integrity": "sha512-a0w0WecTW7bD2YHWrpTz1uyiWA2fDNym0kjmPeNSwZ2XCP+JbirZt31l43m2ey6qXItTYVuQBthm75sPVeHnGA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-packet-length": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-packet-length/-/parser-packet-length-13.0.0.tgz", + "integrity": "sha512-60ZDDIqYRi0Xs2SPZUo4Jr5LLIjtb+rvzPKMJCohrO6tAqSDponcNpcB1O4W21mKTxYjqInSz+eMrtk0LLfZIg==", + "license": "MIT", + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/@serialport/parser-readline": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-readline/-/parser-readline-13.0.0.tgz", + "integrity": "sha512-dov3zYoyf0dt1Sudd1q42VVYQ4WlliF0MYvAMA3MOyiU1IeG4hl0J6buBA2w4gl3DOCC05tGgLDN/3yIL81gsA==", + "license": "MIT", + "dependencies": { + "@serialport/parser-delimiter": "13.0.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-ready": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-ready/-/parser-ready-13.0.0.tgz", + "integrity": "sha512-JNUQA+y2Rfs4bU+cGYNqOPnNMAcayhhW+XJZihSLQXOHcZsFnOa2F9YtMg9VXRWIcnHldHYtisp62Etjlw24bw==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-regex": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-regex/-/parser-regex-13.0.0.tgz", + "integrity": "sha512-m7HpIf56G5XcuDdA3DB34Z0pJiwxNRakThEHjSa4mG05OnWYv0IG8l2oUyYfuGMowQWaVnQ+8r+brlPxGVH+eA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-slip-encoder": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-slip-encoder/-/parser-slip-encoder-13.0.0.tgz", + "integrity": "sha512-fUHZEExm6izJ7rg0A1yjXwu4sOzeBkPAjDZPfb+XQoqgtKAk+s+HfICiYn7N2QU9gyaeCO8VKgWwi+b/DowYOg==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-spacepacket": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-spacepacket/-/parser-spacepacket-13.0.0.tgz", + "integrity": "sha512-DoXJ3mFYmyD8X/8931agJvrBPxqTaYDsPoly9/cwQSeh/q4EjQND9ySXBxpWz5WcpyCU4jOuusqCSAPsbB30Eg==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/stream": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/stream/-/stream-13.0.0.tgz", + "integrity": "sha512-F7xLJKsjGo2WuEWMSEO1SimRcOA+WtWICsY13r0ahx8s2SecPQH06338g28OT7cW7uRXI7oEQAk62qh5gHJW3g==", + "license": "MIT", + "dependencies": { + "@serialport/bindings-interface": "1.2.2", + "debug": "4.4.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/stream/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, "node_modules/@tybys/wasm-util": { @@ -2381,19 +3001,6 @@ "@babel/types": "^7.28.2" } }, - "node_modules/@types/cacheable-request": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", - "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/http-cache-semantics": "*", - "@types/keyv": "^3.1.4", - "@types/node": "*", - "@types/responselike": "^1.0.0" - } - }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -2431,13 +3038,6 @@ "@types/unist": "*" } }, - "node_modules/@types/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", @@ -2445,13 +3045,13 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/keyv": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", - "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", - "dev": true, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", "license": "MIT", "dependencies": { + "@types/ms": "*", "@types/node": "*" } }, @@ -2469,17 +3069,15 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", - "dev": true, "license": "MIT" }, "node_modules/@types/node": { - "version": "22.12.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.12.0.tgz", - "integrity": "sha512-Fll2FZ1riMjNmlmJOdAyY5pUbkftXslB5DgEzlIuNaiWhXd00FhWxVC/r4yV/4wBb9JfImTu+jiSvXTkJ7F/gA==", - "dev": true, + "version": "22.9.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.3.tgz", + "integrity": "sha512-F3u1fs/fce3FFk+DAxbxc78DF8x0cY09RRL8GnXLmkJ1jvx3TtPdWoTT5/NiYfI5ASqXBmfqJi9dZ3gxMx4lzw==", "license": "MIT", "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~6.19.8" } }, "node_modules/@types/parse-json": { @@ -2496,16 +3094,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/responselike": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", - "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -2513,17 +3101,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/yauzl": { - "version": "2.10.3", - "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", - "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@ungap/structured-clone": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", @@ -2675,13 +3252,13 @@ "license": "MIT" }, "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", "license": "MIT", "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" + "mime-types": "~2.1.34", + "negotiator": "0.6.3" }, "engines": { "node": ">= 0.6" @@ -2701,7 +3278,6 @@ "version": "7.4.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", - "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -2836,31 +3412,10 @@ "react-dom": ">=18.0.0" } }, - "node_modules/antd/node_modules/@ant-design/icons": { - "version": "6.3.2", - "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-6.3.2.tgz", - "integrity": "sha512-B6O5a5XJ4wjtNOfZejXYwHW5zvKV5gYkjGf11dHGLEbKn0ABDGndo41+gfIiXyTFhvESj4XTotuud33mUFid0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ant-design/colors": "^8.0.1", - "@ant-design/icons-svg": "^4.5.0", - "@rc-component/util": "^1.11.0", - "clsx": "^2.1.1" - }, - "engines": { - "node": ">=8" - }, - "peerDependencies": { - "react": ">=16.0.0", - "react-dom": ">=16.0.0" - } - }, "node_modules/append-field": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", - "dev": true, "license": "MIT" }, "node_modules/argparse": { @@ -2887,6 +3442,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, "node_modules/array-includes": { "version": "3.1.9", "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", @@ -3034,7 +3595,6 @@ "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", - "dev": true, "license": "MIT" }, "node_modules/asn1": { @@ -3050,7 +3610,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/assert-never/-/assert-never-1.4.0.tgz", "integrity": "sha512-5oJg84os6NMQNl27T9LnZkvvqzvAnHu03ShCnoj6bsJwS7L8AO4lf+C/XjK/nvzEqQB744moC6V128RucQd1jA==", - "dev": true, "license": "MIT" }, "node_modules/async-function": { @@ -3142,7 +3701,6 @@ "version": "3.0.0-canary-5", "resolved": "https://registry.npmjs.org/babel-walk/-/babel-walk-3.0.0-canary-5.tgz", "integrity": "sha512-GAwkz0AihzY5bkwIY5QDR+LvsRQgB/B+1foMPvi0FZPMl5fjD7ICiznUiBdLYMH1QYe6vqu4gWYytZOccLouFw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.9.6" @@ -3166,16 +3724,15 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, "license": "MIT", "engines": { "node": "18 || 20 || >=22" } }, "node_modules/baseline-browser-mapping": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.1.tgz", - "integrity": "sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==", + "version": "2.10.43", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", + "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3189,7 +3746,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", - "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "5.1.2" @@ -3202,7 +3758,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, "license": "MIT" }, "node_modules/basic-ftp": { @@ -3230,56 +3785,60 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", - "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", "dependencies": { - "bytes": "^3.1.2", - "content-type": "^2.0.0", - "debug": "^4.4.3", - "http-errors": "^2.0.1", - "iconv-lite": "^0.7.2", - "on-finished": "^2.4.1", - "qs": "^6.15.2", - "raw-body": "^3.0.2", - "type-is": "^2.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "ms": "2.0.0" } }, - "node_modules/body-parser/node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", "license": "MIT", - "engines": { - "node": ">=18" + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "engines": { + "node": ">=0.10.0" } }, - "node_modules/boolean": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", - "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "dev": true, - "license": "MIT", - "optional": true + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" }, "node_modules/brace-expansion": { "version": "5.0.7", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -3295,9 +3854,9 @@ "license": "MIT" }, "node_modules/browserslist": { - "version": "4.28.7", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", - "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "version": "4.28.6", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", + "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", "dev": true, "funding": [ { @@ -3315,9 +3874,9 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.44", - "caniuse-lite": "^1.0.30001806", - "electron-to-chromium": "^1.5.393", + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001803", + "electron-to-chromium": "^1.5.389", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, @@ -3328,16 +3887,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -3348,7 +3897,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, "license": "MIT" }, "node_modules/builtins": { @@ -3378,7 +3926,6 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "dev": true, "dependencies": { "streamsearch": "^1.1.0" }, @@ -3395,35 +3942,6 @@ "node": ">= 0.8" } }, - "node_modules/cacheable-lookup": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", - "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.6.0" - } - }, - "node_modules/cacheable-request": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", - "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", - "dev": true, - "license": "MIT", - "dependencies": { - "clone-response": "^1.0.2", - "get-stream": "^5.1.0", - "http-cache-semantics": "^4.0.0", - "keyv": "^4.0.0", - "lowercase-keys": "^2.0.0", - "normalize-url": "^6.0.1", - "responselike": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -3568,7 +4086,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/character-parser/-/character-parser-2.2.0.tgz", "integrity": "sha512-+UqJQjFEFaTAs3bNsF2j2kEN1baG/zghZbdqoYEDxGZtJo9LBzl1A+m0D4n3qKx8N2FNv8/Xp6yV9mQmBuptaw==", - "dev": true, "license": "MIT", "dependencies": { "is-regex": "^1.0.3" @@ -3600,6 +4117,24 @@ "trim-buffer": "^5.0.0" } }, + "node_modules/child-shell/node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -3631,19 +4166,6 @@ "node": ">=12" } }, - "node_modules/clone-response": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", - "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-response": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -3698,12 +4220,12 @@ } }, "node_modules/commander": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/compute-scroll-into-view": { @@ -3724,7 +4246,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", - "dev": true, "engines": [ "node >= 6.0" ], @@ -3740,7 +4261,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/constantinople/-/constantinople-4.0.1.tgz", "integrity": "sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.6.0", @@ -3748,16 +4268,15 @@ } }, "node_modules/content-disposition": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", - "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", "license": "MIT", - "engines": { - "node": ">=18" + "dependencies": { + "safe-buffer": "5.2.1" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "engines": { + "node": ">= 0.6" } }, "node_modules/content-type": { @@ -3786,13 +4305,10 @@ } }, "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" }, "node_modules/cosmiconfig": { "version": "7.1.0", @@ -3929,7 +4445,6 @@ "version": "1.11.21", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", - "dev": true, "license": "MIT" }, "node_modules/debug": { @@ -3963,35 +4478,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/decompress-response/node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3999,16 +4485,6 @@ "dev": true, "license": "MIT" }, - "node_modules/defer-to-connect": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", - "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -4073,6 +4549,16 @@ "node": ">=6" } }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -4083,14 +4569,6 @@ "node": ">=8" } }, - "node_modules/detect-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", - "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/devlop": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", @@ -4143,20 +4621,18 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/doctypes/-/doctypes-1.1.0.tgz", "integrity": "sha512-LLBi6pEqS6Do3EKQ3J0NqHWV5hhb78Pi8vvESYwyOy2c31ZEZVdtitdzsQsKb7878PEERhzUk0ftqGhG6Mz+pQ==", - "dev": true, "license": "MIT" }, "node_modules/dotenv": { - "version": "16.4.5", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", - "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", - "dev": true, + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz", + "integrity": "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==", "license": "BSD-2-Clause", "engines": { "node": ">=12" }, "funding": { - "url": "https://dotenvx.com" + "url": "https://github.com/motdotla/dotenv?sponsor=1" } }, "node_modules/dunder-proto": { @@ -4211,35 +4687,10 @@ "jsonwebtoken": "^9.0.3" } }, - "node_modules/electron": { - "version": "39.2.7", - "resolved": "https://registry.npmjs.org/electron/-/electron-39.2.7.tgz", - "integrity": "sha512-KU0uFS6LSTh4aOIC3miolcbizOFP7N1M46VTYVfqIgFiuA2ilfNaOHLDS9tCMvwwHRowAsvqBrh9NgMXcTOHCQ==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "@electron/get": "^2.0.0", - "@types/node": "^22.7.7", - "extract-zip": "^2.0.1" - }, - "bin": { - "electron": "cli.js" - }, - "engines": { - "node": ">= 12.20.55" - } - }, - "node_modules/electron-log": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/electron-log/-/electron-log-4.3.5.tgz", - "integrity": "sha512-J5Ew3axdk7W4jzzxKLSAi1sqbcAoo9CzHuBVsG0tT47j256xKulNrWFf3lZmHJ1KDXOQUcuwOngQF0jjmpEdpw==", - "license": "MIT" - }, "node_modules/electron-to-chromium": { - "version": "1.5.395", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.395.tgz", - "integrity": "sha512-7zt9Aw+SrmxLWLN0zhaTWZQiCdryLVrYTq5R7iZakLvi2UQPYMMsROYV/2qVCzMeCiSXHwKOU+sZ4zOVVlrtKA==", + "version": "1.5.392", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.392.tgz", + "integrity": "sha512-1yQq3VQCZRwsnYc67Oc+1fge6Lwtn0hzi6zmEVkB61Zx21kTbwJAW4dFLadl5Rc1tKhG/kSpYXnfiAhu0f0a1g==", "dev": true, "license": "ISC" }, @@ -4259,26 +4710,6 @@ "node": ">= 0.8" } }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -4484,13 +4915,53 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/es6-error": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", - "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "node_modules/es6-promise": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==", "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "hasInstallScript": true, "license": "MIT", - "optional": true + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } }, "node_modules/escalade": { "version": "3.2.0", @@ -4509,13 +4980,13 @@ "license": "MIT" }, "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", "dev": true, "license": "MIT", "engines": { - "node": ">=10" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -5105,6 +5576,19 @@ "concat-map": "0.0.1" } }, + "node_modules/eslint/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/eslint/node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -5222,48 +5706,96 @@ "license": "MIT" }, "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" }, "engines": { - "node": ">= 18" + "node": ">= 0.10.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/express" } }, + "node_modules/express-http-proxy": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/express-http-proxy/-/express-http-proxy-2.1.2.tgz", + "integrity": "sha512-FXcAcs7Nf/hF73Mzh0WDWPwaOlsEUL/fCHW3L4wU6DH79dypsaxmbnAildCLniFs7HQuuvoiR6bjNVUvGuTb5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.0.1", + "es6-promise": "^4.1.1", + "raw-body": "^2.3.0" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/express-http-proxy/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/express-jwt": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/express-jwt/-/express-jwt-8.5.1.tgz", + "integrity": "sha512-Dv6QjDLpR2jmdb8M6XQXiCcpEom7mK8TOqnr0/TngDKsG2DHVkO8+XnVxkJVN7BuS1I3OrGw6N8j5DaaGgkDRQ==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "^9", + "express-unless": "^2.1.3", + "jsonwebtoken": "^9.0.0" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/express-unless": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/express-unless/-/express-unless-2.1.3.tgz", + "integrity": "sha512-wj4tLMyCVYuIIKHGt0FhCtIViBcwzWejX0EjNxveAa6dG+0XBCQhMbx+PnkLkFCxLC69qoFrxds4pIyL88inaQ==", + "license": "MIT" + }, "node_modules/express-ws": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/express-ws/-/express-ws-5.0.2.tgz", @@ -5279,6 +5811,21 @@ "express": "^4.0.0 || ^5.0.0-alpha.1" } }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -5286,27 +5833,6 @@ "dev": true, "license": "MIT" }, - "node_modules/extract-zip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", - "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - }, - "engines": { - "node": ">= 10.17.0" - }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5332,19 +5858,9 @@ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "pend": "~1.2.0" + "reusify": "^1.0.4" } }, "node_modules/fdir": { @@ -5396,26 +5912,38 @@ "license": "MIT" }, "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", "license": "MIT", "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" }, "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" } }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/find-free-port": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/find-free-port/-/find-free-port-2.0.0.tgz", @@ -5462,9 +5990,9 @@ } }, "node_modules/flatted": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.3.tgz", - "integrity": "sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -5543,27 +6071,6 @@ "node": ">= 6" } }, - "node_modules/form-data/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/form-data/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -5574,27 +6081,12 @@ } }, "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", "license": "MIT", "engines": { - "node": ">= 0.8" - } - }, - "node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" + "node": ">= 0.6" } }, "node_modules/fs.realpath": { @@ -5742,22 +6234,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/get-symbol-description": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", @@ -5776,11 +6252,28 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gist-wrapper": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/gist-wrapper/-/gist-wrapper-1.0.0.tgz", + "integrity": "sha512-vKkE6mkO8TeeS6rcnHeniPIflBCqKqgM2cPqu/lQxJS3pXdmpGyc7KibehpU1M4O+92y3k84UnfwOoQOQaRPCQ==", + "license": "MIT", + "peerDependencies": { + "axios": "*" + } + }, + "node_modules/gitee-client": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/gitee-client/-/gitee-client-1.0.0.tgz", + "integrity": "sha512-r2mmnUnMCOGp5e38fD2c07995gHbhUNKjv32SzwNppFqLCP7cGHkNqKaVMiLuMr9uzytowXPEFgDyQHMtIgygA==", + "license": "MIT", + "peerDependencies": { + "axios": "*" + } + }, "node_modules/glob": { "version": "13.0.6", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "minimatch": "^10.2.2", @@ -5807,39 +6300,6 @@ "node": ">=10.13.0" } }, - "node_modules/global-agent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", - "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "dependencies": { - "boolean": "^3.0.1", - "es6-error": "^4.1.1", - "matcher": "^3.0.0", - "roarr": "^2.15.3", - "semver": "^7.3.2", - "serialize-error": "^7.0.1" - }, - "engines": { - "node": ">=10.0" - } - }, - "node_modules/global-agent/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "optional": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/globals": { "version": "13.24.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", @@ -5885,32 +6345,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/got": { - "version": "11.8.6", - "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", - "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sindresorhus/is": "^4.0.0", - "@szmarczak/http-timer": "^4.0.5", - "@types/cacheable-request": "^6.0.1", - "@types/responselike": "^1.0.0", - "cacheable-lookup": "^5.0.3", - "cacheable-request": "^7.0.2", - "decompress-response": "^6.0.0", - "http2-wrapper": "^1.0.0-beta.5.2", - "lowercase-keys": "^2.0.0", - "p-cancelable": "^2.0.0", - "responselike": "^2.0.0" - }, - "engines": { - "node": ">=10.19.0" - }, - "funding": { - "url": "https://github.com/sindresorhus/got?sponsor=1" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -6118,13 +6552,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", - "dev": true, - "license": "BSD-2-Clause" - }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -6145,20 +6572,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/http2-wrapper": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", - "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "quick-lru": "^5.1.1", - "resolve-alpn": "^1.0.0" - }, - "engines": { - "node": ">=10.19.0" - } - }, "node_modules/https-proxy-agent": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.1.tgz", @@ -6173,9 +6586,9 @@ } }, "node_modules/iconv-lite": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", - "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -6421,7 +6834,6 @@ "version": "2.16.2", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", - "dev": true, "license": "MIT", "dependencies": { "hasown": "^2.0.3" @@ -6499,7 +6911,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-expression/-/is-expression-4.0.0.tgz", "integrity": "sha512-zMIXX63sxzG3XrkHkrAPvm/OVZVSCPNkwMHU8oTX7/U3AL78I0QXCEICXUM13BIa8TYGZ68PiTKfQz3yaTNr4A==", - "dev": true, "license": "MIT", "dependencies": { "acorn": "^7.1.1", @@ -6663,14 +7074,12 @@ "version": "2.2.2", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", - "dev": true, "license": "MIT" }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -6863,7 +7272,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/js-stringify/-/js-stringify-1.0.2.tgz", "integrity": "sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g==", - "dev": true, "license": "MIT" }, "node_modules/js-tokens": { @@ -6950,14 +7358,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "dev": true, - "license": "ISC", - "optional": true - }, "node_modules/json2mq": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz", @@ -6981,16 +7381,6 @@ "node": ">=6" } }, - "node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, "node_modules/jsonwebtoken": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", @@ -7029,7 +7419,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/jstransformer/-/jstransformer-1.0.0.tgz", "integrity": "sha512-C9YK3Rf8q6VAPDCCU9fnqo3mAfOH6vUGnMcP4AQAYIEpWtfGLpwOTmZ+igtdK5y+VvI2n3CyYSzy4Qh34eq24A==", - "dev": true, "license": "MIT", "dependencies": { "is-promise": "^2.0.0", @@ -7107,9 +7496,9 @@ } }, "node_modules/lightningcss": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", - "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -7123,23 +7512,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.33.0", - "lightningcss-darwin-arm64": "1.33.0", - "lightningcss-darwin-x64": "1.33.0", - "lightningcss-freebsd-x64": "1.33.0", - "lightningcss-linux-arm-gnueabihf": "1.33.0", - "lightningcss-linux-arm64-gnu": "1.33.0", - "lightningcss-linux-arm64-musl": "1.33.0", - "lightningcss-linux-x64-gnu": "1.33.0", - "lightningcss-linux-x64-musl": "1.33.0", - "lightningcss-win32-arm64-msvc": "1.33.0", - "lightningcss-win32-x64-msvc": "1.33.0" + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", - "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", "cpu": [ "arm64" ], @@ -7158,9 +7547,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", - "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", "cpu": [ "arm64" ], @@ -7179,9 +7568,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", - "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", "cpu": [ "x64" ], @@ -7200,9 +7589,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", - "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", "cpu": [ "x64" ], @@ -7221,9 +7610,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", - "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", "cpu": [ "arm" ], @@ -7242,9 +7631,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", - "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", "cpu": [ "arm64" ], @@ -7263,9 +7652,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", - "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", "cpu": [ "arm64" ], @@ -7284,9 +7673,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", - "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", "cpu": [ "x64" ], @@ -7305,9 +7694,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", - "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", "cpu": [ "x64" ], @@ -7326,9 +7715,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", - "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", "cpu": [ "arm64" ], @@ -7347,9 +7736,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", - "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", "cpu": [ "x64" ], @@ -7417,6 +7806,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, "node_modules/lodash-es": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", @@ -7497,16 +7892,6 @@ "loose-envify": "cli.js" } }, - "node_modules/lowercase-keys": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", - "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -7524,20 +7909,6 @@ "dev": true, "license": "MIT" }, - "node_modules/matcher": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", - "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "escape-string-regexp": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -7709,12 +8080,12 @@ } }, "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", "license": "MIT", "engines": { - "node": ">= 0.8" + "node": ">= 0.6" } }, "node_modules/memoize-one": { @@ -7725,17 +8096,23 @@ "license": "MIT" }, "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", "license": "MIT", - "engines": { - "node": ">=18" - }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/micromark": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", @@ -8212,46 +8589,43 @@ "miller-rabin": "bin/miller-rabin" } }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "license": "MIT", "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" + "mime-db": "1.52.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/mimic-response": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", - "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", - "dev": true, - "license": "MIT", "engines": { - "node": ">=4" + "node": ">= 0.6" } }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "brace-expansion": "^5.0.5" @@ -8310,7 +8684,6 @@ "version": "1.11.0", "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.11.0.tgz", "integrity": "sha512-zSkVu3t18r39pw4ixfBKvfZi3y2UOqr7d4WYwcj3m8nXpEQK4rPO6GLzs/CExoRgmX3y9EjmmcXqv6jq0SK46g==", - "dev": true, "license": "MIT", "dependencies": { "basic-auth": "~2.0.1", @@ -8331,7 +8704,6 @@ "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "2.0.0" @@ -8341,7 +8713,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, "license": "MIT" }, "node_modules/ms": { @@ -8354,7 +8725,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", - "dev": true, "license": "MIT", "dependencies": { "append-field": "^1.0.0", @@ -8367,60 +8737,13 @@ }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/multer/node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/multer/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/multer/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/multer/node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" + "url": "https://opencollective.com/express" } }, "node_modules/nanoid": { - "version": "3.3.8", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", - "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", + "version": "5.1.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", "funding": [ { "type": "github", @@ -8429,10 +8752,10 @@ ], "license": "MIT", "bin": { - "nanoid": "bin/nanoid.cjs" + "nanoid": "bin/nanoid.js" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": "^18 || >=20" } }, "node_modules/natural-compare": { @@ -8443,14 +8766,20 @@ "license": "MIT" }, "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", "license": "MIT", "engines": { "node": ">= 0.6" } }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, "node_modules/node-bash": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/node-bash/-/node-bash-5.0.1.tgz", @@ -8479,13 +8808,25 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/node-forge": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", - "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", - "license": "(BSD-3-Clause OR GPL-2.0)", - "engines": { - "node": ">= 6.13.0" + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/node-pty": { + "version": "1.2.0-beta.15", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.2.0-beta.15.tgz", + "integrity": "sha512-vORSzHXi4Ofl7HemVWpuudLqCPdaQb4LfpRCUpE5HPxhp4JYscl8zZwxh11p26v2wvW24WMwnMfLjhRLixrfxA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.1.0" } }, "node_modules/node-releases": { @@ -8498,24 +8839,10 @@ "node": ">=18" } }, - "node_modules/normalize-url": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", - "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8649,7 +8976,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -8659,6 +8985,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -8703,14 +9030,13 @@ } }, "node_modules/own-keys": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz", - "integrity": "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.4", - "get-intrinsic": "^1.3.0", + "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" }, @@ -8721,16 +9047,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/p-cancelable": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", - "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/p-finally": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", @@ -8939,14 +9255,12 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, "license": "MIT" }, "node_modules/path-scurry": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^11.0.0", @@ -8963,21 +9277,16 @@ "version": "11.5.2", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } }, "node_modules/path-to-regexp": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", - "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" }, "node_modules/path-type": { "version": "4.0.0", @@ -8989,13 +9298,6 @@ "node": ">=8" } }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true, - "license": "MIT" - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -9117,9 +9419,9 @@ } }, "node_modules/postcss": { - "version": "8.5.22", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", - "integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==", + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", "dev": true, "funding": [ { @@ -9137,7 +9439,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.16", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -9174,21 +9476,10 @@ "node": ">= 0.8.0" } }, - "node_modules/progress": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/promise": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", - "dev": true, "license": "MIT", "dependencies": { "asap": "~2.0.3" @@ -9250,7 +9541,6 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/pug/-/pug-3.0.4.tgz", "integrity": "sha512-kFfq5mMzrS7+wrl5pLJzZEzemx34OQ0w4SARfhy/3yxTlhbstsudDwJzhf1hP02yHzbjoVMSXUj/Sz6RNfMyXg==", - "dev": true, "license": "MIT", "dependencies": { "pug-code-gen": "^3.0.4", @@ -9267,7 +9557,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/pug-attrs/-/pug-attrs-3.0.0.tgz", "integrity": "sha512-azINV9dUtzPMFQktvTXciNAfAuVh/L/JCl0vtPCwvOA21uZrC08K/UnmrL+SXGEVc1FwzjW62+xw5S/uaLj6cA==", - "dev": true, "license": "MIT", "dependencies": { "constantinople": "^4.0.1", @@ -9279,7 +9568,6 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/pug-code-gen/-/pug-code-gen-3.0.4.tgz", "integrity": "sha512-6okWYIKdasTyXICyEtvobmTZAVX57JkzgzIi4iRJlin8kmhG+Xry2dsus+Mun/nGCn6F2U49haHI5mkELXB14g==", - "dev": true, "license": "MIT", "dependencies": { "constantinople": "^4.0.1", @@ -9296,14 +9584,12 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/pug-error/-/pug-error-2.1.0.tgz", "integrity": "sha512-lv7sU9e5Jk8IeUheHata6/UThZ7RK2jnaaNztxfPYUY+VxZyk/ePVaNZ/vwmH8WqGvDz3LrNYt/+gA55NDg6Pg==", - "dev": true, "license": "MIT" }, "node_modules/pug-filters": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/pug-filters/-/pug-filters-4.0.0.tgz", "integrity": "sha512-yeNFtq5Yxmfz0f9z2rMXGw/8/4i1cCFecw/Q7+D0V2DdtII5UvqE12VaZ2AY7ri6o5RNXiweGH79OCq+2RQU4A==", - "dev": true, "license": "MIT", "dependencies": { "constantinople": "^4.0.1", @@ -9317,7 +9603,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/pug-lexer/-/pug-lexer-5.0.1.tgz", "integrity": "sha512-0I6C62+keXlZPZkOJeVam9aBLVP2EnbeDw3An+k0/QlqdwH6rv8284nko14Na7c0TtqtogfWXcRoFE4O4Ff20w==", - "dev": true, "license": "MIT", "dependencies": { "character-parser": "^2.2.0", @@ -9329,7 +9614,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/pug-linker/-/pug-linker-4.0.0.tgz", "integrity": "sha512-gjD1yzp0yxbQqnzBAdlhbgoJL5qIFJw78juN1NpTLt/mfPJ5VgC4BvkoD3G23qKzJtIIXBbcCt6FioLSFLOHdw==", - "dev": true, "license": "MIT", "dependencies": { "pug-error": "^2.0.0", @@ -9340,7 +9624,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/pug-load/-/pug-load-3.0.0.tgz", "integrity": "sha512-OCjTEnhLWZBvS4zni/WUMjH2YSUosnsmjGBB1An7CsKQarYSWQ0GCVyd4eQPMFJqZ8w9xgs01QdiZXKVjk92EQ==", - "dev": true, "license": "MIT", "dependencies": { "object-assign": "^4.1.1", @@ -9351,7 +9634,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/pug-parser/-/pug-parser-6.0.0.tgz", "integrity": "sha512-ukiYM/9cH6Cml+AOl5kETtM9NR3WulyVP2y4HOU45DyMim1IeP/OOiyEWRr6qk5I5klpsBnbuHpwKmTx6WURnw==", - "dev": true, "license": "MIT", "dependencies": { "pug-error": "^2.0.0", @@ -9362,14 +9644,12 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/pug-runtime/-/pug-runtime-3.0.1.tgz", "integrity": "sha512-L50zbvrQ35TkpHwv0G6aLSuueDRwc/97XdY8kL3tOT0FmhgG7UypU3VztfV/LATAvmUfYi4wNxSajhSAeNN+Kg==", - "dev": true, "license": "MIT" }, "node_modules/pug-strip-comments": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/pug-strip-comments/-/pug-strip-comments-2.0.0.tgz", "integrity": "sha512-zo8DsDpH7eTkPHCXFeAk1xZXJbyoTfdPlNR0bK7rpOMuhBYb0f5qUVCO1xlsitYd3w5FQTK7zpNVKb3rZoUrrQ==", - "dev": true, "license": "MIT", "dependencies": { "pug-error": "^2.0.0" @@ -9379,20 +9659,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/pug-walk/-/pug-walk-2.0.0.tgz", "integrity": "sha512-yYELe9Q5q9IQhuvqsZNwA5hfPkMJ8u92bQLIMcsMxf/VADjNtEYptU+inlufAFYcWdHlwNfZOEnOOQrZrcyJCQ==", - "dev": true, "license": "MIT" }, - "node_modules/pump": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -9440,19 +9708,6 @@ ], "license": "MIT" }, - "node_modules/quick-lru": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", - "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -9463,31 +9718,39 @@ } }, "node_modules/range-parser": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", - "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", "license": "MIT", "engines": { "node": ">= 0.6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" } }, "node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", + "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" }, "engines": { - "node": ">= 0.10" + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" } }, "node_modules/react": { @@ -9537,9 +9800,9 @@ } }, "node_modules/react-is": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", - "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", + "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", "dev": true, "license": "MIT" }, @@ -9584,7 +9847,6 @@ "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, "license": "MIT", "dependencies": { "inherits": "^2.0.3", @@ -9801,7 +10063,6 @@ "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -9819,13 +10080,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/resolve-alpn": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", - "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", - "dev": true, - "license": "MIT" - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -9836,19 +10090,6 @@ "node": ">=4" } }, - "node_modules/responselike": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", - "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", - "dev": true, - "license": "MIT", - "dependencies": { - "lowercase-keys": "^2.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -9930,25 +10171,6 @@ "node": "*" } }, - "node_modules/roarr": { - "version": "2.15.4", - "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", - "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "dependencies": { - "boolean": "^3.0.1", - "detect-node": "^2.0.4", - "globalthis": "^1.0.1", - "json-stringify-safe": "^5.0.1", - "semver-compare": "^1.0.0", - "sprintf-js": "^1.1.2" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/rolldown": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", @@ -9990,28 +10212,6 @@ "dev": true, "license": "MIT" }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/router/node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -10154,88 +10354,103 @@ "semver": "bin/semver.js" } }, - "node_modules/semver-compare": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", - "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", "license": "MIT", "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" }, "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">= 0.8.0" } }, - "node_modules/serialize-error": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", - "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", - "dev": true, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", - "optional": true, "dependencies": { - "type-fest": "^0.13.1" + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/serialport": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/serialport/-/serialport-13.0.0.tgz", + "integrity": "sha512-PHpnTd8isMGPfFTZNCzOZp9m4mAJSNWle9Jxu6BPTcWq7YXl5qN7tp8Sgn0h+WIGcD6JFz5QDgixC2s4VW7vzg==", + "license": "MIT", + "dependencies": { + "@serialport/binding-mock": "10.2.2", + "@serialport/bindings-cpp": "13.0.0", + "@serialport/parser-byte-length": "13.0.0", + "@serialport/parser-cctalk": "13.0.0", + "@serialport/parser-delimiter": "13.0.0", + "@serialport/parser-inter-byte-timeout": "13.0.0", + "@serialport/parser-packet-length": "13.0.0", + "@serialport/parser-readline": "13.0.0", + "@serialport/parser-ready": "13.0.0", + "@serialport/parser-regex": "13.0.0", + "@serialport/parser-slip-encoder": "13.0.0", + "@serialport/parser-spacepacket": "13.0.0", + "@serialport/stream": "13.0.0", + "debug": "4.4.0" }, "engines": { - "node": ">=10" + "node": ">=20.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/serialport/donate" } }, - "node_modules/serialize-error/node_modules/type-fest": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", - "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "optional": true, + "node_modules/serialport/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, "engines": { - "node": ">=10" + "node": ">=6.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, "node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", "license": "MIT", "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" }, "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">= 0.8.0" } }, "node_modules/set-function-length": { @@ -10580,13 +10795,11 @@ "dev": true, "license": "LGPL-3.0-or-later" }, - "node_modules/sprintf-js": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true + "node_modules/sql.js": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz", + "integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==", + "license": "MIT" }, "node_modules/ssh-config-loader": { "version": "1.1.2", @@ -10698,7 +10911,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", - "dev": true, "engines": { "node": ">=10.0.0" } @@ -10707,7 +10919,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.2.0" @@ -11044,19 +11255,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/sumchecker": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", - "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "debug": "^4.1.0" - }, - "engines": { - "node": ">= 8.0" - } - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -11074,7 +11272,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -11084,9 +11281,9 @@ } }, "node_modules/tar": { - "version": "7.5.21", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.21.tgz", - "integrity": "sha512-XdhtCvlMywwxpCW8YEq3lOXBJpUPTR2OHHcwLPO3HwsJqOHa2Ok/oJ7ruGzp+JrKoRPVCzJwAdEjqLW/vNRPHA==", + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", @@ -11155,7 +11352,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/token-stream/-/token-stream-1.0.0.tgz", "integrity": "sha512-VSsyNPPW74RpHwR8Fc21uubwHY7wMDeJLys2IX5zJNih+OnAnaifKHo+1LHT7DAdloQ7apeaaWg8l7qnf/TnEg==", - "dev": true, "license": "MIT" }, "node_modules/trim-buffer": { @@ -11275,34 +11471,16 @@ } }, "node_modules/type-is": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", - "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", "license": "MIT", "dependencies": { - "content-type": "^2.0.0", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 18" + "media-typer": "0.3.0", + "mime-types": "~2.1.24" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/type-is/node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", - "license": "MIT", "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">= 0.6" } }, "node_modules/typed-array-buffer": { @@ -11387,7 +11565,6 @@ "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", - "dev": true, "license": "MIT" }, "node_modules/unbox-primitive": { @@ -11410,10 +11587,9 @@ } }, "node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", - "dev": true, + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", "license": "MIT" }, "node_modules/unified": { @@ -11509,16 +11685,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -11573,9 +11739,17 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -11626,16 +11800,16 @@ } }, "node_modules/vite": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", - "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==", + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "~1.1.2", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", "tinyglobby": "^0.2.17" }, "bin": { @@ -11707,7 +11881,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -11822,7 +11995,6 @@ "version": "7.0.2", "resolved": "https://registry.npmjs.org/with/-/with-7.0.2.tgz", "integrity": "sha512-RNGKj82nUPg3g5ygxkQl0R937xLyho1J24ItRCBTr/m1YnZkzJy1hUiHUJrc/VlsDQzsCnInEGSg3bci0Lmd4w==", - "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.9.6", @@ -11885,12 +12057,13 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, "license": "ISC" }, "node_modules/ws": { - "version": "7.5.13", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", - "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", + "version": "7.5.12", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.12.tgz", + "integrity": "sha512-1xGnbYN3zbog9CwuNDQULNRrTCLIn46/WmpR1f0w6PsCYQHkylZr5vkd6kfMZYV6pRnQkcPNRyiA8LsrNKyhpg==", "license": "MIT", "engines": { "node": ">=8.3.0" @@ -11974,17 +12147,6 @@ "node": ">=12" } }, - "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index d89653a..5788ae6 100644 --- a/package.json +++ b/package.json @@ -1,36 +1,35 @@ { - "name": "electerm", - "version": "5.3.15", - "description": "electerm — a free and open-source ssh/sftp/telnet/RDP/VNC/Spice/ftp client for HarmonyOS, built on the electerm codebase.", - "main": "app.js", - "bin": "npm/electerm", + "name": "electerm-harmony", + "version": "5.3.16", + "description": "Free and open-sourced ssh/sftp/telnet/RDP/VNC/Spice/ftp client for HarmonyOS (ArkWeb + on-device Node.js)", + "main": "src/app/app.js", + "type": "module", "scripts": { - "app": "node build/bin/app", - "build": "npm run vite-build", - "start": "node build/bin/start.js", + "dev": "NODE_ENV=development node ./src/app/app.js", + "prod": "NODE_ENV=production node ./src/app/app.js", + "build": "npm run compile", + "start": "NODE_ENV=development node ./build/vite/dev-server.js", "clean": "node build/bin/clean", "compile": "node build/bin/build", - "vite-build": "node build/bin/vite-build.js", + "logo": "python3 build/bin/gen-logo.py", + "vite-build": "cross-env NODE_ENV=production vite build --config ./build/vite/conf.js", "install": "node build/bin/install", - "prepare-file": "node build/bin/prepare", - "build:harmony": "node build/harmony/build.js", "lint": "./node_modules/.bin/standard --verbose", "fix": "./node_modules/.bin/standard --fix", "lock": "npm i --package-lock-only", - "r": "./build/bin/release", - "b": "npm run clean && npm run compile && npm run prepare-file" + "build:web": "node build/web/build.mjs" }, "license": "MIT", "languageRepo": "https://github.com/electerm/electerm-locales", "privacyNoticeLink": "https://github.com/electerm/electerm/wiki/privacy-notice", "knownIssuesLink": "https://github.com/electerm/electerm/wiki/Know-issues", - "sponsorLink": "https://electerm.org/sponsor-electerm", + "sponsorLink": "https://electerm.org/sponsor-electerm.html", "repository": { "type": "git", "url": "git+https://github.com/electerm/electerm-harmony.git" }, "author": { - "name": "赵旭东", + "name": "ZHAO Xudong", "email": "zxdong@gmail.com", "url": "https://github.com/zxdong262" }, @@ -40,17 +39,17 @@ "homepage": "https://electerm.org", "releases": "https://github.com/electerm/electerm-harmony/releases", "engines": { - "node": ">=16.0.0" + "node": ">=24.0.0" }, "preferGlobal": true, "devDependencies": { - "@ant-design/icons": "6.2.5", + "@ant-design/icons": "^6.2.5", "@electerm/electerm-react": "^5.3.15", "@electerm/electerm-resource": "2.2.1", - "@fontsource/maple-mono": "^5.2.5", - "@novnc/novnc": "1.7.0", - "@types/node": "22.12.0", - "@vitejs/plugin-react": "^5.2.0", + "@fontsource/maple-mono": "^5.2.6", + "@novnc/novnc": "^1.7.0", + "@types/node": "22.9.3", + "@vitejs/plugin-react": "5.2.0", "@xterm/addon-attach": "0.13.0-beta.292", "@xterm/addon-fit": "0.12.0-beta.292", "@xterm/addon-image": "0.10.0-beta.292", @@ -60,64 +59,71 @@ "@xterm/addon-web-links": "0.13.0-beta.292", "@xterm/addon-webgl": "0.20.0-beta.291", "@xterm/xterm": "6.1.0-beta.292", - "antd": "6.5.1", + "antd": "^6.5.1", "classnames": "2.5.1", "cross-env": "7.0.3", - "dotenv": "16.4.5", "electerm-icons": "1.0.1", - "electron": "^39.2.7", + "escape-string-regexp": "^5.0.0", + "express-http-proxy": "^2.1.2", "filesize": "10.1.6", "filesize-parser": "1.5.1", - "glob": "^13.0.6", - "ironrdp-wasm": "1.1.0", - "lodash-es": "^4.17.21", - "manate": "2.0.3", - "morgan": "1.11.0", - "multer": "^2.2.0", - "pug": "3.0.4", - "react": "19.2.7", + "ironrdp-wasm": "^1.1.0", + "lodash-es": "^4.18.1", + "manate": "^2.0.3", + "react": "^19.2.6", "react-diff-viewer-continued": "^4.4.0", - "react-dom": "19.2.7", + "react-dom": "^19.2.6", "react-markdown": "9.0.1", "replace-in-file": "6.3.5", "shelljs": "0.8.5", - "spice-client": "1.2.0", + "spice-client": "^1.2.0", "standard": "^17.1.2", "stylus": "^0.64.0", - "vite": "8.1.0" + "vite": "^8.0.15" }, "dependencies": { "@electerm/electerm-locales": "2.3.10", "@electerm/electerm-themes": "^1.0.1", "@electerm/ftp-srv": "1.0.5", - "@electerm/nedb": "2.0.0", + "@electerm/nedb": "^2.0.0", "@electerm/ssh2": "1.22.0", "@xterm/headless": "6.1.0-beta.292", "axios": "1.18.1", "basic-ftp": "6.0.1", - "commander": "12.1.0", + "dayjs": "^1.11.21", "diffie-hellman": "^5.0.3", + "dotenv": "16.3.1", "electerm-sync": "2.0.1", - "electron-log": "4.3.5", - "express": "5.2.1", + "esbuild": "^0.28.1", + "express": "4.22.2", + "express-jwt": "^8.5.1", "express-ws": "5.0.2", "fast-deep-equal": "3.1.3", "find-free-port": "2.0.0", "font-list": "1.5.1", + "gist-wrapper": "1.0.0", + "gitee-client": "1.0.0", + "glob": "^13.0.6", "https-proxy-agent": "7.0.1", - "iconv-lite": "^0.7.2", + "iconv-lite": "0.7.2", "json-deep-copy": "1.3.1", "jsonwebtoken": "^9.0.1", - "nanoid": "3.3.8", + "lodash": "4.18.1", + "morgan": "^1.10.1", + "multer": "^2.2.0", + "nanoid": "^5.1.11", "node-bash": "5.0.1", - "node-forge": "1.4.0", - "os-locale-s": "1.1.3", + "node-pty": "1.2.0-beta.15", + "os-locale-s": "^1.1.3", + "pug": "^3.0.4", + "serialport": "13.0.0", "socks": "2.8.9", "socks-proxy-agent": "8.0.1", "socksv5-server": "^1.0.2", + "sql.js": "^1.12.0", "ssh-config-loader": "1.1.2", "ssh2-scp": "3.2.1", - "tar": "7.5.21", + "tar": "^7.5.21", "trzsz2": "1.2.0", "zmodem2": "1.4.0" }, @@ -127,12 +133,6 @@ "LICENSE" ], "standard": { - "sourceType": "module", - "ignore": [ - "work", - "temp", - "dist" - ], "globals": [ "log", "MouseEvent", @@ -140,8 +140,12 @@ "FileReader", "CustomEvent", "onmessage", - "requestAnimationFrame", "self" - ] + ], + "ignore": [ + "/public/", + "src/client/entry-web/rle.js" + ], + "sourceType": "module" } } diff --git a/scripts/build-app.sh b/scripts/build-app.sh index 49408b2..2daa893 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -89,36 +89,41 @@ echo " ✓ versionCode: ${VERSION_CODE}" echo "==> Verifying build prerequisites ..." -LIBS_DIR="${PROJECT_ROOT}/entry/libs/arm64-v8a" +# Map APP_ARCH -> the entry/libs/ subdirectory holding the native libs. +# (The whole point of the arm64 work: CI builds arm64 against arm64-v8a, and +# the x86_64 emulator build uses x86_64. Neither can consume the other.) +case "${APP_ARCH}" in + arm64) LIBS_ABI="arm64-v8a" ;; + x86_64) LIBS_ABI="x86_64" ;; + *) echo " ✗ Unsupported APP_ARCH: ${APP_ARCH} (use arm64 or x86_64)"; exit 1 ;; +esac +LIBS_DIR="${PROJECT_ROOT}/entry/libs/${LIBS_ABI}" WEB_ENGINE_DIR="${PROJECT_ROOT}/web_engine" RESFILE_DIR="${WEB_ENGINE_DIR}/src/main/resources/resfile" APP_DIR="${RESFILE_DIR}/resources/app" -# Check .so libraries -for lib in libelectron.so libadapter.so libffmpeg.so; do +# Check .so libraries (skip if not present, e.g. x86_64 may not have libelectron.so) +for lib in libnode.so; do if [ ! -f "${LIBS_DIR}/${lib}" ]; then - echo " ✗ Missing: ${LIBS_DIR}/${lib}" - echo " Run ./scripts/prepare-electron-runtime.sh first." + echo " Missing: ${LIBS_DIR}/${lib}" exit 1 fi - echo " ✓ Found: ${lib}" + echo " Found: ${lib}" done -# Check app code (electerm uses app.js as Electron main process entry, not main.js) +# Check app code (skip for x86_64 debug build) if [ ! -f "${APP_DIR}/app.js" ]; then - echo " ✗ Missing: ${APP_DIR}/app.js" - echo " Run ./scripts/prepare-electron-runtime.sh then ./scripts/prepare-web.sh first." - exit 1 + echo " Warning: Missing: ${APP_DIR}/app.js (skipping for x86_64 debug)" +else + echo " Found: app.js" fi -echo " ✓ Found: app.js" -# Check web_engine module +# Check web_engine module (skip for x86_64 debug build) if [ ! -f "${WEB_ENGINE_DIR}/Index.ets" ]; then - echo " ✗ Missing: ${WEB_ENGINE_DIR}/Index.ets" - echo " Run ./scripts/prepare-electron-runtime.sh first." - exit 1 + echo " Warning: Missing: ${WEB_ENGINE_DIR}/Index.ets (skipping for x86_64 debug)" +else + echo " Found: web_engine/Index.ets" fi -echo " ✓ Found: web_engine/Index.ets" # --- Fix permissions for SDK compatibility ------------------------------------ @@ -611,10 +616,12 @@ for f in "${KEYSTORE_PATH}" "${CERT_PATH}" "${PROFILE_PATH}"; do echo " ✓ Found: $(basename "${f}")" done -if [ -z "${KEYSTORE_PASSWORD:-}" ] || [ -z "${KEY_PASSWORD:-}" ]; then - echo " ✗ KEYSTORE_PASSWORD and KEY_PASSWORD environment variables are required." - exit 1 -fi +# Signing passwords must come from the environment (GitHub Actions secrets +# in CI, e.g. OHOS_KEYSTORE_PASSWORD / OHOS_KEY_PASSWORD). Never hardcode +# a keystore password in the script. +: "${KEYSTORE_PASSWORD:?KEYSTORE_PASSWORD is required (set from CI secrets)}" +: "${KEY_PASSWORD:?KEY_PASSWORD is required (set from CI secrets)}" + # --- Locate build tools ----------------------------------------------------- @@ -624,7 +631,9 @@ if [ -z "${COMMANDLINE_TOOLS:-}" ]; then for candidate in \ "/opt/commandline-tools-linux-x64" \ "${HOME}/commandline-tools-linux-x64" \ - "${PROJECT_ROOT}/.cache/commandline-tools"; do + "${PROJECT_ROOT}/.cache/commandline-tools" \ + "/mnt/d/apps/DevEco Studio/tools" \ + "/mnt/c/Program Files/DevEco Studio/tools"; do if [ -d "${candidate}" ]; then COMMANDLINE_TOOLS="${candidate}" break @@ -657,15 +666,61 @@ if [ ! -f "${COMMANDLINE_TOOLS}/package.json" ]; then echo " ✓ Added CommonJS package.json to Command Line Tools root" fi -OHPM="${COMMANDLINE_TOOLS}/bin/ohpm" -HVIGORW="${COMMANDLINE_TOOLS}/bin/hvigorw" +OHPM="${COMMANDLINE_TOOLS}/ohpm/bin/ohpm" +HVIGORW="${COMMANDLINE_TOOLS}/hvigor/bin/hvigorw" if [ -z "${OHOS_SDK_HOME:-}" ]; then - OHOS_SDK_HOME="${COMMANDLINE_TOOLS}/sdk" + # DevEco Studio layout: sdk is sibling of tools, not child + if [ -d "${COMMANDLINE_TOOLS}/sdk" ]; then + OHOS_SDK_HOME="${COMMANDLINE_TOOLS}/sdk" + elif [ -d "$(dirname "${COMMANDLINE_TOOLS}")/sdk" ]; then + OHOS_SDK_HOME="$(dirname "${COMMANDLINE_TOOLS}")/sdk" + fi fi export OHOS_SDK_HOME -export PATH="${PATH}:${COMMANDLINE_TOOLS}/bin:${COMMANDLINE_TOOLS}/hvigor/bin" + +# DEVECO_SDK_HOME: hvigor (6.x) requires this to locate the SDK and refuses to +# run otherwise ("00303217 Configuration Error: Invalid value of +# 'DEVECO_SDK_HOME'"). DevEco Studio sets it itself when it launches hvigor; +# when we invoke hvigorw directly we must provide it. hvigor runs under the +# Windows node, so it must be a Windows-style path (D:/...), not /mnt/d/.... +DEVECO_SDK_HOME="$(echo "${OHOS_SDK_HOME}" | sed 's|^/mnt/\([a-z]\)/|\U\1:/|')" +export DEVECO_SDK_HOME +echo " DEVECO_SDK_HOME: ${DEVECO_SDK_HOME}" + +# WSL-only: WSL does not pass bash exports to the Windows executables it +# spawns (hvigorw runs under DevEco's Windows node.exe). Without this bridge, +# hvigor sees DEVECO_SDK_HOME as empty (SDK config error) and cannot spawn +# java during PackageHap (spawn java ENOENT). Flags: +# /w = share WSL -> Windows as-is (values already in D:/ form) +# /l = PATH: convert each /mnt/d/... entry to D:\... for the Windows child +if [ -n "${WSL_DISTRO_NAME:-}" ] || grep -qi microsoft /proc/version 2>/dev/null; then + export WSLENV="${WSLENV:+${WSLENV}:}DEVECO_SDK_HOME/w:JAVA_HOME/w:PATH/l" + echo " WSL detected, bridging env to Windows tools (WSLENV=${WSLENV})" +fi + +export PATH="${PATH}:${COMMANDLINE_TOOLS}/ohpm/bin:${COMMANDLINE_TOOLS}/hvigor/bin" + +# Set JAVA_HOME for DevEco Studio's bundled JBR (required by hvigor and hap-sign-tool) +JAVA_HOME_BASH="${COMMANDLINE_TOOLS}/jbr" +if [ ! -d "${JAVA_HOME_BASH}" ]; then + JAVA_HOME_BASH="$(dirname "${COMMANDLINE_TOOLS}")/jbr" +fi +if [ -d "${JAVA_HOME_BASH}" ]; then + # Set Windows-style JAVA_HOME env var for node/hvigor (which is Windows node) + JAVA_HOME_WIN=$(echo "${JAVA_HOME_BASH}" | sed 's|^/mnt/\([a-z]\)/|\U\1:/|') + export JAVA_HOME="${JAVA_HOME_WIN}" + # Add bash-style path to PATH so bash can find java + export PATH="${JAVA_HOME_BASH}/bin:${PATH}" +fi + +# Set NODE_HOME for DevEco Studio's bundled Node.js (required by ohpm and hvigorw) +NODE_HOME="${COMMANDLINE_TOOLS}/node" +if [ -d "${NODE_HOME}" ]; then + export NODE_HOME + export PATH="${NODE_HOME}:${PATH}" +fi # Locate hap-sign-tool.jar SIGN_TOOL_JAR="${OHOS_SDK_HOME}/default/openharmony/toolchains/lib/hap-sign-tool.jar" @@ -741,10 +796,6 @@ cat > "${BUILD_PROFILE}" < "${HVIGOR_CONFIG}" < "${HVIGOR_CONFIG}" < Installing ohpm dependencies ..." cd "${PROJECT_ROOT}" -"${OHPM}" install +# On Windows/Git Bash, the ohpm shell wrapper mangles backslash paths. +# Call pm-cli.js directly with node, using Windows-style paths. +OHPM_JS="${COMMANDLINE_TOOLS}/ohpm/bin/pm-cli.js" +if [ -f "${OHPM_JS}" ]; then + # Convert /mnt/d/... to D:/... for Windows node + OHPM_JS_WIN=$(echo "${OHPM_JS}" | sed 's|^/mnt/\([a-z]\)/|\U\1:/|') + node "${OHPM_JS_WIN}" install +else + "${OHPM}" install +fi # --- Build the unsigned APP ------------------------------------------------- echo "==> Building unsigned APP (${BUILD_MODE}) ..." +# On Windows/Git Bash, the hvigorw shell wrapper mangles backslash paths. +# Call hvigorw.js directly with node, using Windows-style paths. +HVIGORW_JS="${COMMANDLINE_TOOLS}/hvigor/bin/hvigorw.js" +if [ -f "${HVIGORW_JS}" ]; then + HVIGORW_JS_WIN=$(echo "${HVIGORW_JS}" | sed 's|^/mnt/\([a-z]\)/|\U\1:/|') + HVIGORW_CMD=(node "${HVIGORW_JS_WIN}") +else + HVIGORW_CMD=("${HVIGORW}") +fi + if [ "${BUILD_MODE}" = "debug" ]; then - "${HVIGORW}" assembleApp -p product=default \ + "${HVIGORW_CMD[@]}" assembleApp -p product=default \ -p buildMode=debug -p enableSignTask=false --no-daemon else - "${HVIGORW}" assembleApp -p product=default \ + "${HVIGORW_CMD[@]}" assembleApp -p product=default \ -p buildMode=release -p enableSignTask=false --no-daemon fi @@ -860,22 +932,41 @@ echo " ✓ Unsigned APP: ${UNSIGNED_APP} ($(du -h "${UNSIGNED_APP}" | cut -f1 echo "==> Signing APP with hap-sign-tool.jar ..." -JAVA_VERSION=$(java -version 2>&1 | head -1) +# Convert all paths to Windows format for java (Windows node compatibility) +to_win_path() { + echo "$1" | sed -e 's|^/mnt/\([a-z]\)/|\U\1:/|' +} + +# Use java.exe on Windows (bash doesn't auto-append .exe) +JAVA_CMD="java" +if command -v java.exe >/dev/null 2>&1; then + JAVA_CMD="java.exe" +fi + +JAVA_VERSION=$(${JAVA_CMD} -version 2>&1 | head -1) echo " Java: ${JAVA_VERSION}" SIGNED_APP="${UNSIGNED_APP%.app}-signed.app" -java -jar "${SIGN_TOOL_JAR}" sign-app \ +# Convert all file paths to Windows format +SIGN_TOOL_JAR_WIN=$(to_win_path "${SIGN_TOOL_JAR}") +CERT_PATH_WIN=$(to_win_path "${CERT_PATH}") +PROFILE_PATH_WIN=$(to_win_path "${PROFILE_PATH}") +KEYSTORE_PATH_WIN=$(to_win_path "${KEYSTORE_PATH}") +UNSIGNED_APP_WIN=$(to_win_path "${UNSIGNED_APP}") +SIGNED_APP_WIN=$(to_win_path "${SIGNED_APP}") + +${JAVA_CMD} -jar "${SIGN_TOOL_JAR_WIN}" sign-app \ -mode localSign \ -keyAlias "${KEY_ALIAS}" \ -keyPwd "${KEY_PASSWORD}" \ - -appCertFile "${CERT_PATH}" \ - -profileFile "${PROFILE_PATH}" \ - -inFile "${UNSIGNED_APP}" \ + -appCertFile "${CERT_PATH_WIN}" \ + -profileFile "${PROFILE_PATH_WIN}" \ + -inFile "${UNSIGNED_APP_WIN}" \ -signAlg SHA256withECDSA \ - -keystoreFile "${KEYSTORE_PATH}" \ + -keystoreFile "${KEYSTORE_PATH_WIN}" \ -keystorePwd "${KEYSTORE_PASSWORD}" \ - -outFile "${SIGNED_APP}" + -outFile "${SIGNED_APP_WIN}" if [ ! -f "${SIGNED_APP}" ]; then echo " ✗ Signing failed — no signed APP produced" @@ -892,11 +983,28 @@ echo " ✓ Signed APP: ${APP_FILE} ($(du -h "${APP_FILE}" | cut -f1))" echo "==> Verifying HAP contents ..." -VERIFY_TMPDIR=$(mktemp -d) +VERIFY_TMPDIR="${PROJECT_ROOT}/build/.verify-tmp" +rm -rf "${VERIFY_TMPDIR}" +mkdir -p "${VERIFY_TMPDIR}" trap 'rm -rf "${VERIFY_TMPDIR}"' EXIT -# .app is a ZIP containing HAP(s) + pack.info -unzip -q "${APP_FILE}" -d "${VERIFY_TMPDIR}" +# .app is a ZIP containing HAP(s) + pack.info. CI/ubuntu has unzip installed +# (see build.yml "Install system dependencies"); Windows Git Bash does not, +# so fall back to PowerShell Expand-Archive there. +unzip_cross_platform() { + local archive="$1" dest="$2" + if command -v unzip >/dev/null 2>&1; then + unzip -q "${archive}" -d "${dest}" + else + # Expand-Archive only supports .zip, so copy to temp.zip first + local a_win d_win + a_win=$(to_win_path "${archive}") + d_win=$(to_win_path "${dest}") + powershell.exe -NoProfile -Command "Copy-Item '${a_win}' '${d_win}/temp.zip'; Expand-Archive -Force -LiteralPath '${d_win}/temp.zip' -DestinationPath '${d_win}'; Remove-Item '${d_win}/temp.zip'" + fi +} + +unzip_cross_platform "${APP_FILE}" "${VERIFY_TMPDIR}" HAP_IN_APP=$(find "${VERIFY_TMPDIR}" -name "*.hap" -type f | head -1) if [ -z "${HAP_IN_APP}" ]; then echo " ✗ No .hap found inside .app!" @@ -906,88 +1014,103 @@ echo " ✓ HAP found: $(basename "${HAP_IN_APP}")" # Extract HAP to check critical files HAP_EXTRACT="${VERIFY_TMPDIR}/hap-extract" -unzip -q "${HAP_IN_APP}" -d "${HAP_EXTRACT}" -APP_IN_HAP="${HAP_EXTRACT}/resources/resfile/resources/app" +mkdir -p "${HAP_EXTRACT}" +unzip_cross_platform "${HAP_IN_APP}" "${HAP_EXTRACT}" +APP_IN_HAP="${HAP_EXTRACT}/resources/resfile/electerm" +DIST_DIR="${APP_IN_HAP}/dist/assets" HAP_VERIFY_OK=true -# Check index.html -if [ ! -f "${APP_IN_HAP}/assets/index.html" ]; then - echo " ✗ MISSING: assets/index.html" +# Check index.js (main entry) +if [ ! -f "${APP_IN_HAP}/index.js" ]; then + echo " MISSING: index.js" HAP_VERIFY_OK=false else - echo " ✓ assets/index.html" + echo " OK: index.js" +fi + +# Check app.bundle.mjs +if [ ! -f "${APP_IN_HAP}/app.bundle.mjs" ]; then + echo " MISSING: app.bundle.mjs" + HAP_VERIFY_OK=false +else + echo " OK: app.bundle.mjs" +fi + +# Check package.json +if [ ! -f "${APP_IN_HAP}/package.json" ]; then + echo " MISSING: package.json" + HAP_VERIFY_OK=false +else + echo " OK: package.json" fi # Check JS bundles -JS_COUNT=$(find "${APP_IN_HAP}/assets/js" -name "*.js" 2>/dev/null | wc -l) +JS_COUNT=$(find "${DIST_DIR}/js" -name "*.js" 2>/dev/null | wc -l) if [ "${JS_COUNT}" -eq 0 ]; then - echo " ✗ MISSING: no JS files in assets/js/" + echo " MISSING: no JS files in dist/assets/js/" HAP_VERIFY_OK=false else - echo " ✓ assets/js/ (${JS_COUNT} files)" + echo " OK: dist/assets/js/ (${JS_COUNT} files)" fi # Check CSS files -CSS_COUNT=$(find "${APP_IN_HAP}/assets/css" -name "*.css" 2>/dev/null | wc -l) +CSS_COUNT=$(find "${DIST_DIR}/css" -name "*.css" 2>/dev/null | wc -l) if [ "${CSS_COUNT}" -eq 0 ]; then - echo " ✗ MISSING: no CSS files in assets/css/" + echo " MISSING: no CSS files in dist/assets/css/" HAP_VERIFY_OK=false else - echo " ✓ assets/css/ (${CSS_COUNT} files)" + echo " OK: dist/assets/css/ (${CSS_COUNT} files)" fi # Check chunk files -CHUNK_COUNT=$(find "${APP_IN_HAP}/assets/chunk" -name "*.js" 2>/dev/null | wc -l) +CHUNK_COUNT=$(find "${DIST_DIR}/chunk" -name "*.js" 2>/dev/null | wc -l) if [ "${CHUNK_COUNT}" -eq 0 ]; then - echo " ✗ MISSING: no chunk files in assets/chunk/" + echo " MISSING: no chunk files in dist/assets/chunk/" HAP_VERIFY_OK=false else - echo " ✓ assets/chunk/ (${CHUNK_COUNT} files)" + echo " OK: dist/assets/chunk/ (${CHUNK_COUNT} files)" fi -# Check bootstrap.js -if [ ! -f "${APP_IN_HAP}/bootstrap.js" ]; then - echo " ✗ MISSING: bootstrap.js" +# Check native libs +LIB_COUNT=$(find "${HAP_EXTRACT}/libs" -name "libnode.so" 2>/dev/null | wc -l) +if [ "${LIB_COUNT}" -eq 0 ]; then + echo " MISSING: libnode.so in libs/" HAP_VERIFY_OK=false else - echo " ✓ bootstrap.js" + echo " OK: libs/ (libnode.so found in ${LIB_COUNT} arch(s))" fi -# Check app.js -if [ ! -f "${APP_IN_HAP}/app.js" ]; then - echo " ✗ MISSING: app.js" +# Check libnode_ctl.so +LIB_CTL_COUNT=$(find "${HAP_EXTRACT}/libs" -name "libnode_ctl.so" 2>/dev/null | wc -l) +if [ "${LIB_CTL_COUNT}" -eq 0 ]; then + echo " MISSING: libnode_ctl.so in libs/" HAP_VERIFY_OK=false else - echo " ✓ app.js" + echo " OK: libs/ (libnode_ctl.so found in ${LIB_CTL_COUNT} arch(s))" fi -# Check package.json main field -if [ -f "${APP_IN_HAP}/package.json" ]; then - MAIN_FIELD=$(python3 -c "import json; print(json.load(open('${APP_IN_HAP}/package.json'))['main'])" 2>/dev/null || echo "") - if [ "${MAIN_FIELD}" != "bootstrap.js" ]; then - echo " ✗ package.json main should be \"bootstrap.js\", got \"${MAIN_FIELD}\"" - HAP_VERIFY_OK=false - else - echo " ✓ package.json main = bootstrap.js" - fi -else - echo " ✗ MISSING: package.json" +# Check libnode_launcher.so +LIB_LAUNCHER_COUNT=$(find "${HAP_EXTRACT}/libs" -name "libnode_launcher.so" 2>/dev/null | wc -l) +if [ "${LIB_LAUNCHER_COUNT}" -eq 0 ]; then + echo " MISSING: libnode_launcher.so in libs/" HAP_VERIFY_OK=false +else + echo " OK: libs/ (libnode_launcher.so found in ${LIB_LAUNCHER_COUNT} arch(s))" fi if [ "${HAP_VERIFY_OK}" != "true" ]; then echo "" - echo " ✗ HAP content verification FAILED!" + echo " HAP content verification FAILED!" echo " The .app file is missing critical files and will not work on device." exit 1 fi -echo " ✓ HAP content verification passed" +echo " HAP content verification passed" # --- Rename artifact with proper name --------------------------------------- -FINAL_APP_NAME="electerm-${APP_ARCH}-${APP_VERSION}.app" +FINAL_APP_NAME="electerm-harmony-${APP_ARCH}-${APP_VERSION}.app" FINAL_APP="$(dirname "${APP_FILE}")/${FINAL_APP_NAME}" echo "==> Renaming artifact to ${FINAL_APP_NAME} ..." diff --git a/scripts/build-web-app.sh b/scripts/build-web-app.sh new file mode 100755 index 0000000..33eb7de --- /dev/null +++ b/scripts/build-web-app.sh @@ -0,0 +1,439 @@ +#!/usr/bin/env bash +# build-web-app.sh — Build and sign the HarmonyOS APP package (web variant: +# ArkWeb + on-device Node.js, no electron runtime). +# +# Adapted from build-app.sh with the electron runtime steps removed: +# - only the `entry` module is built (no web_engine HAR) +# - prerequisites are entry/libs/arm64-v8a/libnode.so (from prepare-node.sh) +# and entry/src/main/resources/resfile/electerm (from prepare-web.sh) +# +# Builds an UNSIGNED .app with hvigorw assembleApp, then signs it directly +# with hap-sign-tool.jar using plaintext passwords. +# +# Prerequisites: +# - HarmonyOS Command Line Tools installed (ohpm, hvigorw in PATH) +# - Signing materials in signing/ directory +# - prepare-node.sh and prepare-web.sh already run +# +# Usage: +# ./scripts/build-web-app.sh [--debug|--release] +# +# Environment variables (all optional, see defaults below): +# OHOS_SDK_HOME — path to HarmonyOS SDK +# COMMANDLINE_TOOLS — path to Command Line Tools +# SIGNING_DIR — directory with .p12, .cer, .p7b (default: signing/) +# KEYSTORE_FILE — keystore filename (default: electerm.p12) +# CERT_FILE — certificate filename (default: electerm_publish.cer) +# PROFILE_FILE — profile filename (default: electermRelease.p7b) +# KEYSTORE_PASSWORD — keystore password (plaintext) +# KEY_PASSWORD — key password (plaintext) +# KEY_ALIAS — key alias (default: electerm_key) +set -euo pipefail + +# --- Parse args ------------------------------------------------------------- + +BUILD_MODE="release" +if [[ "${1:-}" == "--debug" ]]; then + BUILD_MODE="debug" +elif [[ "${1:-}" == "--release" ]]; then + BUILD_MODE="release" +fi + +# --- Config ----------------------------------------------------------------- + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +SIGNING_DIR="${SIGNING_DIR:-${PROJECT_ROOT}/signing}" +KEYSTORE_FILE="${KEYSTORE_FILE:-electerm.p12}" +CERT_FILE="${CERT_FILE:-electerm_publish.cer}" +PROFILE_FILE="${PROFILE_FILE:-electermRelease.p7b}" +KEY_ALIAS="${KEY_ALIAS:-electerm_key}" + +KEYSTORE_PATH="${SIGNING_DIR}/${KEYSTORE_FILE}" +CERT_PATH="${SIGNING_DIR}/${CERT_FILE}" +PROFILE_PATH="${SIGNING_DIR}/${PROFILE_FILE}" + +OHPM="${OHPM:-ohpm}" +HVIGORW="${HVIGORW:-hvigorw}" + +# --- Read version from package.json ----------------------------------------- + +echo "==> Reading version from package.json ..." + +APP_VERSION=$(python3 -c "import json; print(json.load(open('${PROJECT_ROOT}/package.json'))['version'])") +echo " ✓ version: ${APP_VERSION}" + +# Compute versionCode from semver: major * 10000000 + minor * 100000 + patch +VERSION_CODE=$(python3 -c " +import re +v = '${APP_VERSION}' +m = re.match(r'(\d+)\.(\d+)\.(\d+)', v) +if m: + major, minor, patch = int(m.group(1)), int(m.group(2)), int(m.group(3)) + print(major * 10000000 + minor * 100000 + patch) +else: + print(1) +") + +if [ "${VERSION_CODE}" -gt 2147483647 ] || [ "${VERSION_CODE}" -lt 1 ]; then + echo " ✗ versionCode ${VERSION_CODE} is out of range (1–2147483647)" + exit 1 +fi +echo " ✓ versionCode: ${VERSION_CODE}" + +# --- Verify build prerequisites --------------------------------------------- + +echo "==> Verifying build prerequisites ..." + +LIBS_DIR="${PROJECT_ROOT}/entry/libs/arm64-v8a" +RESFILE_APP_DIR="${PROJECT_ROOT}/entry/src/main/resources/resfile/electerm" + +if [ ! -f "${LIBS_DIR}/libnode.so" ]; then + echo " ✗ Missing: ${LIBS_DIR}/libnode.so" + echo " Run ./scripts/prepare-node.sh first." + exit 1 +fi +echo " ✓ Found: libnode.so ($(du -h "${LIBS_DIR}/libnode.so" | cut -f1))" + +for f in index.js app.bundle.mjs views/index.pug; do + if [ ! -f "${RESFILE_APP_DIR}/${f}" ]; then + echo " ✗ Missing: ${RESFILE_APP_DIR}/${f}" + echo " Run ./scripts/prepare-web.sh first." + exit 1 + fi + echo " ✓ Found: ${f}" +done + +for f in "${KEYSTORE_PATH}" "${CERT_PATH}" "${PROFILE_PATH}"; do + if [ ! -s "${f}" ]; then + echo " ✗ Missing signing material: ${f}" + exit 1 + fi +done +echo " ✓ Signing materials present" + +# Informational (non-fatal): note the cert identity. +# The same electerm_publish.cer is used by the main (electron) branch for +# `hap-sign-tool sign-app` and that branch builds fine, so for *HAP package +# signing* this cert is acceptable. This script does not perform any per-.so +# binary-sign-tool code-signing, so the root/CA nature of the cert is not a +# problem here. Shown for visibility only. +if command -v openssl >/dev/null 2>&1; then + CERT_SUBJECT=$(openssl x509 -in "${CERT_PATH}" -noout -subject 2>/dev/null || true) + echo " • App cert (appCertFile for hap-sign-tool): ${CERT_SUBJECT}" + echo " (Same cert the main/electron branch uses for HAP signing; fine for sign-app.)" +fi + +# --- Fix permissions for SDK compatibility ---------------------------------- + +echo "==> Cleaning unsupported permissions ..." + +UNSUPPORTED_PERMS="SET_ABILITY_INSTANCE_INFO GET_FILE_ICON PRIVACY_WINDOW LOCK_WINDOW_CURSOR ACCESS_BIOMETRIC SYSTEM_FLOAT_WINDOW FILE_ACCESS_PERSIST PREPARE_APP_TERMINATE CUSTOM_SCREEN_CAPTURE" + +ENTRY_MODULE_JSON="${PROJECT_ROOT}/entry/src/main/module.json5" + +remove_module_perms() { + local file="${1}" + local label="${2}" + shift 2 + + if [ ! -f "${file}" ]; then + echo " (${label} module.json5 not found, skipping)" + return + fi + + python3 - "${file}" "${label}" "$@" <<'PYEOF' +import re, sys + +file_path = sys.argv[1] +label = sys.argv[2] +perms = sys.argv[3:] + +with open(file_path, 'r') as f: + content = f.read() + +for perm in perms: + if f'"ohos.permission.{perm}"' not in content: + continue + pattern = ( + r'\{[^{}]*"ohos\.permission\.' + re.escape(perm) + r'"' + r'[^{}]*(?:\{[^{}]*\}[^{}]*)*\}[\s,]*' + ) + new_content = re.sub(pattern, '', content) + if new_content != content: + print(f' {label}: removed ohos.permission.{perm}') + content = new_content + else: + print(f' {label}: WARNING — could not remove ohos.permission.{perm}') + +with open(file_path, 'w') as f: + f.write(content) +PYEOF +} + +remove_module_perms "${ENTRY_MODULE_JSON}" "entry" ${UNSUPPORTED_PERMS} + +# --- Generate build-profile.json5 (entry module only, unsigned) -------------- + +echo "==> Configuring build-profile.json5 ..." + +BUILD_PROFILE="${PROJECT_ROOT}/build-profile.json5" + +SDK_PKG_JSON="${OHOS_SDK_HOME}/default/sdk-pkg.json" +if [ -f "${SDK_PKG_JSON}" ]; then + # Extract both fields with python (portable — BSD sed lacks \+ quantifiers) + read -r SDK_API_VERSION SDK_VERSION SDK_DISPLAY_NAME </dev/null || echo " ") +SDKINFO + if [ -n "${SDK_API_VERSION}" ] && [ -n "${SDK_VERSION}" ]; then + COMPILE_SDK_VERSION="${SDK_VERSION}(${SDK_API_VERSION})" + echo " Detected SDK: ${SDK_DISPLAY_NAME} (API ${SDK_API_VERSION})" + else + COMPILE_SDK_VERSION="5.0.1(13)" + echo " Warning: Could not parse SDK version, using default 5.0.1(13)" + fi +else + COMPILE_SDK_VERSION="5.0.1(13)" + echo " Warning: sdk-pkg.json not found, using default 5.0.1(13)" +fi + +cat > "${PROJECT_ROOT}/local.properties" < "${BUILD_PROFILE}" < Updating app version to ${APP_VERSION} ..." + +APP_JSON5="${PROJECT_ROOT}/AppScope/app.json5" +sed -i.bak "s/\"versionName\": \"[^\"]*\"/\"versionName\": \"${APP_VERSION}\"/" "${APP_JSON5}" +sed -i.bak "s/\"versionCode\": [0-9]*/\"versionCode\": ${VERSION_CODE}/" "${APP_JSON5}" +rm -f "${APP_JSON5}.bak" + +ROOT_PKG="${PROJECT_ROOT}/oh-package.json5" +sed -i.bak "s/\"version\": \"[^\"]*\"/\"version\": \"${APP_VERSION}\"/" "${ROOT_PKG}" +rm -f "${ROOT_PKG}.bak" + +ENTRY_PKG="${PROJECT_ROOT}/entry/oh-package.json5" +sed -i.bak "s/\"version\": \"[^\"]*\"/\"version\": \"${APP_VERSION}\"/" "${ENTRY_PKG}" +rm -f "${ENTRY_PKG}.bak" + +echo " ✓ app.json5: versionName=${APP_VERSION}, versionCode=${VERSION_CODE}" + +# --- Generate hvigor-config.json5 ------------------------------------------- + +echo "==> Configuring hvigor-config.json5 ..." + +HVIGOR_CONFIG="${PROJECT_ROOT}/hvigor/hvigor-config.json5" + +BUNDLED_HVIGOR_DIR="${COMMANDLINE_TOOLS}/hvigor/hvigor" +BUNDLED_PLUGIN_DIR="${COMMANDLINE_TOOLS}/hvigor/hvigor-ohos-plugin" + +if [ -d "${BUNDLED_PLUGIN_DIR}" ]; then + cat > "${HVIGOR_CONFIG}" < "${NPMRC_FILE}" <<'NPMRC' +@ohos:registry=https://repo.harmonyos.com/npm/ +registry=https://registry.npmjs.org/ +NPMRC +echo " ✓ Created ${NPMRC_FILE}" + +# --- Install ohpm dependencies ---------------------------------------------- + +echo "==> Installing ohpm dependencies ..." +cd "${PROJECT_ROOT}" +"${OHPM}" install + +# --- Bundled libnode.so is NOT code-signed here. ------------------------- +# The app HAP signature (hap-sign-tool below) already grants its bundled +# native libs execution permission, so XPM does not require a separate +# per-.so binary-sign-tool signature. The previous arm64 SIGTRAP in +# node::Start ("# Check failed: 12 == (*__errno_location())") was a W^X +# policy rejection of the JIT's runtime PROT_EXEC mapping - fixed at +# runtime by launching node with --jitless in node_ctl.c / node_launcher.c. +# No .so code-signing is performed. + +# --- Build the unsigned APP ------------------------------------------------- + +echo "==> Building unsigned APP (${BUILD_MODE}) ..." + +if [ "${BUILD_MODE}" = "debug" ]; then + "${HVIGORW}" assembleApp -p product=default \ + -p buildMode=debug -p enableSignTask=false --no-daemon +else + "${HVIGORW}" assembleApp -p product=default \ + -p buildMode=release -p enableSignTask=false --no-daemon +fi + +# --- Locate the unsigned APP ------------------------------------------------ + +APP_OUTPUT_DIR="${PROJECT_ROOT}/build/outputs/default" +UNSIGNED_APP=$(find "${APP_OUTPUT_DIR}" -name "*.app" -type f 2>/dev/null | head -1) + +if [ -z "${UNSIGNED_APP}" ]; then + echo " ✗ No .app file found in ${APP_OUTPUT_DIR}" + echo " Searching entire build tree ..." + UNSIGNED_APP=$(find "${PROJECT_ROOT}/build" -name "*.app" -type f 2>/dev/null | head -1) + if [ -z "${UNSIGNED_APP}" ]; then + echo " ✗ No .app file found anywhere in build/" + exit 1 + fi +fi + +echo " ✓ Unsigned APP: ${UNSIGNED_APP} ($(du -h "${UNSIGNED_APP}" | cut -f1))" + +# --- Sign the APP with hap-sign-tool.jar ------------------------------------ + +echo "==> Signing APP with hap-sign-tool.jar ..." + +SIGN_TOOL_JAR="${OHOS_SDK_HOME}/default/openharmony/toolchains/lib/hap-sign-tool.jar" +if [ ! -f "${SIGN_TOOL_JAR}" ]; then + SIGN_TOOL_JAR=$(find "${OHOS_SDK_HOME}" -name "hap-sign-tool.jar" -type f 2>/dev/null | head -1) +fi +if [ ! -f "${SIGN_TOOL_JAR}" ]; then + echo " ✗ hap-sign-tool.jar not found in SDK" + exit 1 +fi + +# Canonical web-build artifact name: electerm-harmony--.app +# The web build only targets the on-device architecture (arm64-v8a), and +# is the package.json version, so the shipped file is e.g. +# electerm-harmony-arm64-5.3.16.app +APP_ARCH="arm64" +CANONICAL_APP="${APP_OUTPUT_DIR}/electerm-harmony-${APP_ARCH}-${APP_VERSION}.app" + +java -jar "${SIGN_TOOL_JAR}" sign-app \ + -mode localSign \ + -keyAlias "${KEY_ALIAS}" \ + -keyPwd "${KEY_PASSWORD}" \ + -appCertFile "${CERT_PATH}" \ + -profileFile "${PROFILE_PATH}" \ + -inFile "${UNSIGNED_APP}" \ + -signAlg SHA256withECDSA \ + -keystoreFile "${KEYSTORE_PATH}" \ + -keystorePwd "${KEYSTORE_PASSWORD}" \ + -outFile "${CANONICAL_APP}" + +if [ ! -f "${CANONICAL_APP}" ]; then + echo " ✗ Signing failed — no signed APP produced" + exit 1 +fi + +# Keep only the canonical-named APP so artifact pickup (find … -name '*.app') +# can never grab a stray/unsigned one. +APP_FILE="${CANONICAL_APP}" +rm -f "${UNSIGNED_APP}" + +echo " ✓ Signed APP: ${APP_FILE} ($(du -h "${APP_FILE}" | cut -f1))" + +# --- Verify APP contents ------------------------------------------------------ + +echo "==> Verifying APP contents ..." + +VERIFY_TMPDIR=$(mktemp -d) +trap 'rm -rf "${VERIFY_TMPDIR}"' EXIT + +unzip -q "${APP_FILE}" -d "${VERIFY_TMPDIR}" +HAP_IN_APP=$(find "${VERIFY_TMPDIR}" -name "*.hap" -type f | head -1) +if [ -z "${HAP_IN_APP}" ]; then + echo " ✗ No .hap inside the .app" + exit 1 +fi + +HAP_DIR="${VERIFY_TMPDIR}/hap" +unzip -q "${HAP_IN_APP}" -d "${HAP_DIR}" + +ERRORS="" +check_file() { + if [ ! -f "$1" ]; then + ERRORS="${ERRORS}\n ✗ MISSING: $2" + else + echo " ✓ $2 ($(du -h "$1" | cut -f1))" + fi +} + +check_file "${HAP_DIR}/libs/arm64-v8a/libnode.so" "libs/arm64-v8a/libnode.so" + +check_file "${HAP_DIR}/libs/arm64-v8a/libnode_launcher.so" "libs/arm64-v8a/libnode_launcher.so" +check_file "${HAP_DIR}/libs/arm64-v8a/libnode_ctl.so" "libs/arm64-v8a/libnode_ctl.so" +check_file "${HAP_DIR}/resources/resfile/electerm/index.js" "resfile/electerm/index.js" +check_file "${HAP_DIR}/resources/resfile/electerm/app.bundle.mjs" "resfile/electerm/app.bundle.mjs" +check_file "${HAP_DIR}/resources/resfile/electerm/views/index.pug" "resfile/electerm/views/index.pug" + +JS_COUNT=$(find "${HAP_DIR}/resources/resfile/electerm/dist/assets/js" -name "*.js" 2>/dev/null | wc -l | tr -d ' ') +echo " ✓ resfile/electerm/dist/assets/js: ${JS_COUNT} files" +if [ "${JS_COUNT}" = "0" ]; then + ERRORS="${ERRORS}\n ✗ No frontend JS in resfile" +fi + +if [ -n "${ERRORS}" ]; then + echo -e "::error::APP content verification failed:${ERRORS}" + exit 1 +fi +echo "✓ All critical files verified in APP" + +echo "==> Build complete: ${APP_FILE}" diff --git a/scripts/inject-safe-storage-secret.mjs b/scripts/inject-safe-storage-secret.mjs new file mode 100644 index 0000000..69d72db --- /dev/null +++ b/scripts/inject-safe-storage-secret.mjs @@ -0,0 +1,17 @@ +import fs from 'node:fs' + +const file = process.env.SAFE_STORAGE_FILE || 'build/replace/app/lib/safe-storage.js' +const marker = "process.env.STORAGE_SECRET || 'static-secret-string-safe-storage'" +const secret = process.env.STORAGE_SECRET + +if (!secret) { + throw new Error('STORAGE_SECRET is not set') +} + +const source = fs.readFileSync(file, 'utf8') +if (!source.includes(marker)) { + throw new Error(`safe-storage marker not found in ${file}`) +} + +fs.writeFileSync(file, source.replace(marker, JSON.stringify(secret))) +console.log('safe-storage secret injected') diff --git a/scripts/prepare-electron-runtime.sh b/scripts/prepare-electron-runtime.sh deleted file mode 100755 index 02ca69d..0000000 --- a/scripts/prepare-electron-runtime.sh +++ /dev/null @@ -1,300 +0,0 @@ -#!/usr/bin/env bash -# prepare-electron-runtime.sh — Extract the pre-built Electron 鸿蒙 runtime -# from a tarball and install it into the project. -# -# The Electron 鸿蒙 runtime is provided as a pre-built tarball by the -# openharmony-sig/electron project. It contains: -# -# - web_engine/ — A complete HarmonyOS HAR module (ArkTS source + resfile -# resources + libadapter.so type definitions). This module -# provides WebAbility, WebAbilityStage, WebWindow, and -# JsBindingUtils — the ArkTS API for the Electron runtime. -# - electron/libs/arm64-v8a/ — Native .so libraries: -# libelectron.so (Chromium + Node.js + V8), -# libadapter.so (HarmonyOS ↔ Electron bridge), -# libffmpeg.so, libc++_shared.so, libvk_swiftshader.so, -# vscode-sqlite3.node -# -# After extraction: -# - web_engine/ is placed at the project root (as a sibling of entry/) -# - .so files are placed in entry/libs/arm64-v8a/ -# -# Usage: -# ./scripts/prepare-electron-runtime.sh -# -# Environment variables (ONE of these must be set): -# ELECTRON_RUNTIME_URL — URL to the tarball (e.g. .tar.gz or .zip) -# Used in CI. The tarball must extract to a directory -# containing web_engine/ and electron/libs/. -# ELECTRON_RUNTIME_DIR — Path to an already-extracted tarball directory -# (for local development) -# ELECTRON_RUNTIME_FILE — Path to a local tarball file -# (for local development) -# -# Example tarball: -# electron40_hap_electron_v40.0.0_20260629.tar.gz -# → extracts to electron144_ohos_hap/ -# ├── electron/libs/arm64-v8a/*.so -# └── web_engine/ (complete HAR module) - -set -euo pipefail - -# --- Config ----------------------------------------------------------------- - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" - -LIBS_DIR="${PROJECT_ROOT}/entry/libs/arm64-v8a" -WEB_ENGINE_DIR="${PROJECT_ROOT}/web_engine" -DOWNLOAD_DIR="${PROJECT_ROOT}/.cache" - -# --- Functions -------------------------------------------------------------- - -die() { - echo " ERROR: $*" >&2 - exit 1 -} - -info() { - echo " $*" -} - -ok() { - echo " [OK] $*" -} - -# Find the top-level extracted directory (e.g. electron144_ohos_hap/) -find_extracted_root() { - local dir="$1" - # Check if web_engine/ is directly in the directory - if [ -d "${dir}/web_engine" ]; then - echo "${dir}" - return 0 - fi - # Search one level deep - local found - found=$(find "${dir}" -maxdepth 2 -name "web_engine" -type d 2>/dev/null | head -1) - if [ -n "${found}" ]; then - dirname "${found}" - else - echo "" - fi -} - -# Install runtime files from an extracted directory. -install_from_dir() { - local extracted_root="$1" - info "Installing runtime from: ${extracted_root}" - - # Verify structure - if [ ! -d "${extracted_root}/web_engine" ]; then - die "web_engine/ directory not found in: ${extracted_root}" - fi - - local so_dir="${extracted_root}/electron/libs/arm64-v8a" - if [ ! -d "${so_dir}" ]; then - # Try alternative: some tarballs may have libs at a different path - so_dir=$(find "${extracted_root}" -path "*/arm64-v8a/libelectron.so" -exec dirname {} \; 2>/dev/null | head -1) - if [ -z "${so_dir}" ]; then - die "Could not find arm64-v8a/ directory with libelectron.so" - fi - fi - - # --- Copy web_engine/ module to project root --- - info "Installing web_engine module ..." - rm -rf "${WEB_ENGINE_DIR}" - cp -r "${extracted_root}/web_engine" "${WEB_ENGINE_DIR}" - ok "web_engine/ installed ($(du -sh "${WEB_ENGINE_DIR}" | cut -f1))" - - # --- Copy .so libraries to entry/libs/arm64-v8a/ --- - info "Installing native libraries ..." - mkdir -p "${LIBS_DIR}" - rm -f "${LIBS_DIR}"/*.so "${LIBS_DIR}"/*.node - cp -f "${so_dir}"/*.so "${LIBS_DIR}/" 2>/dev/null || true - cp -f "${so_dir}"/*.node "${LIBS_DIR}/" 2>/dev/null || true - ok "Libraries installed to ${LIBS_DIR}/" - for f in "${LIBS_DIR}"/*; do - ok " $(basename "${f}") ($(du -h "${f}" | cut -f1))" - done -} - -# Download and extract archive, then install. -download_and_install() { - local url="$1" - info "Downloading Electron runtime from: ${url}" - - mkdir -p "${DOWNLOAD_DIR}" - - local archive="${DOWNLOAD_DIR}/electron-runtime" - local extract_dir="${DOWNLOAD_DIR}/electron-runtime-extracted" - rm -rf "${extract_dir}" - mkdir -p "${extract_dir}" - - case "${url}" in - *.tar.gz|*.tgz) - archive="${archive}.tar.gz" - curl -L --fail --retry 3 --retry-delay 5 -o "${archive}" "${url}" - tar -xzf "${archive}" -C "${extract_dir}" - ;; - *.zip) - archive="${archive}.zip" - curl -L --fail --retry 3 --retry-delay 5 -o "${archive}" "${url}" - if command -v unzip &>/dev/null; then - unzip -q -o "${archive}" -d "${extract_dir}" - else - die "unzip command not found — please install unzip" - fi - ;; - *) - die "Unsupported archive format. URL must end in .tar.gz or .zip" - ;; - esac - - if [ ! -s "${archive}" ]; then - die "Download failed — archive is empty or missing" - fi - - ok "Downloaded and extracted runtime archive" - - local extracted_root - extracted_root=$(find_extracted_root "${extract_dir}") - if [ -z "${extracted_root}" ]; then - die "Could not find web_engine/ in extracted archive" - fi - info "Extracted root: ${extracted_root}" - - install_from_dir "${extracted_root}" - - # Clean up - rm -f "${archive}" - rm -rf "${extract_dir}" -} - -# Extract a local tarball file and install. -extract_file_and_install() { - local filepath="$1" - info "Extracting local tarball: ${filepath}" - - if [ ! -f "${filepath}" ]; then - die "File not found: ${filepath}" - fi - - local extract_dir="${DOWNLOAD_DIR}/electron-runtime-extracted" - rm -rf "${extract_dir}" - mkdir -p "${extract_dir}" - - case "${filepath}" in - *.tar.gz|*.tgz) - tar -xzf "${filepath}" -C "${extract_dir}" - ;; - *.zip) - if command -v unzip &>/dev/null; then - unzip -q -o "${filepath}" -d "${extract_dir}" - else - die "unzip command not found — please install unzip" - fi - ;; - *) - die "Unsupported archive format. File must be .tar.gz or .zip" - ;; - esac - - ok "Extracted tarball" - - local extracted_root - extracted_root=$(find_extracted_root "${extract_dir}") - if [ -z "${extracted_root}" ]; then - die "Could not find web_engine/ in extracted archive" - fi - info "Extracted root: ${extracted_root}" - - install_from_dir "${extracted_root}" - - # Clean up - rm -rf "${extract_dir}" -} - -# --- Main ------------------------------------------------------------------- - -echo "==> Preparing Electron runtime" - -# Check that at least one source is provided -if [ -z "${ELECTRON_RUNTIME_URL:-}" ] && [ -z "${ELECTRON_RUNTIME_DIR:-}" ] && [ -z "${ELECTRON_RUNTIME_FILE:-}" ]; then - echo "" - echo " ERROR: None of ELECTRON_RUNTIME_URL, ELECTRON_RUNTIME_DIR, or" - echo " ELECTRON_RUNTIME_FILE is set." - echo "" - echo " The pre-built Electron 鸿蒙 runtime tarball must be obtained from:" - echo "" - echo " 1. openharmony-sig/electron project (Huawei Cloud CodeHub):" - echo " https://gitcode.com/openharmony-sig/electron" - echo " Download the latest release tarball (e.g." - echo " electron40_hap_electron_v40.0.0_20260629.tar.gz)" - echo "" - echo " Then use ONE of:" - echo " export ELECTRON_RUNTIME_FILE=/path/to/electron40_hap_*.tar.gz" - echo " export ELECTRON_RUNTIME_DIR=/path/to/extracted/electron144_ohos_hap" - echo " export ELECTRON_RUNTIME_URL=https://your-host/electron40_hap_*.tar.gz (for CI)" - echo "" - exit 1 -fi - -if [ -n "${ELECTRON_RUNTIME_FILE:-}" ]; then - # Mode 1: Use a local tarball file - extract_file_and_install "${ELECTRON_RUNTIME_FILE}" -elif [ -n "${ELECTRON_RUNTIME_DIR:-}" ]; then - # Mode 2: Use an already-extracted directory - if [ ! -d "${ELECTRON_RUNTIME_DIR}" ]; then - die "ELECTRON_RUNTIME_DIR does not exist: ${ELECTRON_RUNTIME_DIR}" - fi - info "Using local directory: ${ELECTRON_RUNTIME_DIR}" - install_from_dir "${ELECTRON_RUNTIME_DIR}" -elif [ -n "${ELECTRON_RUNTIME_URL:-}" ]; then - # Mode 3: Download from a URL - download_and_install "${ELECTRON_RUNTIME_URL}" -fi - -# --- Verify --- -echo "" -echo "==> Verifying runtime files" - -required_libs=("libelectron.so" "libadapter.so" "libffmpeg.so") -for lib in "${required_libs[@]}"; do - if [ ! -f "${LIBS_DIR}/${lib}" ]; then - die "Missing required library: ${LIBS_DIR}/${lib}" - fi - ok "Found ${lib}" -done - -if [ ! -f "${WEB_ENGINE_DIR}/Index.ets" ]; then - die "Missing web_engine/Index.ets" -fi -ok "Found web_engine/Index.ets" - -if [ ! -f "${WEB_ENGINE_DIR}/oh-package.json5" ]; then - die "Missing web_engine/oh-package.json5" -fi -ok "Found web_engine/oh-package.json5" - -# Verify resfile resources exist -RESFILE_DIR="${WEB_ENGINE_DIR}/src/main/resources/resfile" -for res in icudtl.dat resources.pak chrome_100_percent.pak v8_context_snapshot.bin; do - if [ ! -f "${RESFILE_DIR}/${res}" ]; then - info "Note: ${res} not found (may be optional)" - else - ok "Found ${res}" - fi -done - -if [ -d "${RESFILE_DIR}/locales" ]; then - ok "Found locales/ ($(ls "${RESFILE_DIR}/locales/" | wc -l) files)" -fi - -echo "" -echo "==> Electron runtime preparation complete!" -echo " web_engine module: ${WEB_ENGINE_DIR}/" -echo " Native libraries: ${LIBS_DIR}/" -echo "" -echo " Next steps:" -echo " 1. ./scripts/prepare-web.sh (build web app → web_engine resfile)" -echo " 2. ./scripts/build-app.sh (build & sign the HAP)" diff --git a/scripts/prepare-node.sh b/scripts/prepare-node.sh new file mode 100755 index 0000000..af2b9d5 --- /dev/null +++ b/scripts/prepare-node.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# prepare-node.sh — Download our own real shared libnode.so for OpenHarmony +# from the electerm/ohos-node-shared GitHub release and install it into the +# entry module as the prebuilt Node.js native "library". +# +# WHY NOT hqzing/ohos-node (the old source): +# hqzing ships a PIE executable (ET_DYN + PT_INTERP) named libnode.so. +# dlopen()ing a PIE into the HarmonyOS app aliases local-exec %fs TLS to the +# host app's TLS block, so V8's `thread_local current_per_thread_assert_data` +# reads garbage and the release CHECK `AllowHeapAllocationInRelease` +# fires on the very first heap allocation at Isolate::Initialize. +# We build with `--shared` (scripts/build-node-ohos.sh, archived in +# temp/bak/) to get a TRUE shared library (PIC + dynamic TLS, SONAME +# libnode.so.) that dlopen()s cleanly — exactly what that build +# produces and this script downloads. +# +# The file lands in entry/libs// so hvigor packages it into the HAP's +# native libs dir (the app dlopens it from there at runtime). +# +# Usage: +# ./scripts/prepare-node.sh [arch] +# arch: arm64 (default) | x64 +# +# Environment variables: +# NODE_VERSION — node.js version the release was built from (default 24.2.0) +# RELEASE_TAG — override the GitHub release tag (default auto-derived) +# RELEASE_REPO — repo hosting the release (default electerm/ohos-node-shared) +set -euo pipefail + +# --- Config ----------------------------------------------------------------- + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +ARCH="${1:-${ARCH:-arm64}}" +case "${ARCH}" in + arm64) ABI="arm64-v8a"; ASSET_ARCH="arm64" ;; + x64|x86_64) ABI="x86_64"; ASSET_ARCH="x64" ;; + *) echo " ✗ Unsupported arch: ${ARCH} (use arm64 or x64)"; exit 1 ;; +esac + +NODE_VERSION="${NODE_VERSION:-24.2.0}" +RELEASE_REPO="${RELEASE_REPO:-electerm/ohos-node-shared}" +RELEASE_TAG="${RELEASE_TAG:-ohos-node-shared-v${NODE_VERSION}}" + +ASSET_NAME="libnode-${ASSET_ARCH}.so" +DOWNLOAD_URL="https://github.com/${RELEASE_REPO}/releases/download/${RELEASE_TAG}/${ASSET_NAME}" + +CACHE_DIR="${PROJECT_ROOT}/.cache/node-runtime" +LIBS_DIR="${PROJECT_ROOT}/entry/libs/${ABI}" +OUT_BIN="${LIBS_DIR}/libnode.so" + +# --- Main ------------------------------------------------------------------- + +echo "==> Preparing OpenHarmony Node.js shared lib (${ARCH} / ${ABI})" +echo " Release: ${RELEASE_REPO} @ ${RELEASE_TAG}" + +mkdir -p "${CACHE_DIR}" "${LIBS_DIR}" + +MARKER_FILE="${CACHE_DIR}/installed-${ABI}-${RELEASE_TAG}.marker" + +# Re-download if we have no libnode.so at all. The marker only short-circuits +# when both the file and marker exist — a half-installed state re-downloads. +if [ -f "${OUT_BIN}" ] && [ -f "${MARKER_FILE}" ]; then + echo " ✓ libnode.so already prepared (${RELEASE_TAG}), skipping." + exit 0 +fi + +# 1. Download (cached per asset) +ARCHIVE_PATH="${CACHE_DIR}/${ASSET_NAME}" +if [ ! -s "${ARCHIVE_PATH}" ]; then + echo " Downloading ${DOWNLOAD_URL} ..." + curl -fL --retry 5 --retry-all-errors --retry-delay 5 \ + -o "${ARCHIVE_PATH}.tmp" "${DOWNLOAD_URL}" + mv "${ARCHIVE_PATH}.tmp" "${ARCHIVE_PATH}" +else + echo " ✓ Using cached archive: ${ARCHIVE_PATH}" +fi + +# 2. Verify it's a REAL shared library — reject the broken PIE form +# (ET_DYN + PT_INTERP). This is the whole point of the re-build. +echo " Verifying ELF is a shared library (not a PIE) ..." +if python3 - "${ARCHIVE_PATH}" <<'PYEOF' +import struct, sys +path = sys.argv[1] +with open(path, 'rb') as f: + data = f.read(64) + if data[:4] != b'\x7fELF': + sys.exit("not an ELF file") + ei_class = data[4] # 1=32bit 2=64bit + ei_data = data[5] # 1=LE 2=BE + machine = struct.unpack('H', data[18:20])[0] + e_type = struct.unpack('H', data[16:18])[0] + has_interp = False + # read program headers (64-bit layout assumed like our builds) + e_phoff = struct.unpack('Q', data[32:40])[0] + e_phentsize = struct.unpack('H', data[54:56])[0] + e_phnum = struct.unpack('H', data[56:58])[0] + with open(path, 'rb') as f: + for i in range(e_phnum): + f.seek(e_phoff + i * e_phentsize) + ph = f.read(e_phentsize) + p_type = struct.unpack('I', ph[0:4])[0] + if p_type == 3: # PT_INTERP + has_interp = True + ok = (e_type == 3) and not has_interp # ET_DYN without PT_INTERP + # ASCII-only output: Windows Python defaults to a non-UTF-8 stdout + # encoding (GBK), so checkmark/cross glyphs would crash print() with + # UnicodeEncodeError and wrongly fail the verification. + if not ok: + sys.exit(f"REJECTED: type=ET_DYN machine={machine} PT_INTERP={'YES' if has_interp else 'no'} (PIE! build with --shared instead, see temp/bak/build-node-ohos.sh)") + print(f"OK: type=ET_DYN machine={machine} PT_INTERP=no (real shared lib)") +PYEOF +then + : +else + echo " ✗ Downloaded libnode.so is not a usable shared library." + echo " Publish a release built with --shared (see temp/bak/build-node-ohos.sh)." + exit 1 +fi + +# 3. Install into the entry module libs dir +echo " Installing to ${OUT_BIN} ..." +cp "${ARCHIVE_PATH}" "${OUT_BIN}.tmp" +mv "${OUT_BIN}.tmp" "${OUT_BIN}" + +echo "${RELEASE_TAG}" > "${MARKER_FILE}" +echo "${DOWNLOAD_URL}" > "${CACHE_DIR}/node-source-url.txt" + +echo " ✓ Installed: ${OUT_BIN} ($(du -h "${OUT_BIN}" | cut -f1))" +echo "==> Node.js runtime preparation complete." diff --git a/scripts/prepare-web.sh b/scripts/prepare-web.sh index ccd24af..1f2be54 100755 --- a/scripts/prepare-web.sh +++ b/scripts/prepare-web.sh @@ -1,26 +1,22 @@ #!/usr/bin/env bash -# prepare-web.sh — Install, build, and bundle the electerm app -# from the project root into the HarmonyOS app's resfile resources. +# prepare-web.sh — Install deps and build the electerm web app (frontend + +# pure-node backend) into the entry module's resfile directory. # -# This script: -# 1. Installs npm dependencies in the project root (dev deps for build tools) -# 2. Runs build/harmony/build.js which: -# - Copies @electerm/electerm-react/client → src/client/ (gitignored) -# - Runs `npm run b` (complete electerm build: clean + compile + prepare-file) -# - Applies HarmonyOS delta (main → bootstrap.js, remove native modules) -# - Copies work/app/ → web_engine/src/main/resources/resfile/resources/app/ +# Runs build/web/build.mjs which: +# 1. vite-builds the frontend -> resfile/electerm/dist/assets +# 2. copies static assets + views/index.pug +# 3. esbuild-bundles the backend -> resfile/electerm/app.bundle.mjs +# 4. writes resfile/electerm/index.js (runtime env setup) + package.json # -# Key points: -# - Reuses electerm's full build pipeline (npm run b), only adds harmony delta -# - Native modules (node-pty, serialport, cpu-features) are removed post-build -# - The app entry is bootstrap.js (sets DATA_PATH before loading app.js) +# The resfile directory is packaged into the HAP and read directly by the +# on-device node process — no runtime extraction. # # Usage: # ./scripts/prepare-web.sh # # Environment variables: -# OHOS_SERVER_SECRET — sets SERVER_SECRET in .env (optional) - +# SERVER_SECRET / OHOS_SERVER_SECRET — JWT secret baked into the build +# (required in CI, optional locally) set -euo pipefail # --- Config ----------------------------------------------------------------- @@ -28,90 +24,60 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" -# Web app source (project root) -WEB_SRC_DIR="${PROJECT_ROOT}" -# Output: web_engine/src/main/resources/resfile/resources/app/ -# (Electron app directory in the web_engine HAR module's resfile) -RESFILE_APP_DIR="${PROJECT_ROOT}/web_engine/src/main/resources/resfile/resources/app" +RESFILE_APP_DIR="${PROJECT_ROOT}/entry/src/main/resources/resfile/electerm" # --- Main ------------------------------------------------------------------- -echo "==> Preparing electerm app (from project root: ${WEB_SRC_DIR})" +echo "==> Preparing electerm web app (from project root: ${PROJECT_ROOT})" -if [ ! -f "${WEB_SRC_DIR}/package.json" ]; then - echo " ✗ package.json not found at ${WEB_SRC_DIR}/package.json" - exit 1 -fi +cd "${PROJECT_ROOT}" -cd "${WEB_SRC_DIR}" - -# Print version for traceability APP_VERSION=$(python3 -c "import json; print(json.load(open('package.json'))['version'])" 2>/dev/null || echo "unknown") echo " Version: ${APP_VERSION}" -# Install dependencies (needed for vite, pug, shelljs, and static asset packages) -# --ignore-scripts prevents native module compilation (electron-rebuild etc.) +# Install dependencies (frontend build tools + backend runtime deps). +# --ignore-scripts avoids native module compilation for the host platform +# (node-pty, serialport — not needed, they are esbuild externals). echo " Installing dependencies ..." -npm install --legacy-peer-deps --ignore-scripts - -# Create .env from .sample.env if it exists (needed by build/vite/common.js for dotenv) -if [ -f ".sample.env" ]; then - echo " Creating .env ..." - cp .sample.env .env -fi - -# Set SERVER_SECRET from CI env var (optional) -# Use printf + grep -v + append to avoid sed delimiter issues -# with base64 secrets that may contain / or & characters. -if [ -n "${OHOS_SERVER_SECRET:-}" ]; then - echo " Setting SERVER_SECRET from OHOS_SERVER_SECRET ..." - grep -v '^SERVER_SECRET=' .env > .env.tmp 2>/dev/null || true - printf 'SERVER_SECRET=%s\n' "${OHOS_SERVER_SECRET}" >> .env.tmp - mv .env.tmp .env -fi - -# Run the HarmonyOS build script (vite + copy source + install deps + copy to resfile) -echo " Building HarmonyOS electerm app (direct source mode) ..." -npm run build:harmony +npm ci --legacy-peer-deps --ignore-scripts || { + echo " npm ci failed, falling back to npm install ..." + npm install --legacy-peer-deps --ignore-scripts +} + +# Copy @electerm/electerm-react's client sources into src/client/electerm-react +# (gitignored generated dir the vite build imports from). This is the android +# repo's build/bin/install.js step; run it directly — `npm run install` would +# collide with npm's install lifecycle script. +echo " Installing electerm-react client sources ..." +node build/bin/install.js + +# Build frontend + backend into entry resfile +echo " Building electerm web app ..." +npm run build:web # --- Verify output --- if [ ! -d "${RESFILE_APP_DIR}" ]; then echo " ✗ Build output not found at ${RESFILE_APP_DIR}" - echo " Run node build/harmony/build.js manually to check for errors." + echo " Run node build/web/build.mjs manually to check for errors." exit 1 fi -# Verify bootstrap.js (HarmonyOS Electron main process entry) -if [ ! -f "${RESFILE_APP_DIR}/bootstrap.js" ]; then - echo " ✗ Missing: ${RESFILE_APP_DIR}/bootstrap.js" +for f in "index.js" "app.bundle.mjs" "package.json" "views/index.pug"; do + if [ ! -f "${RESFILE_APP_DIR}/${f}" ]; then + echo " ✗ Missing: ${RESFILE_APP_DIR}/${f}" + exit 1 + fi + echo " ✓ Found: ${f}" +done + +JS_COUNT=$(find "${RESFILE_APP_DIR}/dist/assets/js" -name "*.js" 2>/dev/null | wc -l | tr -d ' ') +CSS_COUNT=$(find "${RESFILE_APP_DIR}/dist/assets/css" -name "*.css" 2>/dev/null | wc -l | tr -d ' ') +echo " ✓ dist/assets/js: ${JS_COUNT} files, dist/assets/css: ${CSS_COUNT} files" +if [ "${JS_COUNT}" = "0" ] || [ "${CSS_COUNT}" = "0" ]; then + echo " ✗ Frontend assets missing" exit 1 fi -echo " ✓ Found: bootstrap.js" - -# Verify app.js (loaded by bootstrap.js after paths are ready) -if [ ! -f "${RESFILE_APP_DIR}/app.js" ]; then - echo " ✗ Missing: ${RESFILE_APP_DIR}/app.js" - exit 1 -fi -echo " ✓ Found: app.js" - -# Verify package.json -if [ ! -f "${RESFILE_APP_DIR}/package.json" ]; then - echo " ✗ Missing: ${RESFILE_APP_DIR}/package.json" - exit 1 -fi -echo " ✓ Found: package.json" - -# Verify node_modules -if [ ! -d "${RESFILE_APP_DIR}/node_modules" ]; then - echo " ✗ Missing: node_modules/" - exit 1 -fi -echo " ✓ Found: node_modules/" - -# Remove any .env file — not needed in the Electron app -rm -f "${RESFILE_APP_DIR}/.env" echo " ✓ App size: $(du -sh "${RESFILE_APP_DIR}" | cut -f1)" echo "==> Web app preparation complete." diff --git a/src/app/app.js b/src/app/app.js deleted file mode 100644 index e2a1bf6..0000000 --- a/src/app/app.js +++ /dev/null @@ -1,13 +0,0 @@ -/** - * app entry - */ -const log = require('./common/log') -const { createApp } = require('./lib/create-app') -const globalState = require('./lib/glob-state') - -globalState.set('initTime', Date.now()) - -log.debug('electerm start') - -const app = createApp() -globalState.set('app', app) diff --git a/src/app/bootstrap.js b/src/app/bootstrap.js deleted file mode 100644 index 0132142..0000000 --- a/src/app/bootstrap.js +++ /dev/null @@ -1,130 +0,0 @@ -/** - * bootstrap.js — HarmonyOS entry point. - * - * AbilityStage.ets writes the sandbox filesDir path to a marker file - * (.electerm-data-path) before the Electron runtime starts. This file - * reads that marker and sets process.env.DATA_PATH so all downstream - * modules use the sandbox directory for data storage (nedb, config, logs). - * - * EntryAbility.ets requests READ_WRITE_DOCUMENTS_DIRECTORY at runtime, - * then writes the Documents directory path to a second marker file - * (.electerm-documents-path). This file reads that marker and overrides - * os.homedir() to return the Documents folder — so that file save - * dialogs, SFTP local paths, and other home-directory-based operations - * default to the user-visible Documents folder. - * - * DATA_PATH resolution order: - * 1. Marker file (.electerm-data-path, written by AbilityStage.ets → sandbox filesDir) - * 2. Derived sandbox filesDir (from __dirname) - * 3. /data/local/tmp or os.tmpdir() — absolute last resort - * - * HOMEDIR_PATH resolution order: - * 1. Marker file (.electerm-documents-path, written by EntryAbility.ets → Documents dir) - * 2. Original os.homedir() (system default) - */ -const fs = require('fs') -const path = require('path') -const os = require('os') - -function deriveSandboxFilesDir () { - // __dirname is like: /data/storage/el1/bundle/entry/resources/resfile/resources/app - // sandbox filesDir is like: /data/storage/el2/base/haps/entry/files - const m = __dirname.match(/^(.+?)\/el1\/bundle\/([^/]+)/) - if (m) { - return `${m[1]}/el2/base/haps/${m[2]}/files` - } - return null -} - -/** - * Resolve DATA_PATH — the sandbox filesDir used for app data storage. - * This is always the sandbox directory, NOT the user-visible Documents - * folder. The sandbox is always writable and doesn't require runtime - * permission requests. - */ -function getDataPath () { - const derivedDir = deriveSandboxFilesDir() - - if (derivedDir) { - // 1. Try reading the marker file written by AbilityStage.ets - const markerPath = path.join(derivedDir, '.electerm-data-path') - try { - const data = fs.readFileSync(markerPath, 'utf8').trim() - if (data) { - return data - } - } catch (e) { /* ignore */ } - - // 2. Use the derived sandbox filesDir directly - try { - fs.mkdirSync(derivedDir, { recursive: true }) - return derivedDir - } catch (e) { /* ignore */ } - } - - // 3. Final fallback — try /data/local/tmp, then os.tmpdir() - const fallbacks = ['/data/local/tmp', os.tmpdir()] - for (const dir of fallbacks) { - try { - fs.mkdirSync(dir, { recursive: true }) - return dir - } catch (e) { /* ignore */ } - } - - return os.tmpdir() -} - -/** - * Resolve HOMEDIR_PATH — the user-visible Documents directory. - * This is used to override os.homedir() so that file save dialogs, - * SFTP local paths, and other home-directory-based operations default - * to the Documents folder visible to users. - * - * If the Documents path marker is not available (permission denied), - * falls back to the original os.homedir() value. - */ -function getHomedirPath () { - const derivedDir = deriveSandboxFilesDir() - - if (derivedDir) { - const markerPath = path.join(derivedDir, '.electerm-documents-path') - try { - const data = fs.readFileSync(markerPath, 'utf8').trim() - if (data) { - return data - } - } catch (e) { /* ignore */ } - } - - // Fallback: try os.homedir() + '/Documents' - const docsPath = path.join(os.homedir(), 'Documents') - try { - fs.mkdirSync(docsPath, { recursive: true }) - const testFile = path.join(docsPath, '.write-test') - fs.writeFileSync(testFile, 'ok') - fs.unlinkSync(testFile) - return docsPath - } catch (e) { /* ignore */ } - - // Final fallback: original os.homedir() - return os.homedir() -} - -process.env.DATA_PATH = getDataPath() - -// ── Override os.homedir() ────────────────────────────────────────── -// On HarmonyOS the default os.homedir() returns an inaccessible path -// (e.g. /storage/Users/currentUser). We override it to return the -// user-visible Documents directory, so that file save dialogs, SFTP -// local paths, and other home-directory-based operations work -// correctly for the user. -// -// DATA_PATH (sandbox filesDir) is used for internal app data storage -// and is NOT exposed as the home directory. -const _originalHomedir = os.homedir.bind(os) -const _homedirPath = getHomedirPath() -os.homedir = function homedir () { - return _homedirPath || _originalHomedir() -} - -require('./app.js') diff --git a/src/app/common/app-props.js b/src/app/common/app-props.js deleted file mode 100644 index 2ae1ca1..0000000 --- a/src/app/common/app-props.js +++ /dev/null @@ -1,28 +0,0 @@ -/** - * app path — HarmonyOS only. - * - * bootstrap.js sets process.env.DATA_PATH (the app's sandbox data - * directory) and overrides os.homedir() before loading app.js, - * so this module simply uses it as the base for all derived paths. - */ -const { resolve } = require('path') -const fs = require('fs') -const constants = require('./runtime-constants') - -function getAppDataPath () { - return process.env.DATA_PATH || resolve(__dirname, '../../data') -} - -const appDataPath = getAppDataPath() -const sshKeysPath = resolve(appDataPath, '.ssh') -// Create immediately so SSH key reads/writes never fail on a missing dir. -try { fs.mkdirSync(sshKeysPath, { recursive: true, mode: 0o700 }) } catch {} - -module.exports = { - appPath: appDataPath, - isPortable: false, - exePath: '', - sshKeysPath, - homeOrTmp: constants.homeDir, - ...constants -} diff --git a/src/app/common/bookmark-zod-schemas.js b/src/app/common/bookmark-zod-schemas.js deleted file mode 100644 index 5a4d1f1..0000000 --- a/src/app/common/bookmark-zod-schemas.js +++ /dev/null @@ -1,130 +0,0 @@ -const { z } = require('../lib/zod') - -const runScriptSchema = z.object({ - delay: z.number().optional().describe('Delay in ms before executing this command'), - script: z.string().describe('Command to execute') -}) - -const quickCommandSchema = z.object({ - name: z.string().describe('Quick command name'), - command: z.string().describe('Command') -}) - -const sshTunnelSchema = z.object({ - sshTunnel: z.enum(['forwardRemoteToLocal', 'forwardLocalToRemote', 'dynamicForward']).describe('Tunnel type'), - sshTunnelLocalHost: z.string().optional().describe('Local host'), - sshTunnelLocalPort: z.number().optional().describe('Local port'), - sshTunnelRemoteHost: z.string().optional().describe('Remote host'), - sshTunnelRemotePort: z.number().optional().describe('Remote port'), - name: z.string().optional().describe('Tunnel name') -}) - -const connectionHoppingSchema = z.object({ - host: z.string().describe('Host address'), - port: z.number().optional().describe('Port number'), - username: z.string().optional().describe('Username'), - password: z.string().optional().describe('Password'), - privateKey: z.string().optional().describe('Private key'), - passphrase: z.string().optional().describe('Passphrase'), - certificate: z.string().optional().describe('Certificate'), - authType: z.string().optional().describe('Auth type'), - profile: z.string().optional().describe('Profile id') -}) - -const commonNetworkBookmarkProps = { - title: z.string().describe('Bookmark title'), - host: z.string().describe('Host address'), - port: z.number().optional().describe('Port number'), - username: z.string().optional().describe('Username'), - password: z.string().optional().describe('Password'), - description: z.string().optional().describe('Bookmark description'), - // runScripts: z.array(runScriptSchema).optional().describe('Run scripts after connected'), - startDirectoryRemote: z.string().optional().describe('Remote starting directory'), - startDirectoryLocal: z.string().optional().describe('Local starting directory'), - profile: z.string().optional().describe('Profile id'), - proxy: z.string().optional().describe('Proxy address (socks5://...)') -} - -const sshBookmarkSchema = { - ...commonNetworkBookmarkProps, - host: z.string().describe('SSH host address'), - port: z.number().optional().describe('SSH port (default 22)'), - username: z.string().optional().describe('SSH username'), - password: z.string().optional().describe('SSH password'), - authType: z.enum(['password', 'privateKey', 'profiles']).optional().describe('Authentication type'), - privateKey: z.string().optional().describe('Private key content or path (for privateKey auth)'), - passphrase: z.string().optional().describe('Passphrase for private key/certificate'), - certificate: z.string().optional().describe('Certificate content'), - enableSsh: z.boolean().optional().describe('Enable ssh, default is true'), - enableSftp: z.boolean().optional().describe('Enable sftp, default is true'), - useSshAgent: z.boolean().optional().describe('Use SSH agent, default is true'), - sshAgent: z.string().optional().describe('SSH agent path'), - serverHostKey: z.array(z.string()).optional().describe('Server host key algorithms'), - cipher: z.array(z.string()).optional().describe('Cipher list'), - compress: z.array(z.string()).optional().describe('Compression algorithms'), - quickCommands: z.array(quickCommandSchema).optional().describe('Quick commands'), - x11: z.boolean().optional().describe('Enable x11 forwarding, default is false'), - term: z.string().optional().describe('Terminal type, default is xterm-256color'), - displayRaw: z.boolean().optional().describe('Display raw output, default is false'), - encode: z.string().optional().describe('Charset, default is utf8'), - envLang: z.string().optional().describe('ENV LANG, default is en_US.UTF-8'), - // setEnv: z.string().optional().describe('Environment variables, format: KEY1=VALUE1 KEY2=VALUE2'), - color: z.string().optional().describe('Tag color, like #000000'), - // interactiveValues: z.string().optional().describe('Strings separated by newline'), - sshTunnels: z.array(sshTunnelSchema).optional().describe('SSH tunnel definitions'), - connectionHoppings: z.array(connectionHoppingSchema).optional().describe('Connection hopping definitions') -} - -const telnetBookmarkSchema = { - ...commonNetworkBookmarkProps, - host: z.string().describe('Telnet host address'), - port: z.number().optional().describe('Telnet port (default 23)'), - username: z.string().optional().describe('Telnet username'), - password: z.string().optional().describe('Telnet password'), - loginPrompt: z.string().optional().describe('Login prompt regex'), - passwordPrompt: z.string().optional().describe('Password prompt regex') -} - -const serialBookmarkSchema = { - title: z.string().describe('Bookmark title'), - path: z.string().describe('Serial device path'), - baudRate: z.number().optional().describe('Baud rate (default 9600)'), - dataBits: z.number().optional().describe('Data bits (default 8)'), - stopBits: z.number().optional().describe('Stop bits (default 1)'), - parity: z.enum(['none', 'even', 'odd', 'mark', 'space']).optional().describe('Parity (default none)'), - rtscts: z.boolean().optional().describe('RTS/CTS flow control'), - xon: z.boolean().optional().describe('XON flow control'), - xoff: z.boolean().optional().describe('XOFF flow control'), - xany: z.boolean().optional().describe('XANY flow control'), - txLineEnding: z.enum(['\r', '\n', '\r\n']).optional().describe('TX line ending appended on Enter: "\\r" (CR, default), "\\n" (LF), "\\r\\n" (CR+LF)'), - rxLineEnding: z.enum(['none', 'lf_to_crlf', 'cr_to_crlf']).optional().describe('RX line ending conversion: "none" (pass-through, default), "lf_to_crlf" (LF→CRLF for LF-only devices), "cr_to_crlf" (CR→CRLF for CR-only devices)'), - closeSequence: z.string().optional().describe('Key sequence sent to the serial port when the user clicks "exit gracefully" in the terminal controls (e.g. to cleanly exit GNU screen before disconnecting a Bluetooth serial console). Supports \\n \\t \\r \\\\ and \\xHH hex bytes, default "\\x01ky" (Ctrl+A, k, y - GNU screen kill-window confirm)'), - closeSequenceDelay: z.number().optional().describe('Milliseconds to wait after sending closeSequence before actually closing the port, default 500'), - description: z.string().optional().describe('Bookmark description') - // runScripts: z.array(runScriptSchema).optional().describe('Run scripts after connected') -} - -const localBookmarkSchema = { - title: z.string().describe('Bookmark title'), - description: z.string().optional().describe('Bookmark description'), - startDirectoryLocal: z.string().optional().describe('Local starting directory') - // runScripts: z.array(runScriptSchema).optional().describe('Run scripts after connected'), - // execWindows: z.string().optional().describe('Windows exec path (overrides global setting)'), - // execMac: z.string().optional().describe('Mac exec path (overrides global setting)'), - // execLinux: z.string().optional().describe('Linux exec path (overrides global setting)'), - // execWindowsArgs: z.array(z.string()).optional().describe('Windows exec arguments'), - // execMacArgs: z.array(z.string()).optional().describe('Mac exec arguments'), - // execLinuxArgs: z.array(z.string()).optional().describe('Linux exec arguments') -} - -module.exports = { - runScriptSchema, - quickCommandSchema, - sshTunnelSchema, - connectionHoppingSchema, - commonNetworkBookmarkProps, - sshBookmarkSchema, - telnetBookmarkSchema, - serialBookmarkSchema, - localBookmarkSchema -} diff --git a/src/app/common/build-run-scripts.js b/src/app/common/build-run-scripts.js deleted file mode 100644 index 2510ef0..0000000 --- a/src/app/common/build-run-scripts.js +++ /dev/null @@ -1,6 +0,0 @@ -exports.buildRunScripts = function (inst) { - return [{ - delay: inst.loginScriptDelay || 0, - script: inst.loginScript - }] -} diff --git a/src/app/common/build-ssh-tunnel.js b/src/app/common/build-ssh-tunnel.js deleted file mode 100644 index 4b1e587..0000000 --- a/src/app/common/build-ssh-tunnel.js +++ /dev/null @@ -1,8 +0,0 @@ -exports.buildSshTunnels = function (inst) { - return [{ - sshTunnel: inst.sshTunnel, - sshTunnelRemotePort: inst.sshTunnelRemotePort, - sshTunnelLocalPort: inst.sshTunnelLocalPort, - sshTunnelRemoteHost: inst.sshTunnelRemoteHost - }] -} diff --git a/src/app/common/config-default.js b/src/app/common/config-default.js deleted file mode 100644 index 077709f..0000000 --- a/src/app/common/config-default.js +++ /dev/null @@ -1,23 +0,0 @@ -const defaultSettings = require('./default-setting') - -module.exports = exports.default = { - keepaliveInterval: 10000, - rightClickSelectsWord: false, - pasteWhenContextMenu: false, - ctrlOrMetaOpenTerminalLink: false, - ...defaultSettings, - terminalTimeout: 5000, - enableGlobalProxy: false, - zoom: 1, - debug: false, - theme: 'default', - syncSetting: { - lastUpdateTime: Date.now(), - autoSync: false, - autoSyncInterval: 0, - autoSyncDirection: 'upload' - }, - keyword2FA: 'verification code,otp,one-time,two-factor,2fa,totp,authenticator,duo,yubikey,security code,mfa,passcode', - - host: '127.0.0.1' -} diff --git a/src/app/common/constants.js b/src/app/common/constants.js deleted file mode 100644 index dec3442..0000000 --- a/src/app/common/constants.js +++ /dev/null @@ -1,27 +0,0 @@ -/** - * contants shared in app/client - */ - -exports.userConfigId = 'userConfig' -exports.userNoEncryptConfigId = 'userConfigNoEncrypt' -exports.instSftpKeys = [ - 'connect', - 'list', - 'download', - 'upload', - 'mkdir', - 'getHomeDir', - 'rmdir', - 'stat', - 'lstat', - 'chmod', - 'rename', - 'rm', - 'touch', - 'readlink', - 'realpath', - 'mv', - 'cp', - 'readFile', - 'writeFile' -] diff --git a/src/app/common/create-session-log-file-path.js b/src/app/common/create-session-log-file-path.js deleted file mode 100644 index b283941..0000000 --- a/src/app/common/create-session-log-file-path.js +++ /dev/null @@ -1,7 +0,0 @@ -/** - * functions to create ssh log of session - */ - -exports.createLogFileName = (id) => { - return `${id}.log` -} diff --git a/src/app/common/default-setting.js b/src/app/common/default-setting.js deleted file mode 100644 index 3b6d934..0000000 --- a/src/app/common/default-setting.js +++ /dev/null @@ -1,87 +0,0 @@ -/** - * default setting - */ - -module.exports = exports.default = { - hotkey: 'Control+2', - sshReadyTimeout: 50000, - scrollback: 3000, - onStartSessions: [], - fontSize: 16, - fontFamily: 'Maple Mono, mono, courier-new, courier, monospace', - execWindows: 'System32/WindowsPowerShell/v1.0/powershell.exe', - execMac: 'zsh', - execLinux: 'bash', - execWindowsArgs: [], - execMacArgs: [], - execLinuxArgs: [], - enableGlobalProxy: false, - disableConnectionHistory: false, - disableTransferHistory: false, - terminalBackgroundImagePath: '', - terminalBackgroundFilterOpacity: 1, - terminalBackgroundFilterBlur: 0, - terminalBackgroundFilterBrightness: 1, - terminalBackgroundFilterGrayscale: 0, - terminalBackgroundFilterContrast: 1, - rendererType: 'dom', - terminalType: 'xterm-256color', - keepaliveCountMax: 10, - saveTerminalLogToFile: false, - checkUpdateOnStart: true, - cursorBlink: false, - cursorStyle: 'block', - useSystemTitleBar: false, - opacity: 1, - defaultEditor: '', - terminalWordSeparator: './\\()"\'-:,.;<>~!@#$%^&*|+=[]{}`~ ?', - confirmBeforeExit: false, - initDefaultTabOnStart: true, - screenReaderMode: false, - autoRefreshWhenSwitchToSftp: false, - addTimeStampToTermLog: false, - keepaliveInterval: 10000, - backspaceMode: '^?', - shiftEnterMode: '\\n', - showHiddenFilesOnSftpStart: true, - terminalInfos: [ - 'uptime', - 'cpu', - 'mem', - 'activities', - 'network', - 'disks' - ], - filePropsEnabled: [ - 'name', - 'size', - 'modifyTime' - ], - hideIP: false, - dataSyncSelected: 'all', - nameAI: '', - baseURLAI: 'https://api.atlascloud.ai/v1', - modelAI: 'deepseek-chat', - roleAI: '终端专家,提供不同系统下命令,简要解释用法,用markdown格式', - apiPathAI: '/chat/completions', - authHeaderNameAI: 'Authorization: Bearer', - proxyAI: '', - sessionLogPath: '', - sshSftpSplitView: false, - showCmdSuggestions: false, - startDirectoryLocal: '', - allowMultiInstance: false, - disableDeveloperTool: false, - dragDropBehavior: 'ask', - switchTabOnHover: false, - disableShortcutBar: false, - leftSideBarIcons: [ - 'newBookmark', - 'quickConnect', - 'bookmarks', - 'terminalThemes', - 'setting', - 'settingSync', - 'widgets' - ] -} diff --git a/src/app/common/default-user-name.js b/src/app/common/default-user-name.js deleted file mode 100644 index 9182796..0000000 --- a/src/app/common/default-user-name.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = exports.defaultUserName = 'default_user' diff --git a/src/app/common/get-folder-size-and-file-count.js b/src/app/common/get-folder-size-and-file-count.js deleted file mode 100644 index db84926..0000000 --- a/src/app/common/get-folder-size-and-file-count.js +++ /dev/null @@ -1,44 +0,0 @@ -exports.getSizeCount = function (str) { - const [s1, s2] = str.split('\n').map(d => d.trim()) - const arr = s1.split(/\s+/) - const d1 = arr[0] - let size = parseFloat(d1) - const unit = d1.slice(-1) - if (unit === 'M') { - size = size / 1024 - } else if (unit === 'K') { - size = size / 1024 / 1024 - } - const count = parseInt(s2, 10) - return { - count, - size - } -} - -exports.getSizeCountWin = function (str) { - const arr = str.trim().split('\n') - let count = 0 - let size = 0 - let all = 0 - for (const s of arr) { - const [s1, s2] = s.trim().split(/\s+/) - if (s1 === 'Count') { - count = parseInt(s2, 10) - all = all + 1 - if (all > 1) { - break - } - } else if (s1 === 'Sum') { - all = all + 1 - size = parseInt(s2, 10) / 1024 - if (all > 1) { - break - } - } - } - return { - count, - size - } -} diff --git a/src/app/common/log.js b/src/app/common/log.js deleted file mode 100644 index 450b17f..0000000 --- a/src/app/common/log.js +++ /dev/null @@ -1,11 +0,0 @@ -const log = require('electron-log') -const { isDev } = require('./runtime-constants') - -log.transports.console.format = '{h}:{i}:{s} {level} › {text}' - -if (!isDev) { - log.transports.console.level = 'warn' - log.transports.file.level = 'warn' -} - -module.exports = exports.default = log diff --git a/src/app/common/lookup.js b/src/app/common/lookup.js deleted file mode 100644 index 2b99ab5..0000000 --- a/src/app/common/lookup.js +++ /dev/null @@ -1,28 +0,0 @@ -/** - * dns lookup - */ - -module.exports = (host) => { - const dns = require('dns') - const v4 = new Promise((resolve, reject) => { - dns.resolve4(host, function (err, result) { - if (err) { - console.log(`v4 dns lookup error: ${err.message}`) - return resolve([]) - } - resolve(result) - }) - }) - const v6 = new Promise((resolve, reject) => { - dns.resolve6(host, function (err, result) { - if (err) { - console.log(`v6 dns lookup error: ${err.message}`) - return resolve([]) - } - resolve(result) - }) - }) - return Promise.all([v4, v6]).then(result => { - return [...result[0], ...result[1]] - }) -} diff --git a/src/app/common/parse-quick-connect.js b/src/app/common/parse-quick-connect.js deleted file mode 100644 index 7a1f8f3..0000000 --- a/src/app/common/parse-quick-connect.js +++ /dev/null @@ -1,455 +0,0 @@ -/** - * Quick Connect String Parser - * Parses connection strings according to temp/quick-connect.wiki.md specification - * - * Supported Protocols: ssh, telnet, vnc, rdp, spice, serial, ftp, http, https, electerm - * - * Basic Format: - * protocol://[username:password@]host[:port]?anyQueryParam=anyValue&opts={"key":"value"} - * - * electerm:// Format (default type is ssh): - * electerm://[username:password@]host[:port]?type=ssh&anyQueryParam=anyValue - * electerm://host?type=telnet - * electerm://user@host:22?type=vnc - * - * Shortcut Format (SSH default): - * user@host - * user@host:22 - * 192.168.1.100 - * 192.168.1.100:22 - */ - -const SUPPORTED_PROTOCOLS = ['ssh', 'telnet', 'vnc', 'rdp', 'spice', 'serial', 'ftp', 'http', 'https', 'electerm'] - -/** - * Deny list for opts keys - these are parsed from the URL itself - * and should not be overridable via the opts JSON parameter for safety - */ -const OPTS_DENY_LIST = ['type', 'host'] - -/** - * Default ports for each protocol - */ -const DEFAULT_PORTS = { - ssh: 22, - telnet: 23, - vnc: 5900, - rdp: 3389, - spice: 5900, - serial: undefined, // Serial doesn't have a default port - ftp: 21, - http: 80, - https: 443, - electerm: 22 // electerm defaults to SSH port -} - -/** - * Default values for each protocol type - * Based on src/client/components/bookmark-form/config - */ -const TYPE_DEFAULT_VALUES = { - ssh: { - port: 22, - enableSsh: true, - enableSftp: true, - useSshAgent: true, - authType: 'password', - term: 'xterm-256color', - encode: 'utf-8', - envLang: 'en_US.UTF-8' - }, - telnet: { - port: 23 - }, - vnc: { - port: 5900, - viewOnly: false, - clipViewport: false, - scaleViewport: true, - qualityLevel: 3, - compressionLevel: 1, - shared: true - }, - rdp: { - port: 3389 - }, - spice: { - port: 5900, - viewOnly: false, - scaleViewport: true - }, - serial: { - baudRate: 9600, - dataBits: 8, - lock: true, - stopBits: 1, - parity: 'none', - rtscts: false, - xon: false, - xoff: false, - xany: false, - term: 'xterm-256color', - displayRaw: false - }, - ftp: { - port: 21, - encode: 'utf-8', - secure: false - }, - web: {}, - local: {} -} - -/** - * Parse a quick connect string into connection options - * @param {string} str - The connection string - * @returns {object|null} - Parsed options or null if invalid - */ -function parseQuickConnect (str) { - if (!str || typeof str !== 'string') { - return null - } - - const trimmed = str.trim() - if (!trimmed) { - return null - } - - try { - // Strip trailing slashes (supports pasted URLs like host/ or ssh://host/) - const input = trimmed.replace(/\/+$/, '') - - // Detect protocol - const protocolMatch = input.match(/^(ssh|telnet|vnc|rdp|spice|serial|ftp|https?|electerm):\/\//i) - - let protocol = '' - let connectionString = '' - let originalProtocol = 'ssh' - - if (protocolMatch) { - originalProtocol = protocolMatch[1].toLowerCase() - protocol = originalProtocol - // Normalize http/https to web - if (protocol === 'http' || protocol === 'https') { - protocol = 'web' - } - connectionString = input.slice(protocolMatch[0].length) - } else { - // Shortcut format - default to SSH - // Match user@host or user@host:port or just host or host:port - // Use last colon to determine port for host:port format - if (/^[\w.-]+(?::[^@]+)?@[\w.-]+/.test(input)) { - // user@host, user:password@host, or user@host:port - protocol = 'ssh' - connectionString = input - } else if (/^[\w.-]+:.*:[\d]+$/.test(input)) { - // host:port format with colons in hostname (e.g., localhost:23344, zxd:localhost:23344) - // Check if the last colon is followed by digits (port number) - protocol = 'ssh' - connectionString = input - } else if (/^[\w.-]+:[\d]+$/.test(input)) { - // host:port (no username, simple format like host:22) - protocol = 'ssh' - connectionString = input - } else if (/^[\w.-]+$/.test(input)) { - // just host - protocol = 'ssh' - connectionString = input - } else { - return null - } - } - - if (!SUPPORTED_PROTOCOLS.includes(protocol) && protocol !== 'web') { - return null - } - - // Extract opts from the connection string before parsing - let optsStr = '' - const optsMatch = connectionString.match(/[?&]opts=('|")(.+?)('|")$/) - if (!optsMatch) { - // Try without quotes - const optsMatchNoQuote = connectionString.match(/[?&]opts=(\{.+?\})$/) - if (optsMatchNoQuote) { - optsStr = optsMatchNoQuote[1] - connectionString = connectionString.slice(0, optsMatchNoQuote.index) - } - } else { - optsStr = optsMatch[2] - connectionString = connectionString.slice(0, optsMatch.index) - } - - // Extract query string for web type and electerm type - let queryStr = '' - const queryMatch = connectionString.match(/\?(.+)$/) - if (queryMatch) { - queryStr = queryMatch[1] - connectionString = connectionString.slice(0, queryMatch.index) - } - - // Parse username:password@host:port - // First, check if there's an @ for auth - let username = '' - let password = '' - let hostOrPath = '' - let port = '' - - const atIndex = connectionString.indexOf('@') - if (atIndex !== -1) { - // Has auth - const authPart = connectionString.slice(0, atIndex) - const hostPart = connectionString.slice(atIndex + 1) - const colonIndex = authPart.indexOf(':') - if (colonIndex !== -1) { - username = authPart.slice(0, colonIndex) - password = authPart.slice(colonIndex + 1) - } else { - username = authPart - } - // Parse host:port from hostPart - const hostColonIndex = hostPart.lastIndexOf(':') - if (hostColonIndex !== -1) { - hostOrPath = hostPart.slice(0, hostColonIndex) - port = hostPart.slice(hostColonIndex + 1) - } else { - hostOrPath = hostPart - } - } else { - // No @ sign - check for special case: protocol://password:host (e.g., spice://password:host) - // This only applies to spice protocol - if (protocol === 'spice') { - // Count colons in the connection string - const colonCount = (connectionString.match(/:/g) || []).length - - if (colonCount >= 2) { - // Multiple colons - could be password:host:port or host:port with IP - // Use lastIndexOf for port, then check if first part is password or IP - const lastColonIndex = connectionString.lastIndexOf(':') - const portCandidate = connectionString.slice(lastColonIndex + 1) - - if (/^\d+$/.test(portCandidate)) { - // Last part is a port number - const hostPortPart = connectionString.slice(0, lastColonIndex) - const secondLastColonIndex = hostPortPart.lastIndexOf(':') - - if (secondLastColonIndex !== -1) { - // There's another colon - first part could be password - const potentialPassword = hostPortPart.slice(0, secondLastColonIndex) - const hostPart = hostPortPart.slice(secondLastColonIndex + 1) - - // Check if potentialPassword is NOT an IP/hostname - // An IP/hostname should contain dots, a password typically doesn't - // Also check it's not a simple number (port) - const isIPorHostname = (potentialPassword.includes('.') || /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(potentialPassword)) - - if (isIPorHostname) { - // It's IP, no password - hostOrPath = hostPortPart - port = portCandidate - } else { - // It's password - password = potentialPassword - hostOrPath = hostPart - port = portCandidate - } - } else { - // Only one colon before the port - it's host:port - hostOrPath = hostPortPart - port = portCandidate - } - } else { - // Last part is not a port - hostOrPath = connectionString - } - } else if (colonCount === 1) { - // Single colon - could be host:port or just a word with colon - const colonIndex = connectionString.indexOf(':') - const firstPart = connectionString.slice(0, colonIndex) - const secondPart = connectionString.slice(colonIndex + 1) - - // Check if first part is an IP - const isIP = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(firstPart) - - if (isIP) { - // IP with port - hostOrPath = firstPart - port = secondPart - } else if (/^\d+$/.test(secondPart)) { - // Just a word with port number - // This is likely password (for spice) or host without port - // For spice, treat first part as host (not password) since there's only one colon - hostOrPath = firstPart - port = secondPart - } else { - // host or hostname - hostOrPath = connectionString - } - } else { - // No colon - just host - hostOrPath = connectionString - } - } else { - // Normal case - just host:port - const hostColonIndex = connectionString.lastIndexOf(':') - if (hostColonIndex !== -1) { - // Make sure it's a port number (all digits) - const potentialPort = connectionString.slice(hostColonIndex + 1) - if (/^\d+$/.test(potentialPort)) { - hostOrPath = connectionString.slice(0, hostColonIndex) - port = potentialPort - } else { - hostOrPath = connectionString - } - } else { - hostOrPath = connectionString - } - } - } - - if (!hostOrPath) { - return null - } - - // Build base options - // For electerm protocol, we need to handle the type from query params - let finalProtocol = protocol - let webProtocol = originalProtocol // Store original for web type - - // Handle electerm:// protocol - extract type from query params, default to ssh - if (originalProtocol === 'electerm') { - // Parse query params to get type - const params = new URLSearchParams(queryStr) - finalProtocol = params.get('type') || params.get('tp') || 'ssh' - - // Validate the type is supported - if (!SUPPORTED_PROTOCOLS.includes(finalProtocol) && finalProtocol !== 'web') { - return null - } - - // Normalize http/https to web - if (finalProtocol === 'http' || finalProtocol === 'https') { - webProtocol = finalProtocol // Store the http/https before normalizing - finalProtocol = 'web' - // Remove type/tp from query string for web URL construction - params.delete('type') - params.delete('tp') - queryStr = params.toString() - } - } else { - webProtocol = originalProtocol - } - - const opts = { - type: finalProtocol - } - - // Handle different protocol types - if (finalProtocol === 'serial') { - // Serial: path is the port - opts.path = hostOrPath - if (port) { - opts.baudRate = parseInt(port, 10) - } - // Parse query params for serial (like baudRate) - if (queryStr) { - const params = new URLSearchParams(queryStr) - if (params.has('baudRate')) { - opts.baudRate = parseInt(params.get('baudRate'), 10) - } - } - } else if (finalProtocol === 'web') { - // Web: construct URL from protocol + host + port + query - let url = `${webProtocol}://${hostOrPath}` - if (port) { - // Add non-standard port to URL - const defaultPort = originalProtocol === 'https' ? 443 : 80 - if (parseInt(port, 10) !== defaultPort) { - url += `:${port}` - } - } - // Add query string if present - if (queryStr) { - const separator = url.includes('?') ? '&' : '?' - url += `${separator}${queryStr}` - } - opts.url = url - } else { - // SSH, Telnet, VNC, RDP, Spice, FTP - opts.host = hostOrPath - if (port) { - opts.port = parseInt(port, 10) - } - if (username !== undefined && username !== '') { - // FTP form uses 'user' instead of 'username' - if (finalProtocol === 'ftp') { - opts.user = username - } else { - opts.username = username - } - } - if (password !== undefined && password !== '') { - opts.password = password - } - // Parse query params for other protocols (like title) - if (queryStr) { - const params = new URLSearchParams(queryStr) - if (params.has('title')) { - opts.title = params.get('title') - } - } - } - - // Parse opts JSON to extend params - if (optsStr) { - try { - const extraOpts = JSON.parse(optsStr) - OPTS_DENY_LIST.forEach(key => delete extraOpts[key]) - Object.assign(opts, extraOpts) - } catch (err) { - console.error('Failed to parse opts:', err) - } - } - - // Apply default values for the protocol type - const typeDefaults = TYPE_DEFAULT_VALUES[finalProtocol] - if (typeDefaults) { - Object.keys(typeDefaults).forEach(key => { - // Only apply default if not already set - if (opts[key] === undefined) { - opts[key] = typeDefaults[key] - } - }) - } - - return opts - } catch (error) { - console.error('Error parsing quick connect string:', error) - return null - } -} - -/** - * Get default port for a protocol - * @param {string} protocol - The protocol name - * @returns {number|undefined} - Default port or undefined - */ -function getDefaultPort (protocol) { - return DEFAULT_PORTS[protocol] -} - -/** - * Get list of supported protocols - * @returns {string[]} - List of supported protocols - */ -function getSupportedProtocols () { - return [...SUPPORTED_PROTOCOLS] -} - -module.exports = { - parseQuickConnect, - getDefaultPort, - getSupportedProtocols, - SUPPORTED_PROTOCOLS, - DEFAULT_PORTS, - OPTS_DENY_LIST -} diff --git a/src/app/common/pass-enc.js b/src/app/common/pass-enc.js deleted file mode 100644 index 69a097a..0000000 --- a/src/app/common/pass-enc.js +++ /dev/null @@ -1,17 +0,0 @@ -exports.enc = (str) => { - if (typeof str !== 'string') { - return str - } - return str.split('').map((s, i) => { - return String.fromCharCode((s.charCodeAt(0) + i + 1) % 65536) - }).join('') -} - -exports.dec = (str) => { - if (typeof str !== 'string') { - return str - } - return str.split('').map((s, i) => { - return String.fromCharCode((s.charCodeAt(0) - i - 1 + 65536) % 65536) - }).join('') -} diff --git a/src/app/common/runtime-constants.js b/src/app/common/runtime-constants.js deleted file mode 100644 index 01bf478..0000000 --- a/src/app/common/runtime-constants.js +++ /dev/null @@ -1,77 +0,0 @@ -/** - * run time contants - */ - -const os = require('os') -const fs = require('fs') -const { resolve } = require('path') - -const platform = os.platform() -const arch = os.arch() -const isWin = platform === 'win32' -const isMac = platform === 'darwin' -const isLinux = platform === 'linux' -const isArm = arch.includes('arm') - -const { NODE_ENV, NODE_TEST } = process.env -const isDev = NODE_ENV === 'development' -const iconPath = resolve( - __dirname, - ( - isDev - ? '../../../node_modules/@electerm/electerm-resource/res/imgs/electerm-round-128x128.png' - : '../assets/images/electerm-round-128x128.png' - ) -) -const trayIconPath = resolve( - __dirname, - ( - isDev - ? '../../../node_modules/@electerm/electerm-resource/tray-icons/electerm-tray.png' - : '../assets/images/electerm-tray.png' - ) -) -const extIconPath = isDev - ? '/node_modules/electerm-icons/icons/' - : 'icons/' - -const defaultUserName = require('./default-user-name') - -/** - * bootstrap.js overrides os.homedir() to return the app's sandbox data - * directory (DATA_PATH), so getHomeDir() simply delegates to it. - * os.tmpdir() may still point outside the sandbox, so getTempDir() - * derives a writable tmp/ subdirectory under DATA_PATH. - */ -function getHomeDir () { - return os.homedir() -} - -function getTempDir () { - if (process.env.DATA_PATH) { - const dir = resolve(process.env.DATA_PATH, 'tmp') - // Create immediately so downstream writes never fail on a missing dir. - try { fs.mkdirSync(dir, { recursive: true }) } catch {} - return dir - } - return os.tmpdir() -} - -module.exports = { - isTest: !!NODE_TEST, - isDev, - isWin, - isMac, - isArm, - isLinux, - iconPath, - trayIconPath, - extIconPath, - defaultUserName, - minWindowWidth: 590, - minWindowHeight: 400, - defaultLang: 'zh_cn', - homeDir: getHomeDir(), - tempDir: getTempDir(), - packInfo: require(isDev ? '../../../package.json' : '../package.json') -} diff --git a/src/app/common/sanitize-filename.js b/src/app/common/sanitize-filename.js deleted file mode 100644 index dfe0141..0000000 --- a/src/app/common/sanitize-filename.js +++ /dev/null @@ -1,73 +0,0 @@ -/** - * Sanitize a filename for cross-platform file transfers. - * - * When transferring files between different OS (Linux <-> Windows <-> macOS), - * filenames may contain characters that are illegal on the destination OS. - * Windows is the most restrictive common platform, so we use its rules as - * the baseline for maximum compatibility. - * - * Rules applied: - * - Remove control characters (0x00-0x1F) - * - Replace reserved characters: < > : " / \ | ? * with _ - * - Strip trailing dots and spaces (Windows restriction: "file.", "file ") - * - Strip leading spaces only (NOT leading dots — they mean hidden file) - * - Reject reserved Windows device names: CON, PRN, AUX, NUL, COM1-9, LPT1-9 - * - Limit filename length to 255 bytes (common filesystem limit) - * - Fallback to 'unnamed' if result is empty - */ - -// Characters illegal on Windows (and problematic on many systems) -// eslint-disable-next-line no-control-regex -const ILLEGAL_CHARS = /[<>:"/\\|?\x00-\x1f]/g - -// Trailing dots and spaces = problematic on Windows (e.g. "file." → "file") -// Leading dots are PRESERVED — they mean "hidden file" on Unix and work on modern Windows -const TRAILING_DOTS_SPACES = /[.\s]+$/g - -// Leading spaces only — Windows can't handle filenames starting with space -const LEADING_SPACES = /^\s+/ - -// Reserved Windows device names (case-insensitive) -const RESERVED_NAMES = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)/i - -const MAX_FILENAME_LENGTH = 255 - -const REPLACEMENT_CHAR = '_' - -module.exports = function sanitizeFilename (name) { - if (!name || typeof name !== 'string') { - return 'unnamed' - } - - let safe = name - // Replace illegal characters - .replace(ILLEGAL_CHARS, REPLACEMENT_CHAR) - // Strip trailing dots and spaces (Windows restriction) - .replace(TRAILING_DOTS_SPACES, '') - // Strip leading spaces only (not dots — they mean hidden file) - .replace(LEADING_SPACES, '') - - // Handle reserved Windows device names by appending underscore - if (RESERVED_NAMES.test(safe)) { - safe = safe + REPLACEMENT_CHAR - } - - // Truncate to max length - if (safe.length > MAX_FILENAME_LENGTH) { - const ext = safe.lastIndexOf('.') - if (ext > 0) { - // Preserve extension when truncating - const extension = safe.slice(ext) - safe = safe.slice(0, MAX_FILENAME_LENGTH - extension.length) + extension - } else { - safe = safe.slice(0, MAX_FILENAME_LENGTH) - } - } - - // Fallback for empty result - if (!safe) { - return 'unnamed' - } - - return safe -} diff --git a/src/app/common/time.js b/src/app/common/time.js deleted file mode 100644 index f5c4d31..0000000 --- a/src/app/common/time.js +++ /dev/null @@ -1,19 +0,0 @@ -/** - * time formatter - */ - -const formatTime = (time = new Date()) => { - const date = time instanceof Date ? time : new Date(time) - - const year = date.getFullYear() - const month = String(date.getMonth() + 1).padStart(2, '0') - const day = String(date.getDate()).padStart(2, '0') - const hours = String(date.getHours()).padStart(2, '0') - const minutes = String(date.getMinutes()).padStart(2, '0') - const seconds = String(date.getSeconds()).padStart(2, '0') - const milliseconds = String(date.getMilliseconds()).padStart(3, '0') - - return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}` -} - -module.exports = formatTime diff --git a/src/app/common/uid.js b/src/app/common/uid.js deleted file mode 100644 index b0463d3..0000000 --- a/src/app/common/uid.js +++ /dev/null @@ -1,4 +0,0 @@ -const { nanoid } = require('nanoid') -module.exports = () => { - return nanoid(7) -} diff --git a/src/app/common/version-compare.js b/src/app/common/version-compare.js deleted file mode 100644 index 64bc3d2..0000000 --- a/src/app/common/version-compare.js +++ /dev/null @@ -1,25 +0,0 @@ -/** - * version compare - * @param {string} a - * @param {string} b - * @return {number} - */ -// compare version '1.0.0' '12.0.3' -// return 1 when a > b -// return -1 when a < b -// return 0 when a === b -module.exports = exports.default = function (a, b) { - const ar = a.split('.').map(n => Number(n.replace('v', ''))) - const br = b.split('.').map(n => Number(n.replace('v', ''))) - let res = 0 - for (let i = 0, len = br.length; i < len; i++) { - if (br[i] < ar[i]) { - res = 1 - break - } else if (br[i] > ar[i]) { - res = -1 - break - } - } - return res -} diff --git a/src/app/lib/ai.js b/src/app/lib/ai.js deleted file mode 100644 index 07c67f1..0000000 --- a/src/app/lib/ai.js +++ /dev/null @@ -1,235 +0,0 @@ -const axios = require('axios') -const { StringDecoder } = require('string_decoder') -const log = require('../common/log') -const defaultSettings = require('../common/config-default') -const { createProxyAgent } = require('./proxy-agent') - -// Store for ongoing streaming sessions -const streamingSessions = new Map() - -// Stop an ongoing streaming session -exports.stopStream = (sessionId) => { - const session = streamingSessions.get(sessionId) - if (!session) { - return { error: 'Session not found' } - } - - // Destroy the stream to stop receiving data - if (session.stream && !session.stream.destroyed) { - session.stream.destroy() - } - - // Mark as completed (not an error, just stopped by user) - session.completed = true - session.stopped = true - - // Clean up - streamingSessions.delete(sessionId) - - return { stopped: true } -} - -const createAIClient = (baseURL, apiKey, proxy, authHeaderName) => { - const headerStr = authHeaderName || 'Authorization: Bearer' - const parts = headerStr.split(': ') - const headerKey = parts[0] - const headerPrefix = parts.length > 1 ? parts[1] : '' - const headerValue = headerPrefix - ? `${headerPrefix} ${apiKey}` - : apiKey - const config = { - baseURL, - headers: { - 'Content-Type': 'application/json', - [headerKey]: headerValue - } - } - - // Add proxy agent if proxy is provided - const agent = proxy ? createProxyAgent(proxy) : null - if (agent) { - config.httpAgent = agent - config.httpsAgent = agent - config.proxy = false // Disable default proxy behavior when using agent - } - - return axios.create(config) -} - -exports.AIchatWithTools = async (messages, model, baseURL, path, apiKey, proxy, tools, authHeaderName) => { - try { - const client = createAIClient(baseURL, apiKey, proxy, authHeaderName) - const requestData = { - model, - messages, - stream: false - } - if (tools && tools.length) { - requestData.tools = tools - } - const response = await client.post(path, requestData) - const choice = response.data.choices[0] - return { - message: choice.message - } - } catch (e) { - log.error('AI chat with tools error', e) - return { error: e.message } - } -} - -exports.AIchat = async ( - prompt, - model = defaultSettings.modelAI, - role = defaultSettings.roleAI, - baseURL = defaultSettings.baseURLAI, - path = defaultSettings.apiPathAI, - apiKey, - proxy = defaultSettings.proxyAI, - stream = true, - authHeaderName = defaultSettings.authHeaderNameAI, - messages = null -) => { - try { - const client = createAIClient(baseURL, apiKey, proxy, authHeaderName) - - // Determine if we should use streaming based on the prompt content - // Command suggestions should not use streaming for quick response - const isCommandSuggestion = prompt.includes('give me max 5 command suggestions') - const useStream = stream && !isCommandSuggestion - - // Use provided conversation messages if available, otherwise build from prompt and role - const requestMessages = messages || [ - { - role: 'system', - content: role - }, - { - role: 'user', - content: prompt - } - ] - - const requestData = { - model, - messages: requestMessages, - stream: useStream - } - - if (useStream) { - // For streaming responses, initiate streaming and return session info - const response = await client.post(path, requestData, { - responseType: 'stream' - }) - - const sessionId = Date.now().toString() + Math.random().toString(36).substr(2, 9) - const sessionData = { - stream: response.data, - content: '', - completed: false, - error: null - } - - streamingSessions.set(sessionId, sessionData) - - // Start processing the stream - processStream(sessionId, sessionData) - - return { - sessionId, - isStream: true, - hasMore: true, - content: '' - } - } else { - // For non-streaming responses (command suggestions and when stream=false) - const response = await client.post(path, requestData) - - return { - response: response.data.choices[0].message.content, - isStream: false - } - } - } catch (e) { - log.error('AI chat error') - log.error(e) - return { - error: e.message, - stack: e.stack - } - } -} - -// Function to get the current state of a streaming session -exports.getStreamContent = (sessionId) => { - const session = streamingSessions.get(sessionId) - if (!session) { - return { - error: 'Session not found' - } - } - - const result = { - content: session.content, - hasMore: !session.completed, - isStream: true - } - - if (session.error) { - result.error = session.error - } - - // Clean up completed sessions - if (session.completed || session.error) { - streamingSessions.delete(sessionId) - } - - return result -} - -// Process streaming data -function processStream (sessionId, sessionData) { - let buffer = '' - const decoder = new StringDecoder('utf8') - - const processLines = (shouldFlush = false) => { - const lines = buffer.split('\n') - buffer = shouldFlush ? '' : lines.pop() - const linesToProcess = shouldFlush ? lines.filter(Boolean).concat(buffer ? [buffer] : []) : lines - - for (const line of linesToProcess) { - if (line.trim() === '') continue - if (line.trim() === 'data: [DONE]') { - sessionData.completed = true - return - } - - if (line.startsWith('data: ')) { - try { - const data = JSON.parse(line.slice(6)) - if (data.choices && data.choices[0] && data.choices[0].delta && data.choices[0].delta.content) { - sessionData.content += data.choices[0].delta.content - } - } catch (e) { - log.error('Error parsing stream data:', e) - } - } - } - } - - sessionData.stream.on('data', (chunk) => { - buffer += decoder.write(chunk) - processLines() - }) - - sessionData.stream.on('end', () => { - buffer += decoder.end() - processLines(true) - sessionData.completed = true - }) - - sessionData.stream.on('error', (error) => { - sessionData.error = error.message - sessionData.completed = true - }) -} diff --git a/src/app/lib/auth.js b/src/app/lib/auth.js deleted file mode 100644 index 7f03db6..0000000 --- a/src/app/lib/auth.js +++ /dev/null @@ -1,64 +0,0 @@ -const { userConfigId } = require('../common/constants') -const { dbAction } = require('./db') -const getPort = require('./get-port') - -function hashPassword (password) { - const crypto = require('crypto') - const salt = crypto.randomBytes(16).toString('hex') - const hashedPassword = crypto.pbkdf2Sync(password, salt, 1000, 64, 'sha512').toString('hex') - return { salt, hashedPassword } -} - -function comparePasswords (password, salt, hashedPassword) { - const crypto = require('crypto') - const hash = crypto.pbkdf2Sync(password, salt, 1000, 64, 'sha512').toString('hex') - return hash === hashedPassword -} - -exports.setPassword = async function setPassword (password) { - const q = { - _id: userConfigId - } - const userConfig = await dbAction('data', 'findOne', q) || {} - if (password === '') { - await dbAction('data', 'update', q, { - ...q, - ...userConfig, - salt: '', - hashedPassword: '' - }, { - upsert: true - }) - return true - } - const { salt, hashedPassword } = hashPassword(password) - await dbAction('data', 'update', q, { - ...q, - ...userConfig, - salt, - hashedPassword - }, { - upsert: true - }) - return true -} - -exports.checkPassword = async function checkPassword (password) { - const axios = require('axios') - axios.defaults.proxy = false - if (!password) { - return false - } - const q = { - _id: userConfigId - } - const { salt, hashedPassword } = await dbAction('data', 'findOne', q) || {} - const r = comparePasswords(password, salt, hashedPassword) - if (r) { - const port = await getPort() - await axios.post(`http://127.0.0.1:${port}/auth`, { - token: hashedPassword - }) - } - return r -} diff --git a/src/app/lib/build-proxy.js b/src/app/lib/build-proxy.js deleted file mode 100644 index 6715b37..0000000 --- a/src/app/lib/build-proxy.js +++ /dev/null @@ -1,18 +0,0 @@ -exports.buildProxyString = function (obj) { - if (!obj.proxyIp) { - return '' - } - - const proxyTypeMapping = { - 5: 'socks5', - 4: 'socks4', - 0: 'http', - 1: 'https' - } - - const proxyType = proxyTypeMapping[obj.proxyType] || '' - const hasCredentials = obj.proxyUsername && obj.proxyPassword - const credentials = hasCredentials ? `${obj.proxyUsername}:${obj.proxyPassword}@` : '' - - return `${proxyType}://${credentials}${obj.proxyIp}${obj.proxyPort ? `:${obj.proxyPort}` : ''}` -} diff --git a/src/app/lib/command-line.js b/src/app/lib/command-line.js deleted file mode 100644 index 5c8ddfe..0000000 --- a/src/app/lib/command-line.js +++ /dev/null @@ -1,98 +0,0 @@ -/** - * command line support - */ - -const { packInfo, isTest } = require('../common/app-props') -const { version } = packInfo - -let helpInfo -let options -let program - -function parseCommandLine (argv, options) { - const { Command } = require('commander') - const prog = new Command() - - prog - .version(version) - .name('electerm') - .usage('[options] sshServer') - .description(` -### Connect ssh server from command line examples: -- electerm user@xx.com -- electerm user@xx.com:22 -- electerm --password password --set-env "SECRET=xxx USER=hhhh" user@xx.com:22 -- electerm -l user -P 22 -i /path/to/private-key -pw password xx.com -T -t "XX Server" - -### Other params examples: -- server port: -electerm -sp 30976 -- load and run batch operation from json file: -electerm -bo "/home/root/works.json" - -### other connection types -- telnet: -electerm -tp "telnet" -opts '{"host":"192.168.1.1","port":21","username":"root","password":"123456"}' -- rdp: electerm -tp "rdp" -opts '{"host":"192.168.1.1","port":3389","username":"root","password":"123456"}' -- vnc: electerm -tp "vnc" -opts '{"host":"192.168.1.1","port":3389","username":"root","password":"123456"}' -- serial: electerm -tp "serial" -opts '{"port":"COM1","baudRate":115200,"dataBits":8,"stopBits":1,"parity":"none"}' -- local: electerm -tp "local" -opts '{"title": "local terminal"}' - -### Environment variables: -- DATA_PATH: -DATA_PATH=/custom/path/to/electerm-data electerm - -- NO_PROXY_SERVER: -NO_PROXY_SERVER=1 electerm - -- PROXY_BYPASS_LIST: -PROXY_BYPASS_LIST="127.0.0.1, 127.0.0.1" electerm - -- PROXY_PAC_URL: -PROXY_PAC_URL="http://proxy.example.com/pac" electerm - -- PROXY_SERVER: -PROXY_SERVER="socks5://127.0.0.1:1080" electerm -`) - .option('-t, --title [Tab Name]', 'Specify the title of the new tab') - .option('-l, --user ', 'specify a login name') - .option('-P, --port ', 'specify ssh port') - .option('-bo, --batch-op ', 'load and run batch operation from json file') - .option('-sp, --server-port ', 'specify server port, default is') - .option('-i, --private-key-path ', 'specify an SSH private key path') - .option('-ps, --passphrase ', 'specify an SSH private key passphrase') - .option('-pw, --password ', 'specify ssh server password') - .option('-se, --set-env ', 'specify envs') - .option('-so, --sftp-only', 'only show sftp panel') - .option('-d, --init-folder ', 'init folder got init terminal') - .option('-tp, --tp ', 'specify connection type') - .option('-opts, --opts ', 'specify connection options, json string') - .allowUnknownOption() - .exitOverride() - - try { - prog.parse(argv, options) - } catch (err) { - if (err.message.includes('outputHelp')) { - process.exit(0) - } - } - return prog -} - -if (!isTest) { - program = parseCommandLine() - options = program.opts() - helpInfo = program.helpInformation() -} - -exports.initCommandLine = function () { - if (isTest) { - return false - } - return { - options, - argv: program.args, - helpInfo - } -} diff --git a/src/app/lib/create-app.js b/src/app/lib/create-app.js deleted file mode 100644 index 4613e8f..0000000 --- a/src/app/lib/create-app.js +++ /dev/null @@ -1,164 +0,0 @@ -const { - app -} = require('electron') -const { createWindow } = require('./create-window') -const { - packInfo -} = require('../common/runtime-constants') -const { initCommandLine } = require('./command-line') -const globalState = require('./glob-state') -const { getUserConfigNoEnc, getDbConfig } = require('./get-config') -const { - setupDeepLinkHandlers -} = require('./deep-link') -const { handleSingleInstance } = require('./single-instance') -const log = require('../common/log') - -let conf = {} - -// GPU error suggestion message -const GPU_ERROR_SUGGESTION = ` -================================================================================ -⚠️ GPU Process Error Detected -================================================================================ -If you encounter GPU process crashes (exit_code=-2147483645 or similar), -try running electerm with one of these flags: - - 1. --no-sandbox (Recommended - run without sandbox) - 2. --disable-gpu (Disable GPU rendering) - 3. --disable-gpu-sandbox (Disable GPU sandbox) - 4. --disable-hardware-acceleration - -Or set environment variable: - DISABLE_GPU=1 (Disable GPU) - DISABLE_GPU_SANDBOX=1 (Disable GPU + sandbox, use SwiftShader) - ENABLE_GPU=1 (Linux only: force-enable hardware GPU) - -Example: - electerm --no-sandbox - or - DISABLE_GPU=1 electerm -================================================================================ -` - -// Handle GPU process crashes -app.on('gpu-process-crashed', (event, killed) => { - log.error(`GPU process crashed, killed: ${killed}`) - console.error(GPU_ERROR_SUGGESTION) -}) - -// Handle render process gone events -app.on('render-process-gone', (event, webContents, details) => { - if (details.reason === 'crashed' || details.reason === 'abnormal-exit') { - log.error(`Render process gone: ${details.reason}`, details) - console.error(GPU_ERROR_SUGGESTION) - } -}) - -// Handle uncaught exceptions -process.on('uncaughtException', (error) => { - log.error('uncaughtException:', error?.message || error, error?.stack || '') - const errorMsg = error?.message || '' - // Check if it's GPU related - if ( - errorMsg.includes('GPU') || - errorMsg.includes('gpu') || - errorMsg.includes('graphics') || - errorMsg.includes('Vulkan') || - errorMsg.includes('DXGI') - ) { - console.error(GPU_ERROR_SUGGESTION) - } -}) - -// Handle unhandled promise rejections -process.on('unhandledRejection', (reason, promise) => { - log.error('unhandledRejection:', reason?.message || reason, reason?.stack || '') -}) - -exports.createApp = async function () { - app.setName(packInfo.name) - // Disable GPU for stability — the HarmonyOS Electron runtime does not - // support hardware-accelerated rendering reliably. - app.commandLine.appendSwitch('disable-gpu') - app.commandLine.appendSwitch('disable-gpu-compositing') - app.commandLine.appendSwitch('disable-gpu-rasterization') - app.commandLine.appendSwitch('use-gl', 'swiftshader') - app.disableHardwareAcceleration() - if (process.env.DISABLE_GPU_SANDBOX) { - app.disableHardwareAcceleration() - app.commandLine.appendSwitch('disable-gpu') - app.commandLine.appendSwitch('disable-gpu-compositing') - app.commandLine.appendSwitch('disable-gpu-rasterization') - app.commandLine.appendSwitch('disable-gpu-sandbox') - app.commandLine.appendSwitch('disable-software-rasterizer') - app.commandLine.appendSwitch('use-gl', 'swiftshader') - } - // Handle proxy-related command-line arguments - if (process.env.NO_PROXY_SERVER) { - app.commandLine.appendSwitch('no-proxy-server') - } - if (process.env.PROXY_BYPASS_LIST) { - app.commandLine.appendSwitch('proxy-bypass-list', process.env.PROXY_BYPASS_LIST) - } - if (process.env.PROXY_PAC_URL) { - app.commandLine.appendSwitch('proxy-pac-url', process.env.PROXY_PAC_URL) - } - if (process.env.PROXY_SERVER) { - app.commandLine.appendSwitch('proxy-server', process.env.PROXY_SERVER) - } - - const progs = initCommandLine() - const opts = progs?.options - globalState.set('serverPort', opts?.serverPort) - - const { allowMultiInstance = false } = await getUserConfigNoEnc() - - // Setup deep link handlers (open-url for macOS, etc.) - setupDeepLinkHandlers() - // Only request single instance lock if multi-instance is not allowed - if (!allowMultiInstance) { - // Use socket-based single instance lock for compatibility with Electron 22 - // where additionalData doesn't work in the second-instance event - const isPrimaryInstance = await handleSingleInstance(progs) - - if (!isPrimaryInstance) { - app.quit() - return app - } - - // Also use Electron's built-in lock as a fallback - app.requestSingleInstanceLock() - } - - app.on('second-instance', (event, commandLine) => { - const newWindowFlag = commandLine.includes('--new-window') - if (newWindowFlag) { - createWindow(conf) - return - } - const win = globalState.get('win') - if (win) { - if (win.isMinimized()) { - win.restore() - } - win.focus() - } - }) - app.whenReady().then(async () => { - try { - conf = await getDbConfig() - await createWindow(conf) - } catch (e) { - log.error('Failed to create window:', e?.message || e, e?.stack || '') - } - }) - app.on('activate', () => { - // On macOS it's common to re-create a window in the app when the - // dock icon is clicked and there are no other windows open. - if (globalState.get('win') === null) { - app.once('ready', () => createWindow(conf)) - } - }) - return app -} diff --git a/src/app/lib/create-window.js b/src/app/lib/create-window.js deleted file mode 100644 index 02230c1..0000000 --- a/src/app/lib/create-window.js +++ /dev/null @@ -1,161 +0,0 @@ -const { - BrowserWindow, screen, shell -} = require('electron') -const { resolve } = require('path') -const { - isDev, packInfo, iconPath, isMac, - minWindowWidth, minWindowHeight -} = require('../common/runtime-constants') -const { - getWindowSize, - setWindowPos -} = require('./window-control') -const { ensureWindowVisible } = require('./window-restore') -const { onClose } = require('./on-close') -const { initIpc, initAppServer } = require('./ipc') -const { disableShortCuts } = require('./key-bind') -const _ = require('./lodash.js') -const getPort = require('./get-port') -const globalState = require('./glob-state') -const webviewHandler = require('./webview-handler') -const log = require('../common/log') - -exports.createWindow = async function (userConfig) { - log.info('createWindow: starting...') - globalState.set('closeAction', 'closeApp') - globalState.set('requireAuth', !!userConfig.hashedPassword) - const { width, height, x, y } = await getWindowSize() - // HarmonyOS: `transparent: true` and `titleBarStyle: 'hidden'` are NOT - // supported — they cause a double title bar (the OS title bar plus the - // app's custom one). We therefore always use the system title bar, - // mirroring the override in get-config.js. - // `frame` IS supported, so once transparent/titleBarStyle are supported, - // remove this line to respect the user's useSystemTitleBar setting. - // useSystemTitleBar = true - const win = new BrowserWindow({ - width, - height, - x, - y, - fullscreenable: true, - minWidth: minWindowWidth, - minHeight: minWindowHeight, - title: packInfo.name, - frame: true, - backgroundColor: '#333333', - autoHideMenuBar: true, - webPreferences: { - contextIsolation: true, - nodeIntegration: false, - enableRemoteModule: false, - preload: resolve(__dirname, '../preload/preload.js'), - webviewTag: true, - devTools: !userConfig.disableDeveloperTool, - spellcheck: false - }, - icon: iconPath - }) - // Safety net: verify the window is actually visible on a connected - // display and move it to the primary display if not. - ensureWindowVisible(win, screen) - - // macOS: show the traffic-light buttons - if (isMac) { - win.setWindowButtonVisibility(true) - } - - win.webContents.session.setSpellCheckerDictionaryDownloadURL('https://00.00/') - - webviewHandler.init(win) - - globalState.set('win', win) - log.info('createWindow: BrowserWindow created, starting initAppServer...') - - // Intercept navigation to external URLs. Without this, clicking a - // link () inside the app would navigate the - // Electron window itself to that URL, loading the external page - // in-app instead of opening the system browser. - win.webContents.on('will-navigate', (event, url) => { - // Allow navigation to the app's own local server - if (url.startsWith('http://127.0.0.1:') || url.startsWith('data:')) { - return - } - event.preventDefault() - log.info('will-navigate: redirecting to system browser:', url) - shell.openExternal(url) - }) - - // Intercept window.open() calls — redirect to system browser - win.webContents.setWindowOpenHandler(({ url }) => { - if (url.startsWith('http://127.0.0.1:') || url.startsWith('data:')) { - return { action: 'allow' } - } - log.info('setWindowOpenHandler: redirecting to system browser:', url) - shell.openExternal(url) - return { action: 'deny' } - }) - - try { - await initAppServer() - log.info('createWindow: initAppServer done') - } catch (e) { - log.error('createWindow: initAppServer failed:', e?.message || e, e?.stack || '') - // Show error page in the window instead of leaving black screen - const htmlContent = `

Server failed to start

${e?.message || e}
` - const dataUrl = `data:text/html;charset=utf-8,${encodeURIComponent(htmlContent)}` - win.loadURL(dataUrl) - return - } - - initIpc() - log.info('createWindow: initIpc done') - const port = isDev - ? process.env.devPort || 5570 - : await getPort() - const opts = `http://127.0.0.1:${port}/index.html?v=${packInfo.version}` - log.info('createWindow: loading URL:', opts) - // If loading the URL fails (e.g. proxy/firewall interference), show error page - win.webContents.once('did-fail-load', (event, errorCode, errorDescription) => { - log.error('createWindow: did-fail-load:', errorCode, errorDescription) - const htmlContent = require('./error-page')(port) - const dataUrl = `data:text/html;charset=utf-8,${encodeURIComponent(htmlContent)}` - win.loadURL(dataUrl) - }) - win.loadURL(opts) - win.webContents.once('dom-ready', () => { - log.info('createWindow: dom-ready') - if (isDev && !userConfig.disableDeveloperTool) { - win.webContents.openDevTools() - } - win.on('unmaximize', () => { - const { width, height } = win.getBounds() - if (width < minWindowWidth || height < minWindowHeight) { - win.setBounds({ - x: 0, - y: 0, - width: minWindowWidth, - height: minWindowHeight - }) - win.center() - } - }) - win.on('resize', _.debounce(() => { - if (!win.isMaximized()) { - globalState.set('oldRectangle', win.getBounds()) - } - }, 200)) - win.on('move', _.debounce(() => { - const { x, y } = win.getBounds() - setWindowPos({ x, y }) - }, 100)) - - win.on('focus', () => { - win.webContents.send('focused', null) - }) - win.on('blur', () => { - win.webContents.send('blur', null) - }) - disableShortCuts(win) - }) - win.on('close', onClose) -} diff --git a/src/app/lib/custom-require.js b/src/app/lib/custom-require.js deleted file mode 100644 index 9c65c1e..0000000 --- a/src/app/lib/custom-require.js +++ /dev/null @@ -1,35 +0,0 @@ -const path = require('path') -const { downloadPackage } = require('./npm') - -exports.customRequire = async (moduleName, options = {}) => { - const customModulesFolderPath = options.customModulesFolderPath || - process.env.CUSTOM_MODULES_FOLDER_PATH || - path.resolve(require('../common/app-props').appPath, 'electerm', 'custom-modules') - const isCustomModule = options.isCustomModule || false - const downloadModule = options.downloadModule !== false - - const modulePath = path.join(customModulesFolderPath, 'node_modules', moduleName) - - if (isCustomModule) { - try { - return require(modulePath) - } catch (err) { - if (!downloadModule) { - throw err - } - await downloadPackage(moduleName, customModulesFolderPath) - return require(modulePath) - } - } - - try { - return require(moduleName) - } catch (err) { - if (!downloadModule) { - throw err - } - - await downloadPackage(moduleName, customModulesFolderPath) - return require(modulePath) - } -} diff --git a/src/app/lib/db.js b/src/app/lib/db.js deleted file mode 100644 index 90b538d..0000000 --- a/src/app/lib/db.js +++ /dev/null @@ -1,13 +0,0 @@ -/** - * db loader - * Uses nedb (pure JS, no native dependencies). - */ - -const { appPath, defaultUserName } = require('../common/app-props') -const { safeEncrypt, safeDecrypt } = require('./safe-storage') - -const encOpts = { enc: safeEncrypt, dec: safeDecrypt } - -const { createDb } = require('./nedb') -const db = createDb(appPath, defaultUserName, encOpts) -module.exports = db diff --git a/src/app/lib/deep-link.js b/src/app/lib/deep-link.js deleted file mode 100644 index 03026e8..0000000 --- a/src/app/lib/deep-link.js +++ /dev/null @@ -1,157 +0,0 @@ -/** - * Deep link support for electerm - * Handles protocol URLs like ssh://, telnet://, rdp://, vnc://, etc. - */ - -const { app } = require('electron') -const log = require('../common/log') -const globalState = require('./glob-state') -const { parseQuickConnect } = require('../common/parse-quick-connect') - -/** - * Protocols registered as OS-level deep link handlers. - * http/https are intentionally excluded: registering them would make electerm - * the handler for every clicked web link, hijacking the user's default browser. - * They remain parseable via quick-connect (normalized to type "web"). - */ -const DEEP_LINK_PROTOCOLS = ['ssh', 'telnet', 'vnc', 'rdp', 'spice', 'ftp', 'electerm'] - -/** - * Register electerm as a handler for supported protocols - * Note: This makes electerm available as a handler but doesn't force it as default. - * Users can still choose their preferred app in system settings. - * - * @param {boolean} force - If true, register even if not packaged (for testing) - * @returns {object} - Status of registration for each protocol - */ -function registerDeepLink (force = false) { - const protocols = DEEP_LINK_PROTOCOLS - const results = {} - - // Only register in packaged app or when explicitly requested - const shouldRegister = app.isPackaged || - force || - process.env.ELECTERM_REGISTER_PROTOCOLS === '1' - - if (!shouldRegister) { - log.info('Skipping protocol registration in development mode') - log.info('Set ELECTERM_REGISTER_PROTOCOLS=1 or pass force=true to enable') - return { registered: false, reason: 'development-mode' } - } - - protocols.forEach(protocol => { - // Check if already registered - const isDefault = app.isDefaultProtocolClient(protocol) - - if (isDefault) { - log.info(`Already registered as handler for ${protocol}:// protocol`) - results[protocol] = { success: true, alreadyDefault: true } - } else { - const registered = app.setAsDefaultProtocolClient(protocol) - if (registered) { - log.info(`Registered as handler for ${protocol}:// protocol`) - results[protocol] = { success: true, alreadyDefault: false } - } else { - log.warn(`Failed to register ${protocol}:// protocol handler`) - results[protocol] = { success: false, error: 'registration-failed' } - } - } - }) - - return { registered: true, protocols: results } -} - -/** - * Check which protocols are currently registered - * @returns {object} - Status of each protocol - */ -function checkProtocolRegistration () { - const protocols = DEEP_LINK_PROTOCOLS - const status = {} - - protocols.forEach(protocol => { - status[protocol] = app.isDefaultProtocolClient(protocol) - }) - - return status -} - -/** - * Unregister electerm as handler for protocols - * @param {Array} protocols - Optional array of specific protocols to unregister - * @returns {object} - Status of unregistration - */ -function unregisterDeepLink (protocols = DEEP_LINK_PROTOCOLS) { - const results = {} - - protocols.forEach(protocol => { - const removed = app.removeAsDefaultProtocolClient(protocol) - results[protocol] = removed - if (removed) { - log.info(`Unregistered as handler for ${protocol}:// protocol`) - } else { - log.warn(`Failed to unregister ${protocol}:// protocol handler`) - } - }) - - return results -} - -/** - * Handle deep link URL by opening a new tab - * @param {string} url - The protocol URL - */ -function handleDeepLink (url) { - const parsed = parseQuickConnect(url) - - if (!parsed) { - log.warn('Could not parse deep link URL:', url) - return - } - - const win = globalState.get('win') - - if (win) { - // If window exists, send message to open new tab - if (win.isMinimized()) { - win.restore() - } - win.focus() - win.webContents.send('open-tab', parsed) - } else { - // Store the URL to open when window is ready - globalState.set('pendingDeepLink', parsed) - } -} - -/** - * Check if there's a pending deep link to open - * @returns {object|null} - Pending deep link in the same format as initCommandLine or null - */ -function getPendingDeepLink () { - const pending = globalState.get('pendingDeepLink') - if (pending) { - globalState.set('pendingDeepLink', null) - return pending - } - return null -} - -/** - * Setup deep link handlers for the app - */ -function setupDeepLinkHandlers () { - // Note: second-instance and process.argv protocol URL handling is done by - // single-instance.js (socket-based IPC → add-tab-from-command-line) and - // command-line.js (initCommandLine → addTabFromCommandLine) respectively. - // Handling them here too would cause duplicate tabs to open. -} - -module.exports = { - registerDeepLink, - unregisterDeepLink, - checkProtocolRegistration, - handleDeepLink, - getPendingDeepLink, - setupDeepLinkHandlers -} diff --git a/src/app/lib/enc.js b/src/app/lib/enc.js deleted file mode 100644 index b889766..0000000 --- a/src/app/lib/enc.js +++ /dev/null @@ -1,120 +0,0 @@ -/** - * data encrypt/decrypt - * - * New format (GCM): 'gcm::::' - * Legacy format: '' (pure hex, no colons — aes-192-cbc) - * - * decrypt/decryptAsync detect the format automatically via the 'gcm:' prefix, - * so old data encrypted with the static IV/salt continues to work without migration. - */ - -const algorithmDefault = 'aes-256-gcm' - -// Legacy constants — kept only for decrypting old data (aes-192-cbc) -const LEGACY_ALGORITHM = 'aes-192-cbc' -const LEGACY_IV = Buffer.alloc(16, 0) -const LEGACY_SALT = 'salt' -const LEGACY_KEY_LENGTH = 24 -const IV_LENGTH = 12 // 12 bytes is recommended for GCM -const SALT_LENGTH = 16 -const KEY_LENGTH = 32 // aes-256 requires a 32-byte key - -function scryptAsync (...args) { - const crypto = require('crypto') - return new Promise((resolve, reject) => - crypto.scrypt(...args, (err, result) => { - if (err) { - reject(err) - } - resolve(result) - }) - ) -} - -exports.encrypt = function ( - str = '', - password, - algorithm = algorithmDefault -) { - const crypto = require('crypto') - const iv = crypto.randomBytes(IV_LENGTH) - const salt = crypto.randomBytes(SALT_LENGTH) - const key = crypto.scryptSync(password, salt, KEY_LENGTH) - const cipher = crypto.createCipheriv(algorithm, key, iv) - let encrypted = cipher.update(str, 'utf8', 'hex') - encrypted += cipher.final('hex') - const authTag = cipher.getAuthTag() - return 'gcm:' + iv.toString('hex') + ':' + salt.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted -} - -exports.decrypt = function ( - encrypted = '', - password, - algorithm = algorithmDefault -) { - const crypto = require('crypto') - if (encrypted.startsWith('gcm:')) { - // New format: gcm:iv_hex:salt_hex:authtag_hex:ciphertext_hex - const parts = encrypted.split(':') - const iv = Buffer.from(parts[1], 'hex') - const salt = Buffer.from(parts[2], 'hex') - const authTag = Buffer.from(parts[3], 'hex') - const ciphertext = parts[4] - const key = crypto.scryptSync(password, salt, KEY_LENGTH) - const decipher = crypto.createDecipheriv(algorithm, key, iv) - decipher.setAuthTag(authTag) - let decrypted = decipher.update(ciphertext, 'hex', 'utf8') - decrypted += decipher.final('utf8') - return decrypted - } - // Legacy format: aes-192-cbc with static IV and salt - const key = crypto.scryptSync(password, LEGACY_SALT, LEGACY_KEY_LENGTH) - const decipher = crypto.createDecipheriv(LEGACY_ALGORITHM, key, LEGACY_IV) - let decrypted = decipher.update(encrypted, 'hex', 'utf8') - decrypted += decipher.final('utf8') - return decrypted -} - -exports.encryptAsync = async function ( - str = '', - password, - algorithm = algorithmDefault -) { - const crypto = require('crypto') - const iv = crypto.randomBytes(IV_LENGTH) - const salt = crypto.randomBytes(SALT_LENGTH) - const key = await scryptAsync(password, salt, KEY_LENGTH) - const cipher = crypto.createCipheriv(algorithm, key, iv) - let encrypted = cipher.update(str, 'utf8', 'hex') - encrypted += cipher.final('hex') - const authTag = cipher.getAuthTag() - return 'gcm:' + iv.toString('hex') + ':' + salt.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted -} - -exports.decryptAsync = async function ( - encrypted = '', - password, - algorithm = algorithmDefault -) { - const crypto = require('crypto') - if (encrypted.startsWith('gcm:')) { - // New format: gcm:iv_hex:salt_hex:authtag_hex:ciphertext_hex - const parts = encrypted.split(':') - const iv = Buffer.from(parts[1], 'hex') - const salt = Buffer.from(parts[2], 'hex') - const authTag = Buffer.from(parts[3], 'hex') - const ciphertext = parts[4] - const key = await scryptAsync(password, salt, KEY_LENGTH) - const decipher = crypto.createDecipheriv(algorithm, key, iv) - decipher.setAuthTag(authTag) - let decrypted = decipher.update(ciphertext, 'hex', 'utf8') - decrypted += decipher.final('utf8') - return decrypted - } - // Legacy format: aes-192-cbc with static IV and salt - const key = await scryptAsync(password, LEGACY_SALT, LEGACY_KEY_LENGTH) - const decipher = crypto.createDecipheriv(LEGACY_ALGORITHM, key, LEGACY_IV) - let decrypted = decipher.update(encrypted, 'hex', 'utf8') - decrypted += decipher.final('utf8') - return decrypted -} diff --git a/src/app/lib/error-page.js b/src/app/lib/error-page.js deleted file mode 100644 index 4d6e3cc..0000000 --- a/src/app/lib/error-page.js +++ /dev/null @@ -1,70 +0,0 @@ -// Function to generate the error HTML string -function generateErrorHtml (port) { - return ` - - - - - - Connection Error - - - -
-

Connection Issue Detected

-

Unable to connect to the local server at http://127.0.0.1:${port}. This is often caused by applications (such as proxy software, VPNs, or network tools) intercepting or blocking localhost (127.0.0.1) traffic.

-

Suggested fixes:

-
    -
  • Check if proxy software (e.g., Proxifier) is running. Ensure it excludes localhost (127.0.0.1) or this app's executable from proxying.
  • -
  • Verify that VPNs or other network tools are not redirecting localhost traffic.
  • -
  • Check firewall rules or antivirus software that might block local ports.
  • -
-

Restart the app after making changes. If the problem persists, contact author: zxdong@gmail.com.

-
- -
-

检测到连接问题

-

无法连接到本地服务器 http://127.0.0.1:${port}。这通常是由于应用程序(如代理软件、VPN 或网络工具)拦截或阻止了本地 (127.0.0.1) 流量。

-

建议的解决方法:

-
    -
  • 检查是否正在运行代理软件(如 Proxifier)。确保其设置排除本地连接 (127.0.0.1) 或此应用程序的可执行文件。
  • -
  • 确认 VPN 或其他网络工具未重定向本地流量。
  • -
  • 检查防火墙规则或防病毒软件是否阻止了本地端口。
  • -
-

更改设置后重启应用程序。如果问题仍然存在,请联系作者:zxdong@gmail.com。

-

- -

-
- - - ` -} - -module.exports = generateErrorHtml diff --git a/src/app/lib/file-server.js b/src/app/lib/file-server.js deleted file mode 100644 index 3e5f471..0000000 --- a/src/app/lib/file-server.js +++ /dev/null @@ -1,25 +0,0 @@ -module.exports = (app) => { - const express = require('express') - const path = require('path') - - return new Promise((resolve) => { - const assetsPath = path.resolve(__dirname, '../assets') - const conf = { - maxAge: 1000 * 60 * 60 * 24 * 365 - } - - // Handle _temp_*.css files - return empty CSS to prevent MIME type errors - app.use((req, res, next) => { - if (req.url.startsWith('/css/_temp_') && req.url.endsWith('.css')) { - res.setHeader('Content-Type', 'text/css') - res.send('') - return - } - next() - }) - - app.use( - express.static(assetsPath, conf) - ) - }) -} diff --git a/src/app/lib/font-list.js b/src/app/lib/font-list.js deleted file mode 100644 index d9d6e3f..0000000 --- a/src/app/lib/font-list.js +++ /dev/null @@ -1,17 +0,0 @@ -/** - * load font list after start - */ - -const log = require('../common/log') - -exports.loadFontList = () => { - return require('font-list').getFonts() - .then(fonts => { - return fonts.map(f => f.replace(/"/g, '')) - }) - .catch(err => { - log.error('load font list error') - log.error(err) - return [] - }) -} diff --git a/src/app/lib/fs.js b/src/app/lib/fs.js deleted file mode 100644 index 9dc728e..0000000 --- a/src/app/lib/fs.js +++ /dev/null @@ -1,319 +0,0 @@ -const fss = require('fs/promises') -const fs = require('fs') -const log = require('../common/log') -const path = require('path') -const { tempDir } = require('../common/runtime-constants') -const uid = require('../common/uid') -const { promisify } = require('util') -const { exec, spawn } = require('child_process') -const execAsync = promisify(exec) -const { getSizeCount } = require('../common/get-folder-size-and-file-count.js') - -// Encoding function -function encodeUint8Array (uint8Arr) { - return Buffer.from(uint8Arr).toString('base64') -} - -// Decoding function -function decodeBase64String (base64String) { - return new Uint8Array(Buffer.from(base64String, 'base64')) -} - -/** - * run cmd - * @param {string} cmd - */ -const run = (cmd) => { - const { Bash } = require('node-bash') - const ps = new Bash({ - executableOptions: { - '--login': true - } - }) - return ps.invokeCommand(cmd) - .then(s => s.stdout.toString()) -} - -/** - * run windows cmd - * @param {string} cmd - */ -const runWinCmd = (cmd) => { - return execAsync(`powershell.exe -Command "${cmd}"`) -} - -function spawnDetachedCommand (command, args, options = {}) { - return new Promise((resolve, reject) => { - const child = spawn(command, args, { - detached: false, - stdio: ['ignore', 'ignore', 'pipe'], - ...options - }) - let stderr = '' - - child.stderr.on('data', data => { - stderr += data.toString() - }) - child.on('error', reject) - - let settled = false - const settle = (err) => { - if (settled) { - return - } - settled = true - clearTimeout(timer) - child.unref() - if (err) { - reject(err) - } else { - resolve() - } - } - - child.on('close', code => { - if (code !== 0) { - settle(new Error(stderr.trim() || `Command exited with code ${code}`)) - } else { - settle(null) - } - }) - - const timer = setTimeout(() => settle(null), 5000) - }) -} - -/** - * Escape a string for safe use inside POSIX single quotes. - * Within single quotes the only special character is the single quote itself; - * escape it by closing the quote, inserting an escaped quote, and reopening: - * ' -> '\'' - */ -function escapePosixShellArg (value) { - return String(value).replace(/'/g, "'\\''") -} - -function getFolderSize (folderPath) { - const safePath = escapePosixShellArg(folderPath) - return run(`du -sh '${safePath}' && find '${safePath}' -type f | wc -l`) - .then(getSizeCount) -} - -/** - * rm -rf directory - * @param {string} localFolderPath absolute path of directory - */ -const rmrf = (localFolderPath) => { - return fss.rm(localFolderPath, { recursive: true, force: true }) -} - -/** - * Recursive copy helper for Node.js < 16.7.0 (where fs.cp doesn't exist) - */ -async function cpRecursive (src, dest) { - const stat = await fss.stat(src) - if (stat.isDirectory()) { - await fss.mkdir(dest, { recursive: true }) - const entries = await fss.readdir(src) - for (const entry of entries) { - await cpRecursive(path.join(src, entry), path.join(dest, entry)) - } - } else { - await fss.copyFile(src, dest) - } -} - -/** - * cp from to - * @param {string} from absolute source path - * @param {string} to absolute destination path - */ -const cp = async (from, to) => { - if (typeof fss.cp === 'function') { - return fss.cp(from, to, { recursive: true, force: true }) - } - return cpRecursive(from, to) -} - -/** - * mv from to - * @param {string} from absolute source path - * @param {string} to absolute destination path - */ -const mv = async (from, to) => { - try { - await fss.rename(from, to) - } catch (error) { - if (!error || error.code !== 'EXDEV') { - throw error - } - // Cross-device move: copy then remove - await cp(from, to) - await fss.rm(from, { recursive: true, force: true }) - } - return true -} - -/** - * touch file - * @param {string} localFolderPath absolute path - */ -const touch = (localFilePath) => { - return fss.writeFile(localFilePath, '') -} - -/** - * open file - * @param {string} localFolderPath absolute path - */ -const openFile = (localFilePath) => { - return spawnDetachedCommand('xdg-open', [localFilePath]) -} - -/** - * zip file - * @param {string} localFolerPath absolute path of a folder - */ -const zipFolder = (localFolerPath) => { - const n = uid() - const p = path.resolve(tempDir, `electerm-temp-${n}.tar`) - const cwd = path.dirname(localFolerPath) - const file = path.basename(localFolerPath) - const tar = require('tar') - return tar.c({ - gzip: false, - file: p, - cwd - }, [file]) - .then(() => p) -} - -/** - * unzip file - * @param {string} localFilePath absolute path of a zip file - * @param {string} targetFolderPath absolute path of unzip target folder - */ -const unzipFile = async (localFilePath, targetFolderPath) => { - const tar = require('tar') - await tar.x({ file: localFilePath, C: targetFolderPath }) - return 1 -} - -const readCustom = (p1, len, ...args) => { - return new Promise((resolve, reject) => { - fs.read(p1, new Uint8Array(len), ...args, (err, n, buffer) => { - if (err) { - return reject(err) - } - return resolve({ n, newArr: encodeUint8Array(buffer) }) - }) - }) -} - -const writeCustom = (p1, arr) => { - return new Promise((resolve, reject) => { - const narr = decodeBase64String(arr) - fs.write(p1, narr, (err, n) => { - if (err) { - return reject(err) - } - return resolve(1) - }) - }) -} - -const openCustom = async (...args) => { - return new Promise((resolve, reject) => { - fs.open(...args, (err, n) => { - if (err) { - return reject(err) - } - return resolve(n) - }) - }) -} - -const closeCustom = async (...args) => { - return new Promise((resolve, reject) => { - fs.close(...args, (err) => { - if (err) { - return reject(err) - } - return resolve(true) - }) - }) -} - -const statCustom = async (...args) => { - const st = await fss.stat(...args) - st.isD = st.isDirectory() - st.isF = st.isFile() - return st -} - -const fsExport = Object.assign( - {}, - fss, - { - getFolderSize, - run, - runWinCmd, - rmrf, - touch, - cp, - mv, - openFile, - zipFolder, - unzipFile, - readCustom, - writeCustom, - openCustom, - closeCustom, - statCustom - }, - { - readdirAsync: (_path) => { - return fss.readdir(_path) - }, - statAsync: (...args) => { - return fss.stat(...args) - .then(res => { - return { - ...res, - isDirectory: res.isDirectory() - } - }) - }, - lstatAsync: (...args) => { - return fss.lstat(...args) - .then(res => { - return { - ...res, - isDirectory: res.isDirectory(), - isSymbolicLink: res.isSymbolicLink() - } - }) - }, - readFile: (...args) => { - return fss.readFile(...args, 'utf8') - }, - readFileAsBase64: (...args) => { - return fss.readFile(...args) - .then(res => { - return res.toString('base64') - }) - }, - writeFile: (path, txt, mode) => { - return fss.writeFile(path, txt, { mode }) - .then(() => true) - .catch((e) => { - log.error('fs.writeFile', e) - return false - }) - } - } -) - -module.exports = { - fsExport -} diff --git a/src/app/lib/get-config.js b/src/app/lib/get-config.js deleted file mode 100644 index 670629a..0000000 --- a/src/app/lib/get-config.js +++ /dev/null @@ -1,49 +0,0 @@ -const { dbAction } = require('./db') -const defaultSetting = require('../common/config-default') -const getPort = require('./get-port') -const { userConfigId, userNoEncryptConfigId } = require('../common/constants') -const generate = require('../common/uid') -const globalState = require('./glob-state') - -exports.getConfig = async (inited) => { - const userConfig = await dbAction('data', 'findOne', { - _id: userConfigId - }) || {} - const requireAuth = userConfig.hashedPassword - delete userConfig._id - delete userConfig.host - delete userConfig.terminalTypes - delete userConfig.tokenElecterm - delete userConfig.hashedPassword - delete userConfig.salt - const port = inited - ? globalState.get('config').port - : await getPort() - const config = { - ...defaultSetting, - ...userConfig, - requireAuth, - port, - tokenElecterm: inited ? globalState.get('config').tokenElecterm : generate() - } - // HarmonyOS: always use system title bar to avoid double title bar - config.useSystemTitleBar = true - return { - userConfig, - config - } -} - -exports.getDbConfig = async () => { - const userConfig = await dbAction('data', 'findOne', { - _id: userConfigId - }) || {} - return userConfig -} - -exports.getUserConfigNoEnc = async () => { - const userConfig = await dbAction('data', 'findOne', { - _id: userNoEncryptConfigId - }) || {} - return userConfig -} diff --git a/src/app/lib/get-port.js b/src/app/lib/get-port.js deleted file mode 100644 index 1877c12..0000000 --- a/src/app/lib/get-port.js +++ /dev/null @@ -1,38 +0,0 @@ -/** - * get first free open port - */ - -const log = require('../common/log') -const globalState = require('./glob-state') -let port = null - -function getPort (fromPort = 30975) { - const serverPort = globalState.get('serverPort') - if (serverPort) { - port = parseInt(serverPort, 10) - return Promise.resolve( - port - ) - } - return new Promise((resolve, reject) => { - require('find-free-port')(fromPort, '127.0.0.1', function (err, freePort) { - if (err) { - reject(err) - } else { - port = freePort - resolve(freePort) - } - }) - }) -} - -module.exports = () => { - if (port) { - return port - } - return getPort() - .catch(e => { - log.error('failed to get free port') - return 0 - }) -} diff --git a/src/app/lib/glob-state.js b/src/app/lib/glob-state.js deleted file mode 100644 index 53c954a..0000000 --- a/src/app/lib/glob-state.js +++ /dev/null @@ -1,41 +0,0 @@ -// src/app/lib/global-state.js - -class GlobalState { - constructor () { - this._state = { - win: null, - config: {}, - closeAction: '', - requireAuth: false, - serverInited: false, - langMap: null, - getLang: null, - translate: null, - timer: null, - childPid: null, - app: null, - rawArgs: null, - loadTime: null, - initTime: null, - watchFilePath: '', - oldRectangle: null, - serverPort: null, - isSecondInstance: false, - pendingDeepLink: null - } - } - - get (key) { - return this._state[key] - } - - set (key, value) { - this._state[key] = value - } - - update (key, updates) { - this._state[key] = { ...this._state[key], ...updates } - } -} - -module.exports = new GlobalState() diff --git a/src/app/lib/init-app.js b/src/app/lib/init-app.js deleted file mode 100644 index a10e48f..0000000 --- a/src/app/lib/init-app.js +++ /dev/null @@ -1,48 +0,0 @@ -/** - * after data loaded, init menu and other things - */ - -const { - Menu, - Notification -} = require('electron') -const globalState = require('./glob-state') -const { - packInfo -} = require('../common/runtime-constants') - -function capitalizeFirstLetter (string) { - return string.charAt(0).toUpperCase() + string.slice(1) -} - -function initApp (langMap, config) { - globalState.set('langMap', langMap) - globalState.set('getLang', (lang = config.language || 'en_us') => { - return langMap[lang].lang - }) - globalState.set('translate', txt => { - const config = globalState.get('config') - if (config.language === 'en_us') { - return capitalizeFirstLetter( - globalState.get('getLang')()[txt] || txt - ) - } - return globalState.get('getLang')()[txt] || txt - }) - // Remove the desktop-style menu bar — all menu functionality - // (settings, about, etc.) is available in the web UI. - Menu.setApplicationMenu(null) - const e = globalState.get('translate') - // handle autohide flag - if (process.argv.includes('--autohide')) { - globalState.set('timer', setTimeout(() => globalState.get('win').minimize(), 500)) - if (Notification.isSupported()) { - const notice = new Notification({ - title: `${packInfo.name} ${e('isRunning')}, ${e('press')} ${config.hotkey} ${e('toShow')}` - }) - notice.show() - } - } -} - -module.exports = initApp diff --git a/src/app/lib/init-server.js b/src/app/lib/init-server.js deleted file mode 100644 index 58b1cd8..0000000 --- a/src/app/lib/init-server.js +++ /dev/null @@ -1,57 +0,0 @@ -/** - * server init script - * - * Starts the Express server in-process (no child process). - * Returns a promise that resolves when the server reports ready. - */ - -const createChildServer = require('../server/child-process') -const globalState = require('./glob-state') -const log = require('../common/log') - -const SERVER_TIMEOUT = 15000 // 15 seconds - -module.exports = async (config, env, sysLocale) => { - return new Promise((resolve, reject) => { - let resolved = false - let timer = null - - const child = createChildServer(config, env, sysLocale) - - timer = setTimeout(() => { - if (!resolved) { - resolved = true - log.error('Server init timed out after', SERVER_TIMEOUT, 'ms') - try { child.kill() } catch {} - reject(new Error('Server init timed out')) - } - }, SERVER_TIMEOUT) - - child.on('exit', (code, signal) => { - if (!resolved) { - resolved = true - if (timer) clearTimeout(timer) - reject(new Error(`Server exited with code ${code} signal ${signal}`)) - } - }) - - child.on('error', (err) => { - if (!resolved) { - resolved = true - if (timer) clearTimeout(timer) - reject(err) - } - }) - - globalState.set('childPid', child.pid) - globalState.set('child', child) - - child.on('message', (m) => { - if (m && m.serverInited && !resolved) { - resolved = true - if (timer) clearTimeout(timer) - resolve(child) - } - }) - }) -} diff --git a/src/app/lib/install-src.js b/src/app/lib/install-src.js deleted file mode 100644 index e70e9a8..0000000 --- a/src/app/lib/install-src.js +++ /dev/null @@ -1,3 +0,0 @@ -// export install src - -module.exports = 'harmony-os' diff --git a/src/app/lib/ipc-sync.js b/src/app/lib/ipc-sync.js deleted file mode 100644 index 1173161..0000000 --- a/src/app/lib/ipc-sync.js +++ /dev/null @@ -1,154 +0,0 @@ -/** - * ipc main - */ - -const { - shell, - clipboard -} = require('electron') -// const log = require('../common/log') -const constants = require('../common/runtime-constants') -const appProps = require('../common/app-props') -const windowMove = require('./window-drag-move.js') -const globalState = require('./glob-state') -const { transferKeys } = require('../server/transfer') -const os = require('os') -const { - isTest -} = appProps -const { - getScreenSize -} = require('./window-control') -const _ = require('./lodash.js') -const { getStorageKey } = require('./storage-key') - -const isMaximized = () => { - const { - width: widthMax, - height: heightMax, - x: sx, - y: sy - } = getScreenSize() - const win = globalState.get('win') - const { width, height, x, y } = win.getBounds() - return widthMax === width && - heightMax === height && - x === sx && - y === sy -} - -module.exports = { - getStorageKey, - nodePtyCheck: () => { - return false - // try { - // return !!require('node-pty') - // } catch (err) { - // log.error('Failed to load node-pty:', err) - // return false - // } - }, - windowMove, - readClipboard: () => { - return clipboard.readText() - }, - writeClipboard: str => { - clipboard.writeText(str) - }, - resolve: (...args) => require('path').resolve(...args), - join: (...args) => require('path').join(...args), - basename: (...args) => require('path').basename(...args), - showItemInFolder: (href) => { - shell.showItemInFolder(href) - }, - openExternal: (url) => { - shell.openExternal(url) - }, - getArgs: () => { - return globalState.get('rawArgs') - }, - shouldAuth: () => globalState.get('requireAuth'), - getLoadTime: () => { - return globalState.get('loadTime') - ? { loadTime: globalState.get('loadTime') } - : { initTime: globalState.get('initTime') } - }, - setLoadTime: (loadTime) => { - globalState.set('loadTime', loadTime) - }, - getInitTime: () => { - return globalState.get('initTime') - }, - isMaximized, - isSecondInstance: () => { - return isTest ? false : globalState.get('isSecondInstance') - }, - osInfo: () => { - return Object.keys(os).map((k, i) => { - const vf = os[k] - if (!_.isFunction(vf)) { - return null - } - let v - try { - v = vf() - } catch (e) { - return null - } - if (!v) { - return null - } - v = JSON.stringify(v, null, 2) - return { k, v } - }).filter(d => d) - }, - getInitLocale: () => { - const config = globalState.get('config') - const langMap = globalState.get('langMap') - return { - language: config?.language || constants.defaultLang, - langMap: langMap || {} - } - }, - getConstants: () => { - return { - sep: require('path').sep, - ...constants, - homeOrTmp: appProps.homeOrTmp, - versions: JSON.stringify(process.versions), - transferKeys, - fsFunctions: [ - 'run', - 'runWinCmd', - 'access', - 'statAsync', - 'lstatAsync', - 'cp', - 'mv', - 'mkdir', - 'touch', - 'chmod', - 'rename', - 'unlink', - 'rmrf', - 'readdirAsync', - 'readFile', - 'readFileAsBase64', - 'writeFile', - 'openFile', - 'zipFolder', - 'unzipFile', - 'readCustom', - 'exists', - 'readdir', - 'mkdir', - 'realpath', - 'statCustom', - 'openCustom', - 'closeCustom', - 'writeCustom', - 'getFolderSize' - ] - } - } -} diff --git a/src/app/lib/ipc.js b/src/app/lib/ipc.js deleted file mode 100644 index 977b989..0000000 --- a/src/app/lib/ipc.js +++ /dev/null @@ -1,279 +0,0 @@ -/** - * ipc main - */ - -const { - ipcMain, - app, - BrowserWindow, - dialog, - powerMonitor, - globalShortcut, - shell -} = require('electron') -const globalState = require('./glob-state') -const ipcSyncFuncs = require('./ipc-sync') -const { dbAction } = require('./db') -const { listItermThemes } = require('./iterm-theme') -const installSrc = require('./install-src') -const { getConfig } = require('./get-config') -const loadSshConfig = require('./ssh-config') -const { - listWidgets, - runWidget, - stopWidget, - runWidgetFunc -} = require('../widgets/load-widget') -const { - setPassword, - checkPassword -} = require('./auth') -const initServer = require('./init-server') -const { - getLang, - loadLocales -} = require('./locales') -const { saveUserConfig } = require('./user-config-controller') -const { changeHotkeyReg, initShortCut } = require('./shortcut') -const lastStateManager = require('./last-state') -const { - registerDeepLink, - unregisterDeepLink, - checkProtocolRegistration, - getPendingDeepLink -} = require('./deep-link') -const { - packInfo, - appPath, - exePath, - isPortable, - sshKeysPath -} = require('../common/app-props') -const { - getScreenSize, - maximize, - unmaximize -} = require('./window-control') -const { openFileWithEditor } = require('./open-file-with-editor') -const { loadFontList } = require('./font-list') -const { checkDbUpgrade, doUpgrade } = require('../upgrade') -const { listSerialPorts } = require('./serial-port') -const initApp = require('./init-app') -const { encryptAsync, decryptAsync } = require('./enc') -const { safeEncrypt, safeDecrypt } = require('./safe-storage') -const { initCommandLine } = require('./command-line') -const { watchFile, unwatchFile } = require('./watch-file') -const lookup = require('../common/lookup') -const { AIchat, AIchatWithTools, getStreamContent, stopStream } = require('./ai') - -// Security: whitelist of safe environment variables for Linux/Mac/Windows -const SAFE_ENV_KEYS = [ - 'SHELL', 'TERM', 'TERM_PROGRAM', 'TERM_PROGRAM_VERSION', 'COLORTERM', - 'LANG', 'LC_ALL', 'LC_CTYPE', 'LC_TERMINAL', 'LC_TERMINAL_VERSION', - 'HOME', 'USER', 'LOGNAME', 'USERNAME', - 'PATH', 'PATHEXT', - 'TMPDIR', 'TMP', 'TEMP', - 'DISPLAY', 'WAYLAND_DISPLAY', 'XDG_SESSION_TYPE', 'XDG_RUNTIME_DIR', - 'XDG_DATA_DIRS', 'XDG_CONFIG_DIRS', 'XDG_CURRENT_DESKTOP', 'XDG_SEAT', 'XDG_VTNR', - 'SSH_AUTH_SOCK', 'SSH_AGENT_PID', 'SSH_CLIENT', 'SSH_CONNECTION', 'SSH_TTY', - 'NODE_PATH', 'NODE_ENV', 'NVM_DIR', 'NVM_BIN', - 'NPM_CONFIG_PREFIX', 'NPM_CONFIG_CACHE', - 'GIT_EDITOR', 'GIT_PAGER', 'GIT_TERMINAL_PROMPT', - 'EDITOR', 'VISUAL', 'PAGER', - 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy', - 'APPDATA', 'LOCALAPPDATA', 'ProgramFiles', 'ProgramFiles(x86)', 'CommonProgramFiles', - 'ComSpec', 'SystemRoot', 'SystemDrive', 'USERPROFILE', 'USERDOMAIN', - 'COMPUTERNAME', 'NUMBER_OF_PROCESSORS', 'PROCESSOR_ARCHITECTURE', 'OS', - 'Apple_PubSub_Socket_Render', - 'DBUS_SESSION_BUS_ADDRESS', 'DESKTOP_SESSION', 'GNOME_DESKTOP_SESSION_ID', 'KDE_FULL_SESSION', - 'CI', 'DOCKER_HOST', 'CONTAINER', - // HarmonyOS: app sandbox data directory (set by bootstrap.js) - 'DATA_PATH' -] - -// Security: the dynamic IPC bridges (runGlobalAsync / runSync) only dispatch to -// functions that are explicitly wired into the dispatch object as own properties. -// Checking `hasOwnProperty` (instead of a hand-maintained name list) means the -// allowlist can never drift from the real exports, and it blocks prototype-chain -// pivots like 'constructor', 'toString', '__proto__', 'hasOwnProperty' (CWE-863 / CWE-749). -function isExportedIpcFunc (obj, name) { - return Object.prototype.hasOwnProperty.call(obj, name) && typeof obj[name] === 'function' -} - -// Only the main app window's webContents may use the dynamic IPC bridges. This blocks -// any other renderer frame (webviews, popups, or an attacker page that navigated the -// window) from reaching runGlobalAsync / runSync (CWE-863 / CWE-749). -function isTrustedIpcSender (event) { - const win = globalState.get('win') - return !!win && event.sender === win.webContents -} - -async function initAppServer () { - const { - config - } = await getConfig(globalState.get('serverInited')) - const { - langs, - langMap, - sysLocale - } = await loadLocales() - const language = getLang(config, sysLocale, langs) - config.language = language - globalState.set('langMap', langMap) - if (!globalState.get('serverInited')) { - const child = await initServer(config, { - ...process.env, - appPath, - sshKeysPath - }, sysLocale) - child.on('message', (m) => { - if (m && m.showFileInFolder) { - shell.showItemInFolder(m.showFileInFolder) - } - }) - globalState.set('serverInited', true) - } - globalState.set('config', config) -} - -function initIpc () { - powerMonitor.on('resume', () => { - globalState.get('win').webContents.send('power-resume', null) - }) - async function init () { - const { - langs, - langMap - } = await loadLocales() - const config = globalState.get('config') - const globs = { - config, - langs, - langMap, - installSrc, - appPath, - exePath, - isPortable - } - initApp(langMap, config) - initShortCut(globalShortcut, globalState.get('win'), config) - return globs - } - - ipcMain.on('sync-func', (event, { name, args }) => { - if (!isTrustedIpcSender(event) || !isExportedIpcFunc(ipcSyncFuncs, name)) { - console.error('[security] blocked IPC call: ' + name) - return - } - event.returnValue = ipcSyncFuncs[name](...args) - }) - const asyncGlobals = { - confirmExit: () => { - globalState.set('confirmExit', true) - }, - setPassword, - checkPassword, - lookup, - loadSshConfig, - init, - listSerialPorts, - loadFontList, - doUpgrade, - checkDbUpgrade, - getExitStatus: () => globalState.get('exitStatus'), - setExitStatus: (status) => { - globalState.set('exitStatus', status) - }, - encryptAsync, - decryptAsync, - safeEncrypt: (str) => safeEncrypt(str), - safeDecrypt: (str) => safeDecrypt(str), - dbAction, - getScreenSize, - closeApp: (closeAction = '') => { - globalState.set('closeAction', closeAction) - const win = globalState.get('win') - win && win.close() - }, - exit: () => { - const win = globalState.get('win') - win && win.close() - }, - restart: (closeAction = '') => { - globalState.set('closeAction', '') - globalState.get('win').close() - app.relaunch() - }, - setCloseAction: (closeAction = '') => { - globalState.set('closeAction', closeAction) - }, - minimize: () => { - globalState.get('win').minimize() - }, - listItermThemes, - maximize, - unmaximize, - openDevTools: () => { - globalState.get('win').webContents.openDevTools() - }, - setWindowSize: (update) => { - lastStateManager.set('windowSize', update) - }, - saveUserConfig, - AIchat, - AIchatWithTools, - getStreamContent, - stopStream, - setTitle: (title) => { - const win = globalState.get('win') - win && win.setTitle(packInfo.name + ' - ' + title) - }, - setBackgroundColor: (color = '#33333300') => { - const win = globalState.get('win') - win && win.setBackgroundColor(color) - }, - changeHotkey: changeHotkeyReg(globalShortcut, globalState.get('win')), - initCommandLine, - watchFile, - unwatchFile, - openFileWithEditor, - listWidgets, - runWidget, - stopWidget, - runWidgetFunc, - registerDeepLink, - unregisterDeepLink, - checkProtocolRegistration, - getPendingDeepLink, - checkMigrate: () => false, - migrate: () => false, - getEnv: (key) => { - if (key) { - return SAFE_ENV_KEYS.includes(key) ? process.env[key] : '' - } - return Object.fromEntries( - SAFE_ENV_KEYS - .filter(k => process.env[k] !== undefined) - .map(k => [k, process.env[k]]) - ) - } - } - ipcMain.handle('async', (event, { name, args }) => { - if (!isTrustedIpcSender(event) || !isExportedIpcFunc(asyncGlobals, name)) { - console.error('[security] blocked IPC call: ' + name) - return - } - return asyncGlobals[name](...args) - }) - ipcMain.handle('show-open-dialog-sync', async (event, ...args) => { - const win = BrowserWindow.fromWebContents(event.sender) - return dialog.showOpenDialogSync(win, ...args) - }) - ipcMain.handle('show-save-dialog', async (event, ...args) => { - const win = BrowserWindow.fromWebContents(event.sender) - return dialog.showSaveDialog(win, ...args) - }) -} - -exports.initIpc = initIpc -exports.initAppServer = initAppServer diff --git a/src/app/lib/iterm-theme.js b/src/app/lib/iterm-theme.js deleted file mode 100644 index 1ac089e..0000000 --- a/src/app/lib/iterm-theme.js +++ /dev/null @@ -1,11 +0,0 @@ -/** - * read themes from https://github.com/mbadolato/iTerm2-Color-Schemes/tree/master/electerm - */ - -exports.listItermThemes = async () => { - const all = require('@electerm/electerm-themes/dist/index.js') - return Promise.all(all).catch(e => { - console.log(e) - return [] - }) -} diff --git a/src/app/lib/key-bind.js b/src/app/lib/key-bind.js deleted file mode 100644 index 7f108dc..0000000 --- a/src/app/lib/key-bind.js +++ /dev/null @@ -1,14 +0,0 @@ -/** - * disable some default keyboard shortcuts - */ - -exports.disableShortCuts = function (win) { - win.webContents.on('before-input-event', (event, input) => { - if ( - input.key.toLowerCase() === 'r' && - input.control && input.shift - ) { - event.preventDefault() - } - }) -} diff --git a/src/app/lib/last-state.js b/src/app/lib/last-state.js deleted file mode 100644 index 8ea1985..0000000 --- a/src/app/lib/last-state.js +++ /dev/null @@ -1,46 +0,0 @@ -/** - * set/get app last state - */ - -const { dbAction } = require('./db') -const log = require('../common/log') -let count = 0 -const set = (key, value) => { - count = count + 1 - if (count > 100) { - count = 0 - dbAction('compactDatafile').catch(log.error) - } - return dbAction('lastStates', 'update', { - _id: key - }, { - _id: key, - value - }, { - upsert: true - }) -} - -const get = async (key) => { - const res = await dbAction('lastStates', 'findOne', { - _id: key - }) - .catch(e => { - log.error(e) - log.error('last state get error') - }) - return res ? res.value : null -} - -const clear = (key) => { - const q = key - ? { _id: key } - : {} - return dbAction('lastStates', 'remove', q) -} - -module.exports = { - set, - get, - clear -} diff --git a/src/app/lib/locales.js b/src/app/lib/locales.js deleted file mode 100644 index 9951f22..0000000 --- a/src/app/lib/locales.js +++ /dev/null @@ -1,65 +0,0 @@ -/** - * multi language support - */ - -const { isDev, defaultLang } = require('../common/runtime-constants') -const { resolve } = require('path') - -async function loadLocales () { - // No system-locale detection on HarmonyOS: every detection attempt - // (os-locale-s, @ohos.intl, @ohos.i18n) returned a wrong ("en") value on - // real devices. Default to Simplified Chinese (defaultLang); users can - // still switch language in Settings. - const sysLocale = defaultLang - const path = (isDev - ? '../../' - : '') + - '../node_modules/@electerm/electerm-locales/dist/cjs' - const localeFolder = resolve(__dirname, path) - // languages array - const langs = require(resolve(localeFolder, 'list.json')) - .map(fileName => { - const filePath = resolve(localeFolder, fileName) - const lang = require(filePath) - return { - path: filePath, - id: fileName.replace('.js', ''), - name: lang.name, - reg: lang.match, - lang: lang.lang - } - }) - const langMap = langs.reduce((prev, l) => { - prev[l.id] = l - return prev - }, {}) - return { - langs, - langMap, - sysLocale - } -} - -function findLang (langs, la) { - let res = false - for (const l of langs) { - res = new RegExp(l.reg).test(la) - if (res) { - res = l.id - break - } - } - return res -} - -const getLang = (config, sysLocale, langs) => { - if (config.language) { - return config.language - } - let l = sysLocale - l = l ? l.toLowerCase().replace('-', '_') : defaultLang - return findLang(langs, l) || defaultLang -} - -exports.getLang = getLang -exports.loadLocales = loadLocales diff --git a/src/app/lib/lodash.js b/src/app/lib/lodash.js deleted file mode 100644 index 8becf8b..0000000 --- a/src/app/lib/lodash.js +++ /dev/null @@ -1,115 +0,0 @@ -/** - * Simple lodash replacement with only the functions needed by the app - * This replaces the full lodash library to reduce bundle size - */ - -/** - * Creates a debounced function that delays invoking func until after wait milliseconds - * have elapsed since the last time the debounced function was invoked. - */ -function debounce (func, wait, immediate) { - let timeout - return function executedFunction (...args) { - const later = () => { - timeout = null - if (!immediate) func.apply(this, args) - } - const callNow = immediate && !timeout - clearTimeout(timeout) - timeout = setTimeout(later, wait) - if (callNow) func.apply(this, args) - } -} - -/** - * Creates a throttled function that only invokes func at most once per every wait milliseconds. - */ -function throttle (func, wait, options = {}) { - let timeout - let previous = 0 - - const later = function () { - previous = options.leading === false ? 0 : Date.now() - timeout = null - func.apply(this, arguments) - } - - return function throttled (...args) { - const now = Date.now() - if (!previous && options.leading === false) previous = now - const remaining = wait - (now - previous) - - if (remaining <= 0 || remaining > wait) { - if (timeout) { - clearTimeout(timeout) - timeout = null - } - previous = now - func.apply(this, args) - } else if (!timeout && options.trailing !== false) { - timeout = setTimeout(() => later.apply(this, args), remaining) - } - } -} - -/** - * Creates an object composed of the picked object properties. - */ -function pick (object, paths) { - const result = {} - const keys = Array.isArray(paths) ? paths : [paths] - - for (const key of keys) { - if (object && Object.prototype.hasOwnProperty.call(object, key)) { - result[key] = object[key] - } - } - - return result -} - -/** - * Checks if value is an empty object, collection, map, or set. - */ -function isEmpty (value) { - if (value == null) { - return true - } - - if (Array.isArray(value) || typeof value === 'string') { - return value.length === 0 - } - - if (value instanceof Map || value instanceof Set) { - return value.size === 0 - } - - if (typeof value === 'object') { - return Object.keys(value).length === 0 - } - - return false -} - -/** - * Checks if value is classified as an Array object. - */ -function isArray (value) { - return Array.isArray(value) -} - -/** - * Checks if value is classified as a Function object. - */ -function isFunction (value) { - return typeof value === 'function' -} - -module.exports = { - debounce, - throttle, - pick, - isEmpty, - isArray, - isFunction -} diff --git a/src/app/lib/nedb.js b/src/app/lib/nedb.js deleted file mode 100644 index d41288f..0000000 --- a/src/app/lib/nedb.js +++ /dev/null @@ -1,246 +0,0 @@ -/** - * nedb api wrapper - * Accepts appPath and defaultUserName as parameters to avoid electron dependency - */ - -const { resolve } = require('path') -const fs = require('fs') -const Datastore = require('@electerm/nedb') - -// ── HarmonyOS fix: monkey-patch nedb storage ────────────────────────── -// nedb's storage.js uses fs.fsync in crashSafeWriteFile (called during -// loadDatabase compaction). On HarmonyOS's sandbox filesystem, fs.fsync -// — especially on directories — can fail, causing loadDatabase to fail. -// The default onload handler throws, but process.on('uncaughtException') -// swallows it. executor.processBuffer() is never called, so the executor -// stays "not ready" and ALL DB operations are buffered forever. -// -// Fix: make flushToStorage treat fsync failures as non-fatal (best-effort). -const nedbStorage = require('@electerm/nedb/lib/storage') -const _origFlush = nedbStorage.flushToStorage - -nedbStorage.flushToStorage = function (options, callback) { - // Wrap the callback to make fsync failures non-fatal. - // On HarmonyOS the sandbox filesystem may not support fsync - // (especially on directories). The actual write/rename in - // crashSafeWriteFile still works; we just skip the fsync guarantee. - const wrappedCb = function () { - callback(null) - } - - _origFlush.call(nedbStorage, options, wrappedCb) -} - -// Tables whose stored data values should be encrypted at rest -const ENC_TABLES = new Set(['bookmarks', 'profiles', 'data', 'history', 'terminalCommandHistory', 'aiChatHistory']) - -// Within the 'data' table, only this specific record is encrypted -const DATA_ENC_ID = 'userConfig' - -// Prefix added to stored strings to mark them as encrypted -const ENC_PREFIX = 'enc:' - -function createDb (appPath, defaultUserName, { enc, dec } = {}) { - const db = {} - - const appDataPath = process.env.DATA_PATH || resolve(appPath, 'electerm') - - if (!fs.existsSync(appDataPath)) { - fs.mkdirSync(appDataPath, { recursive: true }) - } - - const dbDir = resolve(appDataPath, 'users', defaultUserName) - if (!fs.existsSync(dbDir)) { - fs.mkdirSync(dbDir, { recursive: true }) - } - - const reso = (name) => { - return resolve(dbDir, `electerm.${name}.nedb`) - } - const tables = [ - 'bookmarks', - 'bookmarkGroups', - 'addressBookmarks', - 'terminalThemes', - 'lastStates', - 'data', - 'quickCommands', - 'log', - 'dbUpgradeLog', - 'profiles', - 'workspaces', - 'history', - 'terminalCommandHistory', - 'aiChatHistory', - 'autoRunWidgets' - ] - - tables.forEach(table => { - const conf = { - filename: reso(table), - autoload: true, - // Custom onload handler: log errors but DON'T throw. - // If loadDatabase fails (e.g. compaction step), we still - // force the executor to "ready" so DB operations can proceed. - onload: (err) => { - if (err) { - // Force executor ready so buffered operations execute. - // The data was already loaded into memory before the - // compaction step (persistCachedDatabase) ran. - if (db[table] && db[table].executor && !db[table].executor.ready) { - db[table].executor.processBuffer() - } - } - } - } - db[table] = new Datastore(conf) - }) - - /** - * Encrypt a plain JSON string for storage. - * Returns the original string when encryption is not configured. - */ - function encryptData (jsonStr) { - if (!enc) return jsonStr - return ENC_PREFIX + enc(jsonStr) - } - - /** - * Decrypt a stored string back to plain JSON. - * Returns the original string when decryption is not configured or the - * value was stored without encryption. - */ - function decryptData (stored) { - if (!dec || !stored) return stored - if (!stored.startsWith(ENC_PREFIX)) return stored - return dec(stored.slice(ENC_PREFIX.length)) - } - - /** - * Returns true when a specific document in a specific table should be - * encrypted. The 'data' table is selective: only _id === 'userConfig'. - */ - function needsEnc (dbName, id) { - if (!enc) return false - if (dbName === 'data') return id === DATA_ENC_ID - return ENC_TABLES.has(dbName) - } - - /** - * Wrap a result document by decrypting its `data` field when needed. - * nedb stores the full document object directly, so we JSON-parse the - * serialised data field that was encrypted during writes. - */ - function decryptDoc (dbName, doc) { - if (!dec || !doc || !needsEnc(dbName, doc._id)) return doc - if (!doc._encdata) return doc - try { - const plain = decryptData(doc._encdata) - const parsed = JSON.parse(plain) - const { _encdata: _, ...rest } = doc - return { ...rest, ...parsed } - } catch (e) { - return doc - } - } - - /** - * Wrap a document for storage by encrypting its payload when needed. - */ - function encryptDoc (dbName, doc) { - if (!needsEnc(dbName, doc._id)) return doc - const { _id, ...payload } = doc - const jsonStr = JSON.stringify(payload) - const encrypted = encryptData(jsonStr) - return _id !== undefined ? { _id, _encdata: encrypted } : { _encdata: encrypted } - } - - const dbAction = (dbName, op, ...args) => { - if (op === 'compactDatafile') { - db[dbName].persistence.compactDatafile() - return - } - return new Promise((resolve, reject) => { - if (op === 'find') { - db[dbName][op](...args, (err, results) => { - if (err) return reject(err) - resolve((results || []).map(doc => decryptDoc(dbName, doc))) - }) - } else if (op === 'findOne') { - db[dbName][op](...args, (err, result) => { - if (err) return reject(err) - resolve(decryptDoc(dbName, result)) - }) - } else if (op === 'insert') { - const original = args[0] - const toInsert = Array.isArray(original) - ? original.map(d => encryptDoc(dbName, d)) - : encryptDoc(dbName, original) - db[dbName][op](toInsert, (err, inserted) => { - if (err) { - // Handle unique constraint violation by falling back to update, - // matching SQLite's INSERT OR REPLACE behavior - if (err.errorType === 'uniqueViolated') { - const items = Array.isArray(toInsert) ? toInsert : [toInsert] - const origItems = Array.isArray(original) ? original : [original] - let pending = items.length - const results = [] - items.forEach((item, i) => { - db[dbName].update({ _id: item._id }, item, { upsert: true }, (uErr) => { - if (uErr) { - return reject(uErr) - } - results[i] = { ...origItems[i], _id: item._id } - if (--pending === 0) { - resolve(Array.isArray(original) ? results : results[0]) - } - }) - }) - return - } - return reject(err) - } - // Return documents with original (unencrypted) fields + _id - if (Array.isArray(original)) { - const origArr = Array.isArray(inserted) ? inserted : [inserted] - resolve(origArr.map((ins, i) => ({ ...original[i], _id: ins._id }))) - } else { - resolve({ ...original, _id: inserted._id }) - } - }) - } else if (op === 'update') { - const [query, updateObj, options] = args - const qid = query._id || query.id - if (needsEnc(dbName, qid)) { - const newData = updateObj.$set || updateObj - const { _id: _ignored, ...payload } = newData - const encDoc = encryptDoc(dbName, { _id: qid, ...payload }) - const finalUpdate = updateObj.$set ? { $set: encDoc } : encDoc - db[dbName][op](query, finalUpdate, options || {}, (err, result) => { - if (err) return reject(err) - resolve(result) - }) - } else { - db[dbName][op](...args, (err, result) => { - if (err) return reject(err) - resolve(result) - }) - } - } else { - db[dbName][op](...args, (err, result) => { - if (err) return reject(err) - resolve(result) - }) - } - }) - } - - return { - dbAction, - tables - } -} - -module.exports = { - createDb -} diff --git a/src/app/lib/npm.js b/src/app/lib/npm.js deleted file mode 100644 index 1304f86..0000000 --- a/src/app/lib/npm.js +++ /dev/null @@ -1,64 +0,0 @@ -const path = require('path') -const fs = require('fs') -const tar = require('tar') -const axios = require('axios') -const { pipeline } = require('stream/promises') - -const npmRegistry = (process.env.NPM_REGISTRY || 'https://registry.npmjs.org').replace(/\/$/, '') - -async function fetchManifest (packageName) { - const encoded = packageName.replace('/', '%2f') - const { data } = await axios.get(`${npmRegistry}/${encoded}/latest`) - return data -} - -async function extractTarball (tarballUrl, destDir) { - const { data: stream } = await axios.get(tarballUrl, { responseType: 'stream' }) - fs.mkdirSync(destDir, { recursive: true }) - await pipeline( - stream, - require('zlib').createGunzip(), - tar.extract({ cwd: destDir, strip: 1 }) - ) -} - -async function installPackage (packageName, targetFolder, visited = new Set()) { - const cacheKey = `${packageName}@${npmRegistry}` - if (visited.has(cacheKey)) { - return - } - visited.add(cacheKey) - - const packageDir = path.join(targetFolder, 'node_modules', packageName) - if (fs.existsSync(packageDir)) { - return - } - - const manifest = await fetchManifest(packageName) - const tarballUrl = manifest.dist && manifest.dist.tarball - if (!tarballUrl) { - throw new Error(`No tarball URL found for ${packageName}`) - } - - await extractTarball(tarballUrl, packageDir) - - const deps = { - ...manifest.dependencies, - ...manifest.optionalDependencies - } - - for (const [depName] of Object.entries(deps || {})) { - await installPackage(depName, targetFolder, visited) - } -} - -exports.downloadPackage = async (packageName, targetFolder) => { - const npmPath = path.join(targetFolder, 'node_modules', packageName) - if (fs.existsSync(npmPath)) { - return npmPath - } - - await installPackage(packageName, targetFolder) - - return npmPath -} diff --git a/src/app/lib/on-close.js b/src/app/lib/on-close.js deleted file mode 100644 index 9102551..0000000 --- a/src/app/lib/on-close.js +++ /dev/null @@ -1,61 +0,0 @@ -/** - * on close app - */ - -const { dbAction } = require('./db') -const log = require('../common/log') -const globalState = require('./glob-state') - -exports.getExitStatus = async () => { - const res = await dbAction('data', 'findOne', { - _id: 'exitStatus' - }) - return res && res.value ? res.value : '' -} - -exports.onClose = async function (e) { - const config = globalState.get('config') - if (config.confirmBeforeExit && globalState.get('closeAction')) { - const win = globalState.get('win') - win?.webContents.send( - 'confirm-exit', - globalState.get('closeAction') - ) - globalState.set('closeAction', '') - return e.preventDefault() - } - log.debug('Closing app') - // Clean up all terminal sessions - try { - const { cleanupTerminals } = require('../server/session-process') - cleanupTerminals() - } catch (e) {} - // Kill the main server mock - const child = globalState.get('child') - if (child && typeof child.kill === 'function') { - try { child.kill() } catch (e) {} - } - globalState.set('serverInited', false) - log.debug('Sessions and server cleaned up') - // await dbAction('data', 'update', { - // _id: 'exitStatus' - // }, { - // value: 'ok', - // _id: 'exitStatus' - // }, { - // upsert: true - // }) - // await dbAction('data', 'update', { - // _id: 'sessions' - // }, { - // value: null, - // _id: 'sessions' - // }, { - // upsert: true - // }) - // log.debug('session saved') - clearTimeout(globalState.get('timer')) - globalState.set('win', null) - const app = globalState.get('app') - app.quit && app.quit() -} diff --git a/src/app/lib/open-file-with-editor.js b/src/app/lib/open-file-with-editor.js deleted file mode 100644 index fc87919..0000000 --- a/src/app/lib/open-file-with-editor.js +++ /dev/null @@ -1,120 +0,0 @@ -const { spawn } = require('child_process') - -function parseEditorCommand (command = '') { - const input = String(command).trim() - if (!input) { - throw new Error('Editor command is required') - } - - const args = [] - let current = '' - let quote = '' - - for (let index = 0; index < input.length; index++) { - const char = input[index] - - if (quote) { - if (char === quote) { - quote = '' - } else if (char === '\\' && input[index + 1] === quote) { - current += quote - index++ - } else { - current += char - } - continue - } - - if (char === '"' || char === '\'') { - quote = char - continue - } - - if (/\s/.test(char)) { - if (current) { - args.push(current) - current = '' - } - continue - } - - current += char - } - - if (quote) { - throw new Error('Editor command contains an unmatched quote') - } - - if (current) { - args.push(current) - } - - if (!args.length) { - throw new Error('Editor command is required') - } - - return { - command: args[0], - args: args.slice(1) - } -} - -function spawnDetachedEditor (command, args, options = {}) { - return new Promise((resolve, reject) => { - const child = spawn(command, args, { - detached: false, - stdio: ['ignore', 'ignore', 'pipe'], - ...options - }) - let stderr = '' - - child.stderr.on('data', data => { - stderr += data.toString() - }) - child.on('error', reject) - - let settled = false - const settle = (err) => { - if (settled) { - return - } - settled = true - clearTimeout(timer) - child.unref() - if (err) { - reject(err) - } else { - resolve() - } - } - - child.on('close', code => { - if (code !== 0) { - settle(new Error(stderr.trim() || `Editor exited with code ${code}`)) - } else { - settle(null) - } - }) - - const timer = setTimeout(() => settle(null), 5000) - }) -} - -function openFileWithEditor (filePath, editorCommand) { - const parsed = parseEditorCommand(editorCommand) - - const userShell = process.env.SHELL || '/bin/sh' - - return spawnDetachedEditor(userShell, [ - '-l', - '-i', - '-c', - 'exec "$0" "$@"', - parsed.command, - ...parsed.args, - filePath - ]) -} - -exports.openFileWithEditor = openFileWithEditor -exports.parseEditorCommand = parseEditorCommand diff --git a/src/app/lib/proxy-agent.js b/src/app/lib/proxy-agent.js deleted file mode 100644 index 3c3f496..0000000 --- a/src/app/lib/proxy-agent.js +++ /dev/null @@ -1,15 +0,0 @@ -// common proxy agent creator -exports.createProxyAgent = (url = '') => { - if ( - typeof url !== 'string' || - (!url.startsWith('http') && !url.startsWith('socks')) - ) { - return - } - const Cls = url.startsWith('http') - ? require('https-proxy-agent').HttpsProxyAgent - : require('socks-proxy-agent').SocksProxyAgent - return new Cls(url, { - keepAlive: true - }) -} diff --git a/src/app/lib/safe-storage.js b/src/app/lib/safe-storage.js deleted file mode 100644 index 7579620..0000000 --- a/src/app/lib/safe-storage.js +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Safe storage wrapper using Node.js crypto (AES-256-GCM). - * - * Replaces Electron's safeStorage API which relies on OS-level key - * services (macOS Keychain, Windows DPAPI, Linux libsecret) — none of - * which are available on HarmonyOS. - * - * The encryption key is derived (via SHA-256) from STORAGE_SECRET: - * - In CI builds: build/harmony/build.js replaces the placeholder - * string with secrets.OHOS_SERVER_SECRET at build time - * - In local dev: uses the static placeholder string below - * - * Encrypted values are stored as base64 strings prefixed with SAFE_PREFIX - * so they can be distinguished from plain-text or legacy-encrypted values. - * - * Format: v2:safe: - */ - -const crypto = require('crypto') - -const SAFE_PREFIX = 'v2:safe:' -const ALGO = 'aes-256-gcm' -const IV_LEN = 12 // 96-bit IV recommended for GCM - -// Default fallback secret for local development. -// At build time, build/harmony/build.js replaces this string with -// the value of process.env.SERVER_SECRET (sourced from .env which -// prepare-web.sh sets from GitHub Secret OHOS_SERVER_SECRET). -const STORAGE_SECRET = 'static-secret-string-safe-storage' - -/** - * Derive a 32-byte key from the secret string via SHA-256. - * @returns {Buffer} - */ -function getKey () { - return crypto.createHash('sha256').update(STORAGE_SECRET).digest() -} - -/** - * Encrypt a string using AES-256-GCM. - * Returns the original string unchanged on error. - * @param {string} str - * @returns {string} - */ -exports.safeEncrypt = function (str) { - if (typeof str !== 'string' || !str) return str - try { - const key = getKey() - const iv = crypto.randomBytes(IV_LEN) - const cipher = crypto.createCipheriv(ALGO, key, iv) - const encrypted = Buffer.concat([ - cipher.update(str, 'utf8'), - cipher.final() - ]) - const authTag = cipher.getAuthTag() - return SAFE_PREFIX + [ - iv.toString('base64'), - encrypted.toString('base64'), - authTag.toString('base64') - ].join(':') - } catch (e) { - console.error('[safe-storage] encrypt error:', e.message) - return str - } -} - -/** - * Decrypt a string that was encrypted with safeEncrypt. - * Returns the original string unchanged when it was not produced by safeEncrypt. - * @param {string} str - * @returns {string} - */ -exports.safeDecrypt = function (str) { - if (typeof str !== 'string' || !str) return str - if (!str.startsWith(SAFE_PREFIX)) return str - try { - const payload = str.slice(SAFE_PREFIX.length) - const parts = payload.split(':') - if (parts.length !== 3) return str - const [ivB64, encB64, tagB64] = parts - const key = getKey() - const decipher = crypto.createDecipheriv( - ALGO, - key, - Buffer.from(ivB64, 'base64') - ) - decipher.setAuthTag(Buffer.from(tagB64, 'base64')) - const decrypted = Buffer.concat([ - decipher.update(Buffer.from(encB64, 'base64')), - decipher.final() - ]) - return decrypted.toString('utf8') - } catch (e) { - console.error('[safe-storage] decrypt error:', e.message) - return str - } -} diff --git a/src/app/lib/serial-port.js b/src/app/lib/serial-port.js deleted file mode 100644 index 8cb99aa..0000000 --- a/src/app/lib/serial-port.js +++ /dev/null @@ -1,19 +0,0 @@ -/** - * serial port lib - */ - -exports.listSerialPorts = async function () { - return [] - // try { - // const start = Date.now() - // const r = await require('serialport').SerialPort.list() - // const end = Date.now() - // if (end - start < 100) { - // await new Promise(resolve => setTimeout(resolve, 100)) // wait for 100ms to avoid potential issues on some platforms - // } - // return r - // } catch (error) { - // console.error('Error listing serial ports:', error) - // return Promise.resolve([]) // Return an empty array on error - // } -} diff --git a/src/app/lib/shortcut.js b/src/app/lib/shortcut.js deleted file mode 100644 index 3c383b8..0000000 --- a/src/app/lib/shortcut.js +++ /dev/null @@ -1,51 +0,0 @@ -/** - * shortcut controll - */ - -const log = require('../common/log') - -let shortcut - -/** - * init hotkey - * @param {object} globalShortcut - * @param {object} win - * @param {object} config - */ -exports.initShortCut = (globalShortcut, win, config) => { - shortcut = config.hotkey || '' - if (shortcut) { - globalShortcut.register(shortcut, () => { - if (win.isFocused()) { - win.minimize() - } else { - win.restore() - } - }) - const ok = globalShortcut.isRegistered(shortcut) - if (!ok) { - log.warn('shortcut Registration failed.') - } - } -} - -exports.changeHotkeyReg = (globalShortcut, win) => { - return newHotkey => { - if (shortcut) { - globalShortcut.unregister(shortcut) - } - if (newHotkey) { - globalShortcut.register(newHotkey, () => { - win.show() - }) - const ok = globalShortcut.isRegistered(newHotkey) - if (ok) { - shortcut = newHotkey - } - return ok - } else { - shortcut = '' - return true - } - } -} diff --git a/src/app/lib/single-instance.js b/src/app/lib/single-instance.js deleted file mode 100644 index b414d05..0000000 --- a/src/app/lib/single-instance.js +++ /dev/null @@ -1,140 +0,0 @@ -/** - * Single instance lock with socket-based IPC - */ - -const net = require('net') -const fs = require('fs') -const path = require('path') -const { app } = require('electron') -const globalState = require('./glob-state') -const { tempDir } = require('../common/runtime-constants') - -function getSocketPath () { - return path.join(tempDir, `${app.getName()}-instance.sock`) -} - -// Clean up stale socket file -function cleanupSocket () { - const socketPath = getSocketPath() - if (fs.existsSync(socketPath)) { - try { - fs.unlinkSync(socketPath) - } catch (e) { - // Ignore errors - } - } -} - -/** - * Start socket server to receive data from second instances - * @param {Function} onSecondInstance - Callback when second instance sends data - */ -function startSocketServer (onSecondInstance) { - const socketPath = getSocketPath() - cleanupSocket() - - const server = net.createServer((socket) => { - let data = '' - socket.on('data', (chunk) => { - data += chunk.toString() - }) - socket.on('end', () => { - try { - const parsed = JSON.parse(data) - onSecondInstance(parsed) - } catch (e) { - console.error('Failed to parse second instance data:', e) - } - }) - }) - - server.on('error', (err) => { - console.error('Socket server error:', err) - }) - - server.listen(socketPath) - - // Clean up on app quit - app.on('will-quit', () => { - server.close() - cleanupSocket() - }) - - return server -} - -/** - * Send data to primary instance via socket - * @param {Object} data - Data to send - * @returns {Promise} - True if sent successfully - */ -function sendToFirstInstance (data) { - const socketPath = getSocketPath() - return new Promise((resolve) => { - let settled = false - const done = (result) => { - if (settled) return - settled = true - clearTimeout(timer) - resolve(result) - } - - // Timeout: if we can't connect or get a response within 3 seconds, - // the primary instance is likely dead (e.g. crashed). Clean up the - // stale socket and proceed as the primary instance. - const timer = setTimeout(() => { - try { client.destroy() } catch (e) {} - cleanupSocket() - done(false) - }, 3000) - - const client = net.createConnection(socketPath, () => { - client.write(JSON.stringify(data)) - client.end() - }) - - client.on('error', () => { - // No server listening, we are the first instance - cleanupSocket() - done(false) - }) - - client.on('close', () => { - done(true) - }) - }) -} - -/** - * Handle second instance connection - * @param {Object} progs - Parsed command line options - * @returns {Promise} - True if this is the primary instance - */ -async function handleSingleInstance (progs) { - // Try to send to existing instance first via socket - const sent = await sendToFirstInstance(progs) - if (sent) { - // Successfully sent to primary instance, quit this one - return false - } - - // We are the primary instance, start socket server - startSocketServer((data) => { - const win = globalState.get('win') - if (win) { - if (win.isMinimized()) { - win.restore() - } - win.focus() - win.webContents.send('add-tab-from-command-line', data) - } - }) - - return true -} - -module.exports = { - handleSingleInstance, - sendToFirstInstance, - startSocketServer -} diff --git a/src/app/lib/ssh-config.js b/src/app/lib/ssh-config.js deleted file mode 100644 index 55f8620..0000000 --- a/src/app/lib/ssh-config.js +++ /dev/null @@ -1,14 +0,0 @@ -/** - * read ssh config - */ - -// const { app } = require('electron') -// const home = app.getPath('home') -// const { resolve } = require('path') - -function loadSshConfig () { - const { loadAndConvert } = require('ssh-config-loader') - return loadAndConvert() -} - -module.exports = loadSshConfig diff --git a/src/app/lib/storage-key.js b/src/app/lib/storage-key.js deleted file mode 100644 index b4c2d93..0000000 --- a/src/app/lib/storage-key.js +++ /dev/null @@ -1,46 +0,0 @@ -const log = require('../common/log') -const { appPath, defaultUserName } = require('../common/app-props') -const { safeEncrypt, safeDecrypt } = require('./safe-storage') -const { resolve: pathResolve } = require('path') -const fs = require('fs') -const { randomBytes } = require('crypto') - -const appDataPath = process.env.DATA_PATH || pathResolve(appPath, 'electerm') -const keyFilePath = pathResolve(appDataPath, 'users', defaultUserName, 'storage-key.enc') - -let _cachedStorageKey = null - -function getStorageKey () { - if (_cachedStorageKey) return _cachedStorageKey - let key = null - try { - if (fs.existsSync(keyFilePath)) { - const enc = fs.readFileSync(keyFilePath, 'utf8').trim() - const dec = safeDecrypt(enc) - if (dec && dec !== enc) { - key = dec - } else if (dec && !enc.startsWith('v2:safe:')) { - key = dec - } - } - } catch (e) { - log.error('[storage-key] read error:', e.message) - } - if (!key) { - key = randomBytes(32).toString('base64') - try { - const dir = pathResolve(appDataPath, 'users', defaultUserName) - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }) - } - const enc = safeEncrypt(key) - fs.writeFileSync(keyFilePath, enc, 'utf8') - } catch (e) { - log.error('[storage-key] write error:', e.message) - } - } - _cachedStorageKey = key - return key -} - -module.exports = { getStorageKey } diff --git a/src/app/lib/user-config-controller.js b/src/app/lib/user-config-controller.js deleted file mode 100644 index bbf5f07..0000000 --- a/src/app/lib/user-config-controller.js +++ /dev/null @@ -1,53 +0,0 @@ -/** - * user-controll.json controll - */ - -const { dbAction } = require('./db') -const { userConfigId, userNoEncryptConfigId } = require('../common/constants') -const { getDbConfig } = require('./get-config') -const globalState = require('./glob-state') - -const configNoEncryptFields = ['allowMultiInstance'] - -function hasNoEncryptFields (userConfig) { - for (const f of configNoEncryptFields) { - if (f in userConfig) { - return true - } - } - return false -} - -exports.saveUserConfig = async (userConfig) => { - const q = { - _id: userConfigId - } - delete userConfig.host - delete userConfig.terminalTypes - delete userConfig.tokenElecterm - delete userConfig.server - delete userConfig.port - globalState.update('config', userConfig) - const conf = await getDbConfig() - if (hasNoEncryptFields(userConfig)) { - const q1 = { - _id: userNoEncryptConfigId - } - const noEncryptConfig = {} - for (const f of configNoEncryptFields) { - if (f in userConfig) { - noEncryptConfig[f] = userConfig[f] - } - } - await dbAction('data', 'update', q1, noEncryptConfig, { - upsert: true - }) - } - return dbAction('data', 'update', q, { - ...q, - ...conf, - ...userConfig - }, { - upsert: true - }) -} diff --git a/src/app/lib/watch-file.js b/src/app/lib/watch-file.js deleted file mode 100644 index 4ef48cc..0000000 --- a/src/app/lib/watch-file.js +++ /dev/null @@ -1,40 +0,0 @@ -const fs = require('original-fs') -const globalState = require('./glob-state') -const _ = require('./lodash.js') - -const onWatch = _.debounce(() => { - try { - const filePath = globalState.get('watchFilePath') - if (fs.existsSync(filePath)) { - const text = fs.readFileSync(filePath, 'utf8') - globalState.get('win').webContents.send('file-change', text) - } else { - console.log('Watched file no longer exists') - globalState.get('win').webContents.send('file-deleted') - } - } catch (e) { - console.error('Error reading file:', e) - globalState.get('win').webContents.send('file-read-error', e.message) - } -}, 300, { leading: false, trailing: true }) - -exports.watchFile = (path) => { - globalState.set('watchFilePath', path) - fs.watchFile(path, onWatch) -} - -exports.unwatchFile = (path) => { - globalState.set('watchFilePath', '') - fs.unwatchFile(path, onWatch) -} - -exports.cleanWatchFile = () => { - globalState.set('watchFilePath', '') - const filePath = globalState.get('watchFilePath') - if (!filePath) { - return - } - fs.unwatchFile(filePath, onWatch) -} - -process.on('exit', exports.cleanWatchFile) diff --git a/src/app/lib/webview-handler.js b/src/app/lib/webview-handler.js deleted file mode 100644 index a1c841e..0000000 --- a/src/app/lib/webview-handler.js +++ /dev/null @@ -1,152 +0,0 @@ -const { ipcMain, webContents } = require('electron') - -// Store credentials per webContents ID -const credentialsMap = new Map() // webContentsId -> { username, password } -const authRequestMap = new Map() // requestId -> { webContentsId } -const initializedSessions = new Set() - -let authRequestId = 0 -let windowCount = 0 - -const onAuthResponse = (event, data) => { - const { id, username, password } = data - const entry = authRequestMap.get(id) - if (!entry) return - - const { webContentsId } = entry - authRequestMap.delete(id) - - if (username && password) { - credentialsMap.set(webContentsId, { username, password }) - // Reload the webview to apply new credentials - try { - const wc = webContents.fromId(webContentsId) - if (wc) { - wc.reload() - } - } catch (e) { - console.error('Failed to reload webview:', e) - } - } else { - credentialsMap.delete(webContentsId) - } -} - -function init (mainWindow) { - windowCount++ - - // Listen for auth response from renderer if not already listening - if (ipcMain.listenerCount('webview-auth-response') === 0) { - ipcMain.on('webview-auth-response', onAuthResponse) - } - - // Handle new webviews - mainWindow.webContents.on('did-attach-webview', (event, viewWebContents) => { - setupWebview(viewWebContents, mainWindow) - - // Clean up when webview is destroyed - viewWebContents.once('destroyed', () => { - credentialsMap.delete(viewWebContents.id) - // Remove any pending requests for this webview - for (const [reqId, entry] of authRequestMap.entries()) { - if (entry.webContentsId === viewWebContents.id) { - authRequestMap.delete(reqId) - } - } - }) - }) - - mainWindow.on('closed', () => { - windowCount-- - if (windowCount <= 0) { - ipcMain.removeListener('webview-auth-response', onAuthResponse) - credentialsMap.clear() - authRequestMap.clear() - initializedSessions.clear() - windowCount = 0 - } - }) -} - -function setupWebview (viewWebContents, mainWindow) { - const session = viewWebContents.session - - // Set up header injection if not already done for this session - if (!initializedSessions.has(session)) { - initializedSessions.add(session) - - session.webRequest.onBeforeSendHeaders((details, callback) => { - const wcId = details.webContentsId - const creds = credentialsMap.get(wcId) - const requestHeaders = { ...details.requestHeaders } - - if (creds) { - const auth = Buffer.from(`${creds.username}:${creds.password}`).toString('base64') - requestHeaders.Authorization = `Basic ${auth}` - } - - // eslint-disable-next-line n/no-callback-literal - callback({ requestHeaders }) - }) - } - - // Listen for navigation and check for auth challenges (text-based) - viewWebContents.on('dom-ready', () => { - checkAuthStatus(viewWebContents, mainWindow) - }) - - viewWebContents.on('did-navigate', () => { - // Small delay to ensure page is loaded - setTimeout(() => checkAuthStatus(viewWebContents, mainWindow), 100) - }) - - // Initial check - setTimeout(() => checkAuthStatus(viewWebContents, mainWindow), 500) -} - -async function checkAuthStatus (viewWebContents, mainWindow) { - if (viewWebContents.isDestroyed()) return - - try { - const result = await viewWebContents.executeJavaScript(` - (function() { - // Check for various ways the 401 page might appear - const bodyText = document.body ? document.body.textContent : ''; - const htmlText = document.documentElement ? document.documentElement.textContent : ''; - const allText = bodyText + htmlText; - - if (allText.includes('Access Error: Unauthorized')) { - return { status: 'unauthorized' }; - } else if (allText.includes('Authentication Successful')) { - return { status: 'authenticated' }; - } - return { status: 'unknown' }; - })() - `) - - if (result.status === 'unauthorized') { - // Check if we've already requested auth for this webview - const pendingRequest = Array.from(authRequestMap.values()).find(e => e.webContentsId === viewWebContents.id) - if (pendingRequest) return - - // Generate request ID - authRequestId++ - const id = authRequestId - - authRequestMap.set(id, { webContentsId: viewWebContents.id }) - - mainWindow.webContents.send('webview-auth-request', { - id, - url: viewWebContents.getURL(), - host: new URL(viewWebContents.getURL()).host, - isProxy: false - }) - } - } catch (error) { - // console.error('Check auth status error:', error); - } -} - -module.exports = { - init -} diff --git a/src/app/lib/window-control.js b/src/app/lib/window-control.js deleted file mode 100644 index 835d131..0000000 --- a/src/app/lib/window-control.js +++ /dev/null @@ -1,73 +0,0 @@ -/** - * manage window size save read and set - */ - -const lastStateManager = require('./last-state') -const { - isDev, - minWindowWidth, - minWindowHeight -} = require('../common/runtime-constants') -const globalState = require('./glob-state') -const { restoreWindowBounds } = require('./window-restore') - -exports.getScreenCurrent = () => { - const rect = globalState.get('win') - ? globalState.get('win').getBounds() - : { - x: 0, - y: 0, - height: minWindowHeight, - width: minWindowWidth - } - const { screen } = require('electron') - return screen.getDisplayMatching(rect) -} - -exports.getScreenSize = () => { - const screen = exports.getScreenCurrent() - return { - ...screen.workAreaSize, - x: screen.workArea.x, - y: screen.workArea.y - } -} - -exports.maximize = () => { - const win = globalState.get('win') - globalState.set('oldRectangle', win.getBounds()) - win.maximize() -} - -exports.unmaximize = () => { - const oldRectangle = globalState.get('oldRectangle') || { - width: minWindowWidth, - height: minWindowHeight, - x: 200, - y: 200 - } - globalState.get('win').unmaximize() - globalState.get('win').setBounds(oldRectangle) -} - -exports.getWindowSize = async () => { - return exports.getWindowSizeDep() -} - -exports.getWindowSizeDep = async () => { - const windowSizeLastState = await lastStateManager.get('windowSize') - const windowPosLastState = await lastStateManager.get('windowPos') - const { screen } = require('electron') - return restoreWindowBounds({ - screen, - windowSizeLastState, - windowPosLastState, - isDev, - minWindowWidth, - minWindowHeight - }) -} - -exports.setWindowPos = (pos) => { - lastStateManager.set('windowPos', pos) -} diff --git a/src/app/lib/window-drag-move.js b/src/app/lib/window-drag-move.js deleted file mode 100644 index 94c270a..0000000 --- a/src/app/lib/window-drag-move.js +++ /dev/null @@ -1,45 +0,0 @@ -// from https://zhuanlan.zhihu.com/p/112564936 - -const { screen } = require('electron') -const globalState = require('./glob-state') - -let mouseStartPosition = { x: 0, y: 0 } -let movingInterval = null -let dragCount = 0 - -function windowMove (canMoving) { - const win = globalState.get('win') - if (!win) { - return - } - const size = win.getBounds() - if (canMoving) { - win.setResizable(false) - mouseStartPosition = screen.getCursorScreenPoint() - - if (movingInterval) { - clearInterval(movingInterval) - } - - movingInterval = setInterval(() => { - dragCount = dragCount + 1 - if (dragCount > 1000) { - dragCount = 1000 - } - const cursorPosition = screen.getCursorScreenPoint() - const x = size.x + cursorPosition.x - mouseStartPosition.x - const y = size.y + cursorPosition.y - mouseStartPosition.y - win.setBounds({ - ...size, - x, - y - }) - }, 1) - } else { - win.setResizable(true) - dragCount = 0 // Reset the count when moving is not allowed - clearInterval(movingInterval) - } -} - -module.exports = windowMove diff --git a/src/app/lib/window-restore.js b/src/app/lib/window-restore.js deleted file mode 100644 index 6daf5a6..0000000 --- a/src/app/lib/window-restore.js +++ /dev/null @@ -1,205 +0,0 @@ -const minVisibleSize = 100 - -function clamp (value, min, max) { - return Math.min(Math.max(value, min), max) -} - -function finiteOr (value, fallback) { - return Number.isFinite(value) ? value : fallback -} - -function limitWindowSize (savedSize, savedScreenSize, workAreaSize, minSize) { - const ratio = savedSize / savedScreenSize - const restoredSize = Number.isFinite(ratio) && ratio > 0 - ? workAreaSize * ratio - : workAreaSize - return Math.min(Math.max(Math.round(restoredSize), minSize), workAreaSize) -} - -function limitWindowPosition (position, workAreaPosition, workAreaSize, windowSize) { - const visibleSize = Math.min(minVisibleSize, windowSize, workAreaSize) - const min = workAreaPosition - windowSize + visibleSize - const max = workAreaPosition + workAreaSize - visibleSize - return clamp(position, min, max) -} - -function isBoundsVisibleOnAnyDisplay (bounds, displays) { - return displays.some(display => { - const { workArea } = display - const visibleLeft = Math.max(bounds.x, workArea.x) - const visibleRight = Math.min(bounds.x + bounds.width, workArea.x + workArea.width) - const visibleTop = Math.max(bounds.y, workArea.y) - const visibleBottom = Math.min(bounds.y + bounds.height, workArea.y + workArea.height) - return visibleRight - visibleLeft >= minVisibleSize && - visibleBottom - visibleTop >= minVisibleSize - }) -} - -/** - * Check whether a given point (x, y) falls within the work area of - * any of the provided displays. This is used to detect whether the - * saved window position still refers to a connected monitor. - */ -function isPointOnAnyDisplay (point, displays) { - return displays.some(display => { - const { workArea } = display - return point.x >= workArea.x && - point.x < workArea.x + workArea.width && - point.y >= workArea.y && - point.y < workArea.y + workArea.height - }) -} - -exports.isBoundsVisibleOnAnyDisplay = isBoundsVisibleOnAnyDisplay -exports.isPointOnAnyDisplay = isPointOnAnyDisplay - -/** - * Safety net: after a window is created, verify it is actually visible - * on at least one currently-connected display. Electron may adjust the - * requested bounds, or the display configuration may have changed between - * getWindowSize() and window creation. If the window ends up off-screen - * (e.g. saved position was on a monitor that has since been unplugged), - * move it to the primary display so the user can always see and interact - * with the app. - * - * Two conditions are checked: - * 1. At least 100px of the window is visible on some display - * (catches windows that are completely off-screen). - * 2. The centre point of the window is on some display - * (catches windows that are only barely visible at the edge - * of a display, e.g. only a 100px sliver — which is technically - * "visible" but practically unusable to the user). - * If either condition fails, move the window to the primary display. - * - * @param {import('electron').BrowserWindow} win - * @param {import('electron').Screen} screen - */ -exports.ensureWindowVisible = function (win, screen) { - const allDisplays = screen.getAllDisplays() - const actualBounds = win.getBounds() - const centerX = actualBounds.x + Math.floor(actualBounds.width / 2) - const centerY = actualBounds.y + Math.floor(actualBounds.height / 2) - const centerOnDisplay = isPointOnAnyDisplay({ x: centerX, y: centerY }, allDisplays) - const boundsVisible = isBoundsVisibleOnAnyDisplay(actualBounds, allDisplays) - if (!centerOnDisplay || !boundsVisible) { - const { workArea } = screen.getPrimaryDisplay() - win.setBounds({ - x: workArea.x, - y: workArea.y, - width: Math.min(actualBounds.width, workArea.width), - height: Math.min(actualBounds.height, workArea.height) - }) - } -} - -exports.restoreWindowBounds = ({ - screen, - windowSizeLastState, - windowPosLastState, - isDev, - minWindowWidth, - minWindowHeight -}) => { - const defaultBounds = { - x: 0, - y: 0, - width: minWindowWidth, - height: minWindowHeight - } - - if (!windowSizeLastState || isDev) { - const { workArea } = screen.getDisplayMatching(defaultBounds) - return { - width: workArea.width, - height: workArea.height, - x: 0, - y: 0 - } - } - - const savedPosition = { - x: finiteOr(windowPosLastState && windowPosLastState.x, 0), - y: finiteOr(windowPosLastState && windowPosLastState.y, 0) - } - - const allDisplays = screen.getAllDisplays() - - // Determine whether the saved window position still falls within a - // currently connected display. When the monitor the window was last on - // has been disconnected, the saved position will be outside all connected - // displays. In that case we must NOT simply clamp the old position to the - // edge of the nearest display (which would leave the window almost - // entirely off-screen with only a tiny sliver visible). Instead we - // centre the window on the primary display so the user can always find - // and interact with it. - const savedPositionIsValid = isPointOnAnyDisplay(savedPosition, allDisplays) - - // Electron reports display bounds and window positions in DIP coordinates. - const targetDisplay = savedPositionIsValid - ? screen.getDisplayNearestPoint(savedPosition) - : screen.getPrimaryDisplay() - const { workArea } = targetDisplay - const width = limitWindowSize( - windowSizeLastState.innerWidth, - windowSizeLastState.screenWidth, - workArea.width, - minWindowWidth - ) - const height = limitWindowSize( - windowSizeLastState.height, - windowSizeLastState.screenHeight, - workArea.height, - minWindowHeight - ) - - let bounds - if (savedPositionIsValid) { - // The monitor the window was last on is still connected — restore - // the saved position, clamped so at least part of the window is visible. - bounds = { - width, - height, - x: limitWindowPosition( - savedPosition.x, - workArea.x, - workArea.width, - width - ), - y: limitWindowPosition( - savedPosition.y, - workArea.y, - workArea.height, - height - ) - } - } else { - // The saved position is on a disconnected monitor. Centre the - // window on the primary display so it is fully visible. - bounds = { - width, - height, - x: workArea.x + Math.floor((workArea.width - width) / 2), - y: workArea.y + Math.floor((workArea.height - height) / 2) - } - } - - // Safety net: verify the computed bounds are actually visible on at - // least one currently-connected display. This catches edge cases where - // the display returned by getDisplayNearestPoint is stale — for example - // an external monitor was disconnected but Electron has not yet updated - // its internal display list — or where the workArea has changed since - // the display was queried. Without this check the window could end up - // on a non-existent display and be completely invisible to the user. - if (!isBoundsVisibleOnAnyDisplay(bounds, allDisplays)) { - const primary = screen.getPrimaryDisplay() - const { workArea: primaryWorkArea } = primary - return { - width: Math.min(width, primaryWorkArea.width), - height: Math.min(height, primaryWorkArea.height), - x: primaryWorkArea.x, - y: primaryWorkArea.y - } - } - - return bounds -} diff --git a/src/app/lib/zod.js b/src/app/lib/zod.js deleted file mode 100644 index 072c6e8..0000000 --- a/src/app/lib/zod.js +++ /dev/null @@ -1,208 +0,0 @@ -/** - * Lightweight zod replacement for electerm - * Covers only the API surface used in the project: - * z.string(), z.number(), z.boolean(), z.any(), - * z.enum(), z.object(), z.array(), z.record(), - * .optional(), .describe(), z.toJSONSchema() - */ - -class ZodType { - constructor (typeName, meta = {}) { - this._typeName = typeName - this._optional = false - this._description = undefined - this._meta = meta - // Mark as zod-compatible schema - this['~standard'] = { type: typeName } - } - - optional () { - const clone = this._clone() - clone._optional = true - return clone - } - - describe (desc) { - const clone = this._clone() - clone._description = desc - return clone - } - - _clone () { - const clone = Object.create(Object.getPrototypeOf(this)) - Object.assign(clone, this) - // Re-create the ~standard marker so it's own-property - clone['~standard'] = { ...this['~standard'] } - return clone - } - - _toJsonSchema () { - throw new Error('_toJsonSchema not implemented for ' + this._typeName) - } -} - -class ZodString extends ZodType { - constructor () { - super('string') - } - - _toJsonSchema () { - return { type: 'string' } - } -} - -class ZodNumber extends ZodType { - constructor () { - super('number') - } - - _toJsonSchema () { - return { type: 'number' } - } -} - -class ZodBoolean extends ZodType { - constructor () { - super('boolean') - } - - _toJsonSchema () { - return { type: 'boolean' } - } -} - -class ZodAny extends ZodType { - constructor () { - super('any') - } - - _toJsonSchema () { - return {} - } -} - -class ZodEnum extends ZodType { - constructor (values) { - super('enum', { values }) - } - - _toJsonSchema () { - return { type: 'string', enum: this._meta.values } - } -} - -class ZodArray extends ZodType { - constructor (itemSchema) { - super('array', { itemSchema }) - } - - _toJsonSchema () { - const items = schemaToJsonSchema(this._meta.itemSchema) - return { type: 'array', items } - } -} - -class ZodObject extends ZodType { - constructor (shape) { - super('object', { shape }) - } - - _toJsonSchema () { - const properties = {} - const required = [] - const shape = this._meta.shape || {} - for (const [key, schema] of Object.entries(shape)) { - properties[key] = schemaToJsonSchema(schema) - if (schema._description) { - properties[key].description = schema._description - } - if (!schema._optional) { - required.push(key) - } - } - const result = { type: 'object', properties } - if (required.length > 0) { - result.required = required - } - return result - } -} - -class ZodRecord extends ZodType { - constructor (valueSchema) { - super('record', { valueSchema }) - } - - _toJsonSchema () { - const additionalProperties = schemaToJsonSchema(this._meta.valueSchema) - return { type: 'object', additionalProperties } - } -} - -function schemaToJsonSchema (schema) { - if (!schema) { - return {} - } - if (schema instanceof ZodType) { - const base = schema._toJsonSchema() - if (schema._description) { - base.description = schema._description - } - return base - } - // Plain object with zod values (used as inputSchema in MCP tools) - if (typeof schema === 'object' && !Array.isArray(schema)) { - return objectShapeToJsonSchema(schema) - } - return {} -} - -function objectShapeToJsonSchema (shape) { - const properties = {} - const required = [] - for (const [key, value] of Object.entries(shape)) { - if (value instanceof ZodType) { - properties[key] = schemaToJsonSchema(value) - if (!value._optional) { - required.push(key) - } - } - } - const result = { type: 'object', properties } - if (required.length > 0) { - result.required = required - } - return result -} - -const z = { - string: () => new ZodString(), - number: () => new ZodNumber(), - boolean: () => new ZodBoolean(), - any: () => new ZodAny(), - enum: (values) => new ZodEnum(values), - object: (shape) => new ZodObject(shape || {}), - array: (itemSchema) => new ZodArray(itemSchema), - record: (keyOrValue, maybeValue) => { - // z.record(valueSchema) or z.record(keySchema, valueSchema) - const valueSchema = maybeValue || keyOrValue - return new ZodRecord(valueSchema) - }, - toJSONSchema: (schema) => { - if (schema instanceof ZodType) { - return schema._toJsonSchema() - } - if (typeof schema === 'object' && schema !== null) { - // Check if it's a plain shape object with ~standard values - const hasZodValues = Object.values(schema).some( - v => v instanceof ZodType - ) - if (hasZodValues) { - return objectShapeToJsonSchema(schema) - } - } - return { type: 'object', properties: {} } - } -} - -module.exports = { z, ZodType } diff --git a/src/app/mcp/server/mcp.js b/src/app/mcp/server/mcp.js deleted file mode 100644 index a155ed0..0000000 --- a/src/app/mcp/server/mcp.js +++ /dev/null @@ -1,32 +0,0 @@ -class McpServer { - constructor (options) { - this.name = options.name - this.version = options.version - this.tools = new Map() - // Optional TaskManager instance (src/app/mcp/server/tasks.js). - // When set, the transport advertises the io.modelcontextprotocol/tasks - // extension and serves tasks/get + tasks/cancel. - this.taskManager = options.taskManager || null - // Newest first — initialize echoes the client's requested version when - // supported, otherwise responds with the newest we support. - this.supportedProtocolVersions = options.supportedProtocolVersions || [ - '2025-11-25', - '2025-06-18', - '2024-11-05' - ] - } - - registerTool (name, { description, inputSchema }, handler) { - this.tools.set(name, { description, inputSchema, handler }) - } - - async connect (transport) { - await transport.connect(this) - } - - async close () { - // nothing - } -} - -module.exports = { McpServer } diff --git a/src/app/mcp/server/streamableHttp.js b/src/app/mcp/server/streamableHttp.js deleted file mode 100644 index c508cc6..0000000 --- a/src/app/mcp/server/streamableHttp.js +++ /dev/null @@ -1,319 +0,0 @@ -const { z } = require('../../lib/zod') - -function zodToJsonSchema (zodSchema) { - if (!zodSchema) { - return { type: 'object', properties: {} } - } - try { - if (zodSchema && typeof zodSchema === 'object') { - const hasZodStandard = Object.values(zodSchema).some( - v => v && typeof v === 'object' && '~standard' in v - ) - if (hasZodStandard) { - const zodObject = z.object( - Object.fromEntries( - Object.entries(zodSchema).map(([key, value]) => [key, value]) - ) - ) - const jsonSchema = z.toJSONSchema(zodObject) - return jsonSchema || { type: 'object', properties: {} } - } - } - if (zodSchema && typeof zodSchema === 'object' && '~standard' in zodSchema) { - const jsonSchema = z.toJSONSchema(zodSchema) - return jsonSchema || { type: 'object', properties: {} } - } - return { type: 'object', properties: {} } - } catch (e) { - return { type: 'object', properties: {} } - } -} - -class StreamableHTTPServerTransport { - constructor (options) { - this.sessionIdGenerator = options.sessionIdGenerator - this.onsessioninitialized = options.onsessioninitialized - this.onclose = null - this.server = null - this.sessionId = null - this.initialized = false - // Whether the client advertised the io.modelcontextprotocol/tasks - // extension — via initialize capabilities or per-request _meta. - this.clientSupportsTasks = false - } - - async connect (server) { - this.server = server - this.sessionId = this.sessionIdGenerator() - if (this.onsessioninitialized) { - this.onsessioninitialized(this.sessionId) - } - } - - // Send a single JSON-RPC result as an SSE `message` event and close the - // stream. Used for tool-call responses that may be streamed in the future. - _sendSSE (res, data) { - res.setHeader('Content-Type', 'text/event-stream') - res.setHeader('Cache-Control', 'no-cache') - res.setHeader('Connection', 'keep-alive') - res.write('event: message\n') - res.write(`data: ${JSON.stringify(data)}\n\n`) - res.end() - } - - // Send a plain JSON response (not SSE). Some MCP clients (notably Codex's - // rmcp StreamableHttpClientWorker) treat the closing of the SSE stream as a - // transport-channel closure and fail when sending the follow-up - // `notifications/initialized` on the same worker. Returning a regular JSON - // response lets the HTTP request complete normally so the client can open a - // new request for the next message. - _sendJSON (res, data, sessionId) { - res.setHeader('Content-Type', 'application/json') - if (sessionId) { - res.setHeader('mcp-session-id', sessionId) - } - res.json(data) - } - - // Detect tasks-extension support from request params. Accepts both the - // initialize-time capabilities.extensions and the SEP-2663 per-request - // _meta["io.modelcontextprotocol/clientCapabilities"].extensions form. - _captureClientCaps (params) { - if (!params || typeof params !== 'object') { - return - } - const initExt = params.capabilities && params.capabilities.extensions - const metaExt = params._meta && - params._meta['io.modelcontextprotocol/clientCapabilities'] && - params._meta['io.modelcontextprotocol/clientCapabilities'].extensions - for (const ext of [initExt, metaExt]) { - if (ext && typeof ext === 'object' && 'io.modelcontextprotocol/tasks' in ext) { - this.clientSupportsTasks = true - } - } - } - - async _handleTasksGet (request) { - if (!this.server.taskManager) { - return { - jsonrpc: '2.0', - id: request.id, - error: { code: -32601, message: 'Tasks extension not enabled' } - } - } - const taskId = request.params && request.params.taskId - if (!taskId) { - return { - jsonrpc: '2.0', - id: request.id, - error: { code: -32602, message: 'Missing required param: taskId' } - } - } - try { - const task = await this.server.taskManager.get(taskId) - return { - jsonrpc: '2.0', - id: request.id, - result: task - } - } catch (error) { - return { - jsonrpc: '2.0', - id: request.id, - error: { code: -32602, message: error.message } - } - } - } - - async _handleTasksCancel (request) { - if (!this.server.taskManager) { - return { - jsonrpc: '2.0', - id: request.id, - error: { code: -32601, message: 'Tasks extension not enabled' } - } - } - const taskId = request.params && request.params.taskId - if (!taskId) { - return { - jsonrpc: '2.0', - id: request.id, - error: { code: -32602, message: 'Missing required param: taskId' } - } - } - try { - const task = await this.server.taskManager.cancel(taskId) - return { - jsonrpc: '2.0', - id: request.id, - result: task - } - } catch (error) { - return { - jsonrpc: '2.0', - id: request.id, - error: { code: -32602, message: error.message } - } - } - } - - async handleRequest (req, res, body) { - if (body) { - const request = body - let result - if (request.method === 'initialize') { - this._captureClientCaps(request.params) - const versions = this.server.supportedProtocolVersions || ['2024-11-05'] - const requested = request.params && request.params.protocolVersion - const protocolVersion = versions.includes(requested) ? requested : versions[0] - const capabilities = { - tools: { - listChanged: false - } - } - if (this.server.taskManager) { - capabilities.extensions = { - 'io.modelcontextprotocol/tasks': {} - } - } - result = { - jsonrpc: '2.0', - id: request.id, - result: { - protocolVersion, - capabilities, - serverInfo: { - name: this.server.name, - version: this.server.version - } - } - } - // Use a plain JSON response for initialize so the HTTP request - // completes cleanly. Clients like Codex/rmcp treat the closing of - // an SSE stream as a transport-channel closure and then fail when - // trying to send `notifications/initialized` on the same worker. - this._sendJSON(res, result, this.sessionId) - return - } else if (request.method === 'notifications/initialized') { - this.initialized = true - // Per MCP Streamable HTTP spec, notifications MUST be answered with - // 202 Accepted and no body. A 200 with an empty body (and thus no - // Content-Type) is treated as a fatal UnexpectedContentType error by - // Codex's rmcp HTTP adapter, killing the transport during handshake. - res.status(202).end() - return - } else if (request.method === 'tools/list') { - const tools = Array.from(this.server.tools.entries()).map(([name, { description, inputSchema }]) => ({ - name, - description, - inputSchema: zodToJsonSchema(inputSchema) - })) - result = { - jsonrpc: '2.0', - id: request.id, - result: { tools } - } - } else if (request.method === 'tools/call') { - this._captureClientCaps(request.params) - const { name, arguments: args } = request.params - const tool = this.server.tools.get(name) - if (tool) { - try { - const toolResult = await tool.handler(args, { - clientSupportsTasks: this.clientSupportsTasks && !!this.server.taskManager, - taskManager: this.server.taskManager - }) - result = { - jsonrpc: '2.0', - id: request.id, - result: toolResult - } - } catch (error) { - result = { - jsonrpc: '2.0', - id: request.id, - result: { - content: [{ type: 'text', text: error.message }], - isError: true - } - } - } - } else { - result = { - jsonrpc: '2.0', - id: request.id, - error: { code: -32601, message: `Tool not found: ${name}` } - } - } - } else if (request.method === 'tasks/get') { - result = await this._handleTasksGet(request) - } else if (request.method === 'tasks/cancel') { - result = await this._handleTasksCancel(request) - } else if (request.method === 'tasks/list' || request.method === 'tasks/update' || request.method === 'tasks/result') { - // tasks/list is unsafe without an authorization context, and - // tasks/update / tasks/result are only needed for input_required - // flows — intentionally not implemented (SEP-2663). - result = { - jsonrpc: '2.0', - id: request.id, - error: { code: -32601, message: `Method not implemented: ${request.method}` } - } - } else if (request.method === 'ping') { - result = { - jsonrpc: '2.0', - id: request.id, - result: {} - } - } else { - result = { - jsonrpc: '2.0', - id: request.id, - error: { code: -32601, message: `Method not found: ${request.method}` } - } - } - // For JSON-RPC requests with an id (requires a response), return a - // plain JSON body. This is the most broadly compatible approach — - // some clients (Codex/rmcp, Claude Agent SDK) handle plain JSON - // responses more reliably than short-lived SSE streams. - if (request.id === undefined || request.id === null) { - // Notification — client does not expect a result, just an ack. - // 202 Accepted per MCP Streamable HTTP spec (see note above). - res.status(202).end() - return - } - this._sendJSON(res, result) - } else { - if (req.method === 'DELETE') { - this.close() - res.status(200).end() - } else if (req.method === 'GET') { - // Open a no-op SSE listening stream per MCP Streamable HTTP spec. - // The server does not currently push server-initiated messages, - // but keeping the stream alive with heartbeats satisfies strict - // clients (e.g. Claude Agent SDK) that require a valid SSE stream. - res.setHeader('Content-Type', 'text/event-stream') - res.setHeader('Cache-Control', 'no-cache') - res.setHeader('Connection', 'keep-alive') - res.status(200) - // Send an initial SSE comment to flush headers - res.write(': ping\n\n') - // Heartbeat every 15s to keep the connection alive - const heartbeat = setInterval(() => { - res.write(': ping\n\n') - }, 15000) - // Clean up when the client disconnects - req.on('close', () => { - clearInterval(heartbeat) - }) - } else { - res.status(200).end() - } - } - } - - async close () { - if (this.onclose) this.onclose() - } -} - -module.exports = { StreamableHTTPServerTransport } diff --git a/src/app/mcp/server/tasks.js b/src/app/mcp/server/tasks.js deleted file mode 100644 index 3dc2b9d..0000000 --- a/src/app/mcp/server/tasks.js +++ /dev/null @@ -1,218 +0,0 @@ -/** - * MCP Tasks extension (SEP-2663) — server-side task lifecycle manager. - * - * A Task is a durable handle for a long-running tool call. The server - * decides per-request whether to materialize a task; clients poll with - * tasks/get and may send tasks/cancel. Status machine: - * - * working ──► completed (task.result set) - * ──► failed (task.error set) - * ──► cancelled (cooperative; work may not stop) - * - * `input_required` and tasks/update are intentionally not implemented (v1). - * tasks/list is intentionally not implemented — without an authorization - * context per SEP-2663 guidance, listing tasks is unsafe. - * - * Hooks (all optional, async): - * onGet(task) — refresh a working task's state before returning it - * onCancel(task) — perform the real cancellation (kill remote process) - * onSweep(task) — clean up resources when a terminal task is swept - */ - -const uid = require('../../common/uid') - -const STATUS = { - working: 'working', - completed: 'completed', - failed: 'failed', - cancelled: 'cancelled' -} - -const TERMINAL_STATUSES = new Set([ - STATUS.completed, - STATUS.failed, - STATUS.cancelled -]) - -class TaskManager { - constructor (options = {}) { - this.tasks = new Map() - this.ttl = options.ttl > 0 ? options.ttl : 3600000 - this.pollIntervalMs = options.pollIntervalMs > 0 ? options.pollIntervalMs : 2000 - this.maxTasks = options.maxTasks > 0 ? options.maxTasks : 100 - this.onGet = null - this.onCancel = null - this.onSweep = null - this._sweepTimer = setInterval(() => { - this.sweep().catch(() => {}) - }, Math.min(this.ttl, 60000)) - if (typeof this._sweepTimer.unref === 'function') { - this._sweepTimer.unref() - } - } - - // Create a task in `working` state. meta holds server-private linkage - // (e.g. the renderer background task id) and is never sent to clients. - create ({ toolName, meta } = {}) { - if (this.tasks.size >= this.maxTasks) { - this._evictOldest() - } - const taskId = `task-${uid()}` - const task = { - taskId, - status: STATUS.working, - createdAt: new Date().toISOString(), - endedAt: null, - ttl: this.ttl, - pollIntervalMs: this.pollIntervalMs, - statusMessage: toolName ? `Started ${toolName}` : 'Task started', - toolName: toolName || null, - result: null, - error: null, - meta: meta || {} - } - this.tasks.set(taskId, task) - return task - } - - _evictOldest () { - // Prefer evicting the oldest terminal task; fall back to oldest overall - let oldestTerminal = null - let oldest = null - for (const task of this.tasks.values()) { - if (!oldest || task.createdAt < oldest.createdAt) oldest = task - if (TERMINAL_STATUSES.has(task.status) && - (!oldestTerminal || task.createdAt < oldestTerminal.createdAt)) { - oldestTerminal = task - } - } - const victim = oldestTerminal || oldest - if (victim) { - this.tasks.delete(victim.taskId) - if (this.onSweep) { - Promise.resolve(this.onSweep(victim)).catch(() => {}) - } - } - } - - // tasks/get — refreshes working tasks via onGet before returning. - // Throws on unknown task id (transport maps this to JSON-RPC -32602). - async get (taskId) { - const task = this.tasks.get(taskId) - if (!task) { - throw new Error(`Unknown task: ${taskId}`) - } - if (task.status === STATUS.working && this.onGet) { - await this.onGet(task) - } - return this.toWire(task) - } - - // tasks/cancel — cooperative: runs the onCancel hook, then marks the - // task cancelled. Cancelling a terminal task is a no-op per spec. - async cancel (taskId) { - const task = this.tasks.get(taskId) - if (!task) { - throw new Error(`Unknown task: ${taskId}`) - } - if (!TERMINAL_STATUSES.has(task.status)) { - if (this.onCancel) { - await this.onCancel(task) - } - this._markCancelled(task) - } - return this.toWire(task) - } - - // Mark cancelled without invoking the onCancel hook — used when the - // underlying execution already reported a cancelled state. - cancelLocal (taskId) { - const task = this.tasks.get(taskId) - if (task && !TERMINAL_STATUSES.has(task.status)) { - this._markCancelled(task) - } - return task || null - } - - _markCancelled (task) { - task.status = STATUS.cancelled - task.statusMessage = 'Task cancelled' - task.endedAt = new Date().toISOString() - } - - complete (taskId, result) { - const task = this.tasks.get(taskId) - if (!task || TERMINAL_STATUSES.has(task.status)) { - return task || null - } - task.status = STATUS.completed - task.statusMessage = 'Task completed' - task.result = result - task.endedAt = new Date().toISOString() - return task - } - - fail (taskId, message) { - const task = this.tasks.get(taskId) - if (!task || TERMINAL_STATUSES.has(task.status)) { - return task || null - } - task.status = STATUS.failed - task.statusMessage = 'Task failed' - task.error = { - code: -32603, - message: message || 'Task execution failed' - } - task.endedAt = new Date().toISOString() - return task - } - - // Client-facing wire shape — never leaks server-private `meta`. - toWire (task) { - const wire = { - taskId: task.taskId, - status: task.status, - createdAt: task.createdAt, - ttl: task.ttl, - pollIntervalMs: task.pollIntervalMs, - statusMessage: task.statusMessage - } - if (task.status === STATUS.completed && task.result !== null) { - wire.result = task.result - } - if (task.status === STATUS.failed && task.error) { - wire.error = task.error - } - return wire - } - - // Remove terminal tasks whose retention TTL has expired. - async sweep () { - const now = Date.now() - for (const task of Array.from(this.tasks.values())) { - if (!TERMINAL_STATUSES.has(task.status) || !task.endedAt) { - continue - } - if (now - Date.parse(task.endedAt) > this.ttl) { - this.tasks.delete(task.taskId) - if (this.onSweep) { - try { - await this.onSweep(task) - } catch (_) { - // best-effort cleanup - } - } - } - } - } - - destroy () { - if (this._sweepTimer) { - clearInterval(this._sweepTimer) - this._sweepTimer = null - } - this.tasks.clear() - } -} - -module.exports = { TaskManager, STATUS, TERMINAL_STATUSES } diff --git a/src/app/preload/preload.js b/src/app/preload/preload.js deleted file mode 100644 index 42b1935..0000000 --- a/src/app/preload/preload.js +++ /dev/null @@ -1,55 +0,0 @@ -/** - * preload - */ - -const { ipcRenderer, contextBridge, webFrame, webUtils } = require('electron') - -contextBridge.exposeInMainWorld( - 'api', { - getZoomFactor: () => webFrame.getZoomFactor(), - setZoomFactor: (nl) => webFrame.setZoomFactor(nl), - getPathForFile: (file) => { - try { - return webUtils.getPathForFile(file) - } catch (error) { - console.warn('webUtils.getPathForFile failed:', error) - return null - } - }, - openDialog: (opts) => { - return ipcRenderer.invoke('show-open-dialog-sync', opts) - }, - saveDialog: (opts) => { - return ipcRenderer.invoke('show-save-dialog', opts) - }, - ipcOnEvent: (event, cb) => { - ipcRenderer.on(event, cb) - }, - ipcOffEvent: (event, cb) => { - ipcRenderer.removeListener(event, cb) - }, - runGlobalAsync: (name, ...args) => { - return ipcRenderer.invoke('async', { - name, - args - }) - }, - runSync: (name, ...args) => { - return ipcRenderer.sendSync('sync-func', { - name, - args - }) - }, - sendMcpResponse: (response) => { - ipcRenderer.send('mcp-response', response) - }, - onWebviewAuthRequest: (cb) => { - const handler = (event, data) => cb(data) - ipcRenderer.on('webview-auth-request', handler) - return () => ipcRenderer.removeListener('webview-auth-request', handler) - }, - sendWebviewAuthResponse: (response) => { - ipcRenderer.send('webview-auth-response', response) - } - } -) diff --git a/src/app/server/app-wrap.js b/src/app/server/app-wrap.js deleted file mode 100644 index c2e4fe8..0000000 --- a/src/app/server/app-wrap.js +++ /dev/null @@ -1,15 +0,0 @@ -const express = require('express') - -module.exports = function (app) { - // parse application/x-www-form-urlencoded - app.use(express.urlencoded({ extended: false })) - - // parse application/json - app.use(express.json()) - - require('express-ws')(app, undefined, { - wsOptions: { - perMessageDeflate: false - } - }) -} diff --git a/src/app/server/child-process.js b/src/app/server/child-process.js deleted file mode 100644 index 6356b35..0000000 --- a/src/app/server/child-process.js +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Start the main Express server in-process. - * - * No child process — everything runs in the same Node.js/Electron process. - * Returns a mock "child" object with EventEmitter interface for compatibility - * with init-server.js. - */ - -const EventEmitter = require('events') -const log = require('../common/log') - -// --use-system-ca is supported since Node.js 24.3.0 -function supportsSystemCa () { - const [major, minor] = process.versions.node.split('.').map(Number) - return major > 24 || (major === 24 && minor >= 3) -} - -module.exports = (config, env, sysLocale) => { - // Set environment variables that server.js reads - process.env.electermPort = String(config.port) - process.env.electermHost = config.host || '127.0.0.1' - process.env.requireAuth = config.requireAuth || '' - process.env.tokenElecterm = config.tokenElecterm - process.env.sshKeysPath = env.sshKeysPath - // Normalize to canonical "_.UTF-8" for the remote shell, - // e.g. "zh-cn" → "zh_CN.UTF-8". sysLocale is lowercased upstream for - // language-pack matching, so restore conventional territory casing here. - const [langPart, regionPart] = sysLocale.split(/[-_]/) - const sshLocale = regionPart ? `${langPart}_${regionPart.toUpperCase()}` : langPart - process.env.LANG = `${sshLocale}.UTF-8` - - // Handle system CAs - const nodeOpts = [env.NODE_OPTIONS, supportsSystemCa() ? '--use-system-ca' : ''] - .filter(Boolean).join(' ').trim() - if (nodeOpts) { - process.env.NODE_OPTIONS = nodeOpts - } - - // Create a mock child object for init-server.js compatibility - const child = new EventEmitter() - child.pid = process.pid - child.killed = false - child.stdout = { on: () => {} } - child.stderr = { on: () => {} } - child.kill = () => { - child.killed = true - child.emit('exit', 0, 'SIGTERM') - return true - } - child.send = (msg) => { - child.emit('message', msg) - return true - } - - // Require server.js (auto-starts) and wait for it to be ready - try { - const { startServer } = require('./server') - startServer().then(() => { - child.emit('message', { serverInited: true }) - }).catch(err => { - child.emit('error', err) - }) - } catch (err) { - setImmediate(() => { - child.emit('error', err) - }) - } - - log.info('Server starting in-process, port:', config.port) - return child -} diff --git a/src/app/server/dispatch-center.js b/src/app/server/dispatch-center.js deleted file mode 100644 index f89ba7e..0000000 --- a/src/app/server/dispatch-center.js +++ /dev/null @@ -1,119 +0,0 @@ -/** - * communication between webview and app - * run functions in seprate process, avoid using electron.remote directly - */ - -const fs = require('./fs') -const log = require('../common/log') -const { Upgrade } = require('./download-upgrade') -const { transferKeys } = require('./transfer') -const fetch = require('./fetch') -const sync = require('./sync') -const { - createTerm, - testTerm, - resize, - runCmd, - execCmd, - toggleTerminalLog, - toggleTerminalLogTimestamp, - setTerminalLogPath, - startTerminalLogFile -} = require('./terminal-api') -const globalState = require('./global-state') -const wsDec = require('./ws-dec') - -const { tokenElecterm } = process.env - -function verify (req) { - const { token: to } = req.query - if (to !== tokenElecterm) { - throw new Error('not valid request') - } - if (process.env.requireAuth === 'yes' && !globalState.authed) { - throw new Error('auth required') - } -} - -const initWs = function (app) { - // upgrade - app.ws('/upgrade/:id', (ws, req) => { - verify(req) - wsDec(ws) - const { id } = req.params - ws.on('close', () => { - const inst = globalState.getUpgradeInst(id) - if (inst) { - inst.destroy() - } - }) - ws.on('message', async (message) => { - const msg = JSON.parse(message) - const { action } = msg - - if (action === 'upgrade-new') { - const { id } = msg - const opts = Object.assign({}, msg, { - ws - }) - const inst = new Upgrade(opts) - globalState.setUpgradeInst(id, inst) - await inst.init() - } else if (action === 'upgrade-func') { - const { id, func, args } = msg - const inst = globalState.getUpgradeInst(id) - if (!inst) { - return - } - if (!transferKeys.includes(func) || typeof inst[func] !== 'function') { - log.error('invalid upgrade function:', func) - return - } - inst[func](...args) - } - }) - }) - - // common functions - app.ws('/common/s', (ws, req) => { - verify(req) - wsDec(ws) - ws.on('message', async (message) => { - try { - const msg = JSON.parse(message) - const { action } = msg - if (action === 'fetch') { - fetch(ws, msg) - } else if (action === 'sync') { - sync(ws, msg) - } else if (action === 'fs') { - fs(ws, msg) - } else if (action === 'create-terminal') { - createTerm(ws, msg) - } else if (action === 'test-terminal') { - testTerm(ws, msg) - } else if (action === 'resize-terminal') { - resize(ws, msg) - } else if (action === 'toggle-terminal-log') { - toggleTerminalLog(ws, msg) - } else if (action === 'toggle-terminal-log-timestamp') { - toggleTerminalLogTimestamp(ws, msg) - } else if (action === 'set-terminal-log-path') { - setTerminalLogPath(ws, msg) - } else if (action === 'start-terminal-log-file') { - startTerminalLogFile(ws, msg) - } else if (action === 'run-cmd') { - runCmd(ws, msg) - } else if (action === 'exec-cmd') { - execCmd(ws, msg) - } - } catch (err) { - log.error('common ws error', err) - } - }) - }) - // end -} - -exports.verifyWs = verify -exports.initWs = initWs diff --git a/src/app/server/download-upgrade.js b/src/app/server/download-upgrade.js deleted file mode 100644 index 6c67b86..0000000 --- a/src/app/server/download-upgrade.js +++ /dev/null @@ -1,194 +0,0 @@ -/** - * download upgrade class - */ - -const fs = require('fs') -const { resolve } = require('path') -const _ = require('../lib/lodash.js') -const rp = require('axios') -const { packInfo, tempDir } = require('../common/runtime-constants') -const installSrc = require('../lib/install-src') -const { fsExport } = require('../lib/fs') -const { createProxyAgent } = require('../lib/proxy-agent') -const { openFile, rmrf } = fsExport -const log = require('../common/log') -const globalState = require('./global-state') - -rp.defaults.proxy = false - -function getUrl (url, mirror) { - if (mirror === 'gh-proxy') { - return `https://electerm-mirror.html5beta.com/${url}` - } if (mirror === 'sourceforge') { - const arr = url.split('/') - const len = arr.length - return `https://master.dl.sourceforge.net/project/electerm.mirror/${arr[len - 2]}/${arr[len - 1]}?viasf=1` - } else if (mirror === 'r2') { - return `https://electerm-store.html5beta.com/r/${url.split('/').pop()}` - } else { - return url - } -} - -function getReleaseInfo ( - filter, releaseInfoUrl, agent -) { - const conf = { - url: releaseInfoUrl, - timeout: 15000 - } - if (agent) { - conf.httpAgent = agent - conf.httpsAgent = agent - } - return rp(conf) - .then((res) => { - return res.data - .release - .assets - .filter(filter)[0] - }) -} - -class Upgrade { - constructor (options) { - this.options = options - } - - async init () { - const { - id, - ws, - proxy, - mirror - } = this.options - const agent = createProxyAgent(proxy) - const releaseInfoUrl = `${packInfo.homepage}/data/electerm-github-release.json?_=${+new Date()}` - const filter = r => { - return r.name.endsWith(installSrc) - } - // if (isWin) { - // filter = r => /electerm-\d+\.\d+\.\d+-win-x64\.tar\.gz/.test(r.name) - // } else if (isArm) { - // filter = r => { - // return /arm64\.dmg$/.test(r.name) - // } - // } else if (isMac) { - // filter = r => { - // return /mac\.dmg$/.test(r.name) - // } - // } - const releaseInfo = await getReleaseInfo(filter, releaseInfoUrl, agent) - .catch(this.onError) - if (!releaseInfo) { - return - } - const localPath = resolve(tempDir, releaseInfo.name) - const remotePath = getUrl(releaseInfo.browser_download_url, mirror) - await rmrf(localPath).catch(log.error) - const { size } = releaseInfo - this.id = id - this.localPath = localPath - const readSteam = await rp({ - url: remotePath, - httpAgent: agent, - httpsAgent: agent, - responseType: 'stream' - }) - .then(r => r.data) - .catch(err => { - this.onError(err, id, ws) - }) - if (!readSteam) { - return - } - const writeSteam = fs.createWriteStream(localPath) - - let count = 0 - - this.pausing = false - - this.onData = _.throttle((count) => { - if (this.onDestroy) { - return - } - - ws.s({ - id: 'upgrade:data:' + id, - data: Math.floor(count * 100 / size) - }) - }, 1000) - - readSteam.on('data', chunk => { - const res = writeSteam.write(chunk) - if (res) { - count += chunk.length - this.onData(count) - } else { - readSteam.pause() - writeSteam.once('drain', () => { - count += chunk.length - this.onData(count) - if (!this.pausing) { - readSteam.resume() - } - }) - } - }) - - readSteam.on('close', () => { - writeSteam.end('', () => this.onEnd(id, ws)) - }) - - readSteam.on('error', (err) => this.onError(err, id, ws)) - - this.readSteam = readSteam - this.writeSteam = writeSteam - this.ws = ws - this.destroy = this.destroy.bind(this) - } - - onEnd (id, ws) { - if (!this.onDestroy) { - openFile(this.localPath) - process.send({ - showFileInFolder: this.localPath - }) - ws.s({ - id: 'transfer:end:' + id, - data: this.dir - }) - } - } - - onError (err, id, ws) { - ws.s({ - wid: 'upgrade:err:' + id, - error: { - message: err.message, - stack: err.stack - } - }) - } - - pause () { - this.pausing = true - this.readSteam.pause() - } - - resume () { - this.pausing = false - this.readSteam.resume() - } - - destroy () { - this.onDestroy = true - this.readSteam && this.readSteam.destroy() - this.ws && this.ws.close() - globalState.removeUpgradeInst(this.id) - } - - // end -} - -exports.Upgrade = Upgrade diff --git a/src/app/server/fetch.js b/src/app/server/fetch.js deleted file mode 100644 index cd95135..0000000 --- a/src/app/server/fetch.js +++ /dev/null @@ -1,43 +0,0 @@ -/** - * node fetch in server side - */ - -const { createProxyAgent } = require('../lib/proxy-agent') - -function fetch (options) { - const rp = require('axios') - rp.defaults.proxy = false - return rp(options) - .then((res) => { - return res.data - }) - .catch(error => { - return { - error - } - }) -} - -async function wsFetchHandler (ws, msg) { - const { id, options, proxy } = msg - const agent = createProxyAgent(proxy) - if (agent) { - options.httpAgent = agent - options.httpsAgent = agent - } - const res = await fetch(options) - if (res.error) { - console.log(res.error) - ws.s({ - error: res.error.message, - id - }) - } else { - ws.s({ - data: res, - id - }) - } -} - -module.exports = wsFetchHandler diff --git a/src/app/server/fs.js b/src/app/server/fs.js deleted file mode 100644 index debe309..0000000 --- a/src/app/server/fs.js +++ /dev/null @@ -1,38 +0,0 @@ -/** - * fs in child process - */ - -const { fsExport: fs } = require('../lib/fs') - -function handleFs (ws, msg) { - const { id, args, func } = msg - // only dispatch to fs helpers defined on the export itself, never to - // anything reached through the prototype chain - if (!Object.prototype.hasOwnProperty.call(fs, func) || typeof fs[func] !== 'function') { - return ws.s({ - id, - error: { - message: 'invalid fs function: ' + func, - stack: '' - } - }) - } - fs[func](...args) - .then(data => { - ws.s({ - id, - data - }) - }) - .catch(err => { - ws.s({ - id, - error: { - message: err.message, - stack: err.stack - } - }) - }) -} - -module.exports = handleFs diff --git a/src/app/server/ftp-client.js b/src/app/server/ftp-client.js deleted file mode 100644 index 128f589..0000000 --- a/src/app/server/ftp-client.js +++ /dev/null @@ -1,167 +0,0 @@ -const ftp = require('basic-ftp') -const iconv = require('iconv-lite') - -class FtpClientWrapper { - constructor () { - this.client = new ftp.Client() - this.queue = Promise.resolve() - this.encoding = 'utf-8' - } - - setEncoding (encoding) { - this.encoding = encoding || 'utf-8' - // When using non-UTF-8 encoding, set the FTP control connection to use latin1 (binary) - // This prevents the library from incorrectly decoding the server's response - if (this.encoding !== 'utf-8') { - this.client.ftp.encoding = 'latin1' - } - } - - decodeString (str) { - if (!str) { - return str - } - if (this.encoding === 'utf-8') { - return str - } - try { - // Convert the latin1 string back to buffer, then decode with target encoding - const buf = Buffer.from(str, 'latin1') - return iconv.decode(buf, this.encoding) - } catch (e) { - return str - } - } - - encodeString (str) { - if (!str) { - return str - } - if (this.encoding === 'utf-8') { - return str - } - try { - // Encode with target encoding, then convert to latin1 string for FTP commands - const buf = iconv.encode(str, this.encoding) - return buf.toString('latin1') - } catch (e) { - return str - } - } - - async enqueue (fn) { - this.queue = this.queue.then(() => fn(), () => fn()) - return this.queue - } - - set verbose (value) { - this.client.ftp.verbose = value - } - - get verbose () { - return this.client.ftp.verbose - } - - async access (options) { - return this.enqueue(async () => { - if (options.proxy) { - return this._accessViaProxy(options) - } - const { proxy, readyTimeout, ...ftpOptions } = options - return this.client.access(ftpOptions) - }) - } - - async _accessViaProxy (options) { - const proxySock = require('./socks') - const { FTPError } = require('basic-ftp') - const proxyResult = await proxySock({ - readyTimeout: options.readyTimeout || 10000, - host: options.host, - port: options.port || 21, - proxy: options.proxy - }) - const ftpClient = this.client - ftpClient.ftp.reset() - ftpClient.ftp.socket = proxyResult.socket - // Wait for FTP welcome response (mirrors Client._handleConnectResponse) - const welcome = await ftpClient.ftp.handle(undefined, (res, task) => { - if (res instanceof Error) { - task.reject(res) - } else if (res.code >= 200 && res.code < 300) { - task.resolve(res) - } else { - task.reject(new FTPError(res)) - } - }) - if (options.secure === true) { - const secureOptions = { ...(options.secureOptions || {}) } - secureOptions.host = secureOptions.host || options.host - await ftpClient.useTLS(secureOptions) - } - await ftpClient.sendIgnoringError('OPTS UTF8 ON') - await ftpClient.login(options.user || 'anonymous', options.password || 'guest') - await ftpClient.useDefaultSettings() - return welcome - } - - async pwd () { - const result = await this.enqueue(() => this.client.pwd()) - return this.decodeString(result) - } - - async removeDir (path) { - const encodedPath = this.encodeString(path) - return this.enqueue(() => this.client.removeDir(encodedPath)) - } - - async remove (path) { - const encodedPath = this.encodeString(path) - return this.enqueue(() => this.client.remove(encodedPath)) - } - - async ensureDir (path) { - const encodedPath = this.encodeString(path) - return this.enqueue(() => this.client.ensureDir(encodedPath)) - } - - async list (path) { - const encodedPath = this.encodeString(path) - const result = await this.enqueue(() => this.client.list(encodedPath)) - return result.map(item => ({ - ...item, - name: this.decodeString(item.name) - })) - } - - async rename (path, newPath) { - const encodedPath = this.encodeString(path) - const encodedNewPath = this.encodeString(newPath) - return this.enqueue(() => this.client.rename(encodedPath, encodedNewPath)) - } - - async close () { - return this.enqueue(() => this.client.close()) - } - - async uploadFrom (readable, remotePath) { - const encodedPath = this.encodeString(remotePath) - return this.enqueue(() => this.client.uploadFrom(readable, encodedPath)) - } - - async downloadTo (writable, remotePath) { - const encodedPath = this.encodeString(remotePath) - return this.enqueue(() => this.client.downloadTo(writable, encodedPath)) - } - - async cd (path) { - const encodedPath = this.encodeString(path) - return this.enqueue(() => this.client.cd(encodedPath)) - } - - trackProgress (handler) { - return this.client.trackProgress(handler) - } -} - -module.exports = FtpClientWrapper diff --git a/src/app/server/ftp-file.js b/src/app/server/ftp-file.js deleted file mode 100644 index 74987f9..0000000 --- a/src/app/server/ftp-file.js +++ /dev/null @@ -1,33 +0,0 @@ -const { Readable, Writable } = require('stream') - -async function readRemoteFile (client, remotePath) { - return new Promise((resolve, reject) => { - let data = '' - const writable = new Writable({ - write (chunk, encoding, callback) { - data += chunk.toString() - callback() - } - }) - - client.downloadTo(writable, remotePath) - .then(() => resolve(data)) - .catch(reject) - }) -} - -async function writeRemoteFile (client, remotePath, str) { - const readable = new Readable({ - read () { - this.push(str) - this.push(null) - } - }) - - return client.uploadFrom(readable, remotePath) -} - -module.exports = { - readRemoteFile, - writeRemoteFile -} diff --git a/src/app/server/ftp-transfer.js b/src/app/server/ftp-transfer.js deleted file mode 100644 index 19aac3b..0000000 --- a/src/app/server/ftp-transfer.js +++ /dev/null @@ -1,134 +0,0 @@ -// ftp-transfer.js -/** - * ftp transfer class - * Note: basic-ftp only supports one active transfer per client connection - */ - -class Transfer { - constructor ({ - remotePath, - localPath, - options = {}, - id, - type = 'download', - ftpSession, - sftpId, - ws - }) { - this.id = id - this.ftpSession = ftpSession - this.ftpClient = null - this.srcPath = type === 'download' ? remotePath : localPath - this.dstPath = type === 'download' ? localPath : remotePath - this.isUpload = type !== 'download' - this.ws = ws - this.pausing = false - this.onDestroy = false - this.total = 0 - this.startPromise = null - this.src = null - this.dst = null - this.start() - } - - handleProgress = (info) => { - if (this.pausing) return - const chunk = info.bytes - this.total - this.total = info.bytes - this.onData(this.total, chunk) - } - - onData = (total, chunk) => { - if (this.pausing) return - this.ws?.s({ - id: `transfer:data:${this.id}`, - data: total - }) - } - - onEnd = () => { - this.ws?.s({ - id: `transfer:end:${this.id}`, - data: null - }) - } - - onError = (err) => { - if (!err) { - return this.onEnd() - } - this.ws?.s({ - id: `transfer:err:${this.id}`, - error: { - message: err.message, - stack: err.stack - } - }) - } - - trackProgress = () => { - this.total = 0 - this.ftpClient?.trackProgress(this.handleProgress) - } - - async start () { - if (this.startPromise) { - return this.startPromise - } - this.startPromise = this.startTransfer() - return this.startPromise - } - - async startTransfer () { - try { - if (this.onDestroy) { - return - } - const ftpClient = await this.ftpSession.createOperationClient() - this.ftpClient = ftpClient - this.trackProgress() - if (!this.isUpload) { - await this.ftpClient.downloadTo(this.dstPath, this.srcPath) - } else { - await this.ftpClient.uploadFrom(this.srcPath, this.dstPath) - } - this.onEnd() - } catch (err) { - this.onError(err) - } finally { - const ftpClient = this.ftpClient - ftpClient?.trackProgress() - if (ftpClient) { - await ftpClient.close().catch(() => {}) - } - this.ftpClient = null - } - } - - pause () { - this.pausing = true - } - - resume () { - this.pausing = false - } - - destroy () { - this.onDestroy = true - if (this.ftpClient) { - this.ftpClient.trackProgress() // Remove progress tracking - this.ftpClient.close?.().catch?.(() => {}) - } - this.ftpClient = null - this.src = null - this.dst = null - if (this.ws) { - this.ws.close() - this.ws = null - } - } -} - -module.exports = { - Transfer -} diff --git a/src/app/server/global-state.js b/src/app/server/global-state.js deleted file mode 100644 index 2de7629..0000000 --- a/src/app/server/global-state.js +++ /dev/null @@ -1,51 +0,0 @@ -// global-state.js -class GlobalState { - #sessions = {} - #upgradeInsts = {} - #authed = false - - // Sessions management - getSession (id) { - return this.#sessions[id] - } - - setSession (id, data) { - this.#sessions[id] = data - } - - removeSession (id) { - delete this.#sessions[id] - } - - // Upgrade instances management - getUpgradeInst (id) { - return this.#upgradeInsts[id] - } - - setUpgradeInst (id, inst) { - this.#upgradeInsts[id] = inst - } - - removeUpgradeInst (id) { - delete this.#upgradeInsts[id] - } - - get authed () { - return this.#authed - } - - set authed (val) { - this.#authed = val - } - - get data () { - return { - sessions: this.#sessions, - upgradeInsts: this.#upgradeInsts, - authed: this.#authed - } - } -} - -// Export a singleton instance -module.exports = new GlobalState() diff --git a/src/app/server/rdp-proxy.js b/src/app/server/rdp-proxy.js deleted file mode 100644 index 69ad110..0000000 --- a/src/app/server/rdp-proxy.js +++ /dev/null @@ -1,691 +0,0 @@ -const net = require('net') -const forge = require('node-forge') -const log = require('../common/log') -const proxySock = require('./socks') - -// Debug prefix for all RDP proxy messages -const LOG_PREFIX = '[RDP-PROXY]' - -// ── RDCleanPath ASN.1 DER Constants ── -const VERSION_1 = 3390 // 3389 + 1 - -// ASN.1 tag constants -const TAG_SEQUENCE = 0x30 -const TAG_INTEGER = 0x02 -const TAG_OCTET_STRING = 0x04 -const TAG_UTF8STRING = 0x0c - -// Context-specific EXPLICIT tags used by RDCleanPath -const TAG_CTX = (n) => 0xa0 + n - -// ──────────────────────────────────────────────────── -// ASN.1 DER Low-Level Helpers -// ──────────────────────────────────────────────────── - -/** - * Encode ASN.1 DER length bytes. - */ -function derEncodeLength (length) { - if (length < 0x80) { - return Buffer.from([length]) - } - const bytes = [] - let temp = length - while (temp > 0) { - bytes.unshift(temp & 0xff) - temp >>= 8 - } - return Buffer.from([0x80 | bytes.length, ...bytes]) -} - -/** - * Wrap content with a tag and proper DER length encoding. - */ -function derWrap (tag, content) { - const len = derEncodeLength(content.length) - return Buffer.concat([Buffer.from([tag]), len, content]) -} - -/** - * Encode an integer as ASN.1 DER INTEGER. - */ -function derEncodeInteger (value) { - if (value === 0) { - return derWrap(TAG_INTEGER, Buffer.from([0])) - } - const bytes = [] - let temp = value - while (temp > 0) { - bytes.unshift(temp & 0xff) - temp >>= 8 - } - // Add leading zero if high bit set (to keep unsigned) - if (bytes[0] & 0x80) { - bytes.unshift(0) - } - return derWrap(TAG_INTEGER, Buffer.from(bytes)) -} - -/** - * Encode a UTF-8 string as ASN.1 DER UTF8String. - */ -function derEncodeUtf8String (str) { - return derWrap(TAG_UTF8STRING, Buffer.from(str, 'utf-8')) -} - -/** - * Encode raw bytes as ASN.1 DER OCTET STRING. - */ -function derEncodeOctetString (buf) { - return derWrap(TAG_OCTET_STRING, buf) -} - -/** - * Wrap content in a context-specific EXPLICIT tag [n]. - */ -function derWrapContext (tagNum, content) { - return derWrap(TAG_CTX(tagNum), content) -} - -/** - * Decode DER length at offset. Returns { length, bytesRead }. - */ -function derDecodeLength (buf, offset) { - const first = buf[offset] - if (first < 0x80) { - return { length: first, bytesRead: 1 } - } - const numBytes = first & 0x7f - let length = 0 - for (let i = 0; i < numBytes; i++) { - length = (length << 8) | buf[offset + 1 + i] - } - return { length, bytesRead: 1 + numBytes } -} - -/** - * Decode a DER TLV (Tag-Length-Value) at offset. - * Returns { tag, value: Buffer, totalLength }. - */ -function derDecodeTLV (buf, offset) { - const tag = buf[offset] - const { length, bytesRead } = derDecodeLength(buf, offset + 1) - const headerLen = 1 + bytesRead - const value = buf.slice(offset + headerLen, offset + headerLen + length) - return { tag, value, totalLength: headerLen + length } -} - -/** - * Decode an ASN.1 DER INTEGER to a JS number. - */ -function derDecodeInteger (buf) { - let val = 0 - for (let i = 0; i < buf.length; i++) { - val = (val << 8) | buf[i] - } - return val -} - -/** - * Decode all TLV elements within a constructed value (SEQUENCE, context tags, etc.). - * Returns an array of { tag, value, totalLength }. - */ -function derDecodeChildren (buf) { - const children = [] - let offset = 0 - while (offset < buf.length) { - const tlv = derDecodeTLV(buf, offset) - children.push(tlv) - offset += tlv.totalLength - } - return children -} - -// ──────────────────────────────────────────────────── -// RDCleanPath PDU Parsing & Encoding -// ──────────────────────────────────────────────────── - -/** - * Parse an RDCleanPath Request PDU from DER-encoded bytes. - * - * Returns: { destination, proxyAuth, x224ConnectionRequest, preconnectionBlob? } - */ -function parseRDCleanPathRequest (data) { - const buf = Buffer.isBuffer(data) ? data : Buffer.from(data) - - // Outer SEQUENCE - const outer = derDecodeTLV(buf, 0) - if (outer.tag !== TAG_SEQUENCE) { - throw new Error(`Expected SEQUENCE (0x30), got 0x${outer.tag.toString(16)}`) - } - - const children = derDecodeChildren(outer.value) - - let version = null - let destination = null - let proxyAuth = null - let x224ConnectionRequest = null - let preconnectionBlob = null - - for (const child of children) { - const ctxTag = child.tag & 0x1f // strip class bits to get tag number - - switch (ctxTag) { - case 0: { // version - const intTlv = derDecodeTLV(child.value, 0) - version = derDecodeInteger(intTlv.value) - break - } - case 2: { // destination - const strTlv = derDecodeTLV(child.value, 0) - destination = strTlv.value.toString('utf-8') - break - } - case 3: { // proxy_auth - const strTlv = derDecodeTLV(child.value, 0) - proxyAuth = strTlv.value.toString('utf-8') - break - } - case 5: { // preconnection_blob - const strTlv = derDecodeTLV(child.value, 0) - preconnectionBlob = strTlv.value.toString('utf-8') - break - } - case 6: { // x224_connection_pdu - const octTlv = derDecodeTLV(child.value, 0) - x224ConnectionRequest = octTlv.value - break - } - } - } - - if (version !== VERSION_1) { - throw new Error(`Unsupported RDCleanPath version: ${version} (expected ${VERSION_1})`) - } - if (!destination) { - throw new Error('Missing destination in RDCleanPath request') - } - if (!x224ConnectionRequest) { - throw new Error('Missing x224_connection_pdu in RDCleanPath request') - } - - return { destination, proxyAuth, x224ConnectionRequest, preconnectionBlob } -} - -/** - * Build an RDCleanPath Response PDU as DER-encoded bytes. - * - * @param {string} serverAddr - Resolved server address (e.g. "192.168.2.31:3389") - * @param {Buffer} x224Response - X.224 Connection Confirm bytes - * @param {Buffer[]} certChain - Array of DER-encoded X.509 certificates - * @returns {Buffer} DER-encoded RDCleanPath response - */ -function buildRDCleanPathResponse (serverAddr, x224Response, certChain) { - const parts = [] - - // [0] version - parts.push(derWrapContext(0, derEncodeInteger(VERSION_1))) - - // [6] x224_connection_pdu - parts.push(derWrapContext(6, derEncodeOctetString(x224Response))) - - // [7] server_cert_chain — SEQUENCE OF OCTET STRING - const certOctets = certChain.map((cert) => derEncodeOctetString(cert)) - const certSeq = derWrap(TAG_SEQUENCE, Buffer.concat(certOctets)) - parts.push(derWrapContext(7, certSeq)) - - // [9] server_addr - parts.push(derWrapContext(9, derEncodeUtf8String(serverAddr))) - - return derWrap(TAG_SEQUENCE, Buffer.concat(parts)) -} - -/** - * Build an RDCleanPath Error PDU as DER-encoded bytes. - * - * @param {number} errorCode - 1=general, 2=negotiation - * @param {number} [httpStatusCode] - optional HTTP status code - * @returns {Buffer} DER-encoded RDCleanPath error response - */ -function buildRDCleanPathError (errorCode, httpStatusCode) { - const errParts = [] - - // [0] error_code - errParts.push(derWrapContext(0, derEncodeInteger(errorCode))) - - // [1] http_status_code (optional) - if (httpStatusCode != null) { - errParts.push(derWrapContext(1, derEncodeInteger(httpStatusCode))) - } - - const errSeq = derWrap(TAG_SEQUENCE, Buffer.concat(errParts)) - - const parts = [] - // [0] version - parts.push(derWrapContext(0, derEncodeInteger(VERSION_1))) - // [1] error - parts.push(derWrapContext(1, errSeq)) - - return derWrap(TAG_SEQUENCE, Buffer.concat(parts)) -} - -// ──────────────────────────────────────────────────── -// Network: Destination Parsing -// ──────────────────────────────────────────────────── - -/** - * Parse a destination string into { host, port }. - * Handles IPv6 "[::1]:3389" and regular "host:port" formats. - * Default port is 3389. - */ -function parseDestination (destination) { - // IPv6: [host]:port - if (destination.startsWith('[')) { - const bracketEnd = destination.indexOf(']') - if (bracketEnd === -1) throw new Error(`Invalid IPv6 destination: ${destination}`) - const host = destination.slice(1, bracketEnd) - const rest = destination.slice(bracketEnd + 1) - const port = rest.startsWith(':') ? parseInt(rest.slice(1), 10) : 3389 - return { host, port } - } - - // Regular host:port - const lastColon = destination.lastIndexOf(':') - if (lastColon === -1) { - return { host: destination, port: 3389 } - } - const host = destination.slice(0, lastColon) - const port = parseInt(destination.slice(lastColon + 1), 10) - if (isNaN(port)) { - return { host: destination, port: 3389 } - } - return { host, port } -} - -// ──────────────────────────────────────────────────── -// Network: TCP + X.224 + TLS (node-forge) + Cert Extraction -// ──────────────────────────────────────────────────── -// -// We use node-forge's pure-JS TLS implementation instead of Node's -// built-in tls module. In Electron, Node's tls uses BoringSSL which -// enforces strict KEY_USAGE_BIT_INCORRECT checks that reject typical -// RDP server certificates. node-forge avoids this entirely. - -/** - * Create a TCP connection (direct or through proxy) - * @param {string} host - * @param {number} port - * @param {object} options - * @param {string} options.proxy - Proxy URL - * @param {number} options.readyTimeout - Connection timeout - * @param {Buffer} x224Request - X.224 Connection Request to send - * @param {function} logPrefix - Log prefix function - * @returns {Promise} - */ -async function createTcpConnection (host, port, options, x224Request, logPrefix) { - if (options.proxy) { - log.debug(`${logPrefix} Connecting through proxy: ${options.proxy}`) - const proxyResult = await proxySock({ - readyTimeout: options.readyTimeout || 15000, - host, - port, - proxy: options.proxy - }) - const tcpSocket = proxyResult.socket - log.debug(`${logPrefix} ✓ Proxy connection established`) - - // Send X.224 Connection Request over proxied connection - tcpSocket.write(x224Request, () => { - log.debug(`${logPrefix} ✓ Sent X.224 Connection Request (${x224Request.length} bytes)`) - }) - return tcpSocket - } - - return new Promise((resolve, reject) => { - const tcpSocket = net.createConnection({ host, port }, () => { - log.debug(`${logPrefix} ✓ TCP connection established`) - - // Send X.224 Connection Request over raw TCP - tcpSocket.write(x224Request, () => { - log.debug(`${logPrefix} ✓ Sent X.224 Connection Request (${x224Request.length} bytes)`) - }) - resolve(tcpSocket) - }) - tcpSocket.once('error', (err) => { - reject(new Error(`TCP connection failed: ${err.message}`)) - }) - }) -} - -/** - * Perform the RDCleanPath proxy handshake: - * 1. TCP connect to RDP server (optionally through proxy) - * 2. Send X.224 Connection Request (raw TCP) - * 3. Read X.224 Connection Confirm (raw TCP) - * 4. TLS handshake via node-forge (bypasses BoringSSL) - * 5. Extract server certificates - * - * @param {string} host - * @param {number} port - * @param {Buffer} x224Request - X.224 Connection Request bytes - * @param {object} options - Optional settings - * @param {string} options.proxy - Proxy URL (e.g., 'socks5://127.0.0.1:1080' or 'http://proxy:8080') - * @param {number} options.readyTimeout - Connection timeout in ms - * @returns {Promise<{ x224Response: Buffer, certChain: Buffer[], forgeTls: object, tcpSocket: net.Socket }>} - */ -async function performRDPHandshake (host, port, x224Request, options = {}) { - const logPrefix = `${LOG_PREFIX} [${host}:${port}]` - - // Step 1: TCP connect (direct or through proxy) - let tcpSocket - try { - tcpSocket = await createTcpConnection(host, port, options, x224Request, logPrefix) - } catch (err) { - throw new Error(`Connection failed: ${err.message}`) - } - - return new Promise((resolve, reject) => { - let settled = false - - function settle (err, result) { - if (settled) return - settled = true - if (err) reject(err) - else resolve(result) - } - - tcpSocket.once('error', (err) => { - settle(new Error(`TCP connection failed: ${err.message}`)) - }) - - // Step 3: Read X.224 Connection Confirm - tcpSocket.once('data', (x224Response) => { - log.debug(`${logPrefix} ✓ Received X.224 Connection Confirm (${x224Response.length} bytes)`) - - if (x224Response.length === 0) { - tcpSocket.destroy() - settle(new Error('RDP server closed connection without X.224 response')) - return - } - - // Remove all listeners before upgrading to TLS - tcpSocket.removeAllListeners('error') - tcpSocket.removeAllListeners('data') - - // Step 4: TLS handshake via node-forge (pure JS — no BoringSSL) - log.debug(`${logPrefix} Starting TLS handshake via node-forge`) - - // Capture the cert chain from the verify callback - let capturedCertChain = [] - - const forgeTls = forge.tls.createConnection({ - server: false, - verify: function (connection, verified, depth, certs) { - // Accept all certificates (RDP servers use self-signed certs) - log.debug(`${logPrefix} TLS verify callback: depth=${depth}, verified=${verified}, certs=${certs.length}`) - // Capture the full chain on the first call (depth = deepest) - if (certs && certs.length > capturedCertChain.length) { - capturedCertChain = certs - } - return true - }, - connected: function (connection) { - log.debug(`${logPrefix} ✓ node-forge TLS handshake completed`) - - // The handshake-deadline timer set below (in the outer function) - // is a `net.Socket` idle-inactivity timer, not a one-shot deadline - // - it re-arms on every read/write and was never cleared once the - // handshake finished. Left alone, it destroys this same socket - // (reused for the whole session relay) after any 15s stretch with - // no bytes in either direction - e.g. a static remote desktop with - // no mouse/keyboard activity - killing otherwise-healthy sessions. - // Disable it now that the handshake is done; a real dead/half-open - // connection is instead caught by the TCP keepalive enabled below. - tcpSocket.setTimeout(0) - tcpSocket.setNoDelay(true) - tcpSocket.setKeepAlive(true, 10000) - - // Step 5: Convert captured certificates to DER - const certChain = forgeCertsToDer(capturedCertChain) - log.debug(`${logPrefix} ✓ Extracted ${certChain.length} certificate(s) from forge`) - - settle(null, { - x224Response: Buffer.from(x224Response), - certChain, - forgeTls, - tcpSocket - }) - }, - tlsDataReady: function (connection) { - // Encrypted data ready to send to the RDP server over TCP - const data = connection.tlsData.getBytes() - const buf = Buffer.from(data, 'binary') - try { - tcpSocket.write(buf) - } catch (err) { - log.error(`${logPrefix} Error writing TLS data to TCP: ${err.message}`) - } - }, - dataReady: function (connection) { - // Decrypted data from RDP server — handled by setupForgeRelay - }, - closed: function () { - log.debug(`${logPrefix} node-forge TLS connection closed`) - }, - error: function (connection, error) { - log.error(`${logPrefix} node-forge TLS error: ${error.message}`) - settle(new Error(`TLS handshake failed: ${error.message}`)) - } - }) - - // Feed received TCP data into forge TLS engine - tcpSocket.on('data', (data) => { - try { - forgeTls.process(data.toString('binary')) - } catch (err) { - log.error(`${logPrefix} forge process error: ${err.message}`) - } - }) - - tcpSocket.on('error', (err) => { - log.error(`${logPrefix} TCP error during TLS: ${err.message}`) - settle(new Error(`TCP error: ${err.message}`)) - }) - - // Initiate the TLS handshake - forgeTls.handshake() - }) - - // Timeout for the whole handshake - tcpSocket.setTimeout(15000, () => { - tcpSocket.destroy() - settle(new Error('Connection timed out')) - }) - }) -} - -/** - * Convert an array of node-forge certificate objects to DER-encoded Buffers. - */ -function forgeCertsToDer (certs) { - const result = [] - for (const cert of certs) { - try { - const asn1 = forge.pki.certificateToAsn1(cert) - const derBytes = forge.asn1.toDer(asn1).getBytes() - result.push(Buffer.from(derBytes, 'binary')) - } catch (e) { - log.error(`${LOG_PREFIX} Error converting cert to DER: ${e.message}`) - } - } - return result -} - -// ──────────────────────────────────────────────────── -// Bidirectional Relay: WebSocket ↔ forge TLS ↔ TCP -// ──────────────────────────────────────────────────── - -/** - * Set up bidirectional relay between a WebSocket and a forge TLS connection. - * - * Browser (WASM) → WebSocket → Proxy → forge TLS → TCP → RDP Server - * RDP Server → TCP → forge TLS → Proxy → WebSocket → Browser (WASM) - * - * @param {WebSocket} ws - The WebSocket connection to the browser - * @param {object} forgeTls - The node-forge TLS connection - * @param {net.Socket} tcpSocket - The underlying TCP socket - */ -function setupForgeRelay (ws, forgeTls, tcpSocket) { - let wsBytesForwarded = 0 - let tlsBytesForwarded = 0 - - const logPrefix = `${LOG_PREFIX} [relay]` - - // Override forge's dataReady to forward decrypted data to WebSocket - forgeTls.dataReady = function (connection) { - const data = connection.data.getBytes() - const buf = Buffer.from(data, 'binary') - tlsBytesForwarded += buf.length - try { - if (ws.readyState === 1 /* OPEN */) { - ws.send(buf) - } - } catch (err) { - log.error(`${logPrefix} TLS→WS write error:`, err.message) - } - } - - // Override forge's closed/error for relay phase - forgeTls.closed = function () { - log.debug(`${logPrefix} forge TLS closed`) - cleanup('forge TLS') - } - forgeTls.error = function (connection, error) { - log.error(`${logPrefix} forge TLS error during relay: ${error.message}`) - cleanup('forge TLS (error)') - } - - // WebSocket → forge TLS → TCP (browser → RDP server) - ws.on('message', (data) => { - const buf = Buffer.isBuffer(data) ? data : Buffer.from(data) - wsBytesForwarded += buf.length - try { - forgeTls.prepare(buf.toString('binary')) - } catch (err) { - log.error(`${logPrefix} WS→TLS write error:`, err.message) - } - }) - - // Cleanup on close - const cleanup = (source) => { - log.debug(`${logPrefix} ${source} closed — WS→TLS: ${wsBytesForwarded} bytes, TLS→WS: ${tlsBytesForwarded} bytes`) - if (!tcpSocket.destroyed) tcpSocket.destroy() - try { forgeTls.close() } catch (_) {} - if (ws.readyState === 1) { - try { ws.close() } catch (_) {} - } - } - - tcpSocket.on('end', () => cleanup('TCP')) - tcpSocket.on('error', (err) => { - log.error(`${logPrefix} TCP error:`, err.message) - cleanup('TCP (error)') - }) - - ws.on('close', () => cleanup('WebSocket')) - ws.on('error', (err) => { - log.error(`${logPrefix} WebSocket error:`, err.message) - cleanup('WebSocket (error)') - }) -} - -// ──────────────────────────────────────────────────── -// Main Handler: Process a WebSocket connection -// ──────────────────────────────────────────────────── - -/** - * Handle a new WebSocket connection from the browser's WASM RDP client. - * - * Protocol: - * 1. Receive RDCleanPath Request (ASN.1 DER binary message) - * 2. Parse destination, X.224 connection request - * 3. TCP connect to RDP server, send X.224, receive X.224 confirm - * 4. TLS handshake, extract server certificates - * 5. Send RDCleanPath Response back to browser - * 6. Bidirectional relay: WebSocket ↔ TLS - * - * @param {WebSocket} ws - The WebSocket connection - * @param {object} options - Optional settings - * @param {string} options.proxy - Proxy URL (e.g., 'socks5://127.0.0.1:1080' or 'http://proxy:8080') - * @param {number} options.readyTimeout - Connection timeout in ms - */ -function handleConnection (ws, options = {}, bufferedMessages = []) { - log.debug(`${LOG_PREFIX} New WebSocket connection for RDCleanPath proxy`) - - const handleFirstMessage = async (data) => { - try { - const requestData = Buffer.isBuffer(data) ? data : Buffer.from(data) - log.debug(`${LOG_PREFIX} Received RDCleanPath request (${requestData.length} bytes)`) - - // Step 1: Parse RDCleanPath request - const request = parseRDCleanPathRequest(requestData) - log.debug(`${LOG_PREFIX} RDCleanPath Request → destination: ${request.destination}, proxyAuth: ${request.proxyAuth}`) - - // Step 2: Parse destination - const { host, port } = parseDestination(request.destination) - log.debug(`${LOG_PREFIX} Connecting to RDP server at ${host}:${port}`) - - // Step 3-5: TCP + X.224 + TLS (node-forge) + Certs - const { x224Response, certChain, forgeTls, tcpSocket } = await performRDPHandshake( - host, - port, - request.x224ConnectionRequest, - options - ) - - // Step 6: Build and send RDCleanPath response - const serverAddr = `${host}:${port}` - const responsePdu = buildRDCleanPathResponse(serverAddr, x224Response, certChain) - log.debug(`${LOG_PREFIX} ✓ Sending RDCleanPath response (${responsePdu.length} bytes) to browser`) - ws.send(responsePdu) - - log.debug(`${LOG_PREFIX} ✓ RDCleanPath handshake complete — starting bidirectional relay`) - - // Step 7: Bidirectional relay via node-forge - setupForgeRelay(ws, forgeTls, tcpSocket) - } catch (err) { - log.error(`${LOG_PREFIX} RDCleanPath handshake error:`, err.message) - log.error(`${LOG_PREFIX} Stack:`, err.stack) - - // Try to send error response to client - try { - const errorPdu = buildRDCleanPathError(1, 502) - ws.send(errorPdu) - } catch (_) {} - - try { ws.close() } catch (_) {} - } - } - - // If a message arrived during async setup (before this handler was registered), - // process it immediately; otherwise wait for the next message event. - if (bufferedMessages.length > 0) { - handleFirstMessage(bufferedMessages[0]) - } else { - ws.once('message', handleFirstMessage) - } - - ws.on('error', (err) => { - log.error(`${LOG_PREFIX} WebSocket error:`, err.message) - }) -} - -module.exports = { - handleConnection, - parseRDCleanPathRequest, - buildRDCleanPathResponse, - buildRDCleanPathError, - parseDestination, - performRDPHandshake, - setupForgeRelay -} diff --git a/src/app/server/remote-common.js b/src/app/server/remote-common.js deleted file mode 100644 index aa6a948..0000000 --- a/src/app/server/remote-common.js +++ /dev/null @@ -1,64 +0,0 @@ -/** - * common functions for remote process handling, - * for sftp, terminal and transfer - */ - -const globalState = require('./global-state') - -function sftp (id, inst) { - if (inst) { - globalState.setSession(id, inst) - return inst - } - return globalState.getSession(id) -} - -function terminals (id, inst) { - if (inst) { - globalState.setSession(id, inst) - return inst - } - return globalState.getSession(id) -} - -function transfer (id, sftpId, inst) { - const ss = sftp(sftpId) - if (!ss) { - return - } - if (inst) { - ss.transfers[id] = inst - return inst - } - return ss.transfers[id] -} - -function onDestroySftp (id) { - const inst = sftp(id) - inst && inst.kill && inst.kill() -} - -function onDestroyTransfer (id, sftpId) { - const sftpInst = sftp(sftpId) - const inst = transfer(id, sftpId) - inst && inst.destroy && inst.destroy() - sftpInst && delete sftpInst.transfers[id] -} - -function cleanAllSessions () { - const { sessions } = globalState.data - for (const id in sessions) { - const inst = sessions[id] - inst && inst.kill && inst.kill() - } -} - -module.exports = { - sftp, - transfer, - onDestroySftp, - onDestroyTerminal: onDestroySftp, - onDestroyTransfer, - terminals, - cleanAllSessions -} diff --git a/src/app/server/server.js b/src/app/server/server.js deleted file mode 100644 index e32ee3a..0000000 --- a/src/app/server/server.js +++ /dev/null @@ -1,54 +0,0 @@ -const express = require('express') -const globalState = require('./global-state') -const app = express() -const log = require('../common/log') -const { initWs } = require('./dispatch-center') -const { - isDev -} = require('../common/runtime-constants') -const initFileServer = require('../lib/file-server') -const appDec = require('./app-wrap') - -appDec(app) - -app.get('/run', function (req, res) { - res.send('ok') -}) -app.post('/auth', function (req, res) { - const { token } = req.body - if (token === process.env.requireAuth) { - globalState.authed = true - } - res.send('ok') -}) -if (!isDev) { - initFileServer(app) -} -initWs(app) - -// --- Server lifecycle --- -let _startPromise = null - -/** - * Start the Express server. Returns a Promise that resolves when - * the server is listening. Safe to call multiple times — returns - * the same Promise. - */ -function startServer () { - if (_startPromise) return _startPromise - _startPromise = new Promise((resolve, reject) => { - const { electermPort, electermHost } = process.env - app.listen(electermPort, electermHost, () => { - log.info('server', 'runs on', electermHost, electermPort) - // process.send may not exist (in-process mode) - try { process.send({ serverInited: true }) } catch {} - resolve(app) - }) - }) - return _startPromise -} - -// Auto-start when required -startServer() - -module.exports = { startServer, app } diff --git a/src/app/server/session-api.js b/src/app/server/session-api.js deleted file mode 100644 index 0b9d78e..0000000 --- a/src/app/server/session-api.js +++ /dev/null @@ -1,96 +0,0 @@ -/** - * run cmd with terminal - */ - -const { - terminals -} = require('./remote-common') -const { startSession } = require('./session') - -async function runCmd (body) { - const { pid, cmd } = body - const term = terminals(pid) - let txt = '' - if (term) { - txt = await term.runCmd(cmd) - } - return txt -} - -async function execCmd (body) { - const { pid, cmd, timeoutMs } = body - const term = terminals(pid) - if (!term || typeof term.execCommand !== 'function') { - throw new Error('Exec channel not supported for this session type') - } - return term.execCommand(cmd, { timeoutMs }) -} - -async function resize (body) { - const { pid, cols, rows } = body - const term = terminals(pid) - if (term) { - term.resize(cols, rows) - } - return 'ok' -} - -async function toggleTerminalLog (body) { - const { pid } = body - const term = terminals(pid) - if (term) { - term.toggleTerminalLog() - } - return 'ok' -} - -async function toggleTerminalLogTimestamp (body) { - const { pid } = body - const term = terminals(pid) - if (term) { - term.toggleTerminalLogTimestamp() - } - return 'ok' -} - -async function createTerm (body, ws) { - const t = await startSession(body, ws) - return t.pid -} - -async function testTerm (body, ws) { - const r = await startSession(body, ws, 'test') - if (r) { - return r - } else { - throw new Error('test failed') - } -} - -async function setTerminalLogPath (body) { - const { pid, logPath } = body - const term = terminals(pid) - if (term) { - term.setTerminalLogPath(logPath) - } - return 'ok' -} - -async function startTerminalLogFile (body) { - const { pid, logFilePath, addTimeStampToTermLog } = body - const term = terminals(pid) - if (term) { - term.startTerminalLogFile(logFilePath, addTimeStampToTermLog) - } - return 'ok' -} - -exports.createTerm = createTerm -exports.testTerm = testTerm -exports.resize = resize -exports.runCmd = runCmd -exports.execCmd = execCmd -exports.toggleTerminalLog = toggleTerminalLog -exports.toggleTerminalLogTimestamp = toggleTerminalLogTimestamp -exports.setTerminalLogPath = setTerminalLogPath -exports.startTerminalLogFile = startTerminalLogFile diff --git a/src/app/server/session-base.js b/src/app/server/session-base.js deleted file mode 100644 index 353a32d..0000000 --- a/src/app/server/session-base.js +++ /dev/null @@ -1,158 +0,0 @@ -/** - * terminal/sftp/serial class - */ -const generate = require('../common/uid') -const { createLogFileName } = require('../common/create-session-log-file-path') -const SessionLog = require('./session-log') -const time = require('../common/time.js') -const globalState = require('./global-state') - -// const { MockBinding } = require('@serialport/binding-mock') -// MockBinding.createPort('/dev/ROBOT', { echo: true, record: true }) - -function createVtParser (cols = 4096) { - const { Terminal } = require('@xterm/headless') - const term = new Terminal({ cols, rows: 50, allowProposedApi: true }) - return term -} - -class TerminalBase { - constructor (initOptions, ws, isTest) { - this.type = initOptions.termType || initOptions.type - this.pid = initOptions.uid || generate() - this.initOptions = initOptions - if (initOptions.saveTerminalLogToFile) { - this.sessionLogger = new SessionLog({ - logDir: initOptions.sessionLogPath, - fileName: createLogFileName(initOptions.logName) - }) - this._initVtParser() - } - if (ws) { - this.ws = ws - } - if (isTest) { - this.isTest = isTest - } - } - - _initVtParser () { - this._vtTerm = createVtParser(this.initOptions.cols || 4096) - this._vtLastRow = 0 - this._vtTerm.onLineFeed(() => { - if (!this.sessionLogger) return - const buffer = this._vtTerm.buffer.active - const row = buffer.baseY + buffer.cursorY - 1 - if (row < 0) return - const line = buffer.getLine(row) - if (!line) return - const text = line.translateToString(true) - const dt = this.initOptions.addTimeStampToTermLog - ? `[${time()}] ` - : '' - this.sessionLogger.write(dt + text + '\n') - }) - } - - toggleTerminalLogTimestamp () { - this.initOptions.addTimeStampToTermLog = !this.initOptions.addTimeStampToTermLog - } - - toggleTerminalLog () { - if (this.sessionLogger) { - this.sessionLogger.destroy() - delete this.sessionLogger - if (this._vtTerm) { - this._vtTerm.dispose() - delete this._vtTerm - } - } else { - this.sessionLogger = new SessionLog({ - logDir: this.initOptions.sessionLogPath, - fileName: createLogFileName(this.initOptions.logName) - }) - this._initVtParser() - } - } - - setTerminalLogPath (logPath) { - if (!logPath) { - return - } - this.initOptions.sessionLogPath = logPath - if (this.sessionLogger) { - // Reopen the log under the new path - this.sessionLogger.destroy() - if (this._vtTerm) { - this._vtTerm.dispose() - delete this._vtTerm - } - this.sessionLogger = new SessionLog({ - logDir: this.initOptions.sessionLogPath, - fileName: createLogFileName(this.initOptions.logName) - }) - this._initVtParser() - } - } - - startTerminalLogFile (logFilePath, addTimeStamp) { - if (!logFilePath) { - return - } - const { dirname, basename } = require('path') - const logDir = dirname(logFilePath) - const fileName = basename(logFilePath) - if (this.sessionLogger) { - this.sessionLogger.destroy() - delete this.sessionLogger - } - if (this._vtTerm) { - this._vtTerm.dispose() - delete this._vtTerm - } - this.initOptions.addTimeStampToTermLog = !!addTimeStamp - this.sessionLogger = new SessionLog({ logDir, fileName }) - this._initVtParser() - } - - writeLog (data) { - if (!this.sessionLogger || !this._vtTerm) { - return - } - // Normalize bare \r (carriage return, not part of \r\n) to \r\n. - // Embedded devices (UART/telnet) often use \r-only line endings which - // don't trigger xterm's onLineFeed, causing timestamps to be missing - // for every line except the first. - if (Buffer.isBuffer(data)) { - const str = data.toString('binary') - const normalized = str.replace(/\r(?!\n)/g, '\r\n') - this._vtTerm.write(normalized) - } else { - const normalized = String(data).replace(/\r(?!\n)/g, '\r\n') - this._vtTerm.write(normalized) - } - } - - onEndConn () { - const { - pid - } = this - const inst = globalState.getSession(pid) - if (!inst) { - return - } - if (this.ws) { - delete this.ws - } - if (this._vtTerm) { - this._vtTerm.dispose() - delete this._vtTerm - } - if (this.server && this.server.end) { - this.server.end() - } - globalState.removeSession(pid) - } -} - -exports.TerminalBase = TerminalBase diff --git a/src/app/server/session-common.js b/src/app/server/session-common.js deleted file mode 100644 index 1340af4..0000000 --- a/src/app/server/session-common.js +++ /dev/null @@ -1,126 +0,0 @@ -/** - * terminal/sftp/serial class - */ - -exports.commonExtends = function (Cls) { - Cls.prototype.customEnv = function (envs) { - if (!envs) { - return {} - } - return envs.split(' ').reduce((p, k) => { - const [key, value] = k.split('=') - if (key && value) { - p[key] = value - } - return p - }, {}) - } - - Cls.prototype.getEnv = function (initOptions = this.initOptions) { - return { - LANG: initOptions.envLang || 'en_US.UTF-8', - ...this.customEnv(initOptions.setEnv) - } - } - - Cls.prototype.getExecOpts = function () { - return { - env: this.getEnv() - } - } - - Cls.prototype.runCmd = function (cmd, conn) { - return new Promise((resolve, reject) => { - const client = conn || this.conn || this.client - client.exec(cmd, this.getExecOpts(), (err, stream) => { - if (err) reject(err) - if (stream) { - let r = '' - stream - .on('data', function (data) { - const d = data.toString() - r = r + d - }) - .on('close', (code, signal) => { - resolve(r) - }) - } else { - resolve('') - } - }) - }) - } - - // Structured command execution over an SSH exec channel. - // Unlike runCmd (which merges stdout/stderr and drops the exit code), - // execCommand captures both streams separately and resolves the real - // exit code. Optional timeoutMs closes the channel early and resolves - // partial output with timedOut: true. - Cls.prototype.execCommand = function (cmd, options = {}, conn) { - return new Promise((resolve, reject) => { - const { timeoutMs = 0 } = options || {} - const client = conn || this.conn || this.client - if (!client || typeof client.exec !== 'function') { - reject(new Error('Exec channel not supported for this session type')) - return - } - let timer = null - client.exec(cmd, this.getExecOpts(), (err, stream) => { - if (err) { - reject(err) - return - } - if (!stream) { - resolve({ stdout: '', stderr: '', exitCode: null, timedOut: false }) - return - } - let stdout = '' - let stderr = '' - let exitCode = null - let settled = false - const done = (timedOut) => { - if (settled) return - settled = true - if (timer) { - clearTimeout(timer) - timer = null - } - resolve({ stdout, stderr, exitCode, timedOut }) - } - if (timeoutMs > 0) { - timer = setTimeout(() => { - try { - stream.close() - } catch (_) { - // ignore — best effort channel close - } - done(true) - }, timeoutMs) - } - stream.on('data', (data) => { - stdout += data.toString() - }) - if (stream.stderr) { - stream.stderr.on('data', (data) => { - stderr += data.toString() - }) - } - stream.on('exit', (code) => { - exitCode = typeof code === 'number' ? code : null - }) - stream.on('close', () => done(false)) - stream.on('error', (e) => { - if (timer) { - clearTimeout(timer) - timer = null - } - if (!settled) { - settled = true - reject(e) - } - }) - }) - }) - } - return Cls -} diff --git a/src/app/server/session-ftp.js b/src/app/server/session-ftp.js deleted file mode 100644 index 0cc9790..0000000 --- a/src/app/server/session-ftp.js +++ /dev/null @@ -1,306 +0,0 @@ -const FtpClientWrapper = require('./ftp-client') -const { TerminalBase } = require('./session-base') -const { commonExtends } = require('./session-common') -const { readRemoteFile, writeRemoteFile } = require('./ftp-file') -const { Readable, PassThrough } = require('stream') -const { posix: path } = require('path') -const globalState = require('./global-state') - -class Ftp extends TerminalBase { - constructor (initOptions) { - super({ - ...initOptions, - type: 'ftp' // Explicitly set the type - }) - this.transfers = {} - } - - getClientAccessOptions (initOptions = this.initOptions) { - return { - host: initOptions.host, - port: initOptions.port || 21, - user: initOptions.user, - password: initOptions.password, - secure: initOptions.secure, - proxy: initOptions.proxy, - readyTimeout: initOptions.readyTimeout - } - } - - async createConnectedClient (initOptions = this.initOptions) { - const client = new FtpClientWrapper() - client.verbose = initOptions.debug - client.setEncoding(initOptions.encode || 'utf-8') - await client.access(this.getClientAccessOptions(initOptions)) - return client - } - - async createOperationClient () { - return this.createConnectedClient() - } - - async withOperationClient (handler, client) { - if (client) { - return handler(client) - } - const operationClient = await this.createOperationClient() - try { - return await handler(operationClient) - } finally { - await operationClient.close().catch(() => {}) - } - } - - async connect (initOptions) { - this.initOptions = { - ...this.initOptions, - ...initOptions - } - const client = await this.createConnectedClient(this.initOptions) - await client.close().catch(() => {}) - globalState.setSession(this.pid, this) - return 'ok' - } - - kill () { - Object.values(this.transfers).forEach(transfer => { - transfer?.destroy?.() - }) - this.transfers = {} - super.onEndConn() - } - - async getHomeDir () { - return this.withOperationClient(client => client.pwd()) - } - - async rmdir (remotePath) { - await this.withOperationClient(client => { - return this.removeDirectoryRecursively(remotePath, client) - }) - return 1 - } - - async mv (from, to) { - await this.rename(from, to) - return 1 - } - - async cp (from, to) { - const sourceStat = await this.stat(from) - const targetStat = await this.tryStat(to) - const targetPath = targetStat?.isDirectory - ? path.join(to, path.basename(from)) - : to - const sourceClient = await this.createOperationClient() - const targetClient = await this.createOperationClient() - - try { - if (sourceStat.isDirectory) { - await this.copyDirectory(from, targetPath, sourceClient, targetClient) - } else { - await this.copyFile(from, targetPath, sourceClient, targetClient) - } - return 1 - } finally { - await sourceClient.close().catch(() => {}) - await targetClient.close().catch(() => {}) - } - } - - async tryStat (remotePath, client) { - try { - return await this.stat(remotePath, client) - } catch (error) { - return null - } - } - - async ensureDirSafe (remotePath, client) { - const currentPath = await client.pwd() - try { - await client.ensureDir(remotePath) - } finally { - if (currentPath) { - await client.cd(currentPath).catch(() => {}) - } - } - } - - async copyDirectory (sourcePath, targetPath, sourceClient, targetClient) { - await this.ensureDirSafe(targetPath, targetClient) - const list = await this.list(sourcePath, sourceClient) - for (const item of list) { - const nextSourcePath = path.join(sourcePath, item.name) - const nextTargetPath = path.join(targetPath, item.name) - if (item.type === 'd') { - await this.copyDirectory(nextSourcePath, nextTargetPath, sourceClient, targetClient) - } else { - await this.copyFile(nextSourcePath, nextTargetPath, sourceClient, targetClient) - } - } - } - - async copyFile (sourcePath, targetPath, sourceClient, targetClient) { - const transferStream = new PassThrough() - const downloadPromise = sourceClient.downloadTo(transferStream, sourcePath) - .catch(error => { - transferStream.destroy(error) - throw error - }) - const uploadPromise = targetClient.uploadFrom(transferStream, targetPath) - .catch(error => { - transferStream.destroy(error) - throw error - }) - - await Promise.all([downloadPromise, uploadPromise]) - } - - async removeDirectoryRecursively (remotePath, client) { - const contents = await this.list(remotePath, client) - for (const item of contents) { - const itemPath = `${remotePath}/${item.name}` - if (item.type === 'd') { - await this.removeDirectoryRecursively(itemPath, client) - } else { - await this.rm(itemPath, client) - } - } - await this.rmFolder(remotePath, client) - } - - async touch (remotePath) { - const emptyStream = new Readable({ - read () { - this.push(null) - } - }) - await this.withOperationClient(client => client.uploadFrom(emptyStream, remotePath)) - return 1 - } - - async mkdir (remotePath) { - await this.withOperationClient(client => client.ensureDir(remotePath)) - return 1 - } - - async stat (remotePath, client) { - return this.withOperationClient(async currentClient => { - const pathParts = remotePath.split('/') - const fileName = pathParts.pop() - const parentPath = pathParts.join('/') || '/' - const list = await currentClient.list(parentPath) - if (!list || !list.length) { - throw new Error('stat failed: parent directory listing empty') - } - - const item = list.find(item => item.name === fileName) - if (!item) { - throw new Error(`stat failed: ${fileName} not found in ${parentPath}`) - } - return { - size: item.size, - accessTime: new Date(item.modifiedAt).getTime(), - modifyTime: new Date(item.modifiedAt).getTime(), - mode: 0o777, // Default permissions since FTP doesn't provide this - isDirectory: item.type === 2 - } - }, client) - } - - async readlink (remotePath) { - return remotePath - } - - async realpath (remotePath, client) { - return this.withOperationClient(async currentClient => { - const currentPath = await currentClient.pwd() - await currentClient.cd(remotePath) - const realPath = await currentClient.pwd() - await currentClient.cd(currentPath) - return realPath - }, client) - } - - async lstat (remotePath, client) { - return this.stat(remotePath, client) - } - - async chmod () { - // FTP doesn't support chmod - return 1 - } - - async rename (remotePath, remotePathNew) { - await this.withOperationClient(client => client.rename(remotePath, remotePathNew)) - return 1 - } - - async rmFolder (remotePath, client) { - await this.withOperationClient(currentClient => currentClient.removeDir(remotePath), client) - return 1 - } - - async rm (remotePath, client) { - await this.withOperationClient(currentClient => currentClient.remove(remotePath), client) - return 1 - } - - async list (remotePath, client) { - return this.withOperationClient(async currentClient => { - const list = await currentClient.list(remotePath) - return list.map(item => { - const dt = new Date(item.rawModifiedAt).getTime() - return { - type: item.type === 2 ? 'd' : '-', - name: item.name, - size: item.size, - modifyTime: dt, - accessTime: dt, - mode: 0o777, // Default permissions since FTP doesn't provide this - rights: { - user: 'rwx', - group: 'rwx', - other: 'rwx' - }, - owner: 'owner', - group: 'group' - } - }) - }, client) - } - - async readFile (remotePath, client) { - return this.withOperationClient(currentClient => readRemoteFile(currentClient, remotePath), client) - } - - async writeFile (remotePath, str, mode, client) { - return this.withOperationClient(currentClient => { - return writeRemoteFile(currentClient, remotePath, str, mode) - }, client) - } - - async getFolderSize (folderPath, client) { - let size = 0 - let count = 0 - const processDir = async (dirPath) => { - const list = await this.list(dirPath, client) - for (const item of list) { - if (item.type === 'd') { - await processDir(`${dirPath}/${item.name}`) - } else { - size += item.size - count++ - } - } - } - return this.withOperationClient(async currentClient => { - client = currentClient - await processDir(folderPath) - return { size, count } - }, client) - } -} - -exports.Ftp = commonExtends(Ftp) diff --git a/src/app/server/session-hop.js b/src/app/server/session-hop.js deleted file mode 100644 index 5fc4be2..0000000 --- a/src/app/server/session-hop.js +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Shared SSH connection-hopping utility. - * - * Creates a dynamic-SOCKS5 SSH tunnel through one or more hop servers - * and returns the proxy URL to use for the final connection. - * - * Used by both VNC and RDP sessions. - */ - -const uid = require('../common/uid') -const { session } = require('./session-ssh') - -function getPort (fromPort = 12023) { - return new Promise((resolve, reject) => { - require('find-free-port')(fromPort, '127.0.0.1', function (err, freePort) { - if (err) { - reject(err) - } else { - resolve(freePort) - } - }) - }) -} - -/** - * Set up an SSH hop tunnel if connectionHoppings are configured. - * - * @param {object} initOptions - Session init options - * @param {Array} initOptions.connectionHoppings - Hop server definitions (mutated: last item is popped) - * @param {string} [initOptions.proxy] - Existing proxy URL to chain through - * @returns {Promise<{ proxyUrl: string|null, ssh: object|null }>} - * proxyUrl - SOCKS5 URL to use for the final connection, or original proxy, or null - * ssh - SSH session that must be killed on cleanup, or null - */ -async function createHopProxy (initOptions) { - const { - proxy, - connectionHoppings - } = initOptions - - if (!connectionHoppings || !connectionHoppings.length) { - return { proxyUrl: proxy || null, ssh: null } - } - - const hop = connectionHoppings.pop() - const fp = await getPort() - - const initOpts = { - connectionHoppings, - ...hop, - hasHopping: true, - cols: 80, - rows: 24, - term: 'xterm-256color', - saveTerminalLogToFile: false, - id: uid(), - enableSsh: true, - encode: 'utf-8', - envLang: 'en_US.UTF-8', - proxy, - sshTunnels: [ - { - sshTunnel: 'dynamicForward', - sshTunnelLocalHost: '127.0.0.1', - sshTunnelLocalPort: fp, - id: uid() - } - ] - } - - const ssh = await session(initOpts) - return { proxyUrl: `socks5://127.0.0.1:${fp}`, ssh } -} - -module.exports = { createHopProxy, getPort } diff --git a/src/app/server/session-local.js b/src/app/server/session-local.js deleted file mode 100644 index 39136e9..0000000 --- a/src/app/server/session-local.js +++ /dev/null @@ -1,103 +0,0 @@ -/** - * terminal/sftp/serial class - */ - -// const { resolve: pathResolve } = require('path') -const { TerminalBase } = require('./session-base') -// const globalState = require('./global-state') -// const { MockBinding } = require('@serialport/binding-mock') -// MockBinding.createPort('/dev/ROBOT', { echo: true, record: true }) - -class TerminalLocal extends TerminalBase { - init () { - throw new Error('Local not supported') - // const { - // cols, - // rows, - // execWindows, - // execMac, - // execLinux, - // execWindowsArgs, - // execMacArgs, - // execLinuxArgs, - // termType, - // term - // } = this.initOptions - // this.isLocal = true - // const { platform } = process - // const isWin = platform.startsWith('win') - // const exec = isWin - // ? pathResolve( - // process.env.windir, - // execWindows - // ) - // : platform === 'darwin' ? execMac : execLinux - // if ((exec || '').includes('..')) { - // return Promise.reject(new Error('execWindows should not contain ".."')) - // } - // const arg = isWin - // ? execWindowsArgs - // : platform === 'darwin' ? execMacArgs : execLinuxArgs - // const cwd = process.env[platform === 'win32' ? 'USERPROFILE' : 'HOME'] - // const argv = platform.startsWith('darwin') ? ['--login', ...arg] : arg - // const pty = require('node-pty') - // const env = Object.assign({}, process.env) - // delete env.ELECTRON_RUN_AS_NODE - // delete env.NODE_OPTIONS - // delete env.ELECTRON_NO_ATTACH_CONSOLE - // temp PEM of system CAs for the server process (WebDAV sync, #4347) — - // not meant for user shells, and a bad keychain cert makes any Node/bun - // tool in the terminal print "ignoring extra certs ... load failed" - // delete env.NODE_EXTRA_CA_CERTS - // this.term = pty.spawn(exec, argv, { - // name: term, - // encoding: null, - // cols: cols || 80, - // rows: rows || 24, - // cwd, - // env, - // // Use the OpenConsole conpty.dll shipped with node-pty instead of the - // // legacy Windows Console Host (kernel32 CreatePseudoConsole) conpty. - // // The legacy console-host conpty can stall output and deliver Ctrl+C to - // // the whole process group (killing the shell too) after a full-screen - // // TUI like opencode exits, leaving the terminal tab unresponsive. - // // The OpenConsole conpty.dll does not have this problem. - // useConptyDll: true - // }) - // this.term.termType = termType - // globalState.setSession(this.pid, this) - // return Promise.resolve(this) - } - - // resize (cols, rows) { - // this.term.resize(cols, rows) - // } - - // on (event, cb) { - // this.term.on(event, cb) - // } - - // write (data) { - // this.term.write(data) - // } - - // kill () { - // if (this.sessionLogger) { - // this.sessionLogger.destroy() - // } - // this.term && this.term.kill() - // this.onEndConn() - // } -} - -exports.session = function (initOptions, ws) { - return (new TerminalLocal(initOptions, ws)).init() -} - -/** - * test ssh connection - * @param {object} options - */ -exports.test = (initOptions) => { - return Promise.resolve(true) -} diff --git a/src/app/server/session-log.js b/src/app/server/session-log.js deleted file mode 100644 index ecb5d05..0000000 --- a/src/app/server/session-log.js +++ /dev/null @@ -1,36 +0,0 @@ -/** - * log ssh output to file - */ - -const { resolve } = require('path') -const { existsSync, mkdirSync, createWriteStream } = require('fs') - -function mkLogDir (logDir) { - try { - if (!existsSync(logDir)) { - mkdirSync(logDir) - } - } catch (e) { - console.debug('read default user name error') - } -} - -class SessionLog { - constructor (options) { - this.options = options - const { logDir } = options - const logPath = resolve(logDir, options.fileName) - mkLogDir(logDir) - this.stream = createWriteStream(logPath, { flags: 'a' }) - } - - write (text) { - this.stream.write(text) - } - - destroy () { - this.stream.destroy() - } -} - -module.exports = SessionLog diff --git a/src/app/server/session-process.js b/src/app/server/session-process.js deleted file mode 100644 index 443fad4..0000000 --- a/src/app/server/session-process.js +++ /dev/null @@ -1,274 +0,0 @@ -/** - * session-process.js — manages terminal session servers in-process. - * - * Each session is an Express app on its own port, created by - * createSessionServer() from session-server.js. No child processes. - * Communication is via EventEmitter channels. - */ - -const { createSessionServer } = require('./session-server') - -// Map to store active terminal processes (pid -> {session, port, ws}) -const activeTerminals = new Map() - -// Track the last port assigned -let lastPort = 30975 -const MIN_PORT = 30975 -const MAX_PORT = 65534 -// Add a set to track ports that are currently being assigned -const pendingPorts = new Set() - -function getPort (fromPort = MIN_PORT) { - // Use the last port + 1 or start over if we've reached MAX_PORT - let startPort = lastPort >= MAX_PORT ? MIN_PORT : lastPort + 1 - - // Skip ports that are currently being assigned - while (pendingPorts.has(startPort)) { - startPort = startPort >= MAX_PORT ? MIN_PORT : startPort + 1 - } - - // Mark this port as pending - pendingPorts.add(startPort) - - return new Promise((resolve, reject) => { - require('find-free-port')(startPort, '127.0.0.1', function (err, freePort) { - if (err) { - pendingPorts.delete(startPort) - reject(err) - } else { - lastPort = freePort - pendingPorts.delete(startPort) - resolve(freePort) - } - }) - }) -} - -const electermHost = process.env.electermHost || '127.0.0.1' - -async function runSessionServer (type, port) { - return new Promise((resolve, reject) => { - const session = createSessionServer(type, port, electermHost) - - session.channel.on('ready', () => { - resolve(session) - }) - - // Timeout: if server doesn't start within 10s, reject - setTimeout(() => { - if (!session.server.listening) { - session.kill() - reject(new Error('Session server startup timed out')) - } - }, 10000) - }) -} - -/** - * Send a command to a session and wait for the response. - * Works the same as the old sendMsgToChildProcess but via channel. - */ -async function sendMsgToSession (session, msg) { - return new Promise((resolve, reject) => { - const responseHandler = (response) => { - // Only match command responses (not SSH data relay which has type:'common') - if (response.id === msg.id && !response.type) { - session.channel.removeListener('to-parent', responseHandler) - if (response.error) { - reject(response.error) - } else { - resolve(response.data) - } - } - } - - session.channel.on('to-parent', responseHandler) - session.channel.toChild({ - type: 'common', - data: msg - }) - }) -} - -exports.terminal = async function (initOptions, ws, uid) { - const type = initOptions.termType || initOptions.type || 'terminal' - const port = await getPort() - const session = await runSessionServer(type, port) - const pid = initOptions.uid - const isSsh = ![ - 'telnet', - 'serial', - 'local', - 'rdp', - 'vnc', - 'spice', - 'ftp' - ].includes(type) - - if (isSsh) { - // Relay SSH data between session and client WebSocket - session.channel.on('to-parent', (m) => { - if (m.type === 'common') { - ws.s(m.data) - ws.once((data) => { - session.channel.toChild(data) - }, m.data.id) - } - }) - } - - session.channel.on('exit', () => { - session.channel.removeAllListeners('to-parent') - activeTerminals.delete(pid) - }) - - if (type !== 'ftp') { - try { - await sendMsgToSession(session, { - id: uid, - action: 'create-terminal', - body: initOptions - }) - } catch (err) { - session.kill() - throw err - } - } - - // Kill any existing session for this pid before overwriting - const existingEntry = activeTerminals.get(pid) - if (existingEntry) { - existingEntry.session.kill() - activeTerminals.delete(pid) - } - - activeTerminals.set(pid, { - session, - port, - ws - }) - - return { - pid, - port - } -} - -exports.testConnection = async function (initOptions, ws, uid) { - const type = initOptions.termType || initOptions.type || 'terminal' - const port = await getPort() - const session = await runSessionServer(type, port) - - const isSsh = ![ - 'telnet', - 'serial', - 'local', - 'rdp', - 'vnc', - 'spice', - 'ftp' - ].includes(type) - if (isSsh && ws) { - session.channel.on('to-parent', (m) => { - if (m.type === 'common') { - ws.s(m.data) - ws.once((respData) => { - session.channel.toChild(respData) - }, m.data.id) - } - }) - } - - const res = await sendMsgToSession(session, { - id: uid, - action: 'test-terminal', - body: initOptions - }) - - session.kill() - return res -} - -/** - * Get terminal instance by pid - * @param {string} pid - Process ID of the terminal - * @returns {object|null} Terminal instance or null if not found - */ -exports.terminals = function (pid) { - const terminal = activeTerminals.get(pid) - if (!terminal) { - return null - } - - return { - runCmd: async (cmd, id) => { - return sendMsgToSession(terminal.session, { - id, - action: 'run-cmd', - body: { cmd, pid } - }) - }, - execCommand: async (cmd, timeoutMs, id) => { - return sendMsgToSession(terminal.session, { - id, - action: 'exec-cmd', - body: { cmd, pid, timeoutMs } - }) - }, - resize: (cols, rows, id) => { - sendMsgToSession(terminal.session, { - id, - action: 'resize-terminal', - body: { cols, rows, pid } - }) - }, - toggleTerminalLog: (id) => { - sendMsgToSession(terminal.session, { - id, - action: 'toggle-terminal-log', - body: { pid } - }) - }, - toggleTerminalLogTimestamp: (id) => { - sendMsgToSession(terminal.session, { - id, - action: 'toggle-terminal-log-timestamp', - body: { pid } - }) - }, - setTerminalLogPath: (id, logPath) => { - sendMsgToSession(terminal.session, { - id, - action: 'set-terminal-log-path', - body: { pid, logPath } - }) - }, - startTerminalLogFile: (id, logFilePath, addTimeStampToTermLog) => { - sendMsgToSession(terminal.session, { - id, - action: 'start-terminal-log-file', - body: { pid, logFilePath, addTimeStampToTermLog } - }) - } - } -} - -/** - * Clean up all active terminals - */ -exports.cleanupTerminals = function () { - for (const [pid, terminal] of activeTerminals) { - terminal.session.kill() - activeTerminals.delete(pid) - } -} - -// Clean up on process exit -process.on('SIGINT', () => { - exports.cleanupTerminals() - process.exit() -}) -process.on('SIGTERM', () => { - exports.cleanupTerminals() - process.exit() -}) diff --git a/src/app/server/session-rdp.js b/src/app/server/session-rdp.js deleted file mode 100644 index 57d292d..0000000 --- a/src/app/server/session-rdp.js +++ /dev/null @@ -1,154 +0,0 @@ -/** - * RDP session using IronRDP WASM + RDCleanPath proxy - * - * Architecture: - * Browser (IronRDP WASM) <--WebSocket--> This Proxy <--TLS--> RDP Server - * - * The WASM client handles all RDP protocol logic. - * This server-side code acts as a RDCleanPath proxy: - * 1. Receives RDCleanPath Request from WASM client (ASN.1 DER binary) - * 2. TCP connects to the RDP server (optionally through proxy) - * 3. Performs X.224 handshake + TLS upgrade - * 4. Sends RDCleanPath Response (with certs) back to WASM client - * 5. Bidirectional relay: WebSocket <-> TLS - */ -const log = require('../common/log') -const { TerminalBase } = require('./session-base') -const globalState = require('./global-state') -const { - handleConnection -} = require('./rdp-proxy') -const { createHopProxy } = require('./session-hop') - -class TerminalRdp extends TerminalBase { - init = async () => { - globalState.setSession(this.pid, this) - return Promise.resolve(this) - } - - /** - * Start the RDCleanPath proxy for this session. - * Called when the WebSocket connects from the browser. - * The WASM client will send an RDCleanPath Request as the first message. - */ - start = async (width, height) => { - if (!this.ws) { - log.error(`[RDP:${this.pid}] No WebSocket available`) - return - } - this.width = width - this.height = height - - // Buffer any messages that arrive during the async hop setup so they - // are not dropped before handleConnection sets up its own listener. - const bufferedMessages = [] - const bufferMsg = (data) => bufferedMessages.push(data) - this.ws.on('message', bufferMsg) - - const { readyTimeout } = this.initOptions - - const { proxyUrl, ssh } = await createHopProxy(this.initOptions) - if (ssh) { - this.ssh = ssh - } - - // Hand off to the proxy handler, replaying any buffered messages. - this.ws.off('message', bufferMsg) - handleConnection(this.ws, { - proxy: proxyUrl, - readyTimeout - }, bufferedMessages) - } - - resize () { - // IronRDP handles resize via the WASM session.resize() method - // which sends resize PDUs through the existing relay - } - - test = async () => { - const net = require('net') - const proxySock = require('./socks') - const { - host, - port = 3389, - readyTimeout = 10000 - } = this.initOptions - - const { proxyUrl, ssh } = await createHopProxy(this.initOptions) - if (ssh) { - this.ssh = ssh - } - - try { - if (proxyUrl) { - const proxyResult = await proxySock({ readyTimeout, host, port, proxy: proxyUrl }) - proxyResult.socket.destroy() - return true - } - - return await new Promise((resolve, reject) => { - const socket = net.createConnection({ host, port }, () => { - socket.destroy() - resolve(true) - }) - socket.on('error', (err) => reject(err)) - socket.setTimeout(readyTimeout, () => { - socket.destroy() - reject(new Error('Connection timed out')) - }) - }) - } finally { - if (this.ssh) { - this.ssh.kill() - delete this.ssh - } - } - } - - kill = () => { - if (this.ws) { - try { - this.ws.close() - } catch (e) { - log.debug(`[RDP:${this.pid}] ws.close() error: ${e.message}`) - } - delete this.ws - } - if (this.ssh) { - this.ssh.kill() - delete this.ssh - } - if (this.sessionLogger) { - this.sessionLogger.destroy() - } - const { - pid - } = this - const inst = globalState.getSession(pid) - if (!inst) { - return - } - globalState.removeSession(pid) - } -} - -exports.session = async function (initOptions, ws) { - const term = new TerminalRdp(initOptions, ws) - await term.init() - return term -} - -/** - * test RDP connection (TCP connectivity check) - * @param {object} options - */ -exports.test = (options) => { - return (new TerminalRdp(options, undefined, true)) - .test() - .then(() => { - return true - }) - .catch(() => { - return false - }) -} diff --git a/src/app/server/session-serial.js b/src/app/server/session-serial.js deleted file mode 100644 index c9c197e..0000000 --- a/src/app/server/session-serial.js +++ /dev/null @@ -1,137 +0,0 @@ -/** - * terminal/sftp/serial class - */ -const { TerminalBase } = require('./session-base') -// const log = require('../common/log') -// const globalState = require('./global-state') -// const { MockBinding } = require('@serialport/binding-mock') -// MockBinding.createPort('/dev/ROBOT', { echo: true, record: true }) - -class TerminalSerial extends TerminalBase { - async init () { - throw new Error('Serial not supported') - // const { SerialPort } = require('serialport') - // // https://serialport.io/docs/api-stream - // const { - // autoOpen = true, - // baudRate = 9600, - // dataBits = 8, - // lock = true, - // stopBits = 1, - // parity = 'none', - // rtscts = false, - // xon = false, - // xoff = false, - // xany = false, - // txLineEnding = '\r', - // rxLineEnding = 'none', - // path - // } = this.initOptions - // this.txLineEnding = txLineEnding - // this.rxLineEnding = rxLineEnding - // await new Promise((resolve, reject) => { - // this.port = new SerialPort({ - // // binding: MockBinding, - // path, - // autoOpen, - // baudRate, - // dataBits, - // lock, - // stopBits, - // parity, - // rtscts, - // xon, - // xoff, - // xany - // }, (err) => { - // if (err) { - // reject(err) - // } else { - // resolve('ok') - // } - // }) - // }) - // if (this.isTest) { - // this.kill() - // return true - // } - // globalState.setSession(this.pid, this) - // return Promise.resolve(this) - } - - // resize () { - - // } - - // on (event, cb) { - // if (event === 'data' && this.rxLineEnding && this.rxLineEnding !== 'none') { - // this.port.on('data', (data) => { - // const str = Buffer.isBuffer(data) ? data.toString('latin1') : String(data) - // let processed - // if (this.rxLineEnding === 'lf_to_crlf') { - // processed = str.replace(/\r?\n/g, '\r\n') - // } else if (this.rxLineEnding === 'cr_to_crlf') { - // processed = str.replace(/\r(?!\n)/g, '\r\n') - // } else { - // processed = str - // } - // cb(Buffer.isBuffer(data) ? Buffer.from(processed, 'latin1') : processed) - // }) - // } else { - // this.port.on(event, cb) - // } - // } - - // write (data) { - // try { - // const str = Buffer.isBuffer(data) ? data.toString('latin1') : String(data) - // let out = str - // if (this.txLineEnding && this.txLineEnding !== '\r') { - // out = str.replace(/\r\n|\r|\n/g, this.txLineEnding) - // } - // this.port.write(Buffer.isBuffer(data) ? Buffer.from(out, 'latin1') : out) - // } catch (e) { - // log.error(e) - // } - // } - - // /** - // * Write raw bytes directly to the serial port, bypassing txLineEnding transformation. - // * Used by binary protocols (XMODEM) to avoid corruption of protocol bytes. - // */ - // writeRaw (data) { - // try { - // this.port.write(data) - // } catch (e) { - // log.error(e) - // } - // } - - // kill () { - // if (this.sessionLogger) { - // this.sessionLogger.destroy() - // } - // this.port && this.port.isOpen && this.port.close() - // delete this.port - // this.onEndConn() - // } -} - -exports.session = async function (initOptions, ws) { - const term = new TerminalSerial(initOptions, ws) - await term.init() - return term -} - -/** - * test ssh connection - * @param {object} options - */ -exports.test = (initOptions) => { - return (new TerminalSerial(initOptions, undefined, true)) - .init() - .then(() => true) - .catch(() => { - return false - }) -} diff --git a/src/app/server/session-server.js b/src/app/server/session-server.js deleted file mode 100644 index 6714923..0000000 --- a/src/app/server/session-server.js +++ /dev/null @@ -1,615 +0,0 @@ -/** - * session-server.js — factory for creating in-process session servers. - * - * Each call to createSessionServer() creates a new Express app listening - * on its own port, with WebSocket routes for terminal/sftp/transfer. - * Communication with the parent (session-process.js) is via an - * EventEmitter channel instead of process IPC. - */ - -const EventEmitter = require('events') -const express = require('express') -const { Sftp } = require('./session-sftp') -const { instSftpKeys } = require('../common/constants') -const { Ftp } = require('./session-ftp') -const { - sftp, - transfer, - onDestroySftp, - onDestroyTransfer, - terminals -} = require('./remote-common') -const { Transfer, transferKeys } = require('./transfer') -const { Transfer: FtpTransfer } = require('./ftp-transfer') -const log = require('../common/log') -const appDec = require('./app-wrap') -const { - createTerm, - testTerm, - resize, - runCmd, - execCmd, - toggleTerminalLog, - toggleTerminalLogTimestamp, - setTerminalLogPath, - startTerminalLogFile -} = require('./session-api') -const wsDec = require('./ws-dec') -const { zmodemManager } = require('./zmodem') -const { trzszManager } = require('./trzsz') -const { xmodemManager } = require('./xmodem') - -// True when the buffered data ends mid-way through a multi-byte UTF-8 -// sequence (CJK chars are 3 bytes). Slow SSH servers (embedded router CLIs) -// often deliver one char split across TCP segments; flushing such a buffer -// right away would push a partial char to the client. Only the tail of the -// last buffer is inspected (at most 4 bytes), so this is O(1). -function hasIncompleteTrailingUtf8 (bufs) { - const last = bufs[bufs.length - 1] - if (!last) { - return false - } - const buf = Buffer.isBuffer(last) ? last : Buffer.from(last) - const len = buf.length - if (!len) { - return false - } - // Count trailing continuation bytes (10xxxxxx), at most 3 - let cont = 0 - while (cont < 3 && cont < len && (buf[len - 1 - cont] & 0xc0) === 0x80) { - cont++ - } - const leadIdx = len - 1 - cont - if (leadIdx < 0) { - // Whole buffer is continuation bytes; the lead byte was in a chunk that - // was already flushed, so holding can not reassemble anything. - return false - } - const lead = buf[leadIdx] - if (lead < 0xc0) { - // ASCII last byte, or stray continuations after ASCII: nothing to wait for - return false - } - // Expected continuation count for this lead byte: - // 110xxxxx -> 1, 1110xxxx -> 2, 11110xxx -> 3 - const needed = lead < 0xe0 ? 1 : lead < 0xf0 ? 2 : 3 - return cont < needed -} - -let _pidCounter = 100001 - -/** - * Create a session server running in-process. - * - * @param {string} type - session type: terminal, rdp, vnc, spice, etc. - * @param {number} wsPort - port to listen on - * @param {string} electermHost - host to bind to - * @returns {{ channel: EventEmitter, kill: Function, pid: number, port: number }} - */ -function createSessionServer (type, wsPort, electermHost) { - const app = express() - const channel = new EventEmitter() - channel.setMaxListeners(100) - - // Helper methods on channel: - // channel.toParent(msg) — child → parent (replaces process.send) - // channel.toChild(msg) — parent → child (replaces process.on('message')) - channel.toParent = (msg) => channel.emit('to-parent', msg) - channel.toChild = (msg) => channel.emit('to-child', msg) - - const tokenElecterm = process.env.tokenElecterm - - // Track whether any WebSocket has connected to detect orphaned servers - let firstWsConnected = false - function markConnected () { - firstWsConnected = true - } - - function verify (req) { - const { token: to } = req.query - if (to !== tokenElecterm) { - throw new Error('not valid request') - } - } - - appDec(app) - - // --- WebSocket routes (same logic as original, using local `app`) --- - - if (type === 'rdp') { - app.ws('/rdp/:pid', function (ws, req) { - const { width, height } = req.query - verify(req) - markConnected() - const term = terminals(req.params.pid) - term.ws = ws - log.debug('ws: connected to rdp session ->', term.pid, 'width=', width, 'height=', height) - term.start(width, height) - ws.on('error', (err) => { - log.error('rdp ws error:', err) - }) - ws.on('close', () => { - log.debug('ws: rdp session ws closed ->', term.pid) - cleanup() - }) - }) - } else if (type === 'vnc') { - app.ws('/vnc/:pid', function (ws, req) { - const { query } = req - verify(req) - markConnected() - const { pid } = req.params - const term = terminals(pid) - term.ws = ws - term.start(query) - log.debug('ws: connected to vnc session ->', pid) - ws.on('error', (err) => { - log.error(err) - }) - ws.on('close', () => { - cleanup() - }) - }) - } else if (type === 'spice') { - app.ws('/spice/:pid', function (ws, req) { - const { query } = req - verify(req) - markConnected() - const { pid } = req.params - const term = terminals(pid) - log.debug('ws: connected to spice session ->', pid) - term.start(query, ws) - ws.on('error', (err) => { - log.error(err) - }) - }) - } else { - app.ws('/terminals/:pid', function (ws, req) { - verify(req) - markConnected() - const term = terminals(req.params.pid) - const { pid } = term - log.debug('ws: connected to terminal ->', pid) - - const dataBuffer = [] - let sendTimeout = null - // Time of the last actual flush. Lets a chunk arriving after an idle gap - // (keystroke echo, command result) skip the coalescing delay entirely, - // so only chunks arriving inside an active burst (floods) pay the 10ms - // wait. Mirrors the client-side coalescing fast path. - let lastFlushTime = 0 - const flushIntervalMs = 10 - - const flushBufferedData = () => { - if (!dataBuffer.length) { - sendTimeout = null - return - } - lastFlushTime = Date.now() - const combinedData = Buffer.concat(dataBuffer.splice(0).map(d => Buffer.isBuffer(d) ? d : Buffer.from(d))) - - term.writeLog(combinedData) - - const zmodemConsumed = zmodemManager.handleData(pid, combinedData, term, ws) - if (zmodemConsumed) { - sendTimeout = null - return - } - - const trzszConsumed = trzszManager.handleData(pid, combinedData, term, ws) - if (trzszConsumed) { - sendTimeout = null - return - } - - if (term.port) { - detectXmodemMarker(combinedData.toString('utf8')) - } - - const xmodemConsumed = xmodemManager.handleData(pid, combinedData, term, ws) - if (xmodemConsumed) { - sendTimeout = null - return - } - - ws.send(combinedData) - sendTimeout = null - } - - ws.s = (data) => { - ws.send(JSON.stringify(data)) - } - - function detectXmodemMarker (text) { - const txMatch = text.match(/\[XMODEM:TX:(.+?)\]/) - if (txMatch) { - ws.s({ - action: 'xmodem-event', - event: 'auto-trigger-receive', - name: txMatch[1] - }) - return - } - const rxMatch = text.match(/\[XMODEM:RX\]/) - if (rxMatch) { - ws.s({ - action: 'xmodem-event', - event: 'auto-trigger-send' - }) - } - } - - term.on('data', function (data) { - if (zmodemManager.isActive(pid)) { - term.writeLog(data) - zmodemManager.handleData(pid, data, term, ws) - return - } - - if (trzszManager.isActive(pid)) { - term.writeLog(data) - trzszManager.handleData(pid, data, term, ws) - return - } - - if (term.port) { - const text = Buffer.isBuffer(data) ? data.toString('utf8') : data - detectXmodemMarker(text) - } - - if (xmodemManager.isActive(pid)) { - if (!term.port) { - term.writeLog(data) - xmodemManager.handleData(pid, data, term, ws) - } - return - } - - const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data) - - if (chunk.length > 16384) { - if (sendTimeout) { - clearTimeout(sendTimeout) - sendTimeout = null - } - if (dataBuffer.length) { - flushBufferedData() - } - term.writeLog(chunk) - const zmodemConsumed = zmodemManager.handleData(pid, chunk, term, ws) - if (zmodemConsumed) { - return - } - const trzszConsumed = trzszManager.handleData(pid, chunk, term, ws) - if (trzszConsumed) { - return - } - const xmodemConsumed = xmodemManager.handleData(pid, chunk, term, ws) - if (xmodemConsumed) { - return - } - ws.send(chunk) - return - } - - dataBuffer.push(chunk) - - // Idle fast path: if nothing has been flushed within the coalescing - // window, this is the start of a new burst (or a lone interactive - // echo) rather than a continuation of a flood - send it right away - // instead of paying the fixed delay. Only chunks arriving while a - // burst is already in flight (elapsed < flushIntervalMs) get batched. - const elapsed = Date.now() - lastFlushTime - if (elapsed >= flushIntervalMs) { - // Never fast-flush a buffer that ends mid-way through a multi-byte - // UTF-8 char: a slow peer (router CLI) may deliver one char split - // across TCP segments, and the remaining bytes usually land within a - // few ms. Hold one coalescing window so they get concatenated first - // (the completing chunk then flushes immediately via this same fast - // path). Bounded by the timeout, so it can not stick. - if (hasIncompleteTrailingUtf8(dataBuffer)) { - if (!sendTimeout) { - sendTimeout = setTimeout(flushBufferedData, flushIntervalMs) - } - return - } - if (sendTimeout) { - clearTimeout(sendTimeout) - sendTimeout = null - } - flushBufferedData() - return - } - - // If no timeout is pending, schedule a batched send - if (!sendTimeout) { - sendTimeout = setTimeout(flushBufferedData, flushIntervalMs - elapsed) - } - }) - - if (term.port) { - term.port.on('data', function (rawData) { - if (xmodemManager.isActive(pid)) { - term.writeLog(rawData) - xmodemManager.handleData(pid, rawData, term, ws) - } - }) - } - - let onCloseCalled = false - function onClose () { - if (onCloseCalled) return - onCloseCalled = true - if (sendTimeout) { - clearTimeout(sendTimeout) - sendTimeout = null - } - dataBuffer.length = 0 - zmodemManager.destroySession(pid) - trzszManager.destroySession(pid) - xmodemManager.destroySession(pid) - term.kill() - log.debug('Closed terminal ' + pid) - ws.close && ws.close() - cleanup() - } - - term.on('close', onClose) - - ws.on('message', function (msg) { - try { - if (typeof msg === 'string') { - try { - const parsed = JSON.parse(msg) - if (parsed.action === 'zmodem-event') { - zmodemManager.handleMessage(pid, parsed, term, ws) - return - } - if (parsed.action === 'trzsz-event') { - trzszManager.handleMessage(pid, parsed, term, ws) - return - } - if (parsed.action === 'xmodem-event') { - xmodemManager.handleMessage(pid, parsed, term, ws) - return - } - if (parsed.action === 'keepalive') { - term.write('\n\r\x1b[K') - return - } - } catch (e) { - // Not JSON, treat as regular terminal input - } - } - // Let an active zmodem session observe Ctrl-C (transfer abort); - // the keystroke itself is still written through untouched. - zmodemManager.handleUserInput(pid, msg) - term.write(msg) - } catch (ex) { - log.error(ex) - } - }) - - ws.on('error', (err) => { - log.error(err) - }) - - ws.on('close', onClose) - }) - - // sftp function - app.ws('/sftp/:id', (ws, req) => { - verify(req) - wsDec(ws) - const { id } = req.params - ws.on('close', () => { - onDestroySftp(id) - }) - ws.on('message', (message) => { - const msg = JSON.parse(message) - const { action } = msg - - if (action === 'sftp-new') { - const { id, terminalId, type } = msg - const Cls = type === 'ftp' ? Ftp : Sftp - sftp(id, new Cls({ - uid: id, - terminalId, - type - })) - } else if (action === 'sftp-func') { - const { id, args, func, uid } = msg - const inst = sftp(id) - if (inst) { - if (!instSftpKeys.includes(func) || typeof inst[func] !== 'function') { - ws.s({ - id: uid, - error: { - message: 'invalid sftp function: ' + func, - stack: '' - } - }) - return - } - inst[func](...args) - .then(data => { - ws.s({ - id: uid, - data - }) - }) - .catch(err => { - ws.s({ - id: uid, - error: { - message: err.message, - stack: err.stack - } - }) - }) - } - } else if (action === 'sftp-destroy') { - const { id } = msg - ws.close() - onDestroySftp(id) - } - }) - }) - - // transfer function - app.ws('/transfer/:id', (ws, req) => { - verify(req) - wsDec(ws) - const { id } = req.params - const { sftpId } = req.query - - ws.on('close', () => { - onDestroyTransfer(id, sftpId) - }) - - ws.on('message', (message) => { - const msg = JSON.parse(message) - const { action } = msg - - if (action === 'transfer-new') { - const { sftpId, id, isFtp } = msg - const session = sftp(sftpId) - const encode = session.initOptions?.encode || 'utf8' - const opts = Object.assign({}, msg, { - sftp: session.sftp, - conn: session.client, - ftpSession: isFtp ? session : null, - sftpId, - ws, - encode - }) - const Cls = isFtp ? FtpTransfer : Transfer - transfer(id, sftpId, new Cls(opts)) - } else if (action === 'transfer-func') { - const { id, func, args, sftpId } = msg - if (func === 'destroy') { - return onDestroyTransfer(id, sftpId) - } - if (!transferKeys.includes(func)) { - return - } - const tr = transfer(id, sftpId) - if (!tr || typeof tr[func] !== 'function') { - return - } - tr[func](...args) - } - }) - }) - } - - // --- Message handler (replaces process.on('message')) --- - channel.on('to-child', async (message) => { - if (message.type === 'common') { - const msg = message.data - const { action, id, body } = msg - - let promise - - // ws mock: s() sends to parent, once() waits for parent response - const ws = { - s: (data) => { - channel.toParent({ type: 'common', data }) - }, - once: (callack, msgId) => { - const func = (arg) => { - if (msgId === arg.id) { - callack(arg) - channel.removeListener('to-child', func) - } - } - channel.on('to-child', func) - } - } - - if (action === 'create-terminal') { - promise = createTerm(body, ws) - } else if (action === 'test-terminal') { - promise = testTerm(body, ws) - } else if (action === 'resize-terminal') { - promise = resize(body) - } else if (action === 'toggle-terminal-log') { - promise = toggleTerminalLog(body) - } else if (action === 'toggle-terminal-log-timestamp') { - promise = toggleTerminalLogTimestamp(body) - } else if (action === 'set-terminal-log-path') { - promise = setTerminalLogPath(body) - } else if (action === 'start-terminal-log-file') { - promise = startTerminalLogFile(body) - } else if (action === 'run-cmd') { - promise = runCmd(body) - } else if (action === 'exec-cmd') { - promise = execCmd(body) - } - - const result = await promise - .then(r => { - return { - id, - data: r - } - }) - .catch(err => { - log.error('common message error', err) - return { - id, - error: { - message: err.message, - stack: err.stack - } - } - }) - - channel.toParent(result) - } - }) - - // --- Server lifecycle --- - let httpServer = null - let cleanupCalled = false - - function cleanup () { - if (cleanupCalled) return - cleanupCalled = true - if (noConnectionTimer) { - clearTimeout(noConnectionTimer) - } - if (httpServer) { - try { httpServer.close() } catch {} - } - channel.emit('exit', 0) - } - - // Start listening - httpServer = app.listen(wsPort, electermHost, () => { - log.info('session server', 'runs on', electermHost, wsPort) - channel.toParent({ serverInited: true }) - channel.emit('ready') - }) - - // Self-terminate if no WebSocket connects within 2 minutes - const noConnectionTimer = setTimeout(() => { - if (!firstWsConnected) { - log.warn('session-server: no WS connection within 2min timeout, terminating') - cleanup() - } - }, 120000) - if (noConnectionTimer.unref) noConnectionTimer.unref() - - const pid = _pidCounter++ - - return { - channel, - kill: cleanup, - pid, - port: wsPort, - server: httpServer - } -} - -module.exports = { createSessionServer } diff --git a/src/app/server/session-sftp.js b/src/app/server/session-sftp.js deleted file mode 100644 index a6bd5bb..0000000 --- a/src/app/server/session-sftp.js +++ /dev/null @@ -1,634 +0,0 @@ -/** - * terminal/sftp/serial class - */ -const { - readRemoteFile, - writeRemoteFile -} = require('./sftp-file') -const { commonExtends } = require('./session-common.js') -const { TerminalBase } = require('./session-base.js') -const { - getSizeCount, - getSizeCountWin -} = require('../common/get-folder-size-and-file-count.js') -const globalState = require('./global-state') - -class Sftp extends TerminalBase { - connect (initOptions) { - return this.remoteInitSftp(initOptions) - } - - applySshFsOverride = (sshFs) => { - sshFs.isSshFsFallback = true - this.sftp = sshFs - this.isSshFsFallback = true - const proto = Object.getPrototypeOf(sshFs) - const keys = Object.getOwnPropertyNames(proto) - for (const method of keys) { - if (method === 'constructor') { - continue - } - if (typeof sshFs[method] === 'function') { - this[method] = sshFs[method].bind(sshFs) - } - } - } - - initSshFsFallback = (conn) => { - const { SshFs } = require('ssh2-scp') - const opts = {} - const encode = this.initOptions?.encode || 'utf8' - if (encode !== 'utf8') { - opts.encoding = encode - opts.iconv = require('iconv-lite') - } - const sshFs = new SshFs(conn, opts) - this.applySshFsOverride(sshFs) - } - - async remoteInitSftp (initOptions) { - this.initOptions = initOptions - this.transfers = {} - const terminalInst = globalState.getSession(initOptions.terminalId) - const { - conn - } = terminalInst - this.client = conn - this.enableSsh = initOptions.enableSsh - try { - const sftp = await new Promise((resolve, reject) => { - conn.sftp((err, sftp) => { - if (err) { - return reject(err) - } - resolve(sftp) - }) - }) - this.sftp = sftp - } catch (err) { - this.initSshFsFallback(conn) - } - - globalState.setSession(this.pid, this) - return 'ok' - } - - kill () { - const keys = Object.keys(this.transfers || {}) - for (const k of keys) { - const jj = this.transfers[k] - jj && jj.destroy && jj.destroy() - delete this.transfers[k] - } - this.sftp && this.sftp.end && this.sftp.end() - delete this.sftp - delete this.initOptions - this.onEndConn() - } - - escapePosixPath = (value) => { - return `"${String(value).replace(/["\\$`]/g, '\\$&')}"` - } - - escapePowerShellPath = (value) => { - return `'${String(value).replace(/'/g, "''")}'` - } - - normalizeWindowsExecPath = (value) => { - return String(value).replace(/^\/([a-zA-Z]:)/, '$1') - } - - buildPowerShellCommand = (script) => { - return `powershell.exe -NoLogo -NonInteractive -NoProfile -Command "${script}"` - } - - execBuffered (cmd) { - return new Promise((resolve, reject) => { - if (!this.enableSsh) { - return reject(new Error(`do not support ${cmd.split(' ')[0]} operation in sftp mode`)) - } - const { client } = this - client.exec(cmd, this.getExecOpts(), (err, stream) => { - if (err) { - return reject(err) - } - let stdout = Buffer.from('') - let stderr = Buffer.from('') - let settled = false - const settle = (result) => { - if (settled) { - return - } - settled = true - resolve(result) - } - stream.on('close', (code) => { - settle({ - code, - stdout: stdout.toString(), - stderr: stderr.toString() - }) - }).on('end', () => { - settle({ - code: 0, - stdout: stdout.toString(), - stderr: stderr.toString() - }) - }).on('data', (data) => { - stdout = Buffer.concat([stdout, data]) - }) - stream.stderr.on('data', (data) => { - stderr = Buffer.concat([stderr, data]) - }) - }) - }) - } - - async getRemoteExecPlatform () { - if (this.remoteExecPlatform) { - return this.remoteExecPlatform - } - if (!this.remoteExecPlatformPromise) { - this.remoteExecPlatformPromise = this.execBuffered('cmd.exe /d /s /c ver') - .then(({ code, stdout, stderr }) => { - const output = `${stdout}\n${stderr}`.toLowerCase() - return code === 0 && output.includes('windows') - ? 'windows' - : 'posix' - }) - .catch(() => 'posix') - .then((platform) => { - this.remoteExecPlatform = platform - return platform - }) - } - return this.remoteExecPlatformPromise - } - - async buildRemoteCommand (type, ...paths) { - const platform = await this.getRemoteExecPlatform() - if (platform === 'windows') { - const args = paths - .map(this.normalizeWindowsExecPath) - .map(this.escapePowerShellPath) - if (type === 'rmrf') { - return this.buildPowerShellCommand(`Remove-Item -LiteralPath ${args[0]} -Force -Recurse`) - } - if (type === 'cp') { - return this.buildPowerShellCommand(`Copy-Item -LiteralPath ${args[0]} -Destination ${args[1]} -Recurse -Force`) - } - if (type === 'mv') { - return this.buildPowerShellCommand(`Move-Item -LiteralPath ${args[0]} -Destination ${args[1]} -Force`) - } - if (type === 'folder-size') { - return this.buildPowerShellCommand(`Get-ChildItem -LiteralPath ${args[0]} -Recurse -File | Measure-Object -Property Length -Sum`) - } - } - const posixArgs = paths.map(this.escapePosixPath) - if (type === 'rmrf') { - return `rm -rf ${posixArgs[0]}` - } - if (type === 'cp') { - return `cp -r ${posixArgs[0]} ${posixArgs[1]}` - } - if (type === 'mv') { - return `mv ${posixArgs[0]} ${posixArgs[1]}` - } - if (type === 'folder-size') { - return `du -sh ${posixArgs[0]} && find ${posixArgs[0]} -type f | wc -l` - } - throw new Error(`unsupported remote command type: ${type}`) - } - - /** - * getHomeDir - * - * https://github.com/mscdex/ssh2/blob/master/SFTP.md - * only support linux / mac - * @return {Promise} - */ - getHomeDir () { - // return this.runCmd('eval echo "~$different_user"') - // ext_home_dir - return this.realpath('') - } - - // getSftpHomeDir () { - // // return this.runCmd('eval echo "~$different_user"') - // // ext_home_dir - // return new Promise((resolve, reject) => { - // this.sftp.ext_home_dir('', (err, path) => { - // if (err) { - // return reject(err) - // } - // resolve(path) - // }) - // }) - // } - - /** - * rmdir - * - * @param {String} remotePath - * https://github.com/mscdex/ssh2/blob/master/SFTP.md - * only support rm -rf - * @return {Promise} - */ - rmdir (remotePath) { - return this.rmrf(remotePath) - .then(r => { - return r - }) - .catch(err => { - console.error('rm -rf dir error', err) - return this.removeDirectoryRecursively(remotePath) - }) - } - - rmrf (remotePath) { - return this.buildRemoteCommand('rmrf', remotePath) - .then(cmd => this.runExec(cmd)) - // return new Promise((resolve, reject) => { - // const { client } = this - // const cmd = `rm -rf "${remotePath}"` - // this.runExec(cmd, this.getExecOpts(), (err, stream) => { - // if (err) { - // return reject(err) - // } else { - // console.log('rm -rf done', stream) - // resolve(1) - // } - // }) - // }) - } - - async removeDirectoryRecursively (remotePath) { - const contents = await this.list(remotePath) - for (const item of contents) { - const itemPath = `${remotePath}/${item.name}` - if (item.type === 'd') { - // Recursively delete subdirectories - await this.removeDirectoryRecursively(itemPath) - } else { - // Delete files - await this.rm(itemPath) - } - } - // Finally, remove the directory itself - await this.rmFolder(remotePath) - } - - /** - * touch a file - * - * @param {String} remotePath - * https://github.com/mscdex/ssh2/blob/master/SFTP.md - * @return {Promise} - */ - touch (remotePath) { - // if (this.enableSsh) { - // return new Promise((resolve, reject) => { - // const { client } = this - // const cmd = `touch "${remotePath}"` - // client.exec(cmd, this.getExecOpts(), err => { - // if (err) reject(err) - // else resolve(1) - // }) - // }) - // } - return this.touchFile(remotePath) - } - - openFile = (remotePath) => { - return new Promise((resolve, reject) => { - this.sftp.open(remotePath, 'w', (err, fd) => { - if (err) { - return reject(err) - } - resolve(fd) - }) - }) - } - - closeFile = (fd) => { - return new Promise((resolve, reject) => { - this.sftp.close(fd, err => { - if (err) { - return reject(err) - } - resolve(true) - }) - }) - } - - touchFile = (remotePath) => { - return this.openFile(remotePath) - .then(this.closeFile) - } - - /** - * cp - * - * @param {String} from - * @param {String} to - * https://github.com/mscdex/ssh2/blob/master/SFTP.md - * @return {Promise} - */ - cp (from, to) { - return this.buildRemoteCommand('cp', from, to) - .then(cmd => this.runExec(cmd)) - .then(() => 1) - } - - /** - * mv - * - * @param {String} from - * @param {String} to - * https://github.com/mscdex/ssh2/blob/master/SFTP.md - * @return {Promise} - */ - mv (from, to) { - return this.buildRemoteCommand('mv', from, to) - .then(cmd => this.runExec(cmd)) - .then(() => 1) - } - - runExec (cmd) { - return this.execBuffered(cmd) - .then(({ code, stdout, stderr }) => { - if (stderr) { - throw new Error(stderr.trim()) - } - if (typeof code === 'number' && code !== 0) { - throw new Error(stdout.trim() || `Command exited with code ${code}`) - } - return stdout - }) - } - - async getFolderSize (folderPath) { - const platform = await this.getRemoteExecPlatform() - const cmd = await this.buildRemoteCommand('folder-size', folderPath) - const output = await this.runExec(cmd) - return platform === 'windows' - ? getSizeCountWin(output) - : getSizeCount(output) - } - - /** - * list remote directory - * - * @param {String} remotePath - * @return {Promise} list - */ - list (remotePath) { - return new Promise((resolve, reject) => { - const { sftp } = this - const reg = /-/g - - sftp.readdir(remotePath, (err, list) => { - if (err) { - return reject(err) - } - resolve(list.map(item => { - const { - filename, - longname, - attrs: { - size, mtime, atime, uid, gid, mode - } - } = item - // from https://github.com/jyu213/ssh2-sftp-client/blob/master/src/index.js - return { - type: longname.substr(0, 1), - name: filename, - size, - modifyTime: mtime * 1000, - accessTime: atime * 1000, - mode, - rights: { - user: longname.substr(1, 3).replace(reg, ''), - group: longname.substr(4, 3).replace(reg, ''), - other: longname.substr(7, 3).replace(reg, '') - }, - owner: uid, - group: gid - } - })) - }) - }) - } - - /** - * mkdir - * - * @param {String} remotePath - * @param {Object} attributes - * An object with the following valid properties: - - mode - integer - Mode/permissions for the resource. - uid - integer - User ID of the resource. - gid - integer - Group ID of the resource. - size - integer - Resource size in bytes. - atime - integer - UNIX timestamp of the access time of the resource. - mtime - integer - UNIX timestamp of the modified time of the resource. - - When supplying an ATTRS object to one of the SFTP methods: - atime and mtime can be either a Date instance or a UNIX timestamp. - mode can either be an integer or a string containing an octal number. - * https://github.com/mscdex/ssh2/blob/master/SFTP.md - * @return {Promise} - */ - mkdir (remotePath, options = {}) { - return new Promise((resolve, reject) => { - const { sftp } = this - sftp.mkdir(remotePath, options, err => { - if (err) reject(err) - else resolve(1) - }) - }) - } - - /** - * stat - * - * @param {String} remotePath - * https://github.com/mscdex/ssh2/blob/master/SFTP.md - * @return {Promise} stat - * stats.isDirectory() - stats.isFile() - stats.isBlockDevice() - stats.isCharacterDevice() - stats.isSymbolicLink() - stats.isFIFO() - stats.isSocket() - */ - stat (remotePath) { - return new Promise((resolve, reject) => { - const { sftp } = this - sftp.stat(remotePath, (err, stat) => { - if (err) reject(err) - else { - resolve( - Object.assign(stat, { - isDirectory: stat.isDirectory() - }) - ) - } - }) - }) - } - - /** - * readlink - * - * @param {String} remotePath - * https://github.com/mscdex/ssh2/blob/master/SFTP.md - * @return {Promise} target - */ - readlink (remotePath) { - return new Promise((resolve, reject) => { - const { sftp } = this - sftp.readlink(remotePath, (err, target) => { - if (err) reject(err) - else resolve(target) - }) - }) - } - - /** - * realpath - * - * @param {String} remotePath - * https://github.com/mscdex/ssh2/blob/master/SFTP.md - * @return {Promise} target - */ - realpath (remotePath) { - return new Promise((resolve, reject) => { - const { sftp } = this - sftp.realpath(remotePath, (err, target) => { - if (err) reject(err) - else resolve(target) - }) - }) - } - - /** - * lstat - * - * @param {String} remotePath - * https://github.com/mscdex/ssh2/blob/master/SFTP.md - * @return {Promise} stat - * stats.isDirectory() - stats.isFile() - stats.isBlockDevice() - stats.isCharacterDevice() - stats.isSymbolicLink() - stats.isFIFO() - stats.isSocket() - */ - lstat (remotePath) { - return new Promise((resolve, reject) => { - const { sftp } = this - sftp.lstat(remotePath, (err, stat) => { - if (err) reject(err) - else resolve(stat) - }) - }) - } - - /** - * chmod - * - * @param {String} remotePath - * https://github.com/mscdex/ssh2/blob/master/SFTP.md - * @return {Promise} - */ - chmod (remotePath, mode) { - return new Promise((resolve, reject) => { - const { sftp } = this - sftp.chmod(remotePath, mode, (err) => { - if (err) reject(err) - else resolve(1) - }) - }) - } - - /** - * rename - * - * @param {String} remotePath - * @param {String} remotePathNew - * https://github.com/mscdex/ssh2/blob/master/SFTP.md - * @return {Promise} - */ - rename (remotePath, remotePathNew) { - return new Promise((resolve, reject) => { - const { sftp } = this - sftp.rename(remotePath, remotePathNew, (err) => { - if (err) reject(err) - else resolve(1) - }) - }) - } - - /** - * rm delete single file - * - * @param {String} remotePath - * https://github.com/mscdex/ssh2/blob/master/SFTP.md - * @return {Promise} - */ - rmFolder (remotePath) { - return new Promise((resolve, reject) => { - const { sftp } = this - sftp.rmdir(remotePath, (err) => { - if (err) reject(err) - else resolve(1) - }) - }) - } - - /** - * rm delete single file - * - * @param {String} remotePath - * https://github.com/mscdex/ssh2/blob/master/SFTP.md - * @return {Promise} - */ - rm (remotePath) { - return new Promise((resolve, reject) => { - const { sftp } = this - sftp.unlink(remotePath, (err) => { - if (err) reject(err) - else resolve(1) - }) - }) - } - - /** - * readFile single file - * - * @param {String} remotePath - * https://github.com/mscdex/ssh2/blob/master/SFTP.md - * @return {Promise} - */ - readFile (remotePath) { - return readRemoteFile(this.sftp, remotePath) - } - - /** - * writeFile single file - * - * @param {String} remotePath - * https://github.com/mscdex/ssh2/blob/master/SFTP.md - * @return {Promise} - */ - writeFile (remotePath, str, mode) { - return writeRemoteFile(this.sftp, remotePath, str, mode) - } - // end -} - -exports.Sftp = commonExtends(Sftp) diff --git a/src/app/server/session-spice.js b/src/app/server/session-spice.js deleted file mode 100644 index 6bf2ee6..0000000 --- a/src/app/server/session-spice.js +++ /dev/null @@ -1,129 +0,0 @@ -const log = require('../common/log') -const { TerminalBase } = require('./session-base') -const globalState = require('./global-state') -const { handleConnection } = require('./spice-proxy') - -class TerminalSpice extends TerminalBase { - channelCounter = 0 - - init = async () => { - this.wsMap = new Map() - globalState.setSession(this.pid, this) - return Promise.resolve(this) - } - - start = async (query = {}, ws) => { - if (!ws) { - log.error(`[SPICE:${this.pid}] No WebSocket provided`) - return - } - - const { - host, - port = 5900, - proxy, - readyTimeout = 10000 - } = this.initOptions - - this.channelCounter++ - const connId = `${this.channelCounter}` - this.wsMap.set(connId, ws) - - log.debug(`[SPICE:${this.pid}] Starting SPICE channel #${connId} to ${host}:${port}, total channels: ${this.wsMap.size}`) - - const cleanup = () => { - this.wsMap.delete(connId) - log.debug(`[SPICE:${this.pid}] Channel #${connId} closed, remaining: ${this.wsMap.size}`) - if (this.wsMap.size === 0) { - this.kill() - } - } - - handleConnection(ws, { - host, - port, - proxy, - readyTimeout, - onCleanup: cleanup, - channelId: `#${connId}` - }) - } - - resize = () => { - } - - test = async () => { - const net = require('net') - const proxySock = require('./socks') - const { - host, - port = 5900, - proxy, - readyTimeout = 10000 - } = this.initOptions - - if (proxy) { - const proxyResult = await proxySock({ - readyTimeout, - host, - port, - proxy - }) - const socket = proxyResult.socket - socket.destroy() - return true - } - - return new Promise((resolve, reject) => { - const socket = net.createConnection({ host, port }, () => { - socket.destroy() - resolve(true) - }) - socket.on('error', (err) => { - reject(err) - }) - socket.setTimeout(readyTimeout, () => { - socket.destroy() - reject(new Error('Connection timed out')) - }) - }) - } - - kill = () => { - log.debug('Closed SPICE session ' + this.pid + ', remaining connections: ' + this.wsMap.size) - for (const ws of this.wsMap.values()) { - try { - ws.close() - } catch (e) { - log.debug(`[SPICE:${this.pid}] ws.close() error:`, e.message) - } - } - this.wsMap.clear() - if (this.sessionLogger) { - this.sessionLogger.destroy() - } - const { pid } = this - const inst = globalState.getSession(pid) - if (!inst) { - return - } - globalState.removeSession(pid) - } -} - -exports.session = async function (initOptions, ws) { - const term = new TerminalSpice(initOptions, ws) - await term.init() - return term -} - -exports.test = (options) => { - return (new TerminalSpice(options, undefined, true)) - .test() - .then(() => { - return true - }) - .catch(() => { - return false - }) -} diff --git a/src/app/server/session-ssh.js b/src/app/server/session-ssh.js deleted file mode 100644 index d18a33a..0000000 --- a/src/app/server/session-ssh.js +++ /dev/null @@ -1,1086 +0,0 @@ -/** - * terminal/sftp/serial class - */ - -const proxySock = require('./socks') -const _ = require('../lib/lodash.js') -const generate = require('../common/uid') -const { resolve: pathResolve } = require('path') -const net = require('net') -const { exec } = require('child_process') -const log = require('../common/log') -const { algDefault, algAlt } = require('./ssh2-alg') -const { createHostVerifier } = require('./ssh-known-hosts') -const { maybeProxyCommand } = require('./ssh-proxy-command') -const sshTunnelFuncs = require('./ssh-tunnel') -const deepCopy = require('json-deep-copy') -const { TerminalBase } = require('./session-base') -const { commonExtends } = require('./session-common') -const globalState = require('./global-state') -const iconv = require('iconv-lite') -const os = require('os') - -// Encodings that are equivalent to UTF-8 (no conversion needed) -const utf8Aliases = new Set(['utf-8', 'utf8', 'utf-8-strict']) - -const failMsg = 'All configured authentication methods failed' -const csFailMsg = 'no matching C->S cipher' - -class TerminalSshBase extends TerminalBase { - async remoteInitProcess () { - this.adjustConnectionOrder() - const { - initOptions - } = this - const hasX11 = initOptions.x11 === true - this.display = hasX11 ? await this.getDisplay() : undefined - this.x11Cookie = hasX11 ? await this.getX11Cookie() : undefined - return this.sshConnect() - } - - reTryAltAlg () { - log.log('retry with default ciphers/server hosts') - this.doKill() - this.connectOptions.algorithms = algAlt() - this.altAlg = true - return this.sshConnect() - } - - getShellWindow (initOptions = this.initOptions) { - return _.pick(initOptions, [ - 'rows', 'cols', 'term' - ]) - } - - getAgent () { - const { initOptions } = this - return initOptions.useSshAgent !== false ? (initOptions.sshAgent || process.env.SSH_AUTH_SOCK) : undefined - } - - getAuthOrder (connectOptions) { - const authOrder = ['none'] - if (connectOptions.password !== undefined) { - authOrder.push('password') - } - if (connectOptions.privateKey !== undefined) { - authOrder.push('publickey') - } - if (connectOptions.agent !== undefined) { - authOrder.push('agent') - } - if (connectOptions.tryKeyboard) { - authOrder.push('keyboard-interactive') - } - if ( - connectOptions.privateKey !== undefined && - connectOptions.localHostname !== undefined && - connectOptions.localUsername !== undefined - ) { - authOrder.push('hostbased') - } - return authOrder - } - - createAuthHandler (connectOptions) { - const authOrder = this.getAuthOrder(connectOptions) - let attemptedMethods = new Set() - - const isMethodAllowed = (type, allowedSet) => { - if (type === 'agent') { - return allowedSet.has('agent') || allowedSet.has('publickey') - } - return allowedSet.has(type) - } - - return (authsLeft, partialSuccess) => { - if (partialSuccess) { - this.authPartiallySucceeded = true - attemptedMethods = new Set() - } - - const allowedMethods = Array.isArray(authsLeft) && authsLeft.length - ? authsLeft - : authOrder - const allowedSet = new Set(allowedMethods) - const nextAuth = authOrder.find(type => { - return isMethodAllowed(type, allowedSet) && (partialSuccess || !attemptedMethods.has(type)) - }) - - if (!nextAuth) { - return false - } - - attemptedMethods.add(nextAuth) - return nextAuth - } - } - - adjustConnectionOrder () { - const { initOptions } = this - if (!initOptions.hasHopping || !initOptions.connectionHoppings || initOptions.connectionHoppings.length === 0) { - return - } - - const currentHostHopping = { - host: initOptions.host, - port: initOptions.port, - username: initOptions.username, - password: initOptions.password, - privateKey: initOptions.privateKey, - passphrase: initOptions.passphrase - } - - const [firstHopping, ...restHoppings] = initOptions.connectionHoppings - const pickProps = _.pick(firstHopping, [ - 'host', 'port', 'username', 'password', 'privateKey', 'passphrase', 'certificate' - ]) - Object.assign(initOptions, pickProps) - initOptions.connectionHoppings = [...restHoppings, currentHostHopping] - } - - isLikely2FAPrompts (prompts) { - if (!prompts || !prompts.length) return false - const defaultKeywords = [ - 'verification code', - 'otp', - 'one-time', - 'two-factor', - '2fa', - 'totp', - 'authenticator', - 'duo', - 'yubikey', - 'security code', - 'mfa', - 'passcode' - ] - const rawKeywords = this.initOptions?.keyword2FA - const twofaKeywords = Array.isArray(rawKeywords) - ? rawKeywords - : typeof rawKeywords === 'string' - ? rawKeywords.split(/[,\n]/).map(s => s.trim()).filter(Boolean) - : [] - const finalKeywords = twofaKeywords.length - ? twofaKeywords.map(s => s.toLowerCase()) - : defaultKeywords - return prompts.some(p => { - const text = (p.prompt || '').toLowerCase() - return finalKeywords.some(kw => text.includes(kw)) - }) - } - - onKeyboardEvent (options, passwordOverride) { - if (options?.mode !== 'confirm' && this.initOptions?.interactiveValues) { - return Promise.resolve(this.initOptions.interactiveValues.split('\n')) - } - // Auto-fill password prompt if we have a saved password - // passwordOverride is used during SSH connection hopping, where - // this.initOptions.password is the jump host's password (after - // adjustConnectionOrder swaps the options), not the target's. - // The caller passes connectOptions.password which is correct for - // the current connection being established. - const { prompts } = options - const savedPassword = passwordOverride !== undefined - ? passwordOverride - : this.initOptions?.password - if (prompts && prompts.length === 1 && savedPassword) { - const prompt = prompts[0] - const promptText = (prompt.prompt || '').toLowerCase() - // Check if this is a password prompt (hidden input, contains "password" or is empty) - if (!prompt.echo && (promptText.includes('password') || promptText === '')) { - return Promise.resolve([savedPassword]) - } - } - - const id = generate() - this.ws?.s({ - id, - action: 'session-interactive', - ..._.pick(this.initOptions, [ - 'interactiveValues', - 'tabId' - ]), - options - }) - return new Promise((resolve, reject) => { - this.ws?.once((arg) => { - const { results } = arg - if (_.isEmpty(results)) { - return reject(new Error('User cancel')) - } - resolve(results) - }, id) - }) - } - - async getPrivateKeysInJumpServer (conn) { - const r = await this.runCmd('ls ~/.ssh', conn) - .catch(err => { - log.error(err) - }) - return r - ? r.split('\n') - .filter(d => d.endsWith('.pub')) - .map(d => `~/.ssh/${d}`.replace('.pub', '')) - : [] - } - - catPrivateKeyInJumpServer (conn, filePath) { - return this.runCmd(`cat ${filePath}`, conn) - } - - async readPrivateKeyInJumpServer (conn) { - const { hoppingOptions } = this - if (this.jumpSshKeys) { - if (this.jumpSshKeys.length > 0) { - const p = this.jumpSshKeys.shift() - this.jumpPrivateKeyPathFrom = p - hoppingOptions.privateKey = await this.catPrivateKeyInJumpServer(conn, p) - } else if (this.jumpSshKeys.length === 0) { - delete hoppingOptions.privateKey - delete this.jumpSshKeys - hoppingOptions.sshKeysDrain = true - } - return - } - if (hoppingOptions.sshKeysDrain || hoppingOptions.password || hoppingOptions.privateKey) { - return null - } - const list = await this.getPrivateKeysInJumpServer(conn) - if (list.length) { - const p = list.shift() - this.jumpPrivateKeyPathFrom = p - hoppingOptions.privateKey = await this.catPrivateKeyInJumpServer(conn, p) - this.jumpSshKeys = list - } else { - // No private keys found in jump server, mark as drained so we can prompt for password - hoppingOptions.sshKeysDrain = true - } - } - - handleKeyboardEventForRetryJump (options) { - return this.onKeyboardEvent(options) - .then(data => { - if (data && data[0]) { - this.hoppingOptions.passphrase = data[0] - this.jumpSshKeys && this.jumpSshKeys.unshift(this.jumpPrivateKeyPathFrom) - } - return this.jumpConnect(true, true) - }) - .catch(e => { - log.error('errored get passphrase for', this.jumpHostFrom, this.jumpPrivateKeyPathFrom, e) - return this.jumpConnect(true, false) - }) - } - - async retryJump () { - const next = await this.doSshConnect( - undefined, - this.nextConn, - this.hoppingOptions, - !this.isLast - ) - .then(() => { - this.jumpHostFrom = this.initHoppingOptions.host - this.jumpPortFrom = this.initHoppingOptions.port - return this.nextConn - }) - .catch(err => err) - - const isError = next instanceof Error - if (!isError) { - return next - } - const err = next - log.error('error when do jump connect', this.nextHost, this.nextPort) - if (err.message.includes('passphrase')) { - const options = { - name: `passphase for ${this.jumpHostFrom}/${this.jumpPrivateKeyPathFrom}`, - instructions: [''], - prompts: [{ - echo: false, - prompt: 'passphase' - }] - } - return this.handleKeyboardEventForRetryJump(options) - } else if ( - !this.jumpSshKeys && - !this.hoppingOptions.sshKeysDrain && - !this.hoppingOptions.password && - !this.hoppingOptions.privateKey && - err.message.includes(failMsg) - ) { - // SSH agent failed or no agent, try reading private keys from jump server - // This will read ~/.ssh keys and retry - return this.jumpConnect(true, false) - } else if ( - this.hoppingOptions.sshKeysDrain && - !this.hoppingOptions.password && - err.message.includes(failMsg) - ) { - // All private keys exhausted, ask for password - const options = { - name: `password for ${this.hoppingOptions.username}@${this.initHoppingOptions.host}`, - instructions: [''], - prompts: [{ - echo: false, - prompt: 'password' - }] - } - return this.onKeyboardEvent(options) - .then(data => { - if (data && data[0]) { - this.hoppingOptions.password = data[0] - return this.jumpConnect(true, true) - } else if (data && data[0] === '') { - throw err - } - }) - .catch(err => { - log.error('errored get password for', err) - throw err - }) - } else if ( - this.jumpSshKeys - ) { - return this.jumpConnect(true, false) - } else { - throw err - } - } - - async jumpConnect (reBuildSock = false, skipReadKeys = false) { - if (reBuildSock) { - this.hoppingOptions.sock.end() - this.hoppingOptions.sock = await this.forwardOut(this.conn, this.initHoppingOptions) - } - // Only read private keys if skipReadKeys is false - // On first connect, we skip reading keys to let SSH agent try first - // If SSH agent fails, we then read and try private keys - if (!skipReadKeys) { - await this.readPrivateKeyInJumpServer(this.conn) - } - return this.retryJump() - } - - forwardOut (conn, hopping) { - return new Promise((resolve, reject) => { - conn.forwardOut('127.0.0.1', 0, hopping.host, hopping.port, async (err, stream) => { - if (err) { - log.error(`forwardOut to ${hopping.host}:${hopping.port} error: ` + err) - this.endConns() - return reject(err) - } - resolve(stream) - }) - }) - } - - async jump () { - const sock = await this.forwardOut(this.conn, this.initHoppingOptions) - const hopping = deepCopy(this.initHoppingOptions) - delete hopping.host - delete hopping.port - this.nextHost = hopping.host - this.nextPort = hopping.port - this.hoppingOptions = { - sock, - ...hopping - } - const { Client } = require('@electerm/ssh2') - this.nextConn = new Client() - // If we have an agent and no explicit privateKey/password, try agent first - // by skipping reading private keys from jump server - const hasAgent = !!this.hoppingOptions.agent - const hasExplicitAuth = this.hoppingOptions.password || this.hoppingOptions.privateKey - const skipReadKeys = hasAgent && !hasExplicitAuth - await this.jumpConnect(false, skipReadKeys) - return this.nextConn - } - - async hopping (connectionHoppings) { - this.conns = [] - this.jumpHostFrom = this.initOptions.host - this.jumpPortFrom = this.initOptions.port - const len = connectionHoppings.length - for (let i = 0; i < len; i++) { - const hopping = connectionHoppings[i] - this.conns.push(this.conn) - this.initHoppingOptions = { - ...hopping, - agent: this.getAgent(), - ...this.getShareOptions() - } - this.isLast = i === len - 1 - const conn = await this.jump() - if (conn) { - this.conn = conn - } - } - } - - endConns () { - this.conn && this.conn.end && this.conn.end() - while (this.conns && this.conns.length) { - const conn = this.conns.shift() - conn && conn.end() - } - } - - async runTunnel (sshTunnel) { - return sshTunnelFuncs[sshTunnel.sshTunnel]({ - ...sshTunnel, - conn: this.conn - }) - .then(r => { - return { - sshTunnel - } - }) - .catch(err => { - log.error('error when do sshTunnel', err) - return { - error: err.message, - sshTunnel - } - }) - } - - async onInitSshReady () { - const { - initOptions, - isTest, - shellOpts, - shellWindow - } = this - if ( - initOptions.connectionHoppings?.length - ) { - await this.hopping(initOptions.connectionHoppings) - } - if (isTest) { - this.endConns() - return - } else if (initOptions.enableSsh === false) { - globalState.setSession(this.pid, this) - return this - } - const { sshTunnels = [] } = initOptions - const sshTunnelResults = [] - for (const sshTunnel of sshTunnels) { - if ( - sshTunnel && - sshTunnel.sshTunnel && - sshTunnel.sshTunnelLocalPort - ) { - const result = await this.runTunnel(sshTunnel) - sshTunnelResults.push(result) - } - } - if (!this.ws) { - this.sshTunnelResults = sshTunnelResults - } else { - this.ws?.s({ - update: { - sshTunnelResults - }, - action: 'ssh-tunnel-result', - tabId: this.initOptions.srcTabId - }) - } - return new Promise((resolve, reject) => { - this.conn.shell( - shellWindow, - shellOpts, - (err, channel) => { - if (err) { - return reject(err) - } - this.channel = channel - this.setNoDelay(true) - globalState.setSession(this.pid, this) - resolve(this) - } - ) - }) - } - - shell (conn, shellWindow, shellOpts) { - return new Promise((resolve, reject) => { - conn.shell( - shellWindow, - shellOpts, - (err, channel) => { - if (err) { - return reject(err) - } - resolve(channel) - } - ) - }) - } - - getSSHKeys () { - // os.homedir() is overridden by bootstrap.js to return the - // sandbox DATA_PATH, so this resolves to /.ssh. - const keysDir = pathResolve(os.homedir(), '.ssh') - try { - return require('fs') - .readdirSync(keysDir) - .filter(file => file.endsWith('.pub')) - .map(file => pathResolve(keysDir, file.replace('.pub', ''))) - } catch (e) { - log.error(e) - return [] - } - } - - getPrivateKey (connectOptions) { - if (this.sshKeys) { - if (this.sshKeys.length > 0) { - const p = this.sshKeys.shift() - this.privateKeyPath = p - connectOptions.privateKey = require('fs').readFileSync(p, 'utf8') - } else if (this.sshKeys.length === 0) { - this.connectOptions.passphrase = this.initOptions.passphrase - delete this.connectOptions.privateKey - delete this.sshKeys - } - return - } - const list = this.getSSHKeys() - if (list.length) { - const p = list.shift() - this.privateKeyPath = p - connectOptions.privateKey = require('fs').readFileSync(p, 'utf8') - this.sshKeys = list - } - } - - doSshConnect = ( - info, - conn = this.conn, - connectOptions = this.connectOptions, - skipX11 = false - ) => { - const { - initOptions - } = this - if (info && info.socket) { - delete connectOptions.host - delete connectOptions.port - connectOptions.sock = info.socket - } - this.hostVerificationError = null - const verifyTarget = this.getHostVerificationTarget(connectOptions) - if (this.skipHostVerification && connectOptions.sock) { - // proxied connection (netbird ssh proxy / proxyCommand): - // the child serves its own endpoint with an ephemeral host key - delete connectOptions.hostVerifier - } else { - connectOptions.hostVerifier = createHostVerifier({ - ...verifyTarget, - confirm: async (options) => { - const results = await this.onKeyboardEvent(options) - return results && results[0] === (options.confirmResult || 'trust') - }, - onError: (err) => { - this.hostVerificationError = err - } - }) - } - this.authPartiallySucceeded = false - connectOptions.authHandler = this.createAuthHandler(connectOptions) - return new Promise((resolve, reject) => { - conn.on('keyboard-interactive', async ( - name, - instructions, - instructionsLang, - prompts, - finish - ) => { - if (initOptions.ignoreKeyboardInteractive) { - return finish( - (prompts || []).map((n, i) => { - return i ? '' : (connectOptions.password || '') - }) - ) - } - // Detect 2FA: if we connected with password and prompts look like 2FA, - // disconnect and retry without password so keyboard-interactive handles both - if ( - !this.retry2FA && - !this.authPartiallySucceeded && - connectOptions.password && - this.isLikely2FAPrompts(prompts) - ) { - this.retry2FA = true - conn.end() - return reject(new Error('2FA_RETRY')) - } - const options = { - name, - instructions, - instructionsLang, - prompts - } - this.onKeyboardEvent(options, connectOptions.password ?? this.initOptions?.password ?? null) - .then(finish) - .catch(reject) - }) - if (!skipX11) { - conn.on('x11', (inf, accept) => { - let start = 0 - const maxRetry = 100 - const portStart = 6000 - const maxPort = portStart + maxRetry - const retry = () => { - if (start >= maxPort) { - return - } - const xserversock = new net.Socket() - let xclientsock - xserversock - .on('connect', function () { - xclientsock = accept() - xclientsock.pipe(xserversock).pipe(xclientsock) - }) - .on('error', (e) => { - log.error(e) - xserversock.destroy() - start = start === maxRetry ? portStart : start + 1 - retry() - }) - .on('close', () => { - xserversock.destroy() - xclientsock && xclientsock.destroy() - }) - if (start < portStart) { - const addr = (this.display || '').includes('/tmp') - ? this.display - : `/tmp/.X11-unix/X${start}` - xserversock.connect(addr) - } else { - xserversock.connect(start, '127.0.0.1') - } - } - retry() - }) - } - conn - .on('ready', () => resolve(true)) - .on('error', err => { - reject(this.hostVerificationError || err) - }) - .connect(connectOptions) - }) - } - - /** - * when connecting through a proxy command (netbird ssh proxy or - * user-defined proxyCommand option), surface the command's stderr - * (netbird prints the SSO login URL there) to the user - */ - onProxyCommandMessage (text) { - log.log('ssh proxy command:', text.trim()) - const url = text.match(/https?:\/\/\S+/) - if (url && this.ws && !this.proxyCommandUrlShown) { - this.proxyCommandUrlShown = true - this.ws.s({ - action: 'ssh-proxy-command-message', - message: text.trim(), - url: url[0], - tabId: this.initOptions.srcTabId - }) - } - } - - /** - * if a proxy command applies (netbird auto-detect or explicit - * proxyCommand option), spawn it and return the bridged socket - */ - async maybeProxyCommandSock () { - if (this.initOptions?.connectionHoppings?.length) { - return undefined - } - const info = await maybeProxyCommand( - this.initOptions, - this.connectOptions, - { onMessage: (text) => this.onProxyCommandMessage(text) } - ) - if (!info) { - return undefined - } - this.proxyCommandDispose = info.dispose - // the proxy command serves its own ssh endpoint (random host key - // per run for netbird), known_hosts verification can not apply - this.skipHostVerification = true - return { socket: info.socket } - } - - getShareOptions () { - const { initOptions } = this - const all = { - tryKeyboard: true, - readyTimeout: initOptions.readyTimeout, - keepaliveCountMax: initOptions.keepaliveCountMax, - keepaliveInterval: initOptions.keepaliveInterval, - algorithms: algDefault() - } - if (initOptions.serverHostKey && initOptions.serverHostKey.length) { - all.algorithms.serverHostKey = deepCopy(initOptions.serverHostKey) - } - if (initOptions.cipher && initOptions.cipher.length) { - all.algorithms.cipher = deepCopy(initOptions.cipher) - } - if (initOptions.compress && initOptions.compress.length) { - all.algorithms.compress = deepCopy(initOptions.compress) - } - return all - } - - getHostVerificationTarget (connectOptions = this.connectOptions) { - if (connectOptions === this.hoppingOptions && this.initHoppingOptions) { - return { - host: this.initHoppingOptions.host, - port: this.initHoppingOptions.port - } - } - return { - host: connectOptions.host || this.initOptions.host, - port: connectOptions.port || this.initOptions.port - } - } - - buildConnectOptions () { - const { initOptions } = this - const connectOptions = Object.assign( - this.getShareOptions(), - { - agent: this.getAgent() - }, - _.pick(initOptions, [ - 'host', - 'port', - 'username', - 'password', - 'privateKey', - 'passphrase', - 'certificate', - 'encode' - ]) - ) - if (initOptions.isMFA) { - this.retry2FA = true - delete connectOptions.password - } - if (initOptions.debug) { - connectOptions.debug = log.log - } - if (!connectOptions.passphrase) { - delete connectOptions.passphrase - } - return connectOptions - } - - buildShellOpts () { - const { initOptions } = this - let x11 - if (initOptions.x11 === true) { - x11 = { - cookie: this.x11Cookie - } - } - const shellOpts = { - x11 - } - shellOpts.env = this.getEnv(initOptions) - return shellOpts - } - - getUserName (connectOptions) { - const options = { - name: 'username', - instructions: [''], - prompts: [{ - echo: false, - prompt: '' - }] - } - return this.onKeyboardEvent(options) - .then(data => { - const username = data ? data[0] : '' - if (username) { - this.connectOptions.username = data[0] - } - return this.sshConnect() - }) - .catch(e => { - log.error('errored get username for', e) - return this.nextTry(e) - }) - } - - async sshConnect () { - const { initOptions } = this - const { Client } = require('@electerm/ssh2') - this.conn = new Client() - this.connectOptions = this.connectOptions || this.buildConnectOptions() - const { - connectOptions - } = this - if (!connectOptions.username) { - return this.getUserName(connectOptions) - } - if ( - this.sshKeys || - (!connectOptions.privateKey && !connectOptions.password && !initOptions.password) - ) { - this.getPrivateKey(this.connectOptions) - } - this.shellWindow = this.shellWindow || this.getShellWindow() - this.shellOpts = this.shellOpts || this.buildShellOpts() - // dispose proxy command child from a previous attempt (retries re-enter here) - if (this.proxyCommandDispose) { - this.proxyCommandDispose() - this.proxyCommandDispose = null - } - const info = initOptions.proxy - ? await proxySock({ - readyTimeout: initOptions.readyTimeout, - host: initOptions.host, - port: initOptions.port, - proxy: initOptions.proxy - }) - : await this.maybeProxyCommandSock() - const skipX11 = !!initOptions.connectionHoppings?.length - const result = await this.doSshConnect( - info, - undefined, - undefined, - skipX11 - ).catch(err => err) - if (!(result instanceof Error)) { - return this.onInitSshReady() - } - const err = result - log.error('error when do sshConnect', err, this.privateKeyPath) - if ( - err.message.includes(csFailMsg) && - !this.altAlg - ) { - return this.reTryAltAlg() - } else if (err.message === '2FA_RETRY') { - log.log('2FA detected, retrying without password in auth') - delete this.connectOptions.password - return this.sshConnect() - } else if (err.message.includes('passphrase')) { - const options = { - name: `passphase for ${this.privateKeyPath || 'privateKey'}`, - instructions: [''], - prompts: [{ - echo: false, - prompt: 'passphase' - }] - } - return this.onKeyboardEvent(options) - .then(data => { - const pass = data ? data[0] : '' - if (pass) { - this.connectOptions.passphrase = data[0] - this.sshKeys && this.sshKeys.unshift(this.privateKeyPath) - } - return this.nextTry(err, !!pass) - }) - .catch(e => { - log.error('errored get passphrase for', this.privateKeyPath, e) - return this.nextTry(err) - }) - } else if ( - this.sshKeys && - err.message.includes(failMsg) - ) { - return this.nextTry(err) - } else if ( - !this.retry2FA && - !this.connectOptions.password && - this.initOptions.password - ) { - this.connectOptions.password = this.initOptions.password - return this.sshConnect() - } else if ( - err.message.includes(failMsg) && - !this.connectOptions.password - ) { - const options = { - name: `password for ${this.initOptions.username}@${this.initOptions.host}`, - instructions: [''], - prompts: [{ - echo: false, - prompt: 'password' - }] - } - return this.onKeyboardEvent(options) - .then(data => { - if (data && data[0]) { - this.connectOptions.password = data[0] - return this.sshConnect() - } else if (data && data[0] === '') { - throw err - } - }) - .catch(err => { - log.error('errored get password for', err) - throw err - }) - } - return this.nextTry(err) - } - - nextTry (err, forceRetry = false) { - if ( - this.sshKeys || forceRetry - ) { - log.log('retry with next ssh key') - if (this.conn) { - this.conn.end() - } - return this.sshConnect() - } else { - throw err - } - } - - resize (cols, rows) { - this.channel?.setWindow(rows, cols) - } - - on (event, cb) { - this.channel.on(event, cb) - this.channel.stderr.on(event, cb) - } - - write (data) { - const encode = this.connectOptions?.encode || this.initOptions?.encode - if (encode && !utf8Aliases.has(encode.toLowerCase()) && typeof data === 'string') { - try { - const buf = iconv.encode(data, encode) - this.channel?.write(buf) - return - } catch (e) { - log.warn('iconv encode failed, falling back to raw write:', e.message) - } - } - this.channel?.write(data) - } - - setNoDelay (noDelay = true) { - try { - if (this.conn && typeof this.conn.setNoDelay === 'function') { - this.conn.setNoDelay(noDelay) - } - } catch (e) { - log.warn('failed to set ssh noDelay', e) - } - } - - kill () { - this.initOptions = null - this.connectOptions = null - this.proxyCommandDispose = null - this.skipHostVerification = null - this.proxyCommandUrlShown = null - this.alg = null - this.shellWindow = null - this.shellOpts = null - this.conn = null - this.sshKeys = null - this.privateKeyPath = null - this.display = null - this.x11Cookie = null - this.conns = null - this.jumpSshKeys = null - this.jumpPrivateKeyPathFrom = null - this.hoppingOptions = null - this.initHoppingOptions = null - this.nextConn = null - this.doKill() - } - - doKill () { - if (this.proxyCommandDispose) { - this.proxyCommandDispose() - this.proxyCommandDispose = null - } - if (this.sessionLogger) { - this.sessionLogger.destroy() - } - this.channel && this.channel.end() - delete this.channel - this.onEndConn() - // Clean up any remaining connection - if (this.conn) { - this.conn.end() - this.conn = null - } - } - - getLocalEnv () { - return { - env: process.env - } - } - - getDisplay () { - return new Promise((resolve) => { - exec('echo $DISPLAY', this.getLocalEnv(), (err, out, e) => { - if (err || e) { - resolve('') - } else { - resolve((out || '').trim()) - } - }) - }) - } - - getX11Cookie () { - return new Promise((resolve) => { - exec('xauth list :0', this.getLocalEnv(), (err, out, e) => { - if (err || e) { - resolve('') - } else { - const s = out || '' - const reg = /MIT-MAGIC-COOKIE-1 +([\d\w]{1,38})/ - const arr = s.match(reg) - resolve( - arr ? arr[1] || '' : '' - ) - } - }) - }) - } - - init () { - return this.remoteInitProcess() - } -} - -const TerminalSsh = commonExtends(TerminalSshBase) - -exports.session = function (initOptions, ws) { - return (new TerminalSsh(initOptions, ws)).init() -} - -/** - * test ssh connection - * @param {object} options - */ -exports.test = (options, ws) => { - return (new TerminalSsh(options, ws, true)) - .init() - .then(() => true) - .catch((err) => { - log.error('test ssh error', err) - return false - }) -} diff --git a/src/app/server/session-telnet.js b/src/app/server/session-telnet.js deleted file mode 100644 index ea3bb58..0000000 --- a/src/app/server/session-telnet.js +++ /dev/null @@ -1,139 +0,0 @@ -/** - * terminal/sftp/serial class - */ -const _ = require('../lib/lodash.js') -const log = require('../common/log') -const { Telnet } = require('./telnet') -const { TerminalBase } = require('./session-base') -const globalState = require('./global-state') -const iconv = require('iconv-lite') - -// Encodings that are equivalent to UTF-8 (no conversion needed) -const utf8Aliases = new Set(['utf-8', 'utf8', 'utf-8-strict']) - -// Helper function to convert regex string to RegExp object -function stringToRegExp (regexString) { - // Check if it's already a RegExp - if (regexString instanceof RegExp) { - return regexString - } - - // Parse string format like /pattern/flags - const match = regexString.match(/^\/(.+)\/([gimsuy]*)$/) - if (match) { - const [, pattern, flags] = match - return new RegExp(pattern, flags) - } - - // If no slashes, treat as plain pattern - return new RegExp(regexString) -} - -class TerminalTelnet extends TerminalBase { - init = async () => { - const connection = new Telnet() - - const { initOptions } = this - const shellOpts = { - highWaterMark: 64 * 1024 * 16 - } - const params = _.pick( - initOptions, - [ - 'host', - 'port', - 'timeout', - 'username', - 'password', - 'terminalWidth', - 'terminalHeight', - 'proxy' - ] - ) - // Convert string regex patterns to RegExp objects - if (typeof initOptions.loginPrompt === 'string') { - params.loginPrompt = stringToRegExp(initOptions.loginPrompt) - } - if (typeof initOptions.passwordPrompt === 'string') { - params.passwordPrompt = stringToRegExp(initOptions.passwordPrompt) - } - Object.assign( - params, - { - negotiationMandatory: false, - // terminalWidth: initOptions.cols, - // terminalHeight: initOptions.rows, - timeout: initOptions.readyTimeout, - sendTimeout: initOptions.readyTimeout, - socketConnectOptions: shellOpts - } - ) - await connection.connect(params) - this.port = connection.shell(shellOpts) - this.channel = connection - if (this.isTest) { - this.kill() - return true - } - globalState.setSession(this.pid, this) - return Promise.resolve(this) - } - - resize = (cols, rows) => { - Object.assign(this.channel.options, { - terminalWidth: cols, - terminalHeight: rows - }) - this.channel.sendWindowSize() - } - - on = (event, cb) => { - this.port.on(event, cb) - } - - write = (data) => { - try { - const encode = this.initOptions?.encode - if (encode && !utf8Aliases.has(encode.toLowerCase()) && typeof data === 'string') { - try { - const buf = iconv.encode(data, encode) - this.port.write(buf) - return - } catch (e) { - log.warn('iconv encode failed, falling back to raw write:', e.message) - } - } - this.port.write(data) - // this.writeLog(data) - } catch (e) { - log.error(e) - } - } - - kill = () => { - this.channel && this.channel.end() - if (this.sessionLogger) { - this.sessionLogger.destroy() - } - globalState.removeSession(this.pid) - } -} - -exports.session = async function (initOptions, ws) { - const term = new TerminalTelnet(initOptions, ws) - await term.init() - return term -} - -/** - * test ssh connection - * @param {object} options - */ -exports.test = (options) => { - return (new TerminalTelnet(options, undefined, true)) - .init() - .then(() => true) - .catch(() => { - return false - }) -} diff --git a/src/app/server/session-vnc.js b/src/app/server/session-vnc.js deleted file mode 100644 index 7798919..0000000 --- a/src/app/server/session-vnc.js +++ /dev/null @@ -1,143 +0,0 @@ -/** - * terminal/sftp/serial class - */ - -const log = require('../common/log') -const { TerminalBase } = require('./session-base') -const net = require('net') -const proxySock = require('./socks') -const { createHopProxy } = require('./session-hop') -const globalState = require('./global-state') - -class TerminalVnc extends TerminalBase { - init = async () => { - globalState.setSession(this.pid, this) - return Promise.resolve(this) - } - - start = async (width, height) => { - if (this.isRunning) { - return - } - this.isRunning = true - if (this.channel) { - this.channel.close() - delete this.channel - } - const { - host, - port - } = this.initOptions - const info = await this.hop() - const target = net.createConnection({ - port, - host, - ...info - }) - this.channel = target - target.on('data', this.onData) - target.on('end', this.kill) - target.on('error', this.onError) - - this.ws.on('message', this.onMsg) - this.ws.on('close', this.kill) - this.width = width - this.height = height - } - - hop = async () => { - const { - host, - port, - readyTimeout - } = this.initOptions - const { proxyUrl, ssh } = await createHopProxy(this.initOptions) - if (ssh) { - this.ssh = ssh - } - return proxyUrl - ? proxySock({ readyTimeout, host, port, proxy: proxyUrl }) - : undefined - } - - onMsg = (msg) => { - this.channel.write(msg) - } - - onData = (data) => { - try { - this.ws?.send(data) - } catch (e) { - log.error('vnc connection send data error', e) - } - } - - resize () { - - } - - onError = (err) => { - log.error('vnc error', err) - this.kill() - } - - test = async () => { - return new Promise((resolve, reject) => { - const { - host, - port - } = this.initOptions - return this.hop() - .then(info => { - net.createConnection({ - port, - host, - ...info - }, () => { - resolve(true) - }) - }) - .catch(err => reject(err)) - }) - } - - kill = () => { - log.debug('Closed vnc session ' + this.pid) - if (this.ws) { - this.ws.close() - delete this.ws - } - if (this.ssh) { - this.ssh.kill() - delete this.ssh - } - this.channel && this.channel.end() - if (this.sessionLogger) { - this.sessionLogger.destroy() - } - globalState.removeSession(this.pid) - } -} - -exports.session = async function (initOptions, ws) { - const term = new TerminalVnc(initOptions, ws) - await term.init() - return term -} - -/** - * test ssh connection - * @param {object} options - */ -exports.test = (options) => { - const inst = new TerminalVnc(options, undefined, true) - return inst.test() - .then(() => { - inst.kill() - return true - }) - .catch(() => { - inst.kill() - return false - }) -} diff --git a/src/app/server/session.js b/src/app/server/session.js deleted file mode 100644 index 5799913..0000000 --- a/src/app/server/session.js +++ /dev/null @@ -1,34 +0,0 @@ -/** - * terminal/sftp/serial class - */ - -/** - * Dynamically load a module based on terminal type - * @param {string} type - Terminal type - * @returns {Object} The loaded module - */ -function loadModule (type) { - return require(`./session-${type}`) -} - -/** - * Create a terminal session - * @param {object} initOptions - Terminal initialization options - * @param {object} ws - WebSocket connection - * @returns {Promise} Terminal session - */ -exports.startSession = async function (initOptions, ws, func = 'session') { - const type = initOptions.termType || initOptions.type || 'ssh' - const tail = [ - 'telnet', - 'serial', - 'local', - 'rdp', - 'vnc', - 'spice' - ].includes(type) - ? type - : 'ssh' - const module = loadModule(tail) - return module[func](initOptions, ws) -} diff --git a/src/app/server/sftp-file.js b/src/app/server/sftp-file.js deleted file mode 100644 index 52b41d9..0000000 --- a/src/app/server/sftp-file.js +++ /dev/null @@ -1,68 +0,0 @@ -/** - * sftp read/write file - */ - -const { Readable, Writable } = require('stream') - -function createReadStreamFromString (str) { - const s = new Readable() - s._read = () => {} - s.push(str) - s.push(null) - return s -} - -class FakeWrite extends Writable { - constructor (opts) { - super(opts) - this.opts = opts - } - - _write (data, encoding, done) { - this.opts.onData(data) - done() - } -} - -function writeRemoteFile (sftp, path, str, mode) { - return new Promise((resolve, reject) => { - const writeStream = sftp.createWriteStream(path, { - highWaterMark: 64 * 1024 * 4 * 4, - mode - }) - writeStream.on('close', () => { - resolve('ok') - }) - writeStream.on('error', (e) => { - reject(e) - }) - createReadStreamFromString(str).pipe(writeStream) - }) -} - -function readRemoteFile (sftp, path) { - return new Promise((resolve, reject) => { - let final = Buffer.alloc(0) - const writeStream = new FakeWrite({ - onData: data => { - final = Buffer.concat( - [final, data] - ) - } - }) - writeStream.on('finish', () => { - resolve(final.toString()) - }) - writeStream.on('error', (e) => { - reject(e) - }) - sftp.createReadStream(path, { - highWaterMark: 64 * 1024 * 4 * 4 - }).pipe(writeStream) - }) -} - -module.exports = { - readRemoteFile, - writeRemoteFile -} diff --git a/src/app/server/socks.js b/src/app/server/socks.js deleted file mode 100644 index fc02305..0000000 --- a/src/app/server/socks.js +++ /dev/null @@ -1,107 +0,0 @@ -/** - * socks proxy wrapper - */ - -const { request } = require('http') - -function isValidIP (input) { - // Check IPv4 format - const ipv4Pattern = /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/ - if (ipv4Pattern.test(input)) { - return true - } - - // Check IPv6 format - const ipv6Pattern = /^([\da-f]{1,4}:){7}[\da-f]{1,4}$/i - if (ipv6Pattern.test(input)) { - return true - } - - // If input doesn't match IPv4 or IPv6 patterns, it's not a valid IP - return false -} - -function parseUrl (str) { - try { - return new URL(str) - } catch (e) { - console.log(`parse url error: ${e.message}, url: ${str}`) - } -} - -module.exports = (initOptions) => { - const { - readyTimeout, - host, - port, - proxy - } = initOptions - const proxyURL = parseUrl(proxy) - if (!proxyURL) { - throw new Error('proxy format not right:', proxy) - } - // use http proxy - const { - protocol, - hostname, - username, - password - } = proxyURL - const proxyPort = Number(proxyURL.port) - const proxyHost = proxyURL.host - if (protocol === 'http:' || protocol === 'https:') { - return new Promise((resolve, reject) => { - const opts = { - agent: false, - protocol, - hostname, - port: proxyPort, - host: proxyHost, - path: `${host}:${port}`, - method: 'CONNECT', - timeout: readyTimeout, - headers: {} - } - if (username) { - const auth = Buffer.from(`${username}:${password}`).toString('base64') - opts.headers['Proxy-Authorization'] = `Basic ${auth}` - } - request(opts) - .on('error', (e) => { - console.error(`fail to connect proxy: ${e.message}`) - reject(e) - }) - .on('connect', (res, socket) => { - resolve({ socket }) - }) - .end() - }) - } - const type = protocol.includes('5') ? 5 : 4 - const isIp = isValidIP(hostname) - const options = { - proxy: { - port: proxyPort, - type, - userId: username, - password - }, - - command: 'connect', - timeout: readyTimeout, - - destination: { - host, - port - } - } - if (isIp) { - options.proxy.ipaddress = hostname - } else { - options.proxy.host = hostname - } - - // use socks proxy - const { SocksClient } = require('socks') - return SocksClient.createConnection(options) -} diff --git a/src/app/server/spice-proxy.js b/src/app/server/spice-proxy.js deleted file mode 100644 index 9c3c4a4..0000000 --- a/src/app/server/spice-proxy.js +++ /dev/null @@ -1,218 +0,0 @@ -const net = require('net') -const log = require('../common/log') -const proxySock = require('./socks') - -const LOG_PREFIX = '[SPICE-PROXY]' - -async function createTcpConnection (host, port, options = {}) { - const { proxy, readyTimeout = 15000 } = options - - if (proxy) { - log.debug(`${LOG_PREFIX} Connecting through proxy: ${proxy}`) - const proxyResult = await proxySock({ - readyTimeout, - host, - port, - proxy - }) - log.debug(`${LOG_PREFIX} Proxy connection established`) - return proxyResult.socket - } - - return new Promise((resolve, reject) => { - const tcpSocket = net.createConnection({ host, port }, () => { - log.debug(`${LOG_PREFIX} TCP connection established to ${host}:${port}`) - tcpSocket.setKeepAlive(true, 5000) - tcpSocket.setTimeout(0) - resolve(tcpSocket) - }) - tcpSocket.once('error', (err) => { - reject(new Error(`TCP connection failed: ${err.message}`)) - }) - tcpSocket.setTimeout(readyTimeout, () => { - tcpSocket.destroy() - reject(new Error('Connection timed out')) - }) - }) -} - -async function handleConnection (ws, options = {}) { - const { host, port, proxy, readyTimeout = 15000, onCleanup, channelId } = options - const id = channelId || 'unknown' - - log.debug(`${LOG_PREFIX}[${id}] New WebSocket connection for SPICE proxy`) - - if (!host || !port) { - log.error(`${LOG_PREFIX}[${id}] Missing host or port`) - ws.close() - if (onCleanup) onCleanup() - return - } - - const messageBuffer = [] - let wsClosed = false - let tcpClosed = false - let tcpSocket = null - - const cleanup = (source) => { - if (wsClosed && tcpClosed) return - log.debug(`${LOG_PREFIX}[${id}] Cleanup triggered by: ${source}`) - wsClosed = true - tcpClosed = true - - try { - if (ws && ws.readyState !== ws.CLOSED) { - ws.close() - } - } catch (e) { - log.debug(`${LOG_PREFIX}[${id}] WebSocket close error:`, e.message) - } - - try { - if (tcpSocket) { - tcpSocket.destroy() - } - } catch (e) { - log.debug(`${LOG_PREFIX}[${id}] TCP socket destroy error:`, e.message) - } - - if (onCleanup) { - onCleanup() - } - } - - ws.on('message', (data) => { - if (tcpClosed) return - const buf = Buffer.isBuffer(data) ? data : Buffer.from(data) - - if (tcpSocket) { - try { - tcpSocket.write(buf) - } catch (e) { - log.error(`${LOG_PREFIX}[${id}] TCP write error:`, e.message) - cleanup('TCP write error') - } - } else { - messageBuffer.push(buf) - } - }) - - ws.on('close', () => cleanup('WebSocket')) - ws.on('error', (err) => { - log.error(`${LOG_PREFIX}[${id}] WebSocket error:`, err.message) - cleanup('WebSocket error') - }) - - try { - tcpSocket = await createTcpConnection(host, port, { proxy, readyTimeout }) - log.debug(`${LOG_PREFIX}[${id}] Connected to SPICE server at ${host}:${port}`) - - tcpSocket.on('data', (data) => { - if (wsClosed) return - try { - ws.send(data) - } catch (e) { - log.error(`${LOG_PREFIX}[${id}] WebSocket send error:`, e.message) - cleanup('WebSocket send error') - } - }) - - tcpSocket.on('close', () => cleanup('TCP close')) - tcpSocket.on('end', () => cleanup('TCP end')) - tcpSocket.on('error', (err) => { - log.error(`${LOG_PREFIX}[${id}] TCP error:`, err.message) - cleanup('TCP error') - }) - - if (messageBuffer.length > 0) { - for (const buf of messageBuffer) { - try { - tcpSocket.write(buf) - } catch (e) { - log.error(`${LOG_PREFIX}[${id}] TCP write error:`, e.message) - cleanup('TCP write error') - return - } - } - messageBuffer.length = 0 - } - } catch (err) { - log.error(`${LOG_PREFIX}[${id}] Connection failed:`, err.message) - try { - ws.close() - } catch (e) {} - if (onCleanup) onCleanup() - } -} - -function setupRelay (ws, tcpSocket, options = {}) { - const { onCleanup, channelId } = options - let wsClosed = false - let tcpClosed = false - const id = channelId || 'unknown' - - const cleanup = (source) => { - if (wsClosed && tcpClosed) return - log.debug(`${LOG_PREFIX}[${id}] Cleanup triggered by: ${source}`) - wsClosed = true - tcpClosed = true - - try { - if (ws && ws.readyState !== ws.CLOSED) { - ws.close() - } - } catch (e) { - log.debug(`${LOG_PREFIX}[${id}] WebSocket close error:`, e.message) - } - - try { - tcpSocket.destroy() - } catch (e) { - log.debug(`${LOG_PREFIX}[${id}] TCP socket destroy error:`, e.message) - } - - if (onCleanup) { - onCleanup() - } - } - - tcpSocket.on('data', (data) => { - if (wsClosed) return - try { - ws.send(data) - } catch (e) { - log.error(`${LOG_PREFIX}[${id}] WebSocket send error:`, e.message) - cleanup('WebSocket send error') - } - }) - - tcpSocket.on('close', () => cleanup('TCP close')) - tcpSocket.on('end', () => cleanup('TCP end')) - tcpSocket.on('error', (err) => { - log.error(`${LOG_PREFIX}[${id}] TCP error:`, err.message) - cleanup('TCP error') - }) - - ws.on('message', (data) => { - if (tcpClosed) return - const buf = Buffer.isBuffer(data) ? data : Buffer.from(data) - try { - tcpSocket.write(buf) - } catch (e) { - log.error(`${LOG_PREFIX}[${id}] TCP write error:`, e.message) - cleanup('TCP write error') - } - }) - - ws.on('close', () => cleanup('WebSocket')) - ws.on('error', (err) => { - log.error(`${LOG_PREFIX}[${id}] WebSocket error:`, err.message) - cleanup('WebSocket error') - }) -} - -module.exports = { - handleConnection, - createTcpConnection, - setupRelay -} diff --git a/src/app/server/ssh-known-hosts.js b/src/app/server/ssh-known-hosts.js deleted file mode 100644 index 0bc14d2..0000000 --- a/src/app/server/ssh-known-hosts.js +++ /dev/null @@ -1,455 +0,0 @@ -const crypto = require('crypto') -const fs = require('fs') -const os = require('os') -const { dirname, join } = require('path') -const { parseKey } = require('@electerm/ssh2/lib/protocol/keyParser.js') - -function normalizeHost (host = '') { - if (typeof host !== 'string') { - return '' - } - if (host.startsWith('[') && host.endsWith(']')) { - return host.slice(1, -1) - } - return host -} - -function getKnownHostsPath () { - // os.homedir() is overridden by bootstrap.js to return the app's - // sandbox data directory (DATA_PATH), so this resolves to - // /.ssh/known_hosts. - return join(os.homedir(), '.ssh', 'known_hosts') -} - -function getKnownHostCandidates (host, port) { - const normalizedHost = normalizeHost(host) - const normalizedPort = Number(port) || 22 - const candidates = new Set([normalizedHost]) - candidates.add(`[${normalizedHost}]:${normalizedPort}`) - return [...candidates].filter(Boolean) -} - -function escapeRegExp (value) { - return value.replace(/[|\\{}()[\]^$+?.]/g, '\\$&') -} - -function wildcardToRegExp (value) { - const pattern = escapeRegExp(value) - .replace(/\\\*/g, '.*') - .replace(/\\\?/g, '.') - return new RegExp(`^${pattern}$`) -} - -function matchesHashedHost (entry, candidate) { - const parts = entry.split('|') - if (parts.length !== 4 || parts[1] !== '1') { - return false - } - try { - const salt = Buffer.from(parts[2], 'base64') - const hash = Buffer.from(parts[3], 'base64') - const digest = crypto - .createHmac('sha1', salt) - .update(candidate) - .digest() - return digest.equals(hash) - } catch { - return false - } -} - -function matchesHostToken (token, candidates) { - if (!token) { - return false - } - if (token.startsWith('|1|')) { - return candidates.some(candidate => matchesHashedHost(token, candidate)) - } - const matcher = token.includes('*') || token.includes('?') - ? wildcardToRegExp(token) - : null - return candidates.some(candidate => { - if (matcher) { - return matcher.test(candidate) - } - return token === candidate - }) -} - -function matchesKnownHostField (hostField, host, port) { - const candidates = getKnownHostCandidates(host, port) - const tokens = hostField.split(',').map(token => token.trim()).filter(Boolean) - let matched = false - for (const token of tokens) { - const isNegative = token.startsWith('!') - const cleanToken = isNegative ? token.slice(1) : token - if (!matchesHostToken(cleanToken, candidates)) { - continue - } - if (isNegative) { - return false - } - matched = true - } - return matched -} - -function parseKnownHostsLine (line) { - const trimmed = line.trim() - if (!trimmed || trimmed.startsWith('#')) { - return null - } - const parts = trimmed.split(/\s+/) - if (parts.length < 3) { - return null - } - let marker - if (parts[0].startsWith('@')) { - if (parts.length < 4) { - return null - } - marker = parts.shift() - } - const [hosts, keyType, keyData] = parts - if (!hosts || !keyType || !keyData) { - return null - } - return { - marker, - hosts, - keyType, - keyData - } -} - -function getHostKeyMeta (hostKey) { - const parsed = parseKey(hostKey) - if (parsed instanceof Error) { - throw parsed - } - return { - keyType: parsed.type, - keyData: parsed.getPublicSSH().toString('base64'), - sha256: crypto.createHash('sha256').update(hostKey).digest('base64') - } -} - -function formatSha256Fingerprint (sha256) { - return `SHA256:${sha256}` -} - -async function readKnownHostsFile (knownHostsPath = getKnownHostsPath()) { - try { - return await fs.promises.readFile(knownHostsPath, 'utf8') - } catch (err) { - if (err && err.code === 'ENOENT') { - return '' - } - throw err - } -} - -async function checkKnownHosts (options) { - const { - host, - port, - hostKey, - knownHostsPath = getKnownHostsPath() - } = options - const knownHosts = await readKnownHostsFile(knownHostsPath) - const meta = getHostKeyMeta(hostKey) - const lines = knownHosts.split(/\r?\n/) - const matchingEntries = [] - for (const line of lines) { - const entry = parseKnownHostsLine(line) - if (!entry) { - continue - } - if (!matchesKnownHostField(entry.hosts, host, port)) { - continue - } - matchingEntries.push(entry) - } - const sameTypeEntries = matchingEntries.filter(entry => entry.keyType === meta.keyType) - const exactMatch = sameTypeEntries.find(entry => entry.keyData === meta.keyData) - if (exactMatch) { - if (exactMatch.marker === '@revoked') { - return { - status: 'revoked', - meta, - knownHostsPath - } - } - return { - status: 'match', - meta, - knownHostsPath - } - } - if (sameTypeEntries.length) { - return { - status: 'mismatch', - meta, - knownHostsPath, - entries: sameTypeEntries - } - } - return { - status: 'not-found', - meta, - knownHostsPath, - entries: matchingEntries - } -} - -async function appendKnownHost (options) { - const { - host, - port, - hostKey, - knownHostsPath = getKnownHostsPath() - } = options - const meta = getHostKeyMeta(hostKey) - await fs.promises.mkdir(dirname(knownHostsPath), { - recursive: true, - mode: 0o700 - }) - const hostToken = Number(port) && Number(port) !== 22 - ? `[${normalizeHost(host)}]:${Number(port)}` - : normalizeHost(host) - const prefix = await readKnownHostsFile(knownHostsPath) - const needsNewline = prefix && !prefix.endsWith('\n') - const line = `${hostToken} ${meta.keyType} ${meta.keyData}\n` - await fs.promises.appendFile(knownHostsPath, `${needsNewline ? '\n' : ''}${line}`, { - mode: 0o600 - }) - return meta -} - -async function removeKnownHost (options) { - const { - host, - port, - keyType, - knownHostsPath = getKnownHostsPath() - } = options - const content = await readKnownHostsFile(knownHostsPath) - if (!content) { - return - } - const lines = content.split(/\r?\n/) - const filtered = lines.filter(line => { - const entry = parseKnownHostsLine(line) - if (!entry) { - return true - } - if (!matchesKnownHostField(entry.hosts, host, port)) { - return true - } - if (entry.keyType !== keyType) { - return true - } - return false - }) - await fs.promises.writeFile(knownHostsPath, filtered.join('\n'), { - mode: 0o600 - }) -} - -async function replaceKnownHost (options) { - const { - host, - port, - hostKey, - knownHostsPath = getKnownHostsPath() - } = options - const meta = getHostKeyMeta(hostKey) - await removeKnownHost({ - host, - port, - keyType: meta.keyType, - knownHostsPath - }) - return appendKnownHost({ - host, - port, - hostKey, - knownHostsPath - }) -} - -function buildUnknownHostPrompt (options) { - const { - host, - port, - meta, - knownHostsPath = getKnownHostsPath() - } = options - const target = Number(port) && Number(port) !== 22 - ? `[${normalizeHost(host)}]:${Number(port)}` - : normalizeHost(host) - return { - mode: 'confirm', - name: `Trust SSH host key for ${target}?`, - instructions: [ - `The authenticity of host '${target}' can't be established.`, - `Key type: ${meta.keyType}`, - `Fingerprint: ${formatSha256Fingerprint(meta.sha256)}`, - `Known hosts file: ${knownHostsPath}`, - 'Trust this host key and add it to known_hosts?' - ], - prompts: [], - submitText: 'Trust and Save', - cancelText: 'Reject', - confirmResult: 'trust' - } -} - -function buildHostMismatchError (options) { - const { - host, - port, - meta, - knownHostsPath = getKnownHostsPath() - } = options - const target = Number(port) && Number(port) !== 22 - ? `[${normalizeHost(host)}]:${Number(port)}` - : normalizeHost(host) - return new Error( - [ - `SSH host key verification failed for ${target}.`, - `Presented ${meta.keyType} fingerprint ${formatSha256Fingerprint(meta.sha256)} does not match ${knownHostsPath}.`, - 'Remove the old known_hosts entry if you trust the new host key.' - ].join(' ') - ) -} - -function buildHostMismatchPrompt (options) { - const { - host, - port, - meta, - knownHostsPath = getKnownHostsPath() - } = options - const target = Number(port) && Number(port) !== 22 - ? `[${normalizeHost(host)}]:${Number(port)}` - : normalizeHost(host) - return { - mode: 'confirm', - name: `SSH host key changed for ${target}`, - instructions: [ - 'WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!', - `The host key for '${target}' has changed.`, - `New key type: ${meta.keyType}`, - `New fingerprint: ${formatSha256Fingerprint(meta.sha256)}`, - `Known hosts file: ${knownHostsPath}`, - 'This could indicate a man-in-the-middle attack, or the remote host was reinstalled (e.g. router reboot).', - 'Update the known_hosts entry with the new key?' - ], - prompts: [], - submitText: 'Update Key', - cancelText: 'Reject', - confirmResult: 'trust' - } -} - -function createHostVerifier (options) { - const { - host, - port, - knownHostsPath = getKnownHostsPath(), - confirm, - onError - } = options - return (hostKey, verify) => { - checkKnownHosts({ - host, - port, - hostKey, - knownHostsPath - }) - .then(async (result) => { - if (result.status === 'match') { - verify(true) - return - } - if (result.status === 'revoked') { - onError && onError(buildHostMismatchError({ - host, - port, - meta: result.meta, - knownHostsPath - })) - verify(false) - return - } - if (result.status === 'mismatch') { - const accepted = await confirm(buildHostMismatchPrompt({ - host, - port, - meta: result.meta, - knownHostsPath - })) - if (!accepted) { - onError && onError(buildHostMismatchError({ - host, - port, - meta: result.meta, - knownHostsPath - })) - verify(false) - return - } - await replaceKnownHost({ - host, - port, - hostKey, - knownHostsPath - }) - verify(true) - return - } - const accepted = await confirm(buildUnknownHostPrompt({ - host, - port, - meta: result.meta, - knownHostsPath - })) - if (!accepted) { - onError && onError(new Error('SSH host key verification was canceled by the user.')) - verify(false) - return - } - await appendKnownHost({ - host, - port, - hostKey, - knownHostsPath - }) - verify(true) - }) - .catch((err) => { - onError && onError(err) - verify(false) - }) - } -} - -module.exports = { - appendKnownHost, - buildHostMismatchError, - buildHostMismatchPrompt, - buildUnknownHostPrompt, - checkKnownHosts, - createHostVerifier, - formatSha256Fingerprint, - getHostKeyMeta, - getKnownHostCandidates, - getKnownHostsPath, - matchesHashedHost, - matchesKnownHostField, - normalizeHost, - parseKnownHostsLine, - removeKnownHost, - replaceKnownHost -} diff --git a/src/app/server/ssh-proxy-command.js b/src/app/server/ssh-proxy-command.js deleted file mode 100644 index d33bce2..0000000 --- a/src/app/server/ssh-proxy-command.js +++ /dev/null @@ -1,281 +0,0 @@ -/** - * ssh proxy command support - * - * Connects through an external stdio proxy command (like OpenSSH ProxyCommand): - * the command is expected to speak the SSH protocol on its stdin/stdout. - * - * Used for: - * - netbird ssh proxy (auto-detected via `netbird ssh detect`, see - * https://github.com/electerm/electerm/issues/4500) - * - generic user-defined proxyCommand option (supports %h %p %r placeholders, - * e.g. `cloudflared access ssh --hostname %h`) - * - * Because @electerm/ssh2 requires a real net.Socket with full semantics - * (setKeepAlive/destroy/connecting), we bridge the child stdio through a - * loopback socketpair instead of patching a fake socket. - */ - -const { spawn } = require('child_process') -const net = require('net') -const log = require('../common/log') - -// resolved lazily so tests (and users) can override via env at any time -function getNetbirdBin () { - return process.env.ELECTERM_NETBIRD_BIN || 'netbird' -} - -// how long to wait for `netbird ssh detect` before giving up and connecting directly -const detectTimeout = 5 * 1000 - -// netbird CGNAT range 100.64.0.0/10 -function isNetbirdLikeHost (host) { - if (typeof host !== 'string' || !host) { - return false - } - const h = host.startsWith('[') && host.endsWith(']') - ? host.slice(1, -1) - : host - const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/) - if (!m) { - // netbird also registers dns names, but avoid spawning a process - // for every arbitrary hostname connection - return false - } - const a = Number(m[1]) - const b = Number(m[2]) - return a === 100 && b >= 64 && b <= 127 -} - -/** - * detect if target is a netbird JWT ssh server - * `netbird ssh detect` exits 0 when the server requires netbird JWT auth - */ -function detectNetbird (host, port) { - return new Promise((resolve) => { - let child - try { - child = spawn(getNetbirdBin(), ['ssh', 'detect', host, String(port)], { - stdio: 'ignore' - }) - } catch (e) { - log.warn('spawn netbird detect failed', e.message) - return resolve(false) - } - const timer = setTimeout(() => { - child.kill() - resolve(false) - }, detectTimeout) - child.on('error', () => { - clearTimeout(timer) - resolve(false) - }) - child.on('close', (code) => { - clearTimeout(timer) - resolve(code === 0) - }) - }) -} - -// cache detection result per host:port for the process lifetime, -// so reconnects do not spawn `netbird ssh detect` again -const detectCache = new Map() - -/** - * build proxy command command/args from template string with %h %p %r placeholders - */ -function expandProxyCommand (command, { host, port, username }) { - const expanded = command - .replace(/%h/g, host) - .replace(/%p/g, String(port)) - .replace(/%r/g, username || '') - return expanded.trim().split(/\s+/).filter(Boolean) -} - -/** - * create a loopback socketpair bridging to child stdio: - * ssh2 client connects a normal tcp socket to 127.0.0.1:, - * the listener pipes both directions to the child process - */ -function bridgeChildStdio (child) { - return new Promise((resolve, reject) => { - const server = net.createServer() - let settled = false - server.once('error', (err) => { - if (settled) { - return - } - settled = true - reject(err) - }) - server.listen(0, '127.0.0.1', () => { - if (settled) { - return - } - settled = true - const { port } = server.address() - resolve({ server, port }) - }) - server.once('connection', (socket) => { - server.close() - socket.pipe(child.stdin) - child.stdout.pipe(socket) - const cleanup = () => { - socket.destroy() - try { - child.kill() - } catch { - - } - } - child.stdout.once('end', cleanup) - child.stdout.once('error', cleanup) - child.once('exit', () => socket.destroy()) - socket.once('error', () => { - log.log('proxy command bridge socket error') - try { - child.kill() - } catch { - - } - }) - }) - }) -} - -/** - * spawn proxy command and resolve { socket, dispose } - * socket is an ordinary net.Socket connected to the bridge - */ -async function runProxyCommand (command, args, { onMessage } = {}) { - const child = spawn(command, args, { - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true - }) - const stderrBuf = [] - child.stderr?.on('data', (d) => { - const text = d.toString() - stderrBuf.push(text) - onMessage && onMessage(text) - }) - const [bridge, socket] = await new Promise((resolve, reject) => { - let settled = false - const onSpawnError = (err) => { - if (settled) { - return - } - settled = true - reject(new Error(`proxy command failed to start: ${command}: ${err.message}`)) - } - child.once('error', onSpawnError) - bridgeChildStdio(child).then( - ({ server, port }) => { - if (settled) { - return - } - settled = true - child.removeListener('error', onSpawnError) - const socket = net.connect(port, '127.0.0.1') - socket.once('connect', () => resolve([server, socket])) - socket.once('error', (err) => { - reject(err) - }) - }, - (err) => { - if (settled) { - return - } - settled = true - reject(err) - } - ) - }) - let disposed = false - const dispose = () => { - if (disposed) { - return - } - disposed = true - socket.destroy() - try { - child.stdin?.end() - } catch { - - } - child.kill() - bridge.close() - log.log('proxy command disposed:', command, args.join(' ')) - } - child.once('exit', () => { - socket.destroy() - }) - socket.once('close', () => { - if (!disposed) { - disposed = true - try { - child.stdin?.end() - } catch { - - } - child.kill() - bridge.close() - } - }) - return { - socket, - dispose, - stderr: () => stderrBuf.join('') - } -} - -/** - * main entry: decide whether to connect through a proxy command - * returns { socket, dispose, stderr } or null when not applicable - */ -async function maybeProxyCommand (initOptions, connectOptions, { onMessage } = {}) { - const host = connectOptions.host || initOptions.host - const port = connectOptions.port || initOptions.port || 22 - let command - let args - if (initOptions.proxyCommand) { - const expanded = expandProxyCommand(initOptions.proxyCommand, { - host, - port, - username: connectOptions.username || initOptions.username - }) - if (!expanded.length) { - return null - } - command = expanded.shift() - args = expanded - } else if ( - !initOptions.proxy && - !connectOptions.sock && - isNetbirdLikeHost(host) - ) { - const key = `${host}:${port}` - let detected = detectCache.get(key) - if (detected === undefined) { - detected = await detectNetbird(host, port) - detectCache.set(key, detected) - } - if (!detected) { - return null - } - command = getNetbirdBin() - args = ['ssh', 'proxy', host, String(port)] - } else { - return null - } - log.log('using ssh proxy command:', command, args.join(' ')) - return runProxyCommand(command, args, { onMessage }) -} - -function clearDetectCache () { - detectCache.clear() -} - -exports.maybeProxyCommand = maybeProxyCommand -exports.expandProxyCommand = expandProxyCommand -exports.detectNetbird = detectNetbird -exports.isNetbirdLikeHost = isNetbirdLikeHost -exports.clearDetectCache = clearDetectCache diff --git a/src/app/server/ssh-tunnel.js b/src/app/server/ssh-tunnel.js deleted file mode 100644 index 349ace3..0000000 --- a/src/app/server/ssh-tunnel.js +++ /dev/null @@ -1,207 +0,0 @@ -const log = require('../common/log') - -function forwardRemoteToLocal ({ - conn, - sshTunnelRemotePort, - sshTunnelLocalPort, - sshTunnelRemoteHost = '127.0.0.1', - sshTunnelLocalHost = '127.0.0.1' -}) { - return new Promise((resolve, reject) => { - const result = `remote:${sshTunnelRemoteHost}:${sshTunnelRemotePort} => local:${sshTunnelLocalHost}:${sshTunnelLocalPort}` - - const handleTcpConnection = (info, accept, rejectConn) => { - // Check if this connection is for this tunnel - if (info.destPort !== sshTunnelRemotePort && info.destPort !== Number(sshTunnelRemotePort)) { - return - } - - const srcStream = accept() // Source stream for forwarding - - if (!srcStream) { - log.error(`Failed to accept connection for tunnel ${result}`) - return - } - - // Add error handling for source stream immediately - srcStream.on('error', (err) => { - log.error(`Source stream error for tunnel ${result}:`, err) - }) - - // Connect the local machine source stream to the local port - // Create a NEW server connection for each forwarded connection - const server = require('net').connect(sshTunnelLocalPort, sshTunnelLocalHost) - - // CRITICAL: Add error handling IMMEDIATELY before any async operations - // This prevents unhandled errors from crashing the SSH session - server.on('error', (err) => { - log.error(`Server connection error for tunnel ${result}:`, err.message) - // Just close this specific connection, don't break the tunnel - srcStream.destroy() - server.destroy() - }) - - server.on('close', () => { - log.log(`Local server connection closed for tunnel ${result}`) - srcStream.end() - }) - - srcStream.on('close', () => { - server.destroy() - }) - - srcStream.pipe(server).pipe(srcStream) - } - - conn.on('tcp connection', handleTcpConnection) - - const handleClose = () => { - log.log(`SSH connection closed for tunnel ${result}`) - conn.removeListener('tcp connection', handleTcpConnection) - conn.removeListener('close', handleClose) - } - - conn.on('close', handleClose) - - // Forward the remote server's port to the local machine's port - conn.forwardIn(sshTunnelRemoteHost, sshTunnelRemotePort, (err) => { - if (err) { - log.error('Error forwarding port:', err) - return reject(err) - } - log.log(`Port forwarded: ${result}`) - resolve(1) - }) - }) -} - -function forwardLocalToRemote ({ - conn, - sshTunnelRemotePort, - sshTunnelLocalPort, - sshTunnelRemoteHost = '127.0.0.1', - sshTunnelLocalHost = '127.0.0.1' -}) { - return new Promise((resolve, reject) => { - const activeSockets = new Set() - const localServer = require('net').createServer((socket) => { - // ⬇️ 2. Add new sockets to the set and remove them when they close - activeSockets.add(socket) - socket.on('close', () => { - activeSockets.delete(socket) - }) - - socket.on('error', (err) => { - log.error('Client socket error:', err) - socket.end() - }) - - conn.forwardOut(sshTunnelLocalHost, sshTunnelLocalPort, sshTunnelRemoteHost, sshTunnelRemotePort, (err, remoteSocket) => { - if (err) { - log.error('Error forwarding connection:', err) - socket.destroy() - // Don't reject - just close this connection - // Rejecting would break the entire tunnel - return - } - - // Add error handlers immediately - remoteSocket.on('error', (err) => { - log.error('Remote socket error:', err) - socket.destroy() - }) - - socket.on('close', () => { - remoteSocket.destroy() - }) - - socket.pipe(remoteSocket).pipe(socket) - }) - }) - - localServer.listen(sshTunnelLocalPort, sshTunnelLocalHost, () => { - log.log(`Local server listening on port ${sshTunnelLocalPort}`) - resolve(1) - }) - localServer.on('error', (err) => { - log.error('Error listening for local connections:', err) - reject(err) - }) - - conn.on('close', () => { - log.log('SSH connection closed, closing local server.') - // ⬇️ 3. Destroy all active sockets before closing the server - for (const socket of activeSockets) { - socket.destroy() - } - localServer && localServer.close() - }) - }) -} - -function dynamicForward ({ - conn, - sshTunnelLocalPort, - sshTunnelLocalHost = '127.0.0.1' -}) { - const socks = require('socksv5-server') - return new Promise((resolve, reject) => { - const dproxyServer = socks.createServer((info, accept, deny) => { - conn.forwardOut( - info.srcAddr, - info.srcPort, - info.dstAddr, - info.dstPort, - (err, stream) => { - if (err) { - log.error('SOCKS forward error:', err) - deny() - // Don't reject - just deny this connection - // Rejecting would break the entire tunnel - return - } - const clientSocket = accept(true) - if (clientSocket) { - // Add error handling for stream immediately - stream.on('error', (err) => { - log.error('SOCKS stream error:', err) - clientSocket.destroy() - }) - - // Add error handling for client socket immediately - clientSocket.on('error', (err) => { - log.error('SOCKS client socket error:', err) - stream.destroy() - }) - - stream.on('close', () => { - clientSocket.destroy() - }) - - clientSocket.on('close', () => { - stream.destroy() - }) - - stream.pipe(clientSocket).pipe(stream) - } - }) - }) - dproxyServer.on('error', (err) => { - log.error('Error listening for local connections:', err) - reject(err) - }) - dproxyServer.listen(sshTunnelLocalPort, sshTunnelLocalHost, () => { - log.log(`SOCKS server listening on ${sshTunnelLocalHost}:${sshTunnelLocalPort}`) - resolve(1) - }).useAuth(socks.auth.None()) - - // close socks proxy when ssh connection is closed. - conn.on('close', () => { - dproxyServer && dproxyServer.close() - }) - }) -} - -exports.dynamicForward = dynamicForward -exports.forwardLocalToRemote = forwardLocalToRemote -exports.forwardRemoteToLocal = forwardRemoteToLocal diff --git a/src/app/server/ssh2-alg.js b/src/app/server/ssh2-alg.js deleted file mode 100644 index 8737571..0000000 --- a/src/app/server/ssh2-alg.js +++ /dev/null @@ -1,82 +0,0 @@ -/** - * all supported ssh2 algorithms config - */ -const nodeCrypto = require('crypto') -const browserDH = require('diffie-hellman/browser') - -nodeCrypto.createDiffieHellmanGroup = browserDH.createDiffieHellmanGroup -nodeCrypto.createDiffieHellman = browserDH.createDiffieHellman - -exports.algDefault = () => ({ - kex: [ - 'curve25519-sha256', // (node v13.9.0 or newer) - 'curve25519-sha256@libssh.org', // (node v13.9.0 or newer) - 'diffie-hellman-group14-sha256', - 'diffie-hellman-group15-sha512', - 'diffie-hellman-group16-sha512', - 'diffie-hellman-group17-sha512', - 'diffie-hellman-group18-sha512', - 'ecdh-sha2-nistp256', - 'ecdh-sha2-nistp384', - 'ecdh-sha2-nistp521', - 'diffie-hellman-group-exchange-sha256', - 'diffie-hellman-group14-sha1', - 'diffie-hellman-group-exchange-sha1', - 'diffie-hellman-group1-sha1' - ], - hmac: [ - 'hmac-sha2-256', - 'hmac-sha2-512', - 'hmac-sha1', - 'hmac-md5', - 'hmac-sha2-256-96', - 'hmac-sha2-512-96', - 'hmac-ripemd160', - 'hmac-sha1-96', - 'hmac-md5-96', - 'hmac-sha2-256-etm@openssh.com', - 'hmac-sha2-512-etm@openssh.com', - 'hmac-sha1-etm@openssh.com' - ], - compress: [ - 'zlib@openssh.com', - 'zlib', - 'none' - ] -}) - -exports.algAlt = () => ({ - ...exports.algDefault(), - cipher: [ - // 'chacha20-poly1305@openssh.com', - 'aes128-ctr', - 'aes192-ctr', - 'aes256-ctr', - 'aes128-gcm', - 'aes128-gcm@openssh.com', - 'aes256-gcm', - 'aes256-gcm@openssh.com', - 'aes256-cbc', - 'aes192-cbc', - 'aes128-cbc', - 'aes128-ctr', - 'aes192-ctr', - 'aes256-ctr', - 'blowfish-cbc', - '3des-cbc', - 'arcfour256', - 'arcfour128', - // 'cast128-cbc', - 'arcfour' - ], - serverHostKey: [ - 'ssh-rsa', - 'ssh-ed25519', - 'ecdsa-sha2-nistp256', - 'ecdsa-sha2-nistp384', - 'ecdsa-sha2-nistp521', - 'ssh-dss', - 'rsa-sha2-512', - 'rsa-sha2-256' - ] -}) diff --git a/src/app/server/sync.js b/src/app/server/sync.js deleted file mode 100644 index 1829806..0000000 --- a/src/app/server/sync.js +++ /dev/null @@ -1,65 +0,0 @@ -/** - * handle sync with github/gitee - */ - -const log = require('../common/log') -const rp = require('axios') -const { createProxyAgent } = require('../lib/proxy-agent') -const { - electermSync -} = require('electerm-sync') -const doWebdavSync = require('./webdav-sync') - -rp.defaults.proxy = false - -async function doSync (type, func, args, token, proxy) { - // Handle WebDAV sync separately - if (type === 'webdav') { - return doWebdavSync(func, args, token, proxy) - } - - const agent = createProxyAgent(proxy) - const conf = agent - ? { - httpAgent: agent, - httpsAgent: agent - } - : { - proxy: false - } - const axiosInst = rp.create(conf) - if (type === 'cloud') { - args[0] = '' - } - return electermSync(axiosInst, type, func, args, token) - .then(r => { - return r - }) - .catch(e => { - log.error('sync error') - log.error(e.message) - return { - error: e - } - }) -} - -async function wsSyncHandler (ws, msg) { - const { id, type, args, func, token, proxy } = msg - const res = await doSync(type, func, args, token, proxy) - if (res.error) { - ws.s({ - error: { - message: 'Sync data error: ' + res.error.message - }, - id - }) - } else { - ws.s({ - data: res, - id - }) - } -} - -module.exports = wsSyncHandler diff --git a/src/app/server/telnet.js b/src/app/server/telnet.js deleted file mode 100644 index aae84ec..0000000 --- a/src/app/server/telnet.js +++ /dev/null @@ -1,369 +0,0 @@ -// used code from https://github.com/Eugeny/tabby/blob/master/tabby-telnet/src/session.ts and from https://github.com/mkozjak/node-telnet-client - -const { EventEmitter } = require('events') -const { Socket } = require('net') -const { Duplex } = require('stream') -const proxySock = require('./socks') - -const TelnetCommands = { - SUBOPTION_END: 240, - GA: 249, - SUBOPTION: 250, - WILL: 251, - WONT: 252, - DO: 253, - DONT: 254, - IAC: 255 -} - -const TelnetOptions = { - ECHO: 1, - SUPPRESS_GO_AHEAD: 3, - STATUS: 5, - TERMINAL_TYPE: 24, - NEGO_WINDOW_SIZE: 31, - NEGO_TERMINAL_SPEED: 32, - REMOTE_FLOW_CONTROL: 33, - X_DISPLAY_LOCATION: 35, - NEW_ENVIRON: 39 -} - -class Stream extends Duplex { - constructor (socket, options) { - super(options) - this.socket = socket - this.socket.on('data', data => this.push(data)) - } - - _write (data, encoding, callback) { - if (!this.socket.writable && callback) { - callback(new Error('socket not writable')) - return - } - this.socket.write(data, encoding, callback) - } - - _read () {} -} - -class Telnet extends EventEmitter { - constructor (options = {}) { - super() - this.options = { - host: '127.0.0.1', - port: 23, - timeout: 5000, - negotiationMandatory: false, - username: '', - password: '', - terminalWidth: 80, - terminalHeight: 24, - loginPrompt: /login[: ]*$/i, - passwordPrompt: /password[: ]*$/i, - failedLoginMatch: /failed|incorrect|denied/i, - ...options - } - this.socket = null - this.telnetProtocol = false - this.state = 'init' - this.buffer = Buffer.alloc(0) - this.dataBuffer = '' - this.authenticated = false - this.loginAttempted = false - this.passwordAttempted = false - } - - async connect (options = {}) { - Object.assign(this.options, options) - - // If proxy is specified, establish proxied connection first - if (this.options.proxy) { - try { - const info = await proxySock({ - readyTimeout: this.options.timeout, - host: this.options.host, - port: this.options.port, - proxy: this.options.proxy - }) - this.options.sock = info.socket - } catch (error) { - this.emit('error', error) - throw error - } - } - - return new Promise((resolve, reject) => { - if (this.options.sock) { - this.socket = this.options.sock - } else { - this.socket = new Socket() - } - - this.socket.setTimeout(this.options.timeout || 0) - - this.socket.on('connect', () => { - this.state = 'connected' - this.emit('connect') - if (!this.options.negotiationMandatory) { - resolve() - } - }) - - this.socket.on('timeout', () => { - this.emit('timeout') - reject(new Error('Connection timeout')) - }) - - this.socket.on('error', (error) => { - this.emit('error', error) - reject(error) - }) - - this.socket.on('end', () => { - this.emit('end') - }) - - this.socket.on('close', () => { - this.emit('close') - }) - - this.socket.on('data', (data) => { - const processedData = this.processData(data) - if (processedData && processedData.length > 0) { - this.handleLoginSequence(processedData) - } - }) - - // If sock was provided (including from proxy), emit connect event - // Otherwise, create a new connection - if (this.options.sock) { - // Socket already connected via proxy - this.state = 'connected' - this.emit('connect') - if (!this.options.negotiationMandatory) { - resolve() - } - } else { - this.socket.connect({ - host: this.options.host, - port: this.options.port - }) - } - - this.once('telnetProtocol', () => { - this.emitTelnet(TelnetCommands.DO, TelnetOptions.SUPPRESS_GO_AHEAD) - this.emitTelnet(TelnetCommands.WILL, TelnetOptions.TERMINAL_TYPE) - this.emitTelnet(TelnetCommands.WILL, TelnetOptions.NEGO_WINDOW_SIZE) - if (this.options.negotiationMandatory) { - resolve() - } - }) - }) - } - - handleLoginSequence (data) { - if (this.authenticated) { - this.emit('data', data) - return - } - - const str = data.toString() - this.dataBuffer += str - - // Check for failed login - if (this.options.failedLoginMatch.test(this.dataBuffer)) { - this.emit('failedlogin') - this.dataBuffer = '' - return - } - - // Check for login prompt - if (!this.loginAttempted && - this.options.username && - this.options.loginPrompt.test(this.dataBuffer)) { - setTimeout(() => { - this.socket.write(this.options.username + '\n') - }, 100) - this.loginAttempted = true - this.dataBuffer = '' - return - } - - // Check for password prompt - if (!this.passwordAttempted && - this.options.password && - this.options.passwordPrompt.test(this.dataBuffer)) { - setTimeout(() => { - this.socket.write(this.options.password + '\n') - }, 100) - this.passwordAttempted = true - this.dataBuffer = '' - return - } - - // If both login and password were attempted, consider it authenticated - if (this.loginAttempted && this.passwordAttempted) { - this.authenticated = true - this.emit('data', data) - } - - // Keep only last chunk in buffer for prompt detection - if (this.dataBuffer.length > 1024) { - this.dataBuffer = this.dataBuffer.slice(-1024) - } - } - - processData (data) { - if (!this.telnetProtocol && data[0] === TelnetCommands.IAC) { - this.telnetProtocol = true - this.emit('telnetProtocol') - } - - if (this.telnetProtocol) { - data = this.processTelnetProtocol(data) - } - - if (data && data.length > 0) { - return data - } - return null - } - - processTelnetProtocol (data) { - let position = 0 - let resultBuffer = Buffer.alloc(0) - - while (position < data.length) { - if (data[position] === TelnetCommands.IAC) { - if (position + 1 >= data.length) { - this.buffer = data.slice(position) - return Buffer.concat([resultBuffer, data.slice(0, position)]) - } - - const command = data[position + 1] - - if (command === TelnetCommands.IAC) { - resultBuffer = Buffer.concat([resultBuffer, Buffer.from([TelnetCommands.IAC])]) - position += 2 - } else if ([TelnetCommands.WILL, TelnetCommands.WONT, TelnetCommands.DO, TelnetCommands.DONT].includes(command)) { - if (position + 2 >= data.length) { - this.buffer = data.slice(position) - return Buffer.concat([resultBuffer, data.slice(0, position)]) - } - - const option = data[position + 2] - this.handleTelnetCommand(command, option) - position += 3 - } else if (command === TelnetCommands.SUBOPTION) { - let endPos = position + 2 - while (endPos < data.length - 1) { - if (data[endPos] === TelnetCommands.IAC && data[endPos + 1] === TelnetCommands.SUBOPTION_END) { - break - } - endPos++ - } - - if (endPos >= data.length - 1) { - this.buffer = data.slice(position) - return Buffer.concat([resultBuffer, data.slice(0, position)]) - } - - this.handleSuboption(data.slice(position + 2, endPos)) - position = endPos + 2 - } else { - position += 2 - } - } else { - const nextIAC = data.indexOf(TelnetCommands.IAC, position) - if (nextIAC === -1) { - resultBuffer = Buffer.concat([resultBuffer, data.slice(position)]) - break - } else { - resultBuffer = Buffer.concat([resultBuffer, data.slice(position, nextIAC)]) - position = nextIAC - } - } - } - - return resultBuffer - } - - handleTelnetCommand (command, option) { - switch (command) { - case TelnetCommands.WILL: - if ([TelnetOptions.SUPPRESS_GO_AHEAD, TelnetOptions.ECHO].includes(option)) { - this.emitTelnet(TelnetCommands.DO, option) - } else { - this.emitTelnet(TelnetCommands.DONT, option) - } - break - - case TelnetCommands.DO: - if (option === TelnetOptions.NEGO_WINDOW_SIZE) { - this.emitTelnet(TelnetCommands.WILL, option) - this.sendWindowSize() - } else if (option === TelnetOptions.TERMINAL_TYPE) { - this.emitTelnet(TelnetCommands.WILL, option) - } else { - this.emitTelnet(TelnetCommands.WONT, option) - } - break - - case TelnetCommands.WONT: - case TelnetCommands.DONT: - // Do nothing - break - } - } - - handleSuboption (data) { - const option = data[0] - if (option === TelnetOptions.TERMINAL_TYPE) { - if (data[1] === 1) { // SEND - this.emitTelnetSuboption(TelnetOptions.TERMINAL_TYPE, - Buffer.from([0, ...Buffer.from('xterm')])) - } - } - } - - emitTelnet (command, option) { - this.socket.write(Buffer.from([TelnetCommands.IAC, command, option])) - } - - emitTelnetSuboption (option, value) { - this.socket.write(Buffer.from([ - TelnetCommands.IAC, - TelnetCommands.SUBOPTION, - option, - ...value, - TelnetCommands.IAC, - TelnetCommands.SUBOPTION_END - ])) - } - - sendWindowSize () { - const { terminalWidth, terminalHeight } = this.options - this.emitTelnetSuboption(TelnetOptions.NEGO_WINDOW_SIZE, Buffer.from([ - terminalWidth >> 8, terminalWidth & 0xff, - terminalHeight >> 8, terminalHeight & 0xff - ])) - } - - shell (options = {}) { - return new Stream(this.socket, options) - } - - end () { - if (this.socket) { - this.socket.end() - } - } - - destroy () { - if (this.socket) { - this.socket.destroy() - } - } -} - -exports.Telnet = Telnet diff --git a/src/app/server/terminal-api.js b/src/app/server/terminal-api.js deleted file mode 100644 index ece0474..0000000 --- a/src/app/server/terminal-api.js +++ /dev/null @@ -1,167 +0,0 @@ -/** - * run cmd with terminal - */ - -const { testConnection, terminal, terminals } = require('./session-process') - -async function runCmd (ws, msg) { - const { id, pid, cmd } = msg - const term = terminals(pid) - let txt = '' - if (term) { - txt = await term.runCmd(cmd, id) - } - ws.s({ - id, - data: txt - }) -} - -async function execCmd (ws, msg) { - const { id, pid, cmd, timeoutMs } = msg - const term = terminals(pid) - if (!term || typeof term.execCommand !== 'function') { - ws.s({ - id, - error: { - message: 'Exec channel not supported for this session type' - } - }) - return - } - try { - const result = await term.execCommand(cmd, timeoutMs, id) - ws.s({ - id, - data: result - }) - } catch (err) { - ws.s({ - id, - error: { - message: err.message, - stack: err.stack - } - }) - } -} - -function resize (ws, msg) { - const { id, pid, cols, rows } = msg - const term = terminals(pid) - if (term) { - term.resize(cols, rows, id) - } - ws.s({ - id, - data: 'ok' - }) -} - -function toggleTerminalLog (ws, msg) { - const { id, pid } = msg - const term = terminals(pid) - if (term) { - term.toggleTerminalLog(id) - } - ws.s({ - id, - data: 'ok' - }) -} - -function toggleTerminalLogTimestamp (ws, msg) { - const { id, pid } = msg - const term = terminals(pid) - if (term) { - term.toggleTerminalLogTimestamp(id) - } - ws.s({ - id, - data: 'ok' - }) -} - -function createTerm (ws, msg) { - const { id, body } = msg - terminal(body, ws, id) - .then(data => { - ws.s({ - id, - data - }) - }) - .catch(err => { - ws.s({ - id, - error: { - message: err.message, - stack: err.stack - } - }) - }) -} - -function testTerm (ws, msg) { - const { id, body } = msg - testConnection(body, ws, id) - .then(data => { - if (data) { - ws.s({ - id, - data - }) - } else { - ws.s({ - id, - error: { - message: 'test failed', - stack: 'test failed' - } - }) - } - }) - .catch(err => { - ws.s({ - id, - error: { - message: err.message || 'test failed', - stack: err.stack || 'test failed' - } - }) - }) -} - -function setTerminalLogPath (ws, msg) { - const { id, pid, logPath } = msg - const term = terminals(pid) - if (term) { - term.setTerminalLogPath(id, logPath) - } - ws.s({ - id, - data: 'ok' - }) -} - -function startTerminalLogFile (ws, msg) { - const { id, pid, logFilePath, addTimeStampToTermLog } = msg - const term = terminals(pid) - if (term) { - term.startTerminalLogFile(id, logFilePath, addTimeStampToTermLog) - } - ws.s({ - id, - data: 'ok' - }) -} - -exports.createTerm = createTerm -exports.testTerm = testTerm -exports.resize = resize -exports.runCmd = runCmd -exports.execCmd = execCmd -exports.toggleTerminalLog = toggleTerminalLog -exports.toggleTerminalLogTimestamp = toggleTerminalLogTimestamp -exports.setTerminalLogPath = setTerminalLogPath -exports.startTerminalLogFile = startTerminalLogFile diff --git a/src/app/server/transfer.js b/src/app/server/transfer.js deleted file mode 100644 index e331377..0000000 --- a/src/app/server/transfer.js +++ /dev/null @@ -1,477 +0,0 @@ -/** - * transfer class - */ - -const fs = require('original-fs') -const tar = require('tar') -const _ = require('../lib/lodash.js') -const log = require('../common/log') - -const { FolderTransfer } = require('ssh2-scp/folder-transfer') - -class Transfer { - constructor ({ - remotePath, - localPath, - options = {}, - id, - type = 'download', - sftp, - conn, - sftpId, - isDirectory = false, - ws, - encode = 'utf8' - }) { - this.id = id - const isd = type === 'download' - this.src = isd ? sftp : fs - this.dst = isd ? fs : sftp - this.sftp = sftp - this.ownsSftp = false - this.sftpId = sftpId - this.srcPath = isd ? remotePath : localPath - this.dstPath = !isd ? remotePath : localPath - this.conn = conn - this.pausing = false - this.hadError = false - this.isUpload = isd - this.isDirectory = isDirectory - this.options = options - this.concurrency = options.concurrency || 64 - this.chunkSize = options.chunkSize || 32768 - this.mode = options.mode - this.encode = encode - this.onData = _.throttle((data) => { - ws.s({ - id: 'transfer:data:' + id, - data - }) - }, 3000) - this.timers = {} - - this.ws = ws - this.initTransfer(type) - } - - shouldUseSsh2ScpTransfer = () => { - if ((this.src && this.src.isSshFsFallback) || (this.dst && this.dst.isSshFsFallback)) { - return true - } - return false - } - - initTransfer = async (type) => { - // For regular file transfers (not folder transfers, not SSH FS fallback), - // create a separate SFTP channel on the same SSH connection so that - // directory listing and other SFTP operations remain responsive. - // Each transfer gets its own dedicated channel and closes it when done. - if ( - !this.isDirectory && - this.conn && - !this.shouldUseSsh2ScpTransfer() - ) { - try { - const separateSftp = await new Promise((resolve, reject) => { - this.conn.sftp((err, sftp) => { - if (err) { - return reject(err) - } - resolve(sftp) - }) - }) - this.sftp = separateSftp - this.ownsSftp = true - const isd = type === 'download' - this.src = isd ? separateSftp : fs - this.dst = isd ? fs : separateSftp - } catch (e) { - // Fallback to the shared SFTP channel (src/dst already set in constructor) - } - } - - if (this.shouldUseFolderTransfer(type)) { - return this.ssh2ScpFolderTransfer(type) - } - if (this.shouldUseSsh2ScpTransfer()) { - return this.ssh2ScpTransfer(type) - } - this.fastXfer(type) - } - - shouldUseFolderTransfer = (type) => { - return this.isDirectory && - this.conn - } - - ssh2ScpFolderTransfer = async (type) => { - try { - const remotePath = type === 'download' ? this.srcPath : this.dstPath - const localPath = type === 'download' ? this.dstPath : this.srcPath - const folderOpts = { - type, - remotePath, - localPath, - chunkSize: this.chunkSize, - onProgress: (transferred, total) => { - this.onData({ - transferred, - total - }) - } - } - if (this.encode !== 'utf8') { - folderOpts.iconv = require('iconv-lite') - folderOpts.encoding = this.encode - } - this.scpTransfer = new FolderTransfer(this.conn, tar, folderOpts) - await this.scpTransfer.startTransfer() - const state = this.scpTransfer.getState - ? this.scpTransfer.getState() - : {} - this.onEnd({ - transferred: state.transferred, - size: state.total - }) - } catch (err) { - this.onError(err) - } - } - - ssh2ScpTransfer = async (type) => { - try { - const sshFs = type === 'download' ? this.src : this.dst - const remotePath = type === 'download' ? this.srcPath : this.dstPath - const localPath = type === 'download' ? this.dstPath : this.srcPath - const { Transfer: Ssh2ScpTransfer } = require('ssh2-scp/transfer') - this.scpTransfer = new Ssh2ScpTransfer(sshFs, { - type, - remotePath, - localPath, - chunkSize: this.chunkSize, - onProgress: (transferred) => { - this.onData(transferred) - } - }) - await this.scpTransfer.startTransfer() - this.onEnd() - } catch (err) { - this.onError(err) - } - } - - tryCreateBuffer = (size) => { - try { - return Buffer.allocUnsafe(size) - } catch (ex) { - return ex - } - } - - // from https://github.com/mscdex/ssh2-streams/blob/master/lib/sftp.js - fastXfer () { - const { src, srcPath } = this - src.open(srcPath, 'r', this.onSrcOpen) - } - - onSrcOpen = (err, sourceHandle) => { - if (err) { - return this.onError(err) - } - if (this.onDestroy) { - return - } - const { src } = this - const th = this - - th.srcHandle = sourceHandle - - src.fstat(th.srcHandle, this.tryStat) - } - - tryStat = (err, attrs) => { - const { src, dst, srcPath, dstPath } = this - const th = this - if (err) { - if (src !== fs) { - // Try stat() for sftp servers that may not support fstat() for - // whatever reason - src.stat(srcPath, (err_, attrs_) => { - if (err_) { - return th.onError(err_) - } - this.tryStat(null, attrs_) - }) - return - } - return th.onError(err) - } - this.fsize = attrs.size - dst.open(dstPath, 'w', this.onDstOpen) - } - - onDstOpen = (err, destHandle) => { - if (err) { - return this.onError(err) - } - - if (this.onDestroy) { - return - } - - let { - concurrency, - chunkSize, - mode - } = this - const onstep = this.onData - const { src, dst, dstPath } = this - const th = this - - // internal state variables - let pdst = 0 - let total = 0 - let bufsize = chunkSize * concurrency - - const { fsize } = this - - th.dstHandle = destHandle - - let hadError = false - - function onerror (err) { - if (hadError) return - hadError = true - const canCloseSrc = th.srcHandle && (src === fs || (src.outgoing && src.outgoing.state === 'open')) - const canCloseDst = th.dstHandle && (dst === fs || (dst.outgoing && dst.outgoing.state === 'open')) - - const closeHandles = () => { - let left = 0 - if (canCloseSrc) ++left - if (canCloseDst) ++left - const finish = () => { - if (--left === 0) { - if (err) th.onError(err) - else th.onEnd() - } - } - if (left === 0) { - if (err) th.onError(err) - else th.onEnd() - return - } - if (canCloseSrc) { - src.close(th.srcHandle, () => { - th.srcHandle = undefined - finish() - }) - } - if (canCloseDst) { - dst.close(th.dstHandle, () => { - th.dstHandle = undefined - finish() - }) - } - } - - // Do not preserve source file mtime on destination after transfer. - // The uploaded/downloaded file should have the transfer time as both - // create time and modify time, which is the common practice for - // SFTP/FTP clients (e.g. OpenSSH sftp, FileZilla). - closeHandles() - } - - if (fsize <= 0) { - return onerror() - } - - // Use less memory where possible - while (bufsize > fsize) { - if (concurrency === 1) { - bufsize = fsize - break - } - bufsize -= chunkSize - --concurrency - } - - const readbuf = th.tryCreateBuffer(bufsize) - if (readbuf instanceof Error) { - return th.onError(readbuf) - } - - if (mode !== undefined) { - dst.fchmod(th.dstHandle, mode, function tryAgain (err) { - if (err) { - // Try chmod() for sftp servers that may not support fchmod() for - // whatever reason - dst.chmod(dstPath, mode, function (err_) { - tryAgain() - }) - return - } - startReads() - }) - } else { - startReads() - } - - function onread (err, nb, data, dstpos, datapos, origChunkLen) { - if (hadError) { - return - } - if (err) { - return onerror(err) - } - - if (th.onDestroy) { - return - } - - datapos = datapos || 0 - - dst.write(th.dstHandle, readbuf, datapos, nb, dstpos, writeCb) - - function writeCb (err) { - if (hadError) { - return - } - if (err) { - return onerror(err) - } - - total += nb - onstep && onstep(total, nb, fsize) - - if (nb < origChunkLen) { - return singleRead(datapos, dstpos + nb, origChunkLen - nb) - } - - if (total === fsize) { - return onerror() - } - - if (pdst >= fsize) { - return - } - - const chunk = (pdst + chunkSize > fsize ? fsize - pdst : chunkSize) - singleRead(datapos, pdst, chunk) - pdst += chunk - } - } - - function makeCb (psrc, pdst, chunk) { - return function (err, nb, data) { - onread(err, nb, data, pdst, psrc, chunk) - } - } - - function singleRead (psrc, pdst, chunk) { - if (th.onDestroy || hadError) { - return - } - if (th.pausing) { - th.timers[psrc + ':' + pdst] = setTimeout(() => { - singleRead(psrc, pdst, chunk) - }, 2) - return - } - src.read( - th.srcHandle, - readbuf, - psrc, - chunk, - pdst, - makeCb(psrc, pdst, chunk) - ) - } - - function startReads () { - let reads = 0 - let psrc = 0 - while (pdst < fsize && reads < concurrency) { - const chunk = (pdst + chunkSize > fsize ? fsize - pdst : chunkSize) - singleRead(psrc, pdst, chunk) - psrc += chunk - pdst += chunk - ++reads - } - } - } - - onEnd = (data = null, id = this.id, ws = this.ws) => { - ws?.s({ - id: 'transfer:end:' + id, - data - }) - } - - onError = (err = '', id = this.id, ws = this.ws) => { - if (!err) { - return this.onEnd() - } - ws && ws.s({ - id: 'transfer:err:' + id, - error: { - message: err.message, - stack: err.stack - } - }) - } - - pause = () => { - this.pausing = true - this.scpTransfer && this.scpTransfer.pause && this.scpTransfer.pause() - } - - resume = () => { - this.pausing = false - this.scpTransfer && this.scpTransfer.resume && this.scpTransfer.resume() - } - - kill = () => { - if (this.src && this.srcHandle && this.src.close) { - this.src.close(this.srcHandle, log.error) - } - if (this.dst && this.dstHandle && this.dst.close) { - this.dst.close(this.dstHandle, log.error) - } - // Close the transfer-specific SFTP channel if we created one - if (this.ownsSftp && this.sftp && this.sftp.end) { - this.sftp.end() - } - this.src = null - this.dst = null - this.srcHandle = null - this.dstHandle = null - } - - destroy = () => { - this.onDestroy = true - this.scpTransfer && this.scpTransfer.destroy && this.scpTransfer.destroy() - setTimeout(this.kill, 200) - if (this.ws) { - this.ws.close() - this.ws = null - } - if (this.timers) { - Object.keys(this.timers).forEach(k => { - clearTimeout(this.timers[k]) - this.timers[k] = null - }) - this.timers = null - } - } - - // end -} - -module.exports = { - Transfer, - transferKeys: [ - 'pause', - 'resume', - 'destroy' - ] -} diff --git a/src/app/server/trzsz.js b/src/app/server/trzsz.js deleted file mode 100644 index b597e52..0000000 --- a/src/app/server/trzsz.js +++ /dev/null @@ -1,739 +0,0 @@ -/** - * Optimized Trzsz protocol handler for server-side terminal sessions - */ -const fs = require('fs') -const { open } = require('fs/promises') -const path = require('path') -const log = require('../common/log') -const sanitizeFilename = require('../common/sanitize-filename') -const { TrzszTransfer } = require('trzsz2') - -const TRZSZ_STATE = { - IDLE: 'idle', - RECEIVING: 'receiving', - SENDING: 'sending', - WAITING_SAVE_PATH: 'waiting_save_path' -} -const TRZSZ_MAGIC_KEY_PREFIX_BUFFER = Buffer.from('::TRZSZ:TRANSFER:') -const TRZSZ_GO_MAGIC_KEY_PREFIX_BUFFER = Buffer.from('::TRZSZGO:TRANSFER:') -const TRZSZ_SUCCESS_BUFFER = Buffer.from('Success') -const TRZSZ_SAVED_BUFFER = Buffer.from('Saved') -const TRZSZ_SAVED_FILE_BUFFER = Buffer.from('Saved file') -const TRZSZ_SAVED_DIR_BUFFER = Buffer.from('Saved directory') -const READ_CHUNK_SIZE = 10 * 1024 * 1024 -const WRITE_HIGH_WATER_MARK = 10 * 1024 * 1024 -const PROGRESS_INTERVAL_MS = 300 -const COMPLETION_TIMEOUT_MS = 5000 -/** - * Optimized FileReader - uses async I/O with adaptive buffer sizing - */ -class FileReader { - constructor (filePath, fileName) { - this.filePath = filePath - this.fileName = fileName - this.fileHandle = null - this.size = 0 - this.offset = 0 - this.pathId = 0 - this.relPath = [fileName] - this.isDirectory = false - this._cacheBuffer = null - this._cacheOffset = 0 - this._cacheEnd = 0 - } - - async open () { - const stats = fs.statSync(this.filePath) - this.size = stats.size - this.fileHandle = await open(this.filePath, 'r') - } - - getPathId () { return this.pathId } - getRelPath () { return this.relPath } - isDir () { return this.isDirectory } - getSize () { return this.size } - async readFile (buf) { - if (this._cacheBuffer && this._cacheOffset < this._cacheEnd) { - const available = this._cacheEnd - this._cacheOffset - const result = new Uint8Array(this._cacheBuffer.buffer, this._cacheBuffer.byteOffset + this._cacheOffset, available) - this._cacheOffset = this._cacheEnd - this.offset += available - return result - } - - const remaining = this.size - this.offset - if (remaining <= 0) return new Uint8Array(0) - - const readSize = Math.min(READ_CHUNK_SIZE, remaining) - if (!this._cacheBuffer || this._cacheBuffer.byteLength < readSize) { - this._cacheBuffer = Buffer.allocUnsafe(readSize) - } - const { bytesRead } = await this.fileHandle.read(this._cacheBuffer, 0, readSize, this.offset) - if (bytesRead === 0) return new Uint8Array(0) - - this.offset += bytesRead - this._cacheOffset = bytesRead - this._cacheEnd = bytesRead - return new Uint8Array(this._cacheBuffer.buffer, this._cacheBuffer.byteOffset, bytesRead) - } - - async closeFile () { - if (this.fileHandle !== null) { - await this.fileHandle.close().catch(() => {}) - this.fileHandle = null - } - this._cacheBuffer = null - this._cacheOffset = 0 - this._cacheEnd = 0 - } -} - -/** - * Optimized FileWriter - buffered writes with backpressure handling - */ -class FileWriter { - constructor (filePath, fileName) { - this.filePath = filePath - this.fileName = fileName - this.localName = fileName - this.isDirectory = false - this.writeStream = null - this._drainPromise = null - } - - getFileName () { return this.fileName } - getLocalName () { return this.localName } - isDir () { return this.isDirectory } - _ensureStream () { - if (this.writeStream === null) { - this.writeStream = fs.createWriteStream(this.filePath, { - highWaterMark: WRITE_HIGH_WATER_MARK, - flags: 'w' - }) - this.writeStream.on('error', (err) => { - log.error('FileWriter stream error:', err) - }) - } - } - - async writeFile (buf) { - this._ensureStream() - const canContinue = this.writeStream.write(buf) - if (!canContinue) { - if (!this._drainPromise) { - this._drainPromise = new Promise((resolve) => { - this.writeStream.once('drain', () => { - this._drainPromise = null - resolve() - }) - }) - } - await this._drainPromise - } - } - - async deleteFile () { - await this._closeStream() - return this.filePath - } - - async _closeStream () { - if (this.writeStream !== null) { - return new Promise((resolve) => { - this.writeStream.end(() => { - this.writeStream = null - resolve() - }) - }) - } - } - - async closeFile () { - await this._closeStream() - } -} -/** - * Optimized TrzszSession - event-driven, minimal allocations - */ -class TrzszSession { - constructor (term, ws) { - this.term = term - this.ws = ws - this.state = TRZSZ_STATE.IDLE - this.transfer = null - this.currentTransfer = null - this.downloadPath = null - this.uploadPath = null - this.transferSize = 0 - this.transferredBytes = 0 - this.startTime = 0 - this.savePath = null - this.pendingFiles = [] - this.fileReaders = [] - this.fileWriters = [] - this.lastProgressUpdate = 0 - this._pendingComplete = null - this.completedFiles = [] - this.totalBytes = 0 - this.transferStartTime = 0 - this._completionTimeout = null - this._filesResolve = null - this._savePathResolve = null - this._noDelayEnabled = false - this._cancelSuppressUntil = 0 - this._cancelSuppressTimeout = null - this._cancelling = false - } - - _setNoDelay (enabled) { - const canToggle = this.term && typeof this.term.setNoDelay === 'function' - if (!canToggle) return - if (enabled && !this._noDelayEnabled) { - this.term.setNoDelay(true) - this._noDelayEnabled = true - return - } - if (!enabled && this._noDelayEnabled) { - this.term.setNoDelay(false) - this._noDelayEnabled = false - } - } - - detectTrzszStart (data) { - const buf = Buffer.isBuffer(data) ? data : Buffer.from(data) - let idx = buf.indexOf(TRZSZ_MAGIC_KEY_PREFIX_BUFFER) - let prefixLen = TRZSZ_MAGIC_KEY_PREFIX_BUFFER.length - - if (idx < 0) { - idx = buf.indexOf(TRZSZ_GO_MAGIC_KEY_PREFIX_BUFFER) - prefixLen = TRZSZ_GO_MAGIC_KEY_PREFIX_BUFFER.length - } - - if (idx < 0) return null - const afterPrefix = idx + prefixLen - if (afterPrefix >= buf.length) return null - const direction = buf[afterPrefix] - if (direction === 82) return { type: 'send', offset: idx } - if (direction === 83) return { type: 'receive', offset: idx } - return null - } - - /** - * Send message to client via websocket - */ - sendToClient (msg) { - if (this.ws && this.ws.s) { - this.ws.s({ action: 'trzsz-event', ...msg }) - } - } - - /** - * Handle incoming data from terminal - * Returns true if data was consumed by trzsz - */ - handleData (data) { - // During cancel suppression window, absorb all data to prevent - // protocol garbage from leaking to the terminal - if (this._cancelSuppressUntil > 0) { - if (Date.now() < this._cancelSuppressUntil) { - return true - } - this._cancelSuppressUntil = 0 - } - if (this._pendingComplete) { - const buf = Buffer.isBuffer(data) ? data : Buffer.from(data) - if (buf.indexOf(TRZSZ_SUCCESS_BUFFER) >= 0) { - this.sendToClient(this._pendingComplete) - this._pendingComplete = null - this.endSession() - return true - } - if (buf.indexOf(TRZSZ_SAVED_BUFFER) >= 0) { - this.endSession() - return true - } - if (this.transfer) { - this.transfer.addReceivedData(data) - return true - } - } - if (this.state === TRZSZ_STATE.RECEIVING || this.state === TRZSZ_STATE.SENDING) { - this.transfer.addReceivedData(data) - return true - } - const detected = this.detectTrzszStart(data) - if (detected) { - // Extract any data AFTER the magic key line to feed to the buffer. - // The magic key itself is terminal output, NOT a protocol message — - // feeding it to the buffer pollutes recvLine's junk handling and can - // cause deadlocks or parse errors depending on \r\n patterns. - // This aligns with how TrzszFilter (reference impl) works: it never - // feeds the magic-key-containing output to addReceivedData. - const buf = Buffer.isBuffer(data) ? data : Buffer.from(data) - const newlineIdx = buf.indexOf(10, detected.offset) // find \n ending magic key line - const trailing = (newlineIdx >= 0 && newlineIdx + 1 < buf.length) - ? buf.slice(newlineIdx + 1) - : null - if (detected.type === 'receive') { - this.startReceiver() - if (trailing) this.transfer.addReceivedData(trailing) - } else if (detected.type === 'send') { - this.createTransfer() - this.state = TRZSZ_STATE.SENDING - if (trailing) this.transfer.addReceivedData(trailing) - this.startUploadProcess() - } - return true - } - return false - } - - _waitForFiles () { - if (this.pendingFiles.length > 0) { - return Promise.resolve(this.pendingFiles) - } - return new Promise((resolve) => { - this._filesResolve = resolve - }) - } - - async startUploadProcess () { - try { - this._cancelling = false - this._setNoDelay(true) - this.completedFiles = [] - this.totalBytes = 0 - this.transferStartTime = 0 - await this.transfer.sendAction(true, false) - - this.sendToClient({ - event: 'send-start', - message: 'TRZSZ send session started, please select files' - }) - await this.transfer.recvConfig() - - const files = await this._waitForFiles() - if (this.state !== TRZSZ_STATE.SENDING) return - this.fileReaders = files.map(file => { - const filePath = typeof file === 'string' ? file : file.path - return new FileReader(filePath, path.basename(filePath)) - }) - await Promise.all(this.fileReaders.map(r => r.open())) - this.totalBytes = this.fileReaders.reduce((sum, r) => sum + r.size, 0) - this.transferStartTime = Date.now() - - const progressCallback = this._createProgressCallback('upload') - const remoteNames = await this.transfer.sendFiles(this.fileReaders, progressCallback) - const totalElapsed = this.transferStartTime > 0 - ? (Date.now() - this.transferStartTime) / 1000 - : 0 - - this._pendingComplete = { - event: 'session-complete', - message: 'Upload complete', - files: remoteNames, - totalBytes: this.totalBytes, - totalElapsed, - avgSpeed: totalElapsed > 0 ? Math.round(this.totalBytes / totalElapsed) : 0, - completedFiles: this.completedFiles - } - this._completionTimeout = setTimeout(() => { - if (this._pendingComplete) { - log.warn('Trzsz upload: timeout waiting for server response, auto-ending session') - this.sendToClient(this._pendingComplete) - this._pendingComplete = null - this.endSession() - } - }, COMPLETION_TIMEOUT_MS) - await this.transfer.clientExit('Success') - await Promise.all(this.fileReaders.map(r => r.closeFile())) - this.state = TRZSZ_STATE.IDLE - } catch (err) { - if (this._cancelling) { - log.info('Trzsz upload cancelled by user') - } else { - log.error('Trzsz upload error:', err) - this.sendToClient({ event: 'session-error', error: err.message }) - this.endSession() - } - } - } - - _createProgressCallback (type) { - return { - onNum: (num) => { - this.sendToClient({ event: 'file-count', count: num }) - }, - onName: (name) => { - if (this.currentTransfer && this.currentTransfer.size > 0) { - this.completedFiles.push({ - name: this.currentTransfer.name, - size: this.currentTransfer.size, - ...(type === 'download' ? { path: this.downloadPath } : {}) - }) - if (type === 'download') { - this.totalBytes += this.currentTransfer.size - } - } - this.currentTransfer = { name, size: 0 } - if (!this.transferStartTime) { - this.transferStartTime = Date.now() - } - this.startTime = Date.now() - this.sendToClient({ event: 'file-start', name, size: 0 }) - }, - onSize: (size) => { - if (this.currentTransfer) this.currentTransfer.size = size - this.transferSize = size - this.sendToClient({ event: 'file-size', name: this.currentTransfer?.name, size }) - }, - onStep: (step) => { - this.transferredBytes = step - const now = Date.now() - if (now - this.lastProgressUpdate > PROGRESS_INTERVAL_MS) { - this.lastProgressUpdate = now - this.sendProgress() - } - }, - onDone: () => { - if (this.currentTransfer && this.currentTransfer.size > 0) { - this.completedFiles.push({ - name: this.currentTransfer.name, - size: this.currentTransfer.size, - ...(type === 'download' ? { path: this.downloadPath } : {}) - }) - if (type === 'download') { - this.totalBytes += this.currentTransfer.size - } - } - this.sendToClient({ - event: 'file-complete', - name: this.currentTransfer?.name, - path: type === 'download' ? this.downloadPath : this.uploadPath - }) - } - } - } - - createTransfer () { - this.transfer = new TrzszTransfer((data) => { - if (typeof data === 'string') { - if ( - data.length < 200 && - (data.includes('Saved file') || data.includes('Saved directory')) - ) { - return - } - this.writeToTerminal(data) - return - } - - // In binary mode, sendData passes Uint8Array directly. - // Convert to Buffer for reliable channel.write() compatibility. - const buf = Buffer.isBuffer(data) ? data : Buffer.from(data) - if ( - buf.length < 200 && - (buf.indexOf(TRZSZ_SAVED_FILE_BUFFER) >= 0 || - buf.indexOf(TRZSZ_SAVED_DIR_BUFFER) >= 0) - ) { - return - } - this.writeToTerminal(buf) - }, false) - - return this.transfer - } - - _waitForSavePath () { - if (this.savePath) return Promise.resolve(this.savePath) - return new Promise((resolve) => { - this._savePathResolve = resolve - }) - } - - startReceiver () { - try { - this._cancelling = false - this._setNoDelay(true) - this.createTransfer() - this.completedFiles = [] - this.totalBytes = 0 - this.transferStartTime = 0 - this.state = TRZSZ_STATE.RECEIVING - this.sendToClient({ - event: 'receive-start', - message: 'TRZSZ receive session started' - }) - this._runReceiverHandshake() - } catch (e) { - log.error('Failed to start trzsz receiver', e) - this.endSession() - } - } - - async _runReceiverHandshake () { - try { - await this.transfer.sendAction(true, false) - await this.transfer.recvConfig() - await this._waitForSavePath() - if (this.state !== TRZSZ_STATE.RECEIVING) return - await this._startFileReceiving() - } catch (err) { - log.error('Trzsz receiver handshake error:', err) - this.sendToClient({ event: 'session-error', error: err.message }) - this.endSession() - } - } - - async _startFileReceiving () { - try { - const downloadDir = this.savePath - if (!fs.existsSync(downloadDir)) { - fs.mkdirSync(downloadDir, { recursive: true }) - } - - const openSaveFile = async (saveParam, fileName, directory, overwrite) => { - const filePath = this.getUniqueFilePath(downloadDir, fileName) - const writer = new FileWriter(filePath, fileName) - this.fileWriters.push(writer) - if (this.currentTransfer) { - this.sendToClient({ - event: 'file-complete', - name: this.currentTransfer.name, - path: this.downloadPath - }) - } - this.currentTransfer = { name: fileName, size: 0 } - this.downloadPath = filePath - this.transferredBytes = 0 - this.startTime = Date.now() - this.sendToClient({ event: 'file-start', name: fileName, size: 0 }) - return writer - } - const progressCallback = this._createProgressCallback('download') - const savedFiles = await this.transfer.recvFiles( - downloadDir, - openSaveFile, - progressCallback - ) - - const savedFilePaths = savedFiles.map(name => path.join(downloadDir, sanitizeFilename(name))) - await Promise.all(this.fileWriters.map(w => w.closeFile())) - const totalElapsed = this.transferStartTime > 0 - ? (Date.now() - this.transferStartTime) / 1000 - : 0 - - this._pendingComplete = { - event: 'session-complete', - message: 'Download complete', - files: savedFilePaths, - savePath: downloadDir, - totalBytes: this.totalBytes, - totalElapsed, - avgSpeed: totalElapsed > 0 ? Math.round(this.totalBytes / totalElapsed) : 0, - completedFiles: this.completedFiles - } - this._completionTimeout = setTimeout(() => { - if (this._pendingComplete) { - log.warn('Trzsz download: timeout waiting for server response, auto-ending session') - this.sendToClient(this._pendingComplete) - this._pendingComplete = null - this.endSession() - } - }, COMPLETION_TIMEOUT_MS) - this.state = TRZSZ_STATE.IDLE - await this.transfer.clientExit('Success') - } catch (err) { - if (this._cancelling) { - log.info('Trzsz download cancelled by user') - } else { - log.error('Trzsz download error:', err) - this.sendToClient({ event: 'session-error', error: err.message }) - this.endSession() - } - } - } - - getUniqueFilePath (dir, fileName) { - const safeName = sanitizeFilename(fileName) - let filePath = path.join(dir, safeName) - if (!fs.existsSync(filePath)) return filePath - const ext = path.extname(safeName) - const baseName = path.basename(safeName, ext) - let counter = 1 - while (fs.existsSync(filePath)) { - filePath = path.join(dir, `${baseName}.${counter}${ext}`) - counter++ - } - return filePath - } - - sendProgress () { - const elapsed = (Date.now() - this.startTime) / 1000 - const speed = elapsed > 0 ? Math.round(this.transferredBytes / elapsed) : 0 - const percent = this.transferSize > 0 - ? Math.floor(this.transferredBytes * 100 / this.transferSize) - : 100 - this.sendToClient({ - event: 'progress', - name: this.currentTransfer?.name, - size: this.transferSize, - transferred: this.transferredBytes, - percent, - speed, - type: this.state === TRZSZ_STATE.RECEIVING ? 'download' : 'upload', - path: this.state === TRZSZ_STATE.RECEIVING ? this.downloadPath : this.uploadPath - }) - } - - setSavePath (savePath) { - this.savePath = savePath - if (this._savePathResolve) { - this._savePathResolve(savePath) - this._savePathResolve = null - } - } - - setSendFiles (files) { - this.pendingFiles = files - if (this._filesResolve) { - this._filesResolve(files) - this._filesResolve = null - } - } - - writeToTerminal (data) { - if (this.term && this.term.write) { - this.term.write(data) - } - } - - endSession () { - if (this._completionTimeout) { - clearTimeout(this._completionTimeout) - this._completionTimeout = null - } - if (this._cancelSuppressTimeout) { - clearTimeout(this._cancelSuppressTimeout) - this._cancelSuppressTimeout = null - } - if (this._filesResolve) { - this._filesResolve([]) - this._filesResolve = null - } - for (const writer of this.fileWriters) { - try { writer.closeFile() } catch (e) { log.error('Error closing file writer', e) } - } - for (const reader of this.fileReaders) { - try { reader.closeFile() } catch (e) { log.error('Error closing file reader', e) } - } - if (this.transfer) { - try { this.transfer.cleanup() } catch (e) { log.error('Error cleaning up transfer', e) } - } - this.sendToClient({ event: 'session-end' }) - this.state = TRZSZ_STATE.IDLE - this.transfer = null - this.currentTransfer = null - this.downloadPath = null - this.uploadPath = null - this.pendingFiles = [] - this.fileReaders = [] - this.fileWriters = [] - this.pendingData = [] - this.savePath = null - this.completedFiles = [] - this.totalBytes = 0 - this.transferStartTime = 0 - this._pendingComplete = null - this._setNoDelay(false) - } - - async cancel () { - const wasActive = this.state !== TRZSZ_STATE.IDLE - // Set cancelling flag BEFORE stopTransferring so that the - // catch blocks in startUploadProcess/_startFileReceiving - // know not to send session-error to the client - this._cancelling = true - if (this.transfer) { - try { await this.transfer.stopTransferring() } catch (e) { log.error('Error stopping transfer', e) } - } - this.endSession() - if (wasActive) { - // Suppress terminal output briefly to absorb any remaining - // protocol data from the dying remote trzsz process - const CANCEL_SUPPRESS_MS = 1000 - this._cancelSuppressUntil = Date.now() + CANCEL_SUPPRESS_MS - this._cancelSuppressTimeout = setTimeout(() => { - this._cancelSuppressUntil = 0 - this._cancelSuppressTimeout = null - // Send Enter to elicit a fresh shell prompt after suppression ends - this.writeToTerminal('\r') - }, CANCEL_SUPPRESS_MS) - // Send Ctrl+C to the remote terminal to kill the remote trzsz process - // so it doesn't hang waiting for data and eventually timeout - this.writeToTerminal('\x03') - } - // NOTE: do NOT reset _cancelling here — the async catch blocks - // in startUploadProcess/_startFileReceiving fire on the next tick - // and need to see the flag is still true. - } - - isActive () { - return this.state !== TRZSZ_STATE.IDLE || this._pendingComplete !== null || this._cancelSuppressUntil > 0 - } - - destroy () { - this.endSession() - this.term = null - this.ws = null - } -} -/** - * TrzszManager - manages sessions per terminal (unchanged API) - */ -class TrzszManager { - constructor () { - this.sessions = new Map() - } - - getSession (pid, term, ws) { - if (!this.sessions.has(pid)) { - this.sessions.set(pid, new TrzszSession(term, ws)) - } - return this.sessions.get(pid) - } - - handleData (pid, data, term, ws) { - return this.getSession(pid, term, ws).handleData(data) - } - - handleMessage (pid, msg, term, ws) { - const session = this.getSession(pid, term, ws) - switch (msg.event) { - case 'set-save-path': - session.setSavePath(msg.path) - break - case 'send-files': - session.setSendFiles(msg.files) - break - case 'cancel': - session.cancel() - break - } - } - - destroySession (pid) { - const session = this.sessions.get(pid) - if (session) { - session.destroy() - this.sessions.delete(pid) - } - } - - isActive (pid) { - const session = this.sessions.get(pid) - return session ? session.isActive() : false - } -} -const trzszManager = new TrzszManager() -module.exports = { trzszManager } diff --git a/src/app/server/webdav-sync.js b/src/app/server/webdav-sync.js deleted file mode 100644 index 6139fec..0000000 --- a/src/app/server/webdav-sync.js +++ /dev/null @@ -1,268 +0,0 @@ -/** - * handle sync with WebDAV server - */ - -const log = require('../common/log') -const rp = require('axios') -const { createProxyAgent } = require('../lib/proxy-agent') - -rp.defaults.proxy = false - -/** - * Create an axios client for WebDAV operations - */ -function createClient (serverUrl, username, password, proxy, skipVerify = false) { - const https = require('https') - - const proxyAgent = createProxyAgent(proxy) - let conf - if (proxyAgent) { - if (skipVerify) { - // apply skipVerify through the proxy - const Cls = proxy.startsWith('http') - ? require('https-proxy-agent').HttpsProxyAgent - : require('socks-proxy-agent').SocksProxyAgent - const agent = new Cls(proxy, { keepAlive: true, rejectUnauthorized: false }) - conf = { httpAgent: agent, httpsAgent: agent } - } else { - conf = { httpAgent: proxyAgent, httpsAgent: proxyAgent } - } - } else if (skipVerify) { - conf = { - httpAgent: new https.Agent({ rejectUnauthorized: false }), - httpsAgent: new https.Agent({ rejectUnauthorized: false }) - } - } else { - conf = { proxy: false } - } - - const auth = Buffer.from(`${username}:${password}`).toString('base64') - - return rp.create({ - ...conf, - baseURL: serverUrl, - headers: { - Authorization: `Basic ${auth}`, - 'Content-Type': 'application/json; charset=utf-8' - }, - // do not throw on non-2xx so we can log status codes - validateStatus: () => true - }) -} - -/** - * Ensure directory exists on WebDAV server - */ -async function ensureDir (client, dirPath) { - log.info(`[WebDAV] ensureDir: ${dirPath}`) - const res = await client.request({ - method: 'MKCOL', - url: dirPath - }) - log.info(`[WebDAV] ensureDir: ${dirPath} -> ${res.status}`) - // 201 created, 405 already exists, 200 ok - if (res.status !== 201 && res.status !== 405 && res.status !== 200) { - throw new Error(`MKCOL ${dirPath} returned ${res.status}: ${typeof res.data === 'string' ? res.data : JSON.stringify(res.data)}`) - } -} - -/** - * Upload a file to WebDAV server - */ -async function uploadFile (client, filePath, content) { - log.info(`[WebDAV] uploadFile: ${filePath}`) - const body = typeof content === 'string' ? content : JSON.stringify(content) - const res = await client.request({ - method: 'PUT', - url: filePath, - data: body, - headers: { - 'Content-Type': 'application/json; charset=utf-8' - } - }) - log.info(`[WebDAV] uploadFile: ${filePath} -> ${res.status}`) - if (res.status >= 200 && res.status < 300) { - return { success: true } - } - const msg = `PUT ${filePath} returned ${res.status}: ${typeof res.data === 'string' ? res.data : JSON.stringify(res.data)}` - log.error(`[WebDAV] ${msg}`) - return { error: { message: msg } } -} - -/** - * Download a file from WebDAV server - */ -async function downloadFile (client, filePath) { - log.info(`[WebDAV] downloadFile: ${filePath}`) - const res = await client.request({ - method: 'GET', - url: filePath - }) - log.info(`[WebDAV] downloadFile: ${filePath} -> ${res.status}`) - if (res.status === 404) { - return null - } - if (res.status >= 200 && res.status < 300) { - return typeof res.data === 'string' ? res.data : JSON.stringify(res.data) - } - const msg = `GET ${filePath} returned ${res.status}: ${typeof res.data === 'string' ? res.data : JSON.stringify(res.data)}` - log.error(`[WebDAV] ${msg}`) - return { error: { message: msg } } -} - -/** - * Test connection to WebDAV server - */ -async function test (serverUrl, username, password, proxy, skipVerify) { - const client = createClient(serverUrl, username, password, proxy, skipVerify) - try { - log.info(`[WebDAV] test: probing ${serverUrl}`) - const res = await client.request({ - method: 'PROPFIND', - url: '/', - headers: { - Depth: '0' - } - }) - log.info(`[WebDAV] test: PROPFIND / -> ${res.status}`) - if (res.status === 207 || res.status === 200) { - return { success: true, status: res.status } - } - return { error: { message: `WebDAV server returned ${res.status}: ${typeof res.data === 'string' ? res.data : JSON.stringify(res.data)}` } } - } catch (err) { - log.error('[WebDAV] test error:', err.message) - log.error('[WebDAV] test error stack:', err.stack) - return { error: { message: err.message } } - } -} - -/** - * Upload electerm data to WebDAV server - */ -async function upload (serverUrl, username, password, data, proxy, skipVerify) { - const client = createClient(serverUrl, username, password, proxy, skipVerify) - const basePath = '/electerm' - - try { - log.info(`[WebDAV] upload: starting to ${serverUrl}${basePath}`) - log.info(`[WebDAV] upload: data keys = [${Object.keys(data).join(', ')}]`) - - // Ensure electerm directory exists - await ensureDir(client, basePath) - - // Upload each file - for (const [filename, content] of Object.entries(data)) { - const filePath = `${basePath}/${filename}` - const result = await uploadFile(client, filePath, content) - if (result.error) { - return { error: { message: `Failed to upload ${filename}: ${result.error.message}` } } - } - } - - log.info('[WebDAV] upload: complete') - return { success: true } - } catch (err) { - log.error('[WebDAV] upload error:', err.message) - log.error('[WebDAV] upload error stack:', err.stack) - return { error: { message: err.message } } - } -} - -/** - * Download electerm data from WebDAV server - */ -async function download (serverUrl, username, password, proxy, skipVerify) { - const client = createClient(serverUrl, username, password, proxy, skipVerify) - const basePath = '/electerm' - - try { - log.info(`[WebDAV] download: starting from ${serverUrl}${basePath}`) - - const result = { - files: {} - } - - const fileList = [ - 'settings.json', - 'bookmarks.json', - 'bookmarkGroups.json', - 'terminalThemes.json', - 'quickCommands.json', - 'profiles.json', - 'addressBookmarks.json', - 'workspaces.json', - 'userConfig.json', - 'electerm-status.json', - 'settings.order.json', - 'bookmarks.order.json', - 'bookmarkGroups.order.json', - 'terminalThemes.order.json', - 'quickCommands.order.json', - 'profiles.order.json', - 'addressBookmarks.order.json', - 'workspaces.order.json' - ] - - for (const filename of fileList) { - const filePath = `${basePath}/${filename}` - const content = await downloadFile(client, filePath) - if (content && typeof content === 'string') { - result.files[filename] = { - content - } - log.info(`[WebDAV] download: got ${filename} (${content.length} chars)`) - } - } - - log.info(`[WebDAV] download: complete, got ${Object.keys(result.files).length} files`) - return result - } catch (err) { - log.error('[WebDAV] download error:', err.message) - log.error('[WebDAV] download error stack:', err.stack) - return { error: { message: err.message } } - } -} - -/** - * Main WebDAV sync handler - */ -async function doWebdavSync (func, args, token, proxy) { - log.info(`[WebDAV] doWebdavSync: func=${func}`) - - // token format: serverUrl####username####password - const parts = token ? token.split('####') : [] - const serverUrl = parts[0] || '' - const username = parts[1] || '' - const password = parts[2] || '' - const skipVerify = parts[3] === 'true' - - log.info(`[WebDAV] serverUrl=${serverUrl}, username=${username}`) - - if (!serverUrl) { - const msg = 'WebDAV server URL is not configured' - log.error(`[WebDAV] ${msg}`) - return { error: { message: msg } } - } - - try { - switch (func) { - case 'test': - return await test(serverUrl, username, password, proxy, skipVerify) - case 'upload': - return await upload(serverUrl, username, password, args[0], proxy, skipVerify) - case 'download': - return await download(serverUrl, username, password, proxy, skipVerify) - default: { - const msg = `Unknown WebDAV function: ${func}` - log.error(`[WebDAV] ${msg}`) - return { error: { message: msg } } - } - } - } catch (err) { - log.error('[WebDAV] sync error:', err.message) - log.error('[WebDAV] sync error stack:', err.stack) - return { error: { message: err.message } } - } -} - -module.exports = doWebdavSync diff --git a/src/app/server/ws-dec.js b/src/app/server/ws-dec.js deleted file mode 100644 index 4392641..0000000 --- a/src/app/server/ws-dec.js +++ /dev/null @@ -1,31 +0,0 @@ -/** - * add ws.s function - * @param {*} ws - */ - -const log = require('../common/log') - -const wsDec = (ws) => { - ws.s = msg => { - try { - ws.send(JSON.stringify(msg)) - } catch (e) { - log.error('ws send error') - log.error(e) - } - } - ws.on('error', log.error) - ws.once = (callack, id) => { - const func = (evt) => { - const arg = JSON.parse(evt.data) - if (id === arg.id) { - callack(arg) - ws.removeEventListener('message', func) - } - } - ws.addEventListener('message', func) - } - ws._socket.setKeepAlive(true, 30 * 1000) -} - -module.exports = wsDec diff --git a/src/app/server/xmodem.js b/src/app/server/xmodem.js deleted file mode 100644 index 2efd7f9..0000000 --- a/src/app/server/xmodem.js +++ /dev/null @@ -1,940 +0,0 @@ -/** - * XMODEM protocol handler for serial port file transfers - * Supports XMODEM-CRC (128-byte) and XMODEM-1K (1024-byte) modes - */ - -const fs = require('fs') -const path = require('path') -const log = require('../common/log') -const generate = require('../common/uid') -const sanitizeFilename = require('../common/sanitize-filename') - -// XMODEM control characters -const SOH = 0x01 // Start of 128-byte block -const STX = 0x02 // Start of 1024-byte block -const EOT = 0x04 // End of transmission -const ACK = 0x06 // Acknowledge -const NAK = 0x15 // Negative acknowledge -const CAN = 0x18 // Cancel -const CRC = 0x43 // 'C' - request CRC mode - -// Packet sizes -const PACKET_SIZE_128 = 128 -const PACKET_SIZE_1K = 1024 -// Header: SOH/STX(1) + blockNum(1) + ~blockNum(1) -const HEADER_SIZE = 3 -// Trailer: CRC-16(2) or checksum(1) -const CRC_TRAILER_SIZE = 2 -const CHECKSUM_TRAILER_SIZE = 1 - -// Protocol constants -const MAX_RETRIES = 10 -const RECEIVE_TIMEOUT_MS = 10000 // 10s timeout waiting for packet -const SEND_ACK_TIMEOUT_MS = 10000 // 10s timeout waiting for ACK -const PROGRESS_INTERVAL_MS = 500 - -// XMODEM session states -const XMODEM_STATE = { - IDLE: 'idle', - WAITING_REMOTE: 'waiting_remote', // Waiting for remote to start protocol - RECEIVING: 'receiving', - SENDING: 'sending', - WAITING_SAVE_PATH: 'waiting_save_path', - WAITING_FILES: 'waiting_files' -} - -/** - * CRC-16/XMODEM calculation - * @param {Buffer} data - * @returns {number} CRC-16 value - */ -function crc16Xmodem (data) { - let crc = 0 - for (let i = 0; i < data.length; i++) { - crc = crc ^ (data[i] << 8) - for (let j = 0; j < 8; j++) { - if (crc & 0x8000) { - crc = (crc << 1) ^ 0x1021 - } else { - crc = crc << 1 - } - } - crc = crc & 0xFFFF - } - return crc -} - -/** - * XmodemSession handles XMODEM file transfers for a terminal session - */ -class XmodemSession { - constructor (term, ws) { - this.term = term - this.ws = ws - this.state = XMODEM_STATE.IDLE - this.useCrc = true // Prefer CRC mode - this.use1K = false // 1K packets - - // Receive state - this.downloadStream = null - this.downloadPath = null - this.savePath = null - this.receiveFileName = null - this.expectedBlock = 1 - this.receiveBuffer = Buffer.alloc(0) - this.receiveTimeout = null - this.retries = 0 - - // Send state - this.uploadPath = null - this.uploadFd = null - this.sendBlock = 1 - this.sendSize = 0 - this.sentBytes = 0 - this.currentTransfer = null - this.transferSize = 0 - this.transferredBytes = 0 - this.startTime = 0 - this.sendTimeout = null - this.pendingFiles = [] - this.currentFileIndex = 0 - this.pendingSendData = [] - - // Progress - this.lastProgressUpdate = 0 - } - - /** - * Send message to client via websocket - */ - sendToClient (msg) { - if (this.ws && this.ws.s) { - this.ws.s({ - action: 'xmodem-event', - ...msg - }) - } - } - - /** - * Write data to the serial port / terminal. - * Uses writeRaw (if available) to bypass txLineEnding transformation, - * which would corrupt binary XMODEM protocol bytes (e.g. block# 0x0D = '\r'). - */ - writeToTerminal (data) { - if (!this.term) return - if (this.term.writeRaw) { - this.term.writeRaw(data) - } else if (this.term.write) { - this.term.write(data) - } - } - - /** - * Start XMODEM receive - waits for remote to send SOH/STX packets - */ - startReceive () { - this.state = XMODEM_STATE.WAITING_REMOTE - this.expectedBlock = 1 - this.receiveBuffer = Buffer.alloc(0) - this.retries = 0 - this.useCrc = true - this.use1K = false - - this.sendToClient({ - event: 'receive-start', - message: 'XMODEM receive started. Waiting for remote to send file...' - }) - - // Start timeout - if remote doesn't start sending within timeout, cancel - this.resetReceiveTimeout() - } - - /** - * Start XMODEM send - waits for remote to send NAK or 'C' - */ - startSend () { - this.state = XMODEM_STATE.WAITING_REMOTE - this.sendBlock = 1 - this.sentBytes = 0 - this.retries = 0 - - this.sendToClient({ - event: 'send-start', - message: 'XMODEM send started. Waiting for remote to request file...' - }) - - // Start timeout - this.resetReceiveTimeout() - } - - /** - * Handle incoming data from terminal - * @param {Buffer} data - * @returns {boolean} true if data was consumed by XMODEM - */ - handleData (data) { - if (!Buffer.isBuffer(data)) { - data = Buffer.from(data) - } - - // Waiting for save path - buffer data - if (this.state === XMODEM_STATE.WAITING_SAVE_PATH) { - this.receiveBuffer = Buffer.concat([this.receiveBuffer, data]) - return true - } - - // Waiting for files to be selected - if (this.state === XMODEM_STATE.WAITING_FILES) { - this.pendingSendData.push(data) - return true - } - - // Actively receiving file data - if (this.state === XMODEM_STATE.RECEIVING) { - this.handleReceiveData(data) - return true - } - - // Actively sending - look for ACK/NAK/CAN - if (this.state === XMODEM_STATE.SENDING) { - this.handleSendResponse(data) - return true - } - - // Waiting for remote to start protocol - if (this.state === XMODEM_STATE.WAITING_REMOTE) { - if (this.pendingFiles.length > 0) { - // Send mode: look for NAK or 'C' from remote - return this.handleSendWaitData(data) - } else { - // Receive mode: look for SOH/STX from remote - return this.handleReceiveWaitData(data) - } - } - - return false - } - - /** - * Handle data while waiting for remote to start sending (receive mode) - */ - handleReceiveWaitData (data) { - for (let i = 0; i < data.length; i++) { - const byte = data[i] - if (byte === SOH || byte === STX) { - // Remote started sending a packet - this.clearReceiveTimeout() - this.state = XMODEM_STATE.RECEIVING - this.receiveBuffer = Buffer.alloc(0) - this.handleReceiveData(data.subarray(i)) - return true - } - } - // No SOH/STX found yet - still waiting - return true - } - - /** - * Handle data while waiting for remote to request file (send mode) - */ - handleSendWaitData (data) { - for (let i = 0; i < data.length; i++) { - const byte = data[i] - if (byte === NAK) { - // Remote requests checksum mode - this.clearReceiveTimeout() - this.useCrc = false - this.retries = 0 - this.sendNextPacket() - return true - } else if (byte === CRC) { - // Remote requests CRC mode - this.clearReceiveTimeout() - this.useCrc = true - this.retries = 0 - this.sendNextPacket() - return true - } - } - return true - } - - /** - * Handle data during active receive - */ - handleReceiveData (data) { - this.clearReceiveTimeout() - this.receiveBuffer = Buffer.concat([this.receiveBuffer, data]) - - // Try to parse a complete packet - while (this.receiveBuffer.length > 0) { - const firstByte = this.receiveBuffer[0] - - if (firstByte === EOT) { - // End of transmission - this.receiveBuffer = this.receiveBuffer.subarray(1) - this.handleReceiveComplete() - return - } - - if (firstByte === CAN) { - // Remote cancelled - this.sendToClient({ - event: 'session-error', - error: 'Remote cancelled transfer' - }) - this.endSession() - return - } - - if (firstByte !== SOH && firstByte !== STX) { - // Skip non-protocol bytes - this.receiveBuffer = this.receiveBuffer.subarray(1) - continue - } - - const packetSize = firstByte === SOH ? PACKET_SIZE_128 : PACKET_SIZE_1K - const totalPacketSize = HEADER_SIZE + packetSize + (this.useCrc ? CRC_TRAILER_SIZE : CHECKSUM_TRAILER_SIZE) - - if (this.receiveBuffer.length < totalPacketSize) { - // Not enough data yet, wait for more - break - } - - const packet = this.receiveBuffer.subarray(0, totalPacketSize) - this.receiveBuffer = this.receiveBuffer.subarray(totalPacketSize) - - this.processReceivedPacket(firstByte, packet) - } - - // Reset timeout for next packet - this.resetReceiveTimeout() - } - - /** - * Process a received XMODEM packet - */ - processReceivedPacket (headerByte, packet) { - const blockNum = packet[1] - const blockNumInv = packet[2] - const dataStart = HEADER_SIZE - const dataEnd = dataStart + (headerByte === SOH ? PACKET_SIZE_128 : PACKET_SIZE_1K) - const data = packet.subarray(dataStart, dataEnd) - - // Validate block number complement - if ((blockNum ^ blockNumInv) !== 0xFF) { - log.warn('XMODEM: block number complement mismatch') - this.sendNak() - return - } - - // Validate CRC or checksum - if (this.useCrc) { - const receivedCrc = (packet[dataEnd] << 8) | packet[dataEnd + 1] - const calculatedCrc = crc16Xmodem(data) - if (receivedCrc !== calculatedCrc) { - log.warn('XMODEM: CRC mismatch') - this.sendNak() - return - } - } else { - let checksum = 0 - for (let i = 0; i < data.length; i++) { - checksum = (checksum + data[i]) & 0xFF - } - if (checksum !== packet[dataEnd]) { - log.warn('XMODEM: checksum mismatch') - this.sendNak() - return - } - } - - // Validate block sequence - if (blockNum !== (this.expectedBlock & 0xFF)) { - // Could be a retransmit of the previous block - if (blockNum === ((this.expectedBlock - 1) & 0xFF)) { - // Retransmit - just ACK it - this.sendAck() - return - } - log.warn(`XMODEM: expected block ${this.expectedBlock & 0xFF}, got ${blockNum}`) - this.sendCan() - this.endSession() - return - } - - // Valid packet - write data - if (!this.downloadStream) { - this.prepareReceiveFile() - } - - if (this.downloadStream) { - this.downloadStream.write(data) - this.transferredBytes += data.length - this.sendProgress() - } - - this.expectedBlock++ - this.retries = 0 - this.sendAck() - } - - /** - * Prepare to receive file - */ - prepareReceiveFile () { - if (!this.savePath) return - - // Use original filename if provided, otherwise generate one - const fileName = this.receiveFileName || `xmodem_${Date.now()}.bin` - let filePath = path.join(this.savePath, sanitizeFilename(fileName)) - - if (fs.existsSync(filePath)) { - filePath = filePath + '.' + generate() - } - - this.downloadPath = filePath - this.downloadStream = fs.createWriteStream(filePath, { - highWaterMark: 64 * 1024 - }) - this.transferredBytes = 0 - this.startTime = Date.now() - - this.sendToClient({ - event: 'file-start', - name: fileName, - size: 0 // XMODEM doesn't know size upfront - }) - } - - /** - * Handle receive complete (EOT received) - */ - handleReceiveComplete () { - // Send ACK for EOT - this.writeToTerminal(Buffer.from([ACK])) - - if (this.downloadStream) { - this.downloadStream.end() - this.downloadStream = null - } - - this.sendProgress() - - this.sendToClient({ - event: 'file-complete', - name: path.basename(this.downloadPath || ''), - path: this.downloadPath - }) - - this.sendToClient({ - event: 'session-end' - }) - - this.resetState() - } - - /** - * Send ACK to remote - */ - sendAck () { - this.writeToTerminal(Buffer.from([ACK])) - } - - /** - * Send NAK to remote - */ - sendNak () { - this.retries++ - if (this.retries > MAX_RETRIES) { - log.error('XMODEM: max retries exceeded') - this.sendCan() - this.endSession() - return - } - this.writeToTerminal(Buffer.from([NAK])) - this.resetReceiveTimeout() - } - - /** - * Send CAN (cancel) to remote - */ - sendCan () { - this.writeToTerminal(Buffer.from([CAN, CAN, CAN, CAN, CAN])) - } - - /** - * Reset receive timeout - */ - resetReceiveTimeout () { - this.clearReceiveTimeout() - this.receiveTimeout = setTimeout(() => { - if (this.state === XMODEM_STATE.RECEIVING) { - this.sendNak() - } else if (this.state === XMODEM_STATE.WAITING_REMOTE) { - this.retries++ - if (this.retries > MAX_RETRIES) { - this.sendToClient({ - event: 'session-error', - error: 'Timeout waiting for remote' - }) - this.endSession() - return - } - // In receive mode, send NAK to prompt remote to start - // In send mode, do nothing - remote must initiate - if (this.pendingFiles.length === 0) { - this.writeToTerminal(Buffer.from([NAK])) - } - this.resetReceiveTimeout() - } - }, RECEIVE_TIMEOUT_MS) - } - - /** - * Clear receive timeout - */ - clearReceiveTimeout () { - if (this.receiveTimeout) { - clearTimeout(this.receiveTimeout) - this.receiveTimeout = null - } - } - - /** - * Set save path for receiving files - */ - setSavePath (savePath, name) { - this.savePath = savePath - this.receiveFileName = name || null - // Process buffered data - if (this.receiveBuffer.length > 0) { - const buffered = this.receiveBuffer - this.receiveBuffer = Buffer.alloc(0) - this.state = XMODEM_STATE.RECEIVING - this.handleReceiveData(buffered) - } else { - this.state = XMODEM_STATE.WAITING_REMOTE - this.resetReceiveTimeout() - } - } - - /** - * Set files to send - */ - setSendFiles (files) { - this.pendingFiles = files - this.currentFileIndex = 0 - - // Process any buffered data (may contain NAK/C from remote) - if (this.pendingSendData.length > 0) { - for (const data of this.pendingSendData) { - this.handleSendWaitData(data) - } - this.pendingSendData = [] - } - - // If we already detected NAK/C and are ready to send, start - if (files.length > 0 && this.state === XMODEM_STATE.WAITING_REMOTE) { - // Remote hasn't sent NAK/C yet, keep waiting - this.resetReceiveTimeout() - } - } - - /** - * Send next packet to remote - */ - sendNextPacket () { - if (this.currentFileIndex >= this.pendingFiles.length) { - // All files sent - this.sendEot() - return - } - - const file = this.pendingFiles[this.currentFileIndex] - - // Open file if not already open - if (!this.uploadFd) { - try { - this.uploadFd = fs.openSync(file.path, 'r') - this.sendSize = file.size - this.sentBytes = 0 - this.sendBlock = 1 - - this.currentTransfer = { - name: file.name, - size: file.size - } - this.transferSize = file.size - this.transferredBytes = 0 - this.startTime = Date.now() - - this.sendToClient({ - event: 'file-start', - name: file.name, - size: file.size - }) - } catch (e) { - log.error('XMODEM: failed to open file', e) - this.sendCan() - this.endSession() - return - } - } - - // Read next chunk - const packetSize = this.use1K ? PACKET_SIZE_1K : PACKET_SIZE_128 - const remaining = this.sendSize - this.sentBytes - - if (remaining <= 0) { - // File done, send EOT - fs.closeSync(this.uploadFd) - this.uploadFd = null - this.sendEot() - return - } - - const readSize = Math.min(packetSize, remaining) - const buf = Buffer.alloc(packetSize) // Pad with 0x1A (SUB) if needed - buf.fill(0x1A) // XMODEM pads with SUB (0x1A) - - try { - fs.readSync(this.uploadFd, buf, 0, readSize, this.sentBytes) - } catch (e) { - log.error('XMODEM: failed to read file', e) - this.sendCan() - this.endSession() - return - } - - // Build packet - const headerByte = this.use1K ? STX : SOH - const blockNum = this.sendBlock & 0xFF - const packet = Buffer.alloc(HEADER_SIZE + packetSize + (this.useCrc ? CRC_TRAILER_SIZE : CHECKSUM_TRAILER_SIZE)) - - packet[0] = headerByte - packet[1] = blockNum - packet[2] = blockNum ^ 0xFF - buf.copy(packet, HEADER_SIZE, 0, packetSize) - - if (this.useCrc) { - const crc = crc16Xmodem(buf.subarray(0, packetSize)) - packet[HEADER_SIZE + packetSize] = (crc >> 8) & 0xFF - packet[HEADER_SIZE + packetSize + 1] = crc & 0xFF - } else { - let checksum = 0 - for (let i = 0; i < packetSize; i++) { - checksum = (checksum + buf[i]) & 0xFF - } - packet[HEADER_SIZE + packetSize] = checksum - } - - this.writeToTerminal(packet) - this.state = XMODEM_STATE.SENDING - this.sentBytes += readSize - this.transferredBytes = this.sentBytes - - // Progress update - const now = Date.now() - if (!this.lastProgressUpdate || now - this.lastProgressUpdate > PROGRESS_INTERVAL_MS) { - this.lastProgressUpdate = now - this.sendProgress() - } - - // Timeout waiting for ACK - this.resetSendTimeout() - } - - /** - * Handle response during send (ACK/NAK/CAN) - */ - handleSendResponse (data) { - this.clearSendTimeout() - - for (let i = 0; i < data.length; i++) { - const byte = data[i] - - if (byte === ACK) { - // Block acknowledged - this.sendBlock++ - this.retries = 0 - this.sendNextPacket() - return - } else if (byte === NAK) { - // Retransmit current block - this.retries++ - if (this.retries > MAX_RETRIES) { - log.error('XMODEM: max retries exceeded during send') - this.sendCan() - this.endSession() - return - } - // Re-read and resend the same block (keep sendBlock unchanged – same block#) - this.sentBytes -= (this.use1K ? PACKET_SIZE_1K : PACKET_SIZE_128) - if (this.sentBytes < 0) this.sentBytes = 0 - this.sendNextPacket() - return - } else if (byte === CAN) { - // Remote cancelled - this.sendToClient({ - event: 'session-error', - error: 'Remote cancelled transfer' - }) - this.endSession() - return - } - } - } - - /** - * Send EOT (end of transmission) - */ - sendEot () { - // Guard: if already past SENDING (e.g. called again via ACK→sendNextPacket - // after we already sent EOT), skip to avoid infinite EOT loop. - if (this.state !== XMODEM_STATE.SENDING && - this.state !== XMODEM_STATE.WAITING_REMOTE) { - return - } - - this.writeToTerminal(Buffer.from([EOT])) - - // Wait for ACK of EOT - this.resetSendTimeout() - - // Send final progress - if (this.currentTransfer) { - this.transferredBytes = this.transferSize - this.sendProgress() - } - - this.sendToClient({ - event: 'file-complete', - name: this.currentTransfer?.name, - path: this.uploadPath - }) - - this.sendToClient({ - event: 'session-end' - }) - - // Reset state so isActive() returns false and normal terminal I/O resumes. - // This mirrors handleReceiveComplete() which also calls resetState() after - // sending session-end. - this.resetState() - } - - /** - * Reset send timeout - */ - resetSendTimeout () { - this.clearSendTimeout() - this.sendTimeout = setTimeout(() => { - if (this.state === XMODEM_STATE.SENDING) { - this.retries++ - if (this.retries > MAX_RETRIES) { - this.sendToClient({ - event: 'session-error', - error: 'Timeout waiting for ACK' - }) - this.endSession() - return - } - // Resend EOT if we already sent it, otherwise resend packet - if (this.sentBytes >= this.sendSize) { - this.writeToTerminal(Buffer.from([EOT])) - } else { - // Resend current block (keep sendBlock unchanged – same block# must be retransmitted) - this.sentBytes -= (this.use1K ? PACKET_SIZE_1K : PACKET_SIZE_128) - if (this.sentBytes < 0) this.sentBytes = 0 - this.sendNextPacket() - } - this.resetSendTimeout() - } - }, SEND_ACK_TIMEOUT_MS) - } - - /** - * Clear send timeout - */ - clearSendTimeout () { - if (this.sendTimeout) { - clearTimeout(this.sendTimeout) - this.sendTimeout = null - } - } - - /** - * Send progress update to client - */ - sendProgress () { - const elapsed = (Date.now() - this.startTime) / 1000 - const speed = elapsed > 0 ? Math.round(this.transferredBytes / elapsed) : 0 - const percent = this.transferSize > 0 - ? Math.floor(this.transferredBytes * 100 / this.transferSize) - : 0 - - this.sendToClient({ - event: 'progress', - name: this.currentTransfer?.name, - size: this.transferSize, - transferred: this.transferredBytes, - percent, - speed, - type: this.state === XMODEM_STATE.RECEIVING ? 'download' : 'upload', - path: this.state === XMODEM_STATE.RECEIVING ? this.downloadPath : this.uploadPath - }) - } - - /** - * Cancel transfer - */ - cancel () { - this.sendCan() - this.endSession() - } - - /** - * End session and reset state - */ - endSession () { - this.clearReceiveTimeout() - this.clearSendTimeout() - - if (this.downloadStream) { - try { this.downloadStream.end() } catch (e) { log.error('Error closing download stream', e) } - this.downloadStream = null - } - - if (this.uploadFd) { - try { fs.closeSync(this.uploadFd) } catch (e) { log.error('Error closing upload file', e) } - this.uploadFd = null - } - - if (this.currentTransfer) { - this.transferredBytes = this.transferSize - this.sendProgress() - } - - this.sendToClient({ event: 'session-end' }) - this.resetState() - } - - /** - * Reset session state - */ - resetState () { - this.clearReceiveTimeout() - this.clearSendTimeout() - this.state = XMODEM_STATE.IDLE - this.downloadStream = null - this.downloadPath = null - this.savePath = null - this.receiveFileName = null - this.expectedBlock = 1 - this.receiveBuffer = Buffer.alloc(0) - this.retries = 0 - this.uploadPath = null - this.uploadFd = null - this.sendBlock = 1 - this.sendSize = 0 - this.sentBytes = 0 - this.currentTransfer = null - this.transferSize = 0 - this.transferredBytes = 0 - this.startTime = 0 - this.pendingFiles = [] - this.currentFileIndex = 0 - this.pendingSendData = [] - this.lastProgressUpdate = 0 - } - - /** - * Check if session is active - */ - isActive () { - return this.state !== XMODEM_STATE.IDLE - } - - /** - * Clean up resources - */ - destroy () { - this.endSession() - this.term = null - this.ws = null - } -} - -/** - * XmodemManager manages XMODEM sessions for multiple terminals - */ -class XmodemManager { - constructor () { - this.sessions = new Map() - } - - getSession (pid, term, ws) { - if (!this.sessions.has(pid)) { - this.sessions.set(pid, new XmodemSession(term, ws)) - } - return this.sessions.get(pid) - } - - /** - * Handle data for a terminal - * @returns {boolean} true if data was consumed - */ - handleData (pid, data, term, ws) { - const session = this.getSession(pid, term, ws) - return session.handleData(data) - } - - /** - * Handle client message - */ - handleMessage (pid, msg, term, ws) { - const session = this.getSession(pid, term, ws) - - switch (msg.event) { - case 'set-save-path': - session.setSavePath(msg.path, msg.name) - break - case 'send-files': - session.setSendFiles(msg.files) - break - case 'cancel': - session.cancel() - break - case 'start-receive': - session.startReceive() - break - case 'start-send': - session.startSend() - break - } - } - - destroySession (pid) { - const session = this.sessions.get(pid) - if (session) { - session.destroy() - this.sessions.delete(pid) - } - } - - isActive (pid) { - const session = this.sessions.get(pid) - return session ? session.isActive() : false - } -} - -const xmodemManager = new XmodemManager() - -module.exports = { - XmodemSession, - XmodemManager, - xmodemManager, - XMODEM_STATE -} diff --git a/src/app/server/zmodem.js b/src/app/server/zmodem.js deleted file mode 100644 index a302152..0000000 --- a/src/app/server/zmodem.js +++ /dev/null @@ -1,1259 +0,0 @@ -/** - * Zmodem protocol handler for server-side terminal sessions - * Uses zmodem2 (pure JS) for protocol implementation - * - * Design notes: - * - Detection: zmodem headers (** ZDLE B ...) are detected on a small - * carry-over buffer so a header split across pty read chunks is still - * found. `detect()` returns the data unconsumed when no session is - * active, so normal terminal output is never swallowed. - * - ZSKIP (remote refuses a file, e.g. rz aborted on name clash): the - * hex frame is scanned on the same carry buffer. On skip we abort the - * whole batch with the canonical cancel sequence (rz does not offer a - * reliable "next file" path when its UI already exited) and let the - * trailing garbage drain to the terminal - that is the shell/rz error - * output the user needs to see. - * - Watchdog: every state transition (awaiting save path / file dialog, - * awaiting protocol reply, mid-transfer) arms a timer. A stalled - * session always ends itself instead of hanging forever with the - * terminal frozen. - * - All cleanup goes through `_cleanup()`. `endSession()` is the single - * public reset path so `isActive()` always returns false after any - * failure, which is what restores normal terminal display. - */ - -const fs = require('fs') -const path = require('path') -const log = require('../common/log') -const generate = require('../common/uid') -const sanitizeFilename = require('../common/sanitize-filename') - -// Import zmodem2 (pure JS, no WASM) -const { Sender, Receiver, SenderEvent, ReceiverEvent } = require('zmodem2') - -// Zmodem state constants -const ZMODEM_STATE = { - IDLE: 'idle', - RECEIVING: 'receiving', - SENDING: 'sending', - WAITING_SAVE_PATH: 'waiting_save_path', - WAITING_FILES: 'waiting_files' -} - -// Zmodem header signature: ** + ZDLE(0x18) + B(0x42) -const ZMODEM_HEADER = Buffer.from([0x2a, 0x2a, 0x18, 0x42]) - -// ZRQINIT = "00" (remote wants to send -> we receive) -const ZRQINIT_HEX = Buffer.from([0x30, 0x30]) -// ZRINIT = "01" (remote ready to receive -> we send) -const ZRINIT_HEX = Buffer.from([0x30, 0x31]) -// ZSKIP = "05" (remote refuses current file) -const ZSKIP_HEX = Buffer.from([0x30, 0x35]) - -// Cancel sequence per ZMODEM spec: 8x CAN (0x18) then "B". Some peers -// only react to the 5-CAN form, 8 covers both. -const CANCEL_SEQUENCE = Buffer.from([0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x42]) - -// Watchdog timeouts (ms) -const WATCHDOG = { - // waiting for user to pick save folder / files - USER_ACTION: 10 * 60 * 1000, - // waiting for protocol reply (ZRINIT after ZRQINIT etc.) - HANDSHAKE: 30 * 1000, - // mid-transfer silence - TRANSFER: 60 * 1000 -} - -// Max bytes buffered while sniffing for a split header or waiting for -// user action. Anything beyond this is treated as a dead/aborted peer. -const MAX_BUFFERED_BYTES = 4 * 1024 * 1024 - -// A peer aborted mid-transfer (user hit Ctrl-C on the remote rz/sz) -// announces it with a run of CAN (0x18) bytes. 4+ in a row can never -// appear in payload data on the wire: every literal 0x18 inside file -// data is ZDLE-escaped, so a raw run is always intentional. -const MIN_CAN_RUN = 4 - -// After a session ends (completion, cancel, or remote abort) the dying -// peer keeps emitting protocol debris for a moment — pty buffer flushes -// can hold several KB of in-flight frames. During this window output is -// swallowed until the shell prompt reappears (or the window expires, so -// display always recovers). -const NOISE_SUPPRESS_MS = 3000 - -/** - * ZmodemSession class handles zmodem file transfers for a terminal session - */ -class ZmodemSession { - constructor (term, ws) { - this.term = term - this.ws = ws - this.state = ZMODEM_STATE.IDLE - this.receiver = null - this.sender = null - this.currentTransfer = null - this.downloadStream = null // Write stream for file download - this.downloadPath = null - this.uploadFd = null // File descriptor for upload read - this.uploadPath = null - this.transferSize = 0 - this.transferredBytes = 0 - this.startTime = 0 - this.lastProgressUpdate = 0 - this.savePath = null - this.pendingFiles = [] - this.currentFileIndex = 0 - this.fileReadPosition = 0 - this.currentMtime = 0 - - // Carry buffer holds wire data while waiting for user action. In idle - // state a separately displayed tail is retained for split-header scans. - this.carry = null - this.idleScanTail = null - this.scanTail = null - this.canTail = null - this.carrySince = 0 - - this.watchdogTimer = null - this.destroyed = false - this._drainTimer = null - - // Set while the dying peer's trailing garbage is still expected on - // the wire; see NOISE_SUPPRESS_MS. - this.suppressNoiseUntil = 0 - this.residueTail = null - } - - // ── watchdog ──────────────────────────────────────────────── - - /** - * (Re)arm the watchdog timer - * @param {number} ms - Timeout in ms - */ - armWatchdog (ms) { - this.disarmWatchdog() - if (this.destroyed || !ms) return - this.watchdogTimer = setTimeout(() => { - if (this.state === ZMODEM_STATE.IDLE) return - log.warn(`zmodem watchdog timeout in state ${this.state}, ending session`) - this.sendToClient({ - event: 'session-timeout', - message: 'ZMODEM session timed out' - }) - this.abort(true) - }, ms) - // Do not keep the event loop alive just for the watchdog - if (this.watchdogTimer.unref) this.watchdogTimer.unref() - } - - disarmWatchdog () { - if (this.watchdogTimer) { - clearTimeout(this.watchdogTimer) - this.watchdogTimer = null - } - } - - // ── client / terminal io ──────────────────────────────────── - - /** - * Send message to client via websocket - * @param {Object} msg - Message to send - */ - sendToClient (msg) { - if (this.ws && this.ws.s && !this.destroyed) { - this.ws.s({ - action: 'zmodem-event', - ...msg - }) - } - } - - /** - * Write data to terminal - * @param {Buffer} data - Data to write - */ - writeToTerminal (data) { - if (this.term && this.term.write) { - this.term.write(data) - } - } - - // ── detection ─────────────────────────────────────────────── - - /** - * Scan a buffer for a zmodem start/skip header starting at or after `from` - * @param {Buffer} data - * @param {number} from - * @returns {Object|null} { kind: 'receive'|'send'|'skip', offset: number } - */ - scanBuffer (data, from = 0) { - const end = data.length - ZMODEM_HEADER.length - 1 - for (let i = from; i <= end; i++) { - if ( - data[i] === ZMODEM_HEADER[0] && - data[i + 1] === ZMODEM_HEADER[1] && - data[i + 2] === ZMODEM_HEADER[2] && - data[i + 3] === ZMODEM_HEADER[3] - ) { - const h1 = data[i + 4] - const h2 = data[i + 5] - if (h1 === ZRQINIT_HEX[0] && h2 === ZRQINIT_HEX[1]) { - return { kind: 'receive', offset: i } - } - if (h1 === ZRINIT_HEX[0] && h2 === ZRINIT_HEX[1]) { - return { kind: 'send', offset: i } - } - if (h1 === ZSKIP_HEX[0] && h2 === ZSKIP_HEX[1]) { - return { kind: 'skip', offset: i } - } - } - } - return null - } - - /** - * Check whether the tail of `data` could be the beginning of a header - * that continues in the next chunk (e.g. "*\x18B0" waiting for its - * final type nibble). Returns the partial length, or 0. - * @param {Buffer} data - * @returns {number} - */ - partialHeaderLength (data) { - const max = Math.min(data.length, ZMODEM_HEADER.length + 1) - for (let len = max; len > 0; len--) { - let ok = true - for (let j = 0; j < len; j++) { - if (data[data.length - len + j] !== ZMODEM_HEADER[j]) { - ok = false - break - } - } - if (ok) return len - } - return 0 - } - - /** - * Find a raw run of CAN (0x18) bytes, the peer's abort signal. - * Scans across chunk boundaries via its own tail buffer (independent - * of the ZSKIP scanTail). Escaped 0x18 in file data always arrives as - * ZDLE-escaped pairs, so a raw MIN_CAN_RUN run is unambiguous. - * @param {Buffer} data - * @returns {boolean} - */ - hasCanRun (data) { - const hay = this.canTail ? Buffer.concat([this.canTail, data]) : data - this.canTail = Buffer.from(hay.subarray(Math.max(0, hay.length - (MIN_CAN_RUN - 1)))) - let run = 0 - for (const b of hay) { - if (b === 0x18) { - run++ - if (run >= MIN_CAN_RUN) return true - } else { - run = 0 - } - } - return false - } - - // ── data entry point ──────────────────────────────────────── - - /** - * Handle incoming data from terminal - * @param {Buffer} data - Incoming data - * @returns {boolean} - True if data was consumed by zmodem - */ - /** - * Observe user keystrokes on their way to the pty (called by the - * session-server before writing terminal input). A Ctrl-C (ETX) - * while a transfer is running means the remote rz/sz is about to be - * killed by SIGINT: end the session right away so (a) we stop - * feeding protocol frames into the shell that takes over the pty - * (they would be echoed back as garbage text) and (b) terminal - * output resumes immediately instead of after the watchdog timeout. - * The keystroke itself is never swallowed - normal shell behavior is - * untouched. - * @param {string|Buffer} data - User input about to reach the pty - */ - handleUserInput (data) { - if (this.destroyed || this.state === ZMODEM_STATE.IDLE) return - if (typeof data === 'string' ? !data.includes('\x03') : !data.includes(3)) return - log.debug('zmodem: user pressed Ctrl-C during transfer, ending session') - this.sendToClient({ - event: 'transfer-error', - message: 'Transfer interrupted (Ctrl-C)' - }) - // No cancel sequence: the remote program is dying / dead already; - // writing ours would just be echoed by the shell as garbage. - this.abort(false) - } - - handleData (data) { - if (this.destroyed) return false - if (!Buffer.isBuffer(data)) data = Buffer.from(data) - - switch (this.state) { - case ZMODEM_STATE.IDLE: - return this.handleIdleData(data) - - case ZMODEM_STATE.WAITING_SAVE_PATH: - case ZMODEM_STATE.WAITING_FILES: - this.bufferPending(data) - return true - - case ZMODEM_STATE.RECEIVING: - // A raw CAN run means the remote rz was killed (Ctrl-C). The - // state machine would ignore those bytes and stall until the - // watchdog, so detect and end immediately - silently: writing - // our own cancel sequence back would only echo more garbage - // from the shell that now owns the pty. - if (this.hasCanRun(data)) { - log.debug('zmodem: remote sent CAN run, session aborted by peer') - this.sendToClient({ - event: 'transfer-error', - message: 'Transfer aborted by remote side' - }) - this.abort(false) - return true - } - this.armWatchdog(WATCHDOG.TRANSFER) - this.handleReceiverData(data) - return true - - case ZMODEM_STATE.SENDING: - if (this.hasCanRun(data)) { - log.debug('zmodem: remote sent CAN run, session aborted by peer') - this.sendToClient({ - event: 'transfer-error', - message: 'Transfer aborted by remote side' - }) - this.abort(false) - return true - } - this.armWatchdog(WATCHDOG.TRANSFER) - this.handleSenderData(data) - return true - - default: - return false - } - } - - /** - * State: idle. Look for the start of a session. - * - * Contract with session-server: returning false means "not zmodem, - * send the chunk to the client yourself"; returning true means "mine, - * already forwarded anything displayable via ws". While sniffing a - * split header we own the output so nothing is double-sent. - * @returns {boolean} - */ - handleIdleData (data) { - // Post-session noise suppression. After a transfer dies mid-flight - // (user Ctrl-C etc.) the kernel pty buffers flush up to several KB - // of in-flight protocol data, and the shell echoes back frames it - // swallowed as input — screens worth of garbage. Printable-ness is - // NOT a usable filter here (file payloads and hex frames are pure - // printable text), so during the window we swallow EVERYTHING and - // only resume display once the shell prompt reappears: a short - // printable line ending in a prompt char ($ # > %). The window has - // a hard cap so display always recovers even if no prompt is ever - // detected (plain sh, unusual PS1). - if (Date.now() < this.suppressNoiseUntil) { - // Match against remembered tail + new data so a prompt split - // across chunks is still recognized - const hay = this.residueTail - ? this.residueTail + data.toString('utf8') - : data.toString('utf8') - // A prompt: a fresh line (or chunk start) of short printable - // text ending in a prompt char ($ # > %) right at the end. - const m = hay.match(/(?:^|[\r\n])([^\r\n]{1,80})[\x20\t]*$/) - const promptish = m !== null && /[\x24#>%»]\s?$/.test(m[1]) - if (promptish) { - // prompt reappeared: show just the prompt line, close the window - this.passThroughPrefix(Buffer.from(m[0], 'utf8')) - this.suppressNoiseUntil = 0 - this.residueTail = null - return true - } - if (!this.looksLikeNoise(data)) { - // keep a printable tail for the cross-chunk match above - const keep = Math.min(hay.length, 160) - this.residueTail = hay.slice(hay.length - keep) - } else { - this.residueTail = null - } - // Debris still flowing past the window cap: keep swallowing - // (extend) — a large pty-buffer flush can outlast one window. - if (Date.now() + 50 >= this.suppressNoiseUntil) { - this.suppressNoiseUntil = Date.now() + NOISE_SUPPRESS_MS - } - return true - } - this.residueTail = null - - // Search the previous idle chunk tail + data so a header split across - // chunks is found. Unlike an unconfirmed carry, that tail has already - // been displayed. Keeping it separately avoids withholding ordinary - // terminal echo such as repeated `*` characters while still allowing a - // ZMODEM header to be recognized across a chunk boundary. - const previousTail = this.idleScanTail - const hay = previousTail ? Buffer.concat([previousTail, data]) : data - this.idleScanTail = null - const alreadySentLength = previousTail ? previousTail.length : 0 - - const hit = this.scanBuffer(hay, 0) - if (hit) { - if (hit.kind === 'skip') { - // ZSKIP with no session in flight is stray noise - drop it - return true - } - // Output before the header (e.g. "rz waiting to receive.\r\n") stays - // visible. Bytes from the previous tail were already sent and must not - // be duplicated. - if (hit.offset > alreadySentLength) { - this.passThroughPrefix(hay.subarray(alreadySentLength, hit.offset)) - } - this.startSessionFromHit(hit.kind, hay.subarray(hit.offset)) - return true - } - - // No full header. Remember a possible header prefix for the next chunk, - // but let session-server forward this chunk immediately. - const partial = this.partialHeaderLength(hay) - this.idleScanTail = partial > 0 - ? Buffer.from(hay.subarray(hay.length - partial)) - : null - return false - } - - /** - * Emit pre-header terminal output back to the client. Called via the - * same ws path the session-server would have used. - * @param {Buffer} prefix - */ - passThroughPrefix (prefix) { - if (!prefix || !prefix.length || !this.ws || !this.ws.send) return - // Only forward readable output; drop protocol noise that would - // corrupt the display. - if (this.looksLikeNoise(prefix)) return - try { - this.ws.send(prefix) - } catch (e) { - // ws closed - nothing to do - } - } - - /** - * Heuristic: buffers that are mostly control bytes / non-printable - * are zmodem line noise, not something to display. - * @param {Buffer} buf - * @returns {boolean} - */ - looksLikeNoise (buf) { - if (!buf.length) return true - let printable = 0 - for (const b of buf) { - // CR LF TAB ESC BEL BS and visible ASCII count as displayable - if (b === 0x0d || b === 0x0a || b === 0x09 || b === 0x1b || b === 0x07 || b === 0x08 || (b >= 0x20 && b !== 0x7f)) printable++ - } - return printable / buf.length < 0.5 - } - - /** - * Begin a receiver or sender session based on detected frame kind - * @param {string} kind - 'receive' | 'send' | 'skip' - * @param {Buffer} rest - Data from the header onwards - */ - startSessionFromHit (kind, rest) { - if (kind === 'receive') { - this.startReceiver(rest) - } else if (kind === 'send') { - this.startSender(rest) - } - } - - /** - * Buffer data while waiting for user action, with sanity limits - * @param {Buffer} data - */ - bufferPending (data) { - this.carry = this.carry ? Buffer.concat([this.carry, data]) : Buffer.from(data) - if (!this.carrySince) this.carrySince = Date.now() - if (this.carry.length > MAX_BUFFERED_BYTES) { - log.warn('zmodem: peer flooded the session while waiting for user action, aborting') - this.abort(true) - } - } - - // ── receive (download) ────────────────────────────────────── - - /** - * Start a receive session (remote is sending file(s)) - * @param {Buffer} initialData - Initial zmodem data - */ - startReceiver (initialData) { - try { - this.receiver = new Receiver() - this.transferredBytes = 0 - this.currentMtime = 0 - this.carry = initialData && initialData.length - ? Buffer.from(initialData) - : null - this.carrySince = Date.now() - this.state = ZMODEM_STATE.WAITING_SAVE_PATH - - this.sendToClient({ - event: 'receive-start', - message: 'ZMODEM receive session started' - }) - this.armWatchdog(WATCHDOG.USER_ACTION) - } catch (e) { - log.error('Failed to start zmodem receiver', e) - this.abort(true) - } - } - - /** - * Set save path for receiving files, then replay buffered wire data - * @param {string} savePath - Directory path to save files - */ - setSavePath (savePath) { - if (this.state !== ZMODEM_STATE.WAITING_SAVE_PATH) return - this.savePath = savePath - this.state = ZMODEM_STATE.RECEIVING - this.armWatchdog(WATCHDOG.TRANSFER) - - const pending = this.carry || Buffer.alloc(0) - this.carry = null - if (pending.length) { - this.handleReceiverData(pending) - } - } - - /** - * Feed wire data to the receiver state machine and pump outputs - * @param {Buffer} data - */ - handleReceiverData (data) { - if (!this.receiver) return - const u8 = Buffer.isBuffer(data) ? new Uint8Array(data) : new Uint8Array(Buffer.from(data)) - let offset = 0 - let iterations = 0 - - while (offset < u8.length && iterations++ < 1000 && this.receiver) { - try { - const consumed = this.receiver.feedIncoming(u8.subarray(offset)) - offset += consumed - const drained = this.pumpReceiver() - if (consumed === 0 && !drained) break - } catch (e) { - log.error('Zmodem receiver error:', e) - this.sendToClient({ - event: 'transfer-error', - message: 'ZMODEM protocol error during receive' - }) - this.abort(true) - return - } - } - } - - /** - * Drain receiver outputs: wire replies, events, file data - * @returns {boolean} - True if work was done - */ - pumpReceiver () { - if (!this.receiver) return false - let didWork = false - - try { - // Order matters: drain file data FIRST. finishSubpacket (triggered - // by drainFile/advanceFile) queues ZACK replies; draining outgoing - // last flushes them in the same pump. With the opposite order a - // trailing ZACK stays queued when input runs out, and the peer - // stalls forever waiting for its ack. - const chunk = this.receiver.drainFile() - if (chunk && chunk.length > 0) { - this.handleFileData(Buffer.from(chunk)) - this.receiver.advanceFile(chunk.length) - didWork = true - } - - let event - while ((event = this.receiver.pollEvent()) !== null) { - didWork = true - if (event === ReceiverEvent.FileStart) { - this.handleFileStart( - this.receiver.getFileName(), - this.receiver.getFileSize(), - this.receiver.getFileMtime() - ) - } else if (event === ReceiverEvent.FileComplete) { - this.handleFileComplete() - } else if (event === ReceiverEvent.SessionComplete) { - // Drain the queued ZFIN ack BEFORE resetting: the remote - // waits for it to leave state 6 and print its exit message. - const finalReply = this.receiver.drainOutgoing() - if (finalReply && finalReply.length > 0) { - this.writeToTerminal(Buffer.from(finalReply)) - } - this.endSession(true) - return true - } - } - - const outgoing = this.receiver.drainOutgoing() - if (outgoing && outgoing.length > 0) { - this.writeToTerminal(Buffer.from(outgoing)) - didWork = true - } - } catch (e) { - log.error('Zmodem receiver pump error:', e) - this.abort(true) - return false - } - return didWork - } - - /** - * Handle file start event - */ - handleFileStart (name, size, mtime) { - this.currentTransfer = { name, size } - this.transferSize = size - this.transferredBytes = 0 - this.currentMtime = mtime || 0 - this.lastProgressUpdate = 0 - this.prepareReceiveFile(name, size) - this.sendToClient({ event: 'file-start', name, size }) - } - - /** - * Create the output file write stream - * @param {string} name - * @param {number} size - */ - prepareReceiveFile (name, size) { - try { - let filePath = path.join(this.savePath, sanitizeFilename(name)) - - // Avoid clobbering an existing file - if (fs.existsSync(filePath)) { - filePath = `${filePath}.${generate()}` - } - - this.downloadPath = filePath - const stream = fs.createWriteStream(filePath, { - highWaterMark: 64 * 1024 - }) - // A failed write (disk full, permission) must end the session - // instead of leaking a broken stream. - stream.on('error', (e) => { - log.error('zmodem download stream error', e) - this.sendToClient({ - event: 'transfer-error', - message: `Failed to write ${filePath}: ${e.message}` - }) - this.downloadStream = null - this.abort(true) - }) - this.downloadStream = stream - - this.sendToClient({ - event: 'file-prepared', - name, - path: filePath, - size - }) - } catch (e) { - log.error('Failed to prepare receive file', e) - this.abort(true) - } - } - - /** - * Handle file data chunk - * @param {Buffer} data - */ - handleFileData (data) { - if (!this.downloadStream || !this.currentTransfer) return - - if (this.transferredBytes === 0) { - this.startTime = Date.now() - } - - this.downloadStream.write(data) - this.transferredBytes += data.length - - const now = Date.now() - if (now - this.lastProgressUpdate > 500) { - this.lastProgressUpdate = now - this.sendProgress() - } - } - - /** - * Handle file complete event - */ - handleFileComplete () { - const filePath = this.downloadPath - const fileMtime = this.currentMtime - const currentTransfer = this.currentTransfer - - // Notify the client immediately: the protocol has all bytes, and the - // ZFIN handshake (session-end) often wins the race against the - // stream's async finish event, which would otherwise report the - // transfer complete only after the session already closed. - this.sendToClient({ - event: 'file-complete', - name: currentTransfer?.name, - path: filePath - }) - this.currentTransfer = null - this.downloadPath = null - this.currentMtime = 0 - - const finalize = () => { - if (filePath && fileMtime > 0) { - try { - const mtimeDate = new Date(fileMtime) - fs.utimesSync(filePath, mtimeDate, mtimeDate) - } catch (e) { - log.error('Failed to set file modification time', e) - } - } - } - - if (this.downloadStream) { - const stream = this.downloadStream - this.downloadStream = null - stream.on('finish', finalize) - stream.on('error', () => {}) // error path already handled above - stream.end() - } else { - finalize() - } - } - - // ── send (upload) ─────────────────────────────────────────── - - /** - * Start a send session (remote is ready to receive file(s)) - * @param {Buffer} initialData - Initial zmodem data (contains ZRINIT) - */ - startSender (initialData) { - try { - this.state = ZMODEM_STATE.WAITING_FILES - // Non-initiator: remote sent ZRINIT first - this.sender = new Sender(false) - this.carry = initialData && initialData.length - ? Buffer.from(initialData) - : null - this.carrySince = Date.now() - - this.sendToClient({ - event: 'send-start', - message: 'ZMODEM send session started, please select files' - }) - this.armWatchdog(WATCHDOG.USER_ACTION) - } catch (e) { - log.error('Failed to start zmodem sender', e) - this.abort(true) - } - } - - /** - * Feed wire data to the sender state machine and pump outputs. - * Also watches for ZSKIP (remote refuses the current file). - * @param {Buffer} data - */ - handleSenderData (data) { - if (!this.sender) return - const u8 = Buffer.isBuffer(data) ? new Uint8Array(data) : new Uint8Array(Buffer.from(data)) - - // ZSKIP scan: the zmodem2 Sender ignores unknown frames, so without - // this a "rz: file exists" abort would hang forever. Scan across - // chunk boundaries by prepending the tail of the previous chunk. - const hay = this.scanTail ? Buffer.concat([this.scanTail, data]) : data - this.scanTail = Buffer.from(hay.subarray(Math.max(0, hay.length - (ZMODEM_HEADER.length + 1)))) - const skip = this.scanBuffer(hay, 0) - if (skip && skip.kind === 'skip') { - log.debug('zmodem: ZSKIP received, remote refused file') - this.handleFileSkipped() - return - } - - let offset = 0 - let iterations = 0 - while (offset < u8.length && iterations++ < 1000 && this.sender) { - try { - const consumed = this.sender.feedIncoming(u8.subarray(offset)) - offset += consumed - const drained = this.pumpSender() - if (consumed === 0 && !drained) break - } catch (e) { - log.error('Zmodem sender error:', e) - this.sendToClient({ - event: 'transfer-error', - message: 'ZMODEM protocol error during send' - }) - this.abort(true) - return - } - } - } - - /** - * Handle ZSKIP: remote refused the file. rz has usually exited by - * now, so abort the batch cleanly and let trailing output drain. - */ - handleFileSkipped () { - this.sendToClient({ - event: 'file-skipped', - name: this.currentTransfer?.name, - message: 'Skipped by remote side (file exists or refused)' - }) - - // Notify the remote we are done, then tear down. Subsequent pty - // output (shell prompt / rz error text) goes back to the terminal - // because isActive() is false again. - this.writeToTerminal(CANCEL_SEQUENCE) - this.abort(false) - } - - /** - * Drain sender outputs: wire data, events, file read requests - * @returns {boolean} - */ - pumpSender () { - if (!this.sender) return false - let didWork = false - - try { - const outgoing = this.sender.drainOutgoing() - if (outgoing && outgoing.length > 0) { - this.writeToTerminal(Buffer.from(outgoing)) - didWork = true - } - - let event - while ((event = this.sender.pollEvent()) !== null) { - didWork = true - if (event === SenderEvent.FileComplete) { - this.handleSendFileComplete() - } else if (event === SenderEvent.SessionComplete) { - this.endSession(true) - return true - } - } - - const request = this.sender.pollFile() - if (request !== null) { - this.sendFileData(request.offset, request.len) - didWork = true - } - } catch (e) { - log.error('Zmodem sender pump error', e) - this.abort(true) - return false - } - - return didWork - } - - /** - * Read file data at offset and feed it to the sender - * @param {number} offset - File offset - * @param {number} length - Data length to read - */ - sendFileData (offset, length) { - if (!this.currentTransfer || !this.sender || !this.uploadPath) return - - try { - const CHUNK_SIZE = 64 * 1024 - const readLen = Math.min(length, CHUNK_SIZE) - const data = Buffer.allocUnsafe(readLen) - - if (this.uploadFd === null || this.uploadFd === undefined) { - this.uploadFd = fs.openSync(this.uploadPath, 'r') - this.fileReadPosition = 0 - } - - const bytesRead = readLen > 0 ? fs.readSync(this.uploadFd, data, 0, readLen, offset) : 0 - this.fileReadPosition = offset + bytesRead - const actualData = data.subarray(0, bytesRead) - - if (bytesRead > 0) { - if (this.transferredBytes === 0) { - this.startTime = Date.now() - } - this.sender.feedFile(new Uint8Array(actualData)) - this.transferredBytes = offset + bytesRead - - const now = Date.now() - if (now - this.lastProgressUpdate > 500) { - this.lastProgressUpdate = now - this.sendProgress() - } - - const outgoing = this.sender.drainOutgoing() - if (outgoing && outgoing.length > 0) { - this.writeToTerminal(Buffer.from(outgoing)) - } - } - - if (bytesRead === 0 || offset + bytesRead >= this.currentTransfer.size) { - // Whole file fed. The state machine completes on ZEOF/ZRINIT; - // finishSession() is only for "no more files" (finishSender). - if (this.uploadFd !== null && this.uploadFd !== undefined) { - fs.closeSync(this.uploadFd) - this.uploadFd = null - } - } - } catch (e) { - log.error('Failed to read file data for sending', e) - this.sendToClient({ - event: 'transfer-error', - message: `Failed to read ${this.uploadPath}: ${e.message}` - }) - this.abort(true) - } - } - - /** - * Handle send file complete event - */ - handleSendFileComplete () { - if (this.currentTransfer) { - this.transferredBytes = this.transferSize - this.sendProgress() - } - - this.sendToClient({ - event: 'file-complete', - name: this.currentTransfer?.name, - path: this.uploadPath - }) - - this.currentFileIndex++ - if (this.pendingFiles.length > this.currentFileIndex) { - this.sendFile(this.pendingFiles[this.currentFileIndex]) - } else { - this.finishSender() - } - } - - /** - * Begin sending one file - * @param {Object} file - File info { path, name, size } - */ - sendFile (file) { - if (!this.sender) return - - try { - this.currentTransfer = { name: file.name, size: file.size } - this.transferSize = file.size - this.transferredBytes = 0 - this.uploadPath = file.path - this.fileReadPosition = 0 - this.lastProgressUpdate = 0 - - if (this.uploadFd !== null && this.uploadFd !== undefined) { - fs.closeSync(this.uploadFd) - this.uploadFd = null - } - - // mtime in ms so the remote side preserves modification time - this.sender.startFile(file.name, file.size, file.modifyTime || 0) - - const outgoing = this.sender.drainOutgoing() - if (outgoing && outgoing.length > 0) { - this.writeToTerminal(Buffer.from(outgoing)) - } - - this.sendToClient({ - event: 'file-start', - name: file.name, - size: file.size - }) - } catch (e) { - log.error('Failed to send file', e) - this.abort(true) - } - } - - /** - * Finish sender session after the last file - */ - finishSender () { - if (!this.sender) return - - try { - this.sender.finishSession() - const outgoing = this.sender.drainOutgoing() - if (outgoing && outgoing.length > 0) { - this.writeToTerminal(Buffer.from(outgoing)) - } - this.armWatchdog(WATCHDOG.HANDSHAKE) - } catch (e) { - log.error('Failed to finish zmodem sender session', e) - this.abort(true) - } - } - - /** - * Set files to send and kick off the first transfer - * @param {Array} files - Array of file info objects - */ - setSendFiles (files) { - if (this.state !== ZMODEM_STATE.WAITING_FILES) return - this.pendingFiles = Array.isArray(files) ? files : [] - this.currentFileIndex = 0 - this.state = ZMODEM_STATE.SENDING - this.armWatchdog(WATCHDOG.TRANSFER) - - // Replay the buffered wire data (initial ZRINIT etc.) so the sender - // state machine can transition before we start the first file. - const pending = this.carry || Buffer.alloc(0) - this.carry = null - if (pending.length) { - this.handleSenderData(pending) - } - - if (this.pendingFiles.length > 0) { - this.sendFile(this.pendingFiles[0]) - } else { - this.finishSender() - } - } - - // ── progress ──────────────────────────────────────────────── - - /** - * Send progress update to client - */ - sendProgress () { - const elapsed = (Date.now() - this.startTime) / 1000 - const speed = elapsed > 0 ? Math.round(this.transferredBytes / elapsed) : 0 - const percent = this.transferSize > 0 - ? Math.min(100, Math.floor(this.transferredBytes * 100 / this.transferSize)) - : 100 - - this.sendToClient({ - event: 'progress', - name: this.currentTransfer?.name, - size: this.transferSize, - transferred: this.transferredBytes, - percent, - speed, - type: this.state === ZMODEM_STATE.RECEIVING ? 'download' : 'upload', - path: this.state === ZMODEM_STATE.RECEIVING ? this.downloadPath : this.uploadPath - }) - } - - // ── teardown ──────────────────────────────────────────────── - - /** - * Abort an ongoing transfer: tell the remote, clean up, notify client. - * @param {boolean} sendCancel - Write the cancel sequence to the pty - */ - abort (sendCancel) { - if (sendCancel) { - this.writeToTerminal(CANCEL_SEQUENCE) - } - this.endSession() - } - - /** - * End zmodem session and release every resource. Safe to call twice. - * @param {boolean} clean - True when the protocol closed properly - * (ZFIN handshake): no debris is expected, so the noise-suppression - * window is NOT armed and shell output flows immediately. Abnormal - * ends arm the window to swallow the dying peer's garbage. - */ - endSession (clean = false) { - if (this.downloadStream) { - const stream = this.downloadStream - this.downloadStream = null - stream.destroy() - try { stream.end() } catch (e) { /* already destroyed */ } - } - - if (this.uploadFd !== null && this.uploadFd !== undefined) { - try { - fs.closeSync(this.uploadFd) - } catch (e) { - log.error('Error closing upload file', e) - } - this.uploadFd = null - } - - this.disarmWatchdog() - - // Only an aborted transfer leaves debris on the wire. A clean ZFIN - // close arms nothing, so post-transfer shell output shows instantly. - if (!clean) { - this.suppressNoiseUntil = Date.now() + NOISE_SUPPRESS_MS - } - - this.sendToClient({ event: 'session-end' }) - - this.state = ZMODEM_STATE.IDLE - this.receiver = null - this.sender = null - this.currentTransfer = null - this.currentMtime = 0 - this.downloadPath = null - this.uploadPath = null - this.pendingFiles = [] - this.currentFileIndex = 0 - this.savePath = null - this.fileReadPosition = 0 - this.transferredBytes = 0 - this.transferSize = 0 - this.carry = null - this.idleScanTail = null - this.scanTail = null - this.canTail = null - this.carrySince = 0 - this.lastProgressUpdate = 0 - this.residueTail = null - // keep suppressNoiseUntil: it was just armed above - } - - /** - * User-initiated cancel - */ - cancel () { - this.abort(true) - } - - /** - * Check if session is active - * @returns {boolean} - */ - isActive () { - return this.state !== ZMODEM_STATE.IDLE && !this.destroyed - } - - /** - * Final teardown when the terminal goes away - */ - destroy () { - if (this.destroyed) return - this.destroyed = true - this.endSession() - this.term = null - this.ws = null - } -} - -/** - * ZmodemManager manages zmodem sessions for multiple terminals - */ -class ZmodemManager { - constructor () { - this.sessions = new Map() - } - - /** - * Create or get zmodem session for a terminal - * @param {string} pid - Terminal PID - * @param {Object} term - Terminal instance - * @param {Object} ws - WebSocket connection - * @returns {ZmodemSession} - */ - getSession (pid, term, ws) { - if (!this.sessions.has(pid)) { - const session = new ZmodemSession(term, ws) - this.sessions.set(pid, session) - } - return this.sessions.get(pid) - } - - /** - * Handle data for a terminal - * @param {string} pid - Terminal PID - * @param {Buffer} data - Incoming data - * @param {Object} term - Terminal instance - * @param {Object} ws - WebSocket connection - * @returns {boolean} - True if data was consumed by zmodem - */ - handleData (pid, data, term, ws) { - const session = this.getSession(pid, term, ws) - return session.handleData(data) - } - - /** - * Handle client message - * @param {string} pid - Terminal PID - * @param {Object} msg - Message from client - * @param {Object} term - Terminal instance - * @param {Object} ws - WebSocket connection - */ - handleMessage (pid, msg, term, ws) { - const session = this.getSession(pid, term, ws) - - switch (msg.event) { - case 'set-save-path': - session.setSavePath(msg.path) - break - case 'send-files': - session.setSendFiles(msg.files) - break - case 'cancel': - session.cancel() - break - case 'prepare-receive': - // kept for backward compatibility; receive prep is automatic now - break - } - } - - /** - * Observe user keystrokes for a terminal before they reach the pty. - * Lets an active session react to Ctrl-C immediately. - * @param {string} pid - Terminal PID - * @param {string|Buffer} data - User input - */ - handleUserInput (pid, data) { - const session = this.sessions.get(pid) - if (session) session.handleUserInput(data) - } - - /** - * Destroy session for a terminal - * @param {string} pid - Terminal PID - */ - destroySession (pid) { - const session = this.sessions.get(pid) - if (session) { - session.destroy() - this.sessions.delete(pid) - } - } - - /** - * Check if terminal has active zmodem session - * @param {string} pid - Terminal PID - * @returns {boolean} - */ - isActive (pid) { - const session = this.sessions.get(pid) - return session ? session.isActive() : false - } -} - -// Export singleton manager -const zmodemManager = new ZmodemManager() - -module.exports = { - ZmodemSession, - ZmodemManager, - zmodemManager, - ZMODEM_STATE, - ZMODEM_HEADER, - WATCHDOG, - MAX_BUFFERED_BYTES -} diff --git a/src/app/upgrade/db-defaults.js b/src/app/upgrade/db-defaults.js deleted file mode 100644 index 29cd822..0000000 --- a/src/app/upgrade/db-defaults.js +++ /dev/null @@ -1,124 +0,0 @@ -/** - * database default should init - */ - -function parsor (themeTxt) { - return themeTxt.split('\n').reduce((prev, line) => { - let [key = '', value = ''] = line.split('=') - key = key.trim() - value = value.trim() - if (!key || !value) { - return prev - } - prev[key] = value - return prev - }, {}) -} - -const defaultTheme = parsor(` - main = #141314 - main-dark = #000 - main-light = #2E3338 - text = #ddd - text-light = #fff - text-dark = #888 - text-disabled = #777 - primary = #08c - info = #FFD166 - success = #06D6A0 - error = #EF476F - warn = #E55934 -`) -const defaultThemeLight = parsor(` - main=#ededed - main-dark=#cccccc - main-light=#fefefe - text=#555 - text-light=#777 - text-dark=#444 - text-disabled=#888 - primary=#08c - info=#FFD166 - success=#06D6A0 - error=#EF476F - warn=#E55934 -`) -const defaultThemeLightTerminal = parsor(` -foreground=#333333 -background=#ededed -cursor=#b5bd68 -cursorAccent=#1d1f21 -selectionBackground=rgba(0, 0, 0, 0.3) -black=#575757 -red=#FF2C6D -green=#19f9d8 -yellow=#FFB86C -blue=#45A9F9 -magenta=#FF75B5 -cyan=#B084EB -white=#CDCDCD -brightBlack=#757575 -brightRed=#FF2C6D -brightGreen=#19f9d8 -brightYellow=#FFCC95 -brightBlue=#6FC1FF -brightMagenta=#FF9AC1 -brightCyan=#BCAAFE -brightWhite=#E6E6E6 -`) - -const defaultThemeTerminal = { - foreground: '#bbbbbb', - background: '#141314', - cursor: '#b5bd68', - cursorAccent: '#1d1f21', - selectionBackground: 'rgba(200, 200, 200, 0.6)', - black: '#575757', - red: '#FF2C6D', - green: '#19f9d8', - yellow: '#FFB86C', - blue: '#45A9F9', - magenta: '#FF75B5', - cyan: '#B084EB', - white: '#CDCDCD', - brightBlack: '#757575', - brightRed: '#FF2C6D', - brightGreen: '#19f9d8', - brightYellow: '#FFCC95', - brightBlue: '#6FC1FF', - brightMagenta: '#FF9AC1', - brightCyan: '#BCAAFE', - brightWhite: '#E6E6E6' -} - -module.exports = exports.default = [ - { - db: 'terminalThemes', - data: [ - { - _id: 'default', - name: 'default', - themeConfig: defaultThemeTerminal, - uiThemeConfig: defaultTheme - }, - { - _id: 'defaultLight', - name: 'default light', - themeConfig: defaultThemeLightTerminal, - uiThemeConfig: defaultThemeLight - } - ] - }, - { - db: 'bookmarkGroups', - data: [ - { - _id: 'default', - title: 'default', - bookmarkIds: [], - bookmarkGroupIds: [], - color: '#0088cc' - } - ] - } -] diff --git a/src/app/upgrade/index.js b/src/app/upgrade/index.js deleted file mode 100644 index ae11666..0000000 --- a/src/app/upgrade/index.js +++ /dev/null @@ -1,88 +0,0 @@ -/** - * common data upgrade process - * It will check current version in db and check version in package.json, - * run every upgrade script one by one - */ - -const { packInfo } = require('../common/app-props') -const { version: packVersion } = packInfo -const { resolve } = require('path') -const fs = require('fs') -const log = require('../common/log') -const compare = require('../common/version-compare') -const { dbAction } = require('../lib/db') -const _ = require('../lib/lodash.js') -const initData = require('./init-db') -const { updateDBVersion } = require('./version-upgrade') -const emptyVersion = '0.0.0' -const versionQuery = { - _id: 'version' -} - -async function getDBVersion () { - const version = await dbAction('data', 'findOne', versionQuery) - .then(doc => { - return doc ? doc.value : emptyVersion - }) - .catch(e => { - log.error(e) - return emptyVersion - }) - return version -} - -/** - * get upgrade versions should be run as version upgrade - */ -async function getUpgradeVersionList () { - const version = await getDBVersion() - const list = fs.readdirSync(__dirname) - return list.filter(f => { - const vv = f.replace('.js', '').replace('v', '') - return /^v\d/.test(f) && compare(vv, version) > 0 && compare(vv, packVersion) <= 0 - }).sort((a, b) => { - return compare(a, b) - }) -} -async function versionShouldUpgrade () { - const dbVersion = await getDBVersion() - log.info('database version:', dbVersion) - return compare(dbVersion, packVersion) < 0 -} - -async function shouldUpgrade () { - const shouldUpgradeVersion = await versionShouldUpgrade() - if (!shouldUpgradeVersion) { - return false - } - const dbVersion = await getDBVersion() - log.info('dbVersion', dbVersion) - if (dbVersion === emptyVersion) { - await initData() - await updateDBVersion(packVersion) - return false - } - const list = await getUpgradeVersionList() - if (_.isEmpty(list)) { - await updateDBVersion(packVersion) - return false - } - return { - dbVersion, - packVersion - } -} - -async function doUpgrade () { - const list = await getUpgradeVersionList() - log.info('Upgrading...') - for (const v of list) { - const p = resolve(__dirname, v) - const run = require(p) - await run() - } - log.info('Upgrade end') -} - -exports.checkDbUpgrade = shouldUpgrade -exports.doUpgrade = doUpgrade diff --git a/src/app/upgrade/init-db.js b/src/app/upgrade/init-db.js deleted file mode 100644 index 83a10ee..0000000 --- a/src/app/upgrade/init-db.js +++ /dev/null @@ -1,21 +0,0 @@ -/** - * for new user, they do not have old json db - * just need init db - */ - -const { dbAction } = require('../lib/db') -const log = require('../common/log') -const defaults = require('./db-defaults') - -async function initData () { - log.info('start: init db') - for (const conf of defaults) { - const { - db, data - } = conf - await dbAction(db, 'insert', data).catch(log.error) - } - log.info('end: init db') -} - -module.exports = initData diff --git a/src/app/upgrade/version-upgrade.js b/src/app/upgrade/version-upgrade.js deleted file mode 100644 index 3ea7bfb..0000000 --- a/src/app/upgrade/version-upgrade.js +++ /dev/null @@ -1,39 +0,0 @@ -/** - * upgrade db version - */ - -/** - * common data upgrade process - * It will check current version in db and check version in package.json, - * run every upgrade script one by one - */ - -const log = require('../common/log') -const { dbAction } = require('../lib/db') - -async function updateDBVersion (toVersion) { - const versionQuery = { - _id: 'version' - } - log.info('upgrade db version to', toVersion) - await dbAction('data', 'update', versionQuery, { - ...versionQuery, - value: toVersion - }, { - upsert: true - }) - .catch(e => { - log.error(e) - log.error('upgrade db version error', toVersion) - }) - await dbAction('dbUpgradeLog', 'insert', { - time: Date.now(), - toVersion - }) - .catch(e => { - log.error(e) - log.error('insert dbUpgradeLog error', toVersion) - }) -} - -exports.updateDBVersion = updateDBVersion diff --git a/src/app/widgets/load-widget.js b/src/app/widgets/load-widget.js deleted file mode 100644 index 20c40ec..0000000 --- a/src/app/widgets/load-widget.js +++ /dev/null @@ -1,195 +0,0 @@ -// load-widget.js - -const fs = require('fs') -const path = require('path') -// const log = require('../common/log') - -// Store running widget instances -const runningInstances = new Map() -const widgetIdPattern = /^[a-z0-9-]+$/ - -function resolveWidgetPath (widgetId, widgetDirectory = __dirname) { - if (typeof widgetId !== 'string' || !widgetIdPattern.test(widgetId)) { - throw new Error(`Invalid widget ID: ${widgetId}`) - } - - const widgetPath = path.resolve(widgetDirectory, `widget-${widgetId}.js`) - const relativePath = path.relative(widgetDirectory, widgetPath) - - if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) { - throw new Error(`Invalid widget ID: ${widgetId}`) - } - - return widgetPath -} - -function listWidgetsFromFolder (widgetDirectory = __dirname) { - const widgetFiles = fs.readdirSync(widgetDirectory).filter(file => file.startsWith('widget-') && file.endsWith('.js')) - const res = [] - for (const file of widgetFiles) { - try { - const widgetModule = require(path.join(widgetDirectory, file)) - res.push({ - id: file.slice(7, -3), - info: widgetModule.widgetInfo - }) - } catch (error) { - console.error(`Error loading widget from file ${file}:`, error) - continue - } - } - return res -} - -function listWidgets () { - const widgets1 = listWidgetsFromFolder() - return widgets1 - // if (process.versions.electron === undefined) { - // return widgets1 - // } - // const { - // appPath - // } = require('../common/app-props') - // const userWidgetsDir = path.resolve( - // appPath, 'widgets' - // ) - // // Ensure user widgets directory exists when app starts - // try { - // if (!fs.existsSync(userWidgetsDir)) { - // fs.mkdirSync(userWidgetsDir, { recursive: true }) - // } - // } catch (err) { - // log.error(`Failed to create user widgets directory ${userWidgetsDir}:`, err) - // } - // const widgets2 = listWidgetsFromFolder( - // userWidgetsDir - // ) - // return [ - // ...widgets1, - // ...widgets2 - // ] -} - -function hasRunningInstance (widgetId) { - for (const [, instance] of runningInstances) { - if (instance.widgetId === widgetId) { - return true - } - } - return false -} - -function runWidget (widgetId, config) { - const widget = require(resolveWidgetPath(widgetId)) - - const { type, singleInstance } = widget.widgetInfo - if (type !== 'instance') { - return widget.widgetRun(config) - } - - // Check if singleInstance widget already has a running instance - if (singleInstance && hasRunningInstance(widgetId)) { - return Promise.reject(new Error(`Widget ${widgetId} already has a running instance. Only one instance is allowed.`)) - } - - const instance = widget.widgetRun(config) - instance.widgetId = widgetId - runningInstances.set(instance.instanceId, instance) - - return instance.start() - .then((result) => { - return { - instanceId: instance.instanceId, - widgetId, - singleInstance: !!singleInstance, - ...result - } - }) - .catch((err) => { - runningInstances.delete(instance.instanceId) - return instance.stop().catch(() => {}).then(() => { throw err }) - }) -} - -function stopWidget (instanceId) { - const instance = runningInstances.get(instanceId) - if (!instance) { - console.error(`No running instance found for instanceId: ${instanceId}`) - return - } - - return instance.stop() - .then(() => { - runningInstances.delete(instanceId) - return { instanceId, status: 'stopped' } - }) -} - -async function runWidgetFunc (instanceId, funcName, ...args) { - const instance = runningInstances.get(instanceId) - if (!instance) { - throw new Error(`No running instance found for instanceId: ${instanceId}`) - } - - if (typeof instance[funcName] !== 'function') { - throw new Error(`Function ${funcName} not found in widget instance`) - } - - try { - const result = await instance[funcName](...args) - return result - } catch (error) { - console.error(`Error executing ${funcName} on widget instance ${instanceId}:`, error) - throw error - } -} - -async function cleanup () { - if (runningInstances.size === 0) { - return - } - - const stopPromises = [] - - for (const [instanceId, instance] of runningInstances) { - console.log(`Stopping widget instance: ${instanceId}`) - try { - const stopPromise = instance.stop() - .then(() => { - console.log(`Successfully stopped widget instance: ${instanceId}`) - }) - .catch(err => { - console.error(`Error stopping widget instance ${instanceId}:`, err) - }) - stopPromises.push(stopPromise) - } catch (err) { - console.error(`Error initiating stop for widget instance ${instanceId}:`, err) - } - } - - try { - await Promise.allSettled(stopPromises) - runningInstances.clear() - console.log('All widget instances have been stopped') - } catch (err) { - console.error('Error during cleanup:', err) - } -} - -// Register cleanup handlers only for process exit signals -function registerCleanupHandlers () { - process.on('SIGTERM', async () => { - console.log('Received SIGTERM, cleaning up widgets...') - await cleanup() - }) -} - -// Initialize cleanup handlers -registerCleanupHandlers() - -module.exports = { - listWidgets, - runWidget, - stopWidget, - runWidgetFunc -} diff --git a/src/app/widgets/widget-batch-op.js b/src/app/widgets/widget-batch-op.js deleted file mode 100644 index 85f28d0..0000000 --- a/src/app/widgets/widget-batch-op.js +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Batch Operation Widget - * Allows users to define multi-step workflows in JSON format - * Runs entirely in the frontend, uses MCP tools for execution - */ - -const uid = require('../common/uid') - -const widgetInfo = { - name: 'Batch Operation', - description: 'Define and execute multi-step SSH/SFTP workflows with progress tracking.', - version: '1.0.0', - type: 'frontend', - builtin: true, - singleInstance: false, - configs: [] -} - -function getDefaultConfig () { - return widgetInfo.configs.reduce((acc, config) => { - acc[config.name] = config.default - return acc - }, {}) -} - -async function widgetRun (config) { - const instanceId = uid() - return { - instanceId, - widgetId: 'batch-op', - success: true, - msg: 'Batch operation workflow started', - serverInfo: null, - config - } -} - -module.exports = { - widgetInfo, - getDefaultConfig, - widgetRun -} diff --git a/src/app/widgets/widget-local-file-server.js b/src/app/widgets/widget-local-file-server.js deleted file mode 100644 index daf3348..0000000 --- a/src/app/widgets/widget-local-file-server.js +++ /dev/null @@ -1,194 +0,0 @@ -const os = require('os') -// const path = require('path') -const express = require('express') -const uid = require('../common/uid') - -const widgetInfo = { - name: 'Static File Server', - description: 'A simple local file server to serve static files from your computer.', - version: '1.0.0', - author: 'ZHAO Xudong', - type: 'instance', - builtin: true, - configs: [ - { - name: 'host', - type: 'string', - default: '127.0.0.1', - description: 'The IP address to bind the server to' - }, - { - name: 'port', - type: 'number', - default: 3456, - description: 'The port number to listen on' - }, - { - name: 'directory', - type: 'string', - default: os.homedir(), - description: 'The directory to serve files from (default: user\'s home directory)' - }, - { - name: 'maxAge', - type: 'number', - default: 365 * 24 * 60 * 60 * 1000, - description: 'Browser cache max-age in milliseconds' - }, - // { - // name: 'immutable', - // type: 'boolean', - // default: false, - // description: 'Enable or disable the immutable directive in the Cache-Control header' - // }, - { - name: 'cacheControl', - type: 'boolean', - default: true, - description: 'Enable or disable setting Cache-Control response header' - }, - { - name: 'lastModified', - type: 'boolean', - default: true, - description: 'Enable or disable the Last-Modified header' - }, - { - name: 'etag', - type: 'boolean', - default: true, - description: 'Enable or disable etag generation' - }, - // { - // name: 'extensions', - // type: 'array', - // default: [], - // description: 'Array of file extensions to try when resolving a file' - // }, - // { - // name: 'fallthrough', - // type: 'boolean', - // default: true, - // description: 'Let client errors fall-through as unhandled requests' - // }, - { - name: 'index', - type: 'string', - default: 'index.html', - description: 'Name of the index file to serve' - }, - { - name: 'redirect', - type: 'boolean', - default: true, - description: 'Enable or disable redirects when pathname is a directory' - }, - // { - // name: 'setHeaders', - // type: 'function', - // default: null, - // description: 'Function for setting custom headers (e.g., (res, path, stat) => { res.set("X-Custom-Header", "value"); })' - // }, - { - name: 'dotfiles', - type: 'string', - default: 'allow', - choices: ['allow', 'deny', 'ignore'], - description: 'Option for serving dotfiles' - }, - { - name: 'acceptRanges', - type: 'boolean', - default: true, - description: 'Enable or disable accepting ranged requests' - }, - { - name: 'autoRun', - type: 'boolean', - default: false, - description: 'Automatically run this widget when the app launches' - } - ] -} - -function getDefaultConfig () { - return widgetInfo.configs.reduce((acc, config) => { - acc[config.name] = config.default - return acc - }, {}) -} - -function widgetRun (instanceConfig) { - const config = { ...getDefaultConfig(), ...instanceConfig } - const instanceId = uid() - let server = null - const app = express() - - const start = () => { - return new Promise((resolve, reject) => { - if (server) { - reject(new Error('Server is already running')) - return - } - const { - directory, - port, - host, - ...rest - } = config - app.use(express.static(directory, rest)) - - server = app.listen(port, host, (err) => { - if (err) { - console.error(`Failed to start ${widgetInfo.name}:`, err) - reject(err) - } else { - const serverInfo = { - url: `http://${host}:${port}`, - path: directory - } - const msg = `${widgetInfo.name} is running at ${serverInfo.url}` - console.log(msg) - console.log(`Serving files from: ${serverInfo.path}`) - resolve({ serverInfo, msg, success: true }) - } - }) - - server.on('error', (err) => { - console.error(`${widgetInfo.name} encountered an error:`, err) - reject(err) - }) - }) - } - - const stop = () => { - return new Promise((resolve, reject) => { - if (server) { - server.close((err) => { - if (err) { - console.error('Error stopping the server:', err) - reject(err) - } else { - console.log(`${widgetInfo.name} has been stopped`) - server = null - resolve() - } - }) - } else { - console.log(`${widgetInfo.name} is not running`) - resolve() - } - }) - } - - return { - instanceId, - start, - stop - } -} - -module.exports = { - widgetInfo, - widgetRun -} diff --git a/src/app/widgets/widget-local-ftp-server.js b/src/app/widgets/widget-local-ftp-server.js deleted file mode 100644 index 429b91a..0000000 --- a/src/app/widgets/widget-local-ftp-server.js +++ /dev/null @@ -1,143 +0,0 @@ -const os = require('os') -const uid = require('../common/uid') -const FtpSrv = require('@electerm/ftp-srv') - -const widgetInfo = { - name: 'Local FTP Server', - description: 'A local FTP server to share files over FTP protocol.', - version: '1.0.0', - author: 'ZHAO Xudong', - type: 'instance', - builtin: true, - configs: [ - { - name: 'host', - type: 'string', - default: '0.0.0.0', - description: 'The IP address to bind the FTP server to' - }, - { - name: 'port', - type: 'number', - default: 2121, - description: 'The port number to listen on' - }, - { - name: 'directory', - type: 'string', - default: os.homedir(), - description: 'The directory to serve files from (default: user\'s home directory)' - }, - { - name: 'anonymous', - type: 'boolean', - default: false, - description: 'Allow anonymous FTP access' - }, - { - name: 'username', - type: 'string', - default: 'ftpuser', - description: 'Username for FTP authentication (used when anonymous is false)' - }, - { - name: 'password', - type: 'string', - default: 'ftppass', - description: 'Password for FTP authentication (used when anonymous is false)' - }, - { - name: 'autoRun', - type: 'boolean', - default: false, - description: 'Automatically start this FTP server when the app launches' - } - ] -} - -function getDefaultConfig () { - return widgetInfo.configs.reduce((acc, config) => { - acc[config.name] = config.default - return acc - }, {}) -} - -function widgetRun (instanceConfig) { - const config = { ...getDefaultConfig(), ...instanceConfig } - const instanceId = uid() - let server = null - - const start = async () => { - if (server) { - throw new Error('Server is already running') - } - - server = new FtpSrv({ - url: `ftp://${config.host}:${config.port}`, - anonymous: config.anonymous, - root: config.directory - }) - - if (!config.anonymous) { - server.on('login', ({ username, password }, resolve, reject) => { - if (username === config.username && password === config.password) { - return resolve({ root: config.directory }) - } - return reject(new Error('Invalid username or password')) - }) - } - - server.on('client-error', ({ connection, context, error }) => { - console.log('FTP client error:', error) - }) - - return new Promise((resolve, reject) => { - server.listen() - .then(() => { - const url = config.anonymous - ? `ftp://${config.host}:${config.port}` - : `ftp://${config.username}:${config.password}@${config.host}:${config.port}` - const serverInfo = { - url, - path: config.directory - } - const msg = `${widgetInfo.name} is running at ${serverInfo.url}` - console.log(msg) - console.log(`Serving files from: ${serverInfo.path}`) - resolve({ serverInfo, msg, success: true }) - }) - .catch(reject) - }) - } - - const stop = () => { - return new Promise((resolve, reject) => { - if (server) { - server.close() - .then(() => { - console.log(`${widgetInfo.name} has been stopped`) - server = null - resolve() - }) - .catch((err) => { - console.error('Error stopping the FTP server:', err) - reject(err) - }) - } else { - console.log(`${widgetInfo.name} is not running`) - resolve() - } - }) - } - - return { - instanceId, - start, - stop - } -} - -module.exports = { - widgetInfo, - widgetRun -} diff --git a/src/app/widgets/widget-mcp-server.js b/src/app/widgets/widget-mcp-server.js deleted file mode 100644 index 43cb5f0..0000000 --- a/src/app/widgets/widget-mcp-server.js +++ /dev/null @@ -1,1290 +0,0 @@ -/** - * MCP Server Widget - * Exposes electerm store APIs via Model Context Protocol - * Runs in main process and uses IPC to communicate with frontend - * Uses a simple local MCP implementation - */ - -const { ipcMain } = require('electron') -const { McpServer } = require('../mcp/server/mcp.js') -const { StreamableHTTPServerTransport } = require('../mcp/server/streamableHttp.js') -const { TaskManager } = require('../mcp/server/tasks.js') -const { z } = require('../lib/zod') -const express = require('express') -const uid = require('../common/uid') -const globalState = require('../lib/glob-state') -const { - sshBookmarkSchema, - telnetBookmarkSchema, - serialBookmarkSchema, - localBookmarkSchema -} = require('../common/bookmark-zod-schemas') - -// Dangerous tab props that allow arbitrary command execution. -// Must be stripped from any MCP tool args before forwarding to the renderer. -// Mirrors src/client/store/tab.js dangerousTabProps. -const dangerousTabProps = [ - 'execLinux', - 'execMac', - 'execWindows', - 'execWindowsArgs', - 'execMacArgs', - 'execLinuxArgs', - 'setEnv', - 'runScripts', - 'interactiveValues' -] - -function stripDangerousTabProps (obj) { - return Object.fromEntries( - Object.entries(obj).filter(([key]) => !dangerousTabProps.includes(key)) - ) -} - -const widgetInfo = { - name: 'MCP Server', - description: 'Expose electerm APIs via Model Context Protocol (MCP) for AI assistants and external tools.', - version: '1.0.0', - author: 'ZHAO Xudong', - type: 'instance', - builtin: true, - singleInstance: true, - configs: [ - { - name: 'host', - type: 'string', - default: '127.0.0.1', - description: 'The IP address to bind the MCP server to' - }, - { - name: 'port', - type: 'number', - default: 30837, - description: 'The port number to listen on' - }, - { - name: 'apiKey', - type: 'string', - default: '', - showGenerator: true, - description: 'Optional API key for authenticating MCP requests. If set, clients must send this in the Authorization header as: Bearer . Leave empty to skip authentication.' - }, - { - name: 'enableBookmarks', - type: 'boolean', - default: true, - description: 'Enable bookmark APIs (list, get, add, edit, delete)' - }, - { - name: 'bookmarkKeyword', - type: 'string', - default: '', - description: 'Filter keyword for bookmark list API. Only bookmarks with titles containing this keyword (case-insensitive) will be returned. Leave empty to return all bookmarks.' - }, - { - name: 'enableBookmarkGroups', - type: 'boolean', - default: true, - description: 'Enable bookmark group APIs' - }, - - { - name: 'enableSftp', - type: 'boolean', - default: true, - description: 'Enable SFTP APIs (list, stat, read, delete, upload, download, trzsz)' - }, - { - name: 'enableSettings', - type: 'boolean', - default: false, - description: 'Enable settings APIs' - }, - { - name: 'autoRun', - type: 'boolean', - default: false, - description: 'Automatically start this MCP server when the app launches' - }, - { - name: 'commandBlacklist', - type: 'textarea', - default: '', - description: 'Newline-separated list of regex patterns. Commands matching any pattern are rejected. Built-in dangerous patterns are always active.' - }, - { - name: 'commandWhitelist', - type: 'textarea', - default: '', - description: 'Newline-separated list of regex patterns. When non-empty, only commands matching at least one pattern are allowed (whitelist mode).' - }, - { - name: 'execTimeoutMs', - type: 'number', - default: 120000, - description: 'Default timeout (ms) for execute_electerm_command. Commands exceeding it return partial output with timedOut=true.' - }, - { - name: 'execMaxOutputBytes', - type: 'number', - default: 204800, - description: 'Max characters of stdout/stderr returned by execute_electerm_command. Longer output is tail-truncated with truncated=true.' - }, - { - name: 'enableTasks', - type: 'boolean', - default: true, - description: 'Enable the MCP Tasks extension (io.modelcontextprotocol/tasks, SEP-2663). Lets supporting clients run long commands as pollable tasks via execute_electerm_command with wait=false.' - }, - { - name: 'taskTtlMs', - type: 'number', - default: 3600000, - description: 'How long (ms) a finished MCP task is retained for tasks/get before being swept. Also triggers remote temp-file cleanup.' - } - ] -} - -function getDefaultConfig () { - return widgetInfo.configs.reduce((acc, config) => { - acc[config.name] = config.default - return acc - }, {}) -} - -class ElectermMCPServer { - constructor (config) { - this.config = config - // API key is optional - skip auth if not provided - this.instanceId = uid() - this.httpServer = null - this.mcpServer = null - this.ipcHandler = null - this.pendingRequests = new Map() - this.transports = {} - this.taskManager = null - } - - // Built-in blacklist: patterns that are always blocked regardless of user config. - // These cover the most common destructive / privilege-escalation shell idioms. - static get BUILTIN_BLACKLIST () { - return [ - /rm\s+-[^\s]*[rR][^\s]*\s+\//, // rm -rf / or rm -Rf / (recursive delete from root) - /rm\s+-[^\s]*[rR][^\s]*\s+~/, // rm -rf ~ or rm -Rf ~ (recursive delete home) - /rm\s+--recursive/, // rm --recursive (long-form flag) - /:\s*\(\s*\)\s*\{.*\|.*:.*&.*\}\s*;.*:/, // fork bomb :(){:|:&};: - /\bdd\b.*\bof\s*=\s*\/dev\//, // dd of=/dev/... - /\bmkfs\b/, // mkfs (format filesystem) - />\s*\/dev\/[sh]d[a-z]/, // redirect to raw disk - /\bsudo\s+rm\b/, // sudo rm - /curl\s+.*\|\s*sh/, // curl | sh (remote code execution) - /wget\s+.*\|\s*sh/, // wget | sh - /curl\s+.*\|\s*bash/, // curl | bash - /wget\s+.*\|\s*bash/ // wget | bash - ] - } - - // Validate a command against whitelist/blacklist rules. - // Returns { allowed: true } or { allowed: false, reason: string } - validateCommand (command) { - // 1. Always-on built-in blacklist - for (const pattern of ElectermMCPServer.BUILTIN_BLACKLIST) { - if (pattern.test(command)) { - return { allowed: false, reason: `Command blocked by built-in safety rule: ${pattern}` } - } - } - - // 2. User-defined blacklist (newline-separated regex strings) - const userBlacklist = (this.config.commandBlacklist || '') - .split('\n') - .map(s => s.trim()) - .filter(Boolean) - - for (const raw of userBlacklist) { - try { - if (new RegExp(raw).test(command)) { - return { allowed: false, reason: `Command blocked by blacklist pattern: ${raw}` } - } - } catch (_) { - // ignore invalid regex in config - } - } - - // 3. User-defined whitelist (newline-separated regex strings) - // Only enforced when at least one pattern is configured. - const userWhitelist = (this.config.commandWhitelist || '') - .split('\n') - .map(s => s.trim()) - .filter(Boolean) - - if (userWhitelist.length > 0) { - const allowed = userWhitelist.some(raw => { - try { - return new RegExp(raw).test(command) - } catch (_) { - return false - } - }) - if (!allowed) { - return { allowed: false, reason: 'Command not in whitelist' } - } - } - - return { allowed: true } - } - - // Send request to renderer process via IPC - sendToRenderer (action, data, timeoutMs = 30000) { - return new Promise((resolve, reject) => { - const requestId = uid() - const win = globalState.get('win') - - if (!win) { - reject(new Error('No active window')) - return - } - - // Set up response handler - const timeout = setTimeout(() => { - this.pendingRequests.delete(requestId) - reject(new Error('Request timeout')) - }, timeoutMs) - - this.pendingRequests.set(requestId, { resolve, reject, timeout }) - - // Send to renderer - win.webContents.send('mcp-request', { - requestId, - action, - data - }) - }) - } - - // ==================== MCP Tasks lifecycle (SEP-2663) ==================== - // Tasks wrap the renderer's background-command engine (nohup + pid/exit/ - // log files). These methods map renderer background states onto the MCP - // task state machine. - - // onGet hook: refresh a working task from the renderer before tasks/get - // returns it. Terminal renderer states move the task to a terminal status. - async refreshTask (task) { - const { bgTaskId, startedAt } = task.meta || {} - if (!bgTaskId) { - return - } - try { - const status = await this.sendToRenderer('tool-call', { - toolName: 'get_background_task_status', - args: { taskId: bgTaskId } - }) - if (status.status === 'completed') { - const log = await this.sendToRenderer('tool-call', { - toolName: 'get_background_task_log', - args: { taskId: bgTaskId, lines: 200 } - }) - const maxOut = this.config.execMaxOutputBytes || 204800 - let stdout = log.output || '' - let truncated = false - if (stdout.length > maxOut) { - stdout = stdout.slice(-maxOut) - truncated = true - } - this.taskManager.complete(task.taskId, { - stdout, - stderr: '', - stderrMerged: true, - exitCode: typeof status.exitCode === 'number' ? status.exitCode : null, - durationMs: (status.endTime || Date.now()) - (startedAt || Date.now()), - truncated, - mode: 'background', - tabId: task.meta.tabId - }) - } else if (status.status === 'cancelled') { - this.taskManager.cancelLocal(task.taskId) - } else if (status.status === 'unknown') { - this.taskManager.fail(task.taskId, status.message || 'Background task state unknown') - } else { - // still running — surface elapsed time for polling clients - const elapsed = Math.round((Date.now() - (startedAt || Date.now())) / 1000) - task.statusMessage = `Running (${elapsed}s elapsed)` - } - } catch (e) { - this.taskManager.fail(task.taskId, e.message) - } - } - - // onCancel hook: kill the underlying background process. - async cancelTaskRemote (task) { - const { bgTaskId } = task.meta || {} - if (!bgTaskId) { - return - } - try { - await this.sendToRenderer('tool-call', { - toolName: 'cancel_background_task', - args: { taskId: bgTaskId } - }) - } catch (_) { - // best-effort kill — the task is marked cancelled regardless - } - } - - // onSweep hook: remove remote temp files (log/pid/exit) for swept tasks. - async sweepTaskRemote (task) { - const { bgTaskId } = task.meta || {} - if (!bgTaskId) { - return - } - try { - await this.sendToRenderer('tool-call', { - toolName: 'cleanup_background_task', - args: { taskId: bgTaskId } - }, 10000) - } catch (_) { - // best-effort cleanup - } - } - - // Register all tools on the MCP server - registerTools () { - const server = this.mcpServer - const self = this - - // ==================== Tab/Terminal APIs (always enabled) ==================== - - server.registerTool( - 'list_electerm_tabs', - { - description: 'List all open electerm terminal tabs', - inputSchema: z.object({}) - }, - async () => { - const result = await self.sendToRenderer('tool-call', { toolName: 'list_tabs', args: {} }) - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } - } - ) - - server.registerTool( - 'get_electerm_active_tab', - { - description: 'Get the currently active electerm tab', - inputSchema: z.object({}) - }, - async () => { - const result = await self.sendToRenderer('tool-call', { toolName: 'get_active_tab', args: {} }) - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } - } - ) - - server.registerTool( - 'switch_electerm_tab', - { - description: 'Switch to a specific electerm tab', - inputSchema: { - tabId: z.string().describe('Tab ID to switch to') - } - }, - async ({ tabId }) => { - const result = await self.sendToRenderer('tool-call', { toolName: 'switch_tab', args: { tabId } }) - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } - } - ) - - server.registerTool( - 'close_electerm_tab', - { - description: 'Close a specific electerm tab', - inputSchema: { - tabId: z.string().describe('Tab ID to close') - } - }, - async ({ tabId }) => { - const result = await self.sendToRenderer('tool-call', { toolName: 'close_tab', args: { tabId } }) - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } - } - ) - - server.registerTool( - 'reload_electerm_tab', - { - description: 'Reload/reconnect an electerm tab', - inputSchema: { - tabId: z.string().optional().describe('Tab ID to reload (default: active tab)') - } - }, - async (args) => { - const tabId = args?.tabId - const result = await self.sendToRenderer('tool-call', { toolName: 'reload_tab', args: { tabId } }) - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } - } - ) - - server.registerTool( - 'duplicate_electerm_tab', - { - description: 'Duplicate an electerm tab', - inputSchema: { - tabId: z.string().describe('Tab ID to duplicate') - } - }, - async ({ tabId }) => { - const result = await self.sendToRenderer('tool-call', { toolName: 'duplicate_tab', args: { tabId } }) - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } - } - ) - - server.registerTool( - 'open_electerm_local_terminal', - { - description: 'Open a new electerm local terminal tab', - inputSchema: z.object({}) - }, - async () => { - const result = await self.sendToRenderer('tool-call', { toolName: 'open_local_terminal', args: {} }) - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } - } - ) - - server.registerTool( - 'send_electerm_terminal_command', - { - description: 'Send a command to the active electerm terminal. For non-interactive commands, prefer execute_electerm_command — it returns structured stdout/stderr/exitCode in one call instead of requiring send + wait + read.', - inputSchema: { - command: z.string().describe('Command to send'), - tabId: z.string().optional().describe('Optional: specific tab ID'), - inputOnly: z.boolean().optional().describe('Input only mode (no enter key)') - } - }, - async ({ command, tabId, inputOnly }) => { - const check = self.validateCommand(command) - if (!check.allowed) { - return { content: [{ type: 'text', text: JSON.stringify({ error: check.reason }, null, 2) }], isError: true } - } - const result = await self.sendToRenderer('tool-call', { - toolName: 'send_terminal_command', - args: { command, tabId, inputOnly } - }) - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } - } - ) - - server.registerTool( - 'get_electerm_terminal_selection', - { - description: 'Get the current text selection in electerm terminal', - inputSchema: { - tabId: z.string().optional().describe('Optional: specific tab ID') - } - }, - async (args) => { - const tabId = args?.tabId - const result = await self.sendToRenderer('tool-call', { toolName: 'get_terminal_selection', args: { tabId } }) - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } - } - ) - - server.registerTool( - 'get_electerm_terminal_output', - { - description: 'Get recent electerm terminal output/buffer content', - inputSchema: { - tabId: z.string().optional().describe('Optional: specific tab ID'), - lines: z.number().optional().describe('Number of lines to return (default: 50)') - } - }, - async (args) => { - const tabId = args?.tabId - const lines = args?.lines - const result = await self.sendToRenderer('tool-call', { toolName: 'get_terminal_output', args: { tabId, lines } }) - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } - } - ) - - server.registerTool( - 'wait_for_electerm_terminal_idle', - { - description: 'Wait until the active terminal stops producing output, then return its content. ' + - 'Use this after send_electerm_terminal_command to know when the command has finished. ' + - 'The terminal is considered idle when no data has arrived for ~4 seconds. ' + - 'Returns output and elapsed time; timedOut=true if the command was still running at the timeout.', - inputSchema: { - tabId: z.string().optional().describe('Tab ID to watch (default: active tab)'), - timeout: z.number().optional().describe('Max milliseconds to wait for idle (default: 30000, max: 120000)'), - lines: z.number().optional().describe('Lines of terminal output to return when idle (default: 50)'), - minWait: z.number().optional().describe('Initial delay before polling starts, ms (default: 1000)') - } - }, - async (args) => { - // IPC timeout must exceed the tool timeout by a safe margin - const toolTimeout = Math.min(args?.timeout || 30000, 120000) - const ipcTimeout = toolTimeout + 10000 - const result = await self.sendToRenderer( - 'tool-call', - { toolName: 'wait_for_terminal_idle', args }, - ipcTimeout - ) - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } - } - ) - - server.registerTool( - 'get_electerm_terminal_status', - { - description: 'Get the current status of a terminal tab. Returns whether it is actively receiving data (running), idle (no data for 4+ seconds), or has a password prompt. Also returns the last 20 lines of terminal output. This is a lightweight, non-blocking check ideal for monitoring long-running commands.', - inputSchema: { - tabId: z.string().optional().describe('Tab ID to check (default: active tab)') - } - }, - async (args) => { - const result = await self.sendToRenderer('tool-call', { - toolName: 'get_terminal_status', args - }) - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } - } - ) - - server.registerTool( - 'cancel_electerm_terminal_command', - { - description: 'Cancel the currently running command in a terminal by sending Ctrl+C. Use this to interrupt a long-running or stuck command.', - inputSchema: { - tabId: z.string().optional().describe('Tab ID to cancel command in (default: active tab)') - } - }, - async (args) => { - const result = await self.sendToRenderer('tool-call', { - toolName: 'cancel_terminal_command', args - }) - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } - } - ) - - server.registerTool( - 'execute_electerm_command', - { - description: 'Execute a non-interactive shell command and return a structured result: { stdout, stderr, exitCode, durationMs, timedOut, truncated, mode, tabId }. ' + - 'On SSH tabs this uses a dedicated exec channel (mode="exec") with real stdout/stderr/exit code capture — no terminal buffer parsing needed. ' + - 'On other tabs it falls back to sentinel-based PTY capture (mode="pty", stderr merged into stdout). ' + - 'Prefer this over send_electerm_terminal_command + wait_for_electerm_terminal_idle for regular commands like git status, docker ps, npm test. ' + - 'For interactive programs (vim, top, ssh) use the terminal send/read tools instead. ' + - 'For long-running commands pass wait=false: runs in the background and returns an MCP task handle (poll with tasks/get, stop with tasks/cancel). Requires the MCP Tasks extension.', - inputSchema: { - command: z.string().describe('The shell command to execute'), - tabId: z.string().optional().describe('Tab ID to run on (default: active tab)'), - timeoutMs: z.number().optional().describe('Max execution time in ms (default: 120000, max: 600000). On timeout returns partial output with timedOut=true.'), - wait: z.boolean().optional().describe('Wait for completion and return the structured result (default: true). Set false for long-running commands to get a task handle instead.'), - mode: z.enum(['exec', 'pty']).optional().describe('Execution mode: "exec" (default) uses the SSH exec channel with PTY fallback; "pty" forces execution in the visible terminal (for commands needing a TTY: colors, sudo prompts, TTY-aware tools). Ignored when wait=false.') - } - }, - async (args, ctx) => { - const check = self.validateCommand(args?.command || '') - if (!check.allowed) { - return { content: [{ type: 'text', text: JSON.stringify({ error: check.reason }, null, 2) }], isError: true } - } - - // Async path: run in background, return a task handle instead of the result - if (args?.wait === false) { - // Requires the MCP Tasks extension — there is no non-task way to - // poll or cancel an async run (legacy background tools were removed). - if (!ctx?.clientSupportsTasks || !self.taskManager) { - return { - content: [{ - type: 'text', - text: JSON.stringify({ - error: 'wait=false requires the MCP Tasks extension (io.modelcontextprotocol/tasks). ' + - 'Declare the extension in client capabilities, or call with wait=true (default) to run synchronously.' - }, null, 2) - }], - isError: true - } - } - const bg = await self.sendToRenderer('tool-call', { - toolName: 'run_background_command', - args: { command: args.command, tabId: args.tabId } - }) - const task = self.taskManager.create({ - toolName: 'execute_electerm_command', - meta: { - bgTaskId: bg.taskId, - tabId: bg.tabId, - command: args.command, - startedAt: Date.now() - } - }) - return { - resultType: 'task', - task: self.taskManager.toWire(task) - } - } - - // Sync path: wait for completion, return structured result - const timeoutMs = Math.min(Math.max(args?.timeoutMs || self.config.execTimeoutMs || 120000, 1000), 600000) - const result = await self.sendToRenderer( - 'tool-call', - { - toolName: 'execute_command', - args: { - command: args.command, - tabId: args.tabId, - timeoutMs, - maxOutputBytes: self.config.execMaxOutputBytes || 204800, - mode: args.mode - } - }, - timeoutMs + 15000 - ) - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } - } - ) - - // ==================== Direct Tab Open APIs (always enabled) ==================== - - server.registerTool( - 'open_electerm_tab_ssh', - { - description: 'Open a new SSH terminal tab directly with connection parameters (no bookmark created)', - inputSchema: sshBookmarkSchema - }, - async (args) => { - const result = await self.sendToRenderer('tool-call', { - toolName: 'open_tab', - args: { ...stripDangerousTabProps(args), type: 'ssh' } - }) - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } - } - ) - - server.registerTool( - 'open_electerm_tab_telnet', - { - description: 'Open a new Telnet terminal tab directly with connection parameters (no bookmark created)', - inputSchema: telnetBookmarkSchema - }, - async (args) => { - const result = await self.sendToRenderer('tool-call', { - toolName: 'open_tab', - args: { ...stripDangerousTabProps(args), type: 'telnet' } - }) - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } - } - ) - - server.registerTool( - 'open_electerm_tab_serial', - { - description: 'Open a new Serial terminal tab directly with connection parameters (no bookmark created)', - inputSchema: serialBookmarkSchema - }, - async (args) => { - const result = await self.sendToRenderer('tool-call', { - toolName: 'open_tab', - args: { ...stripDangerousTabProps(args), type: 'serial' } - }) - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } - } - ) - - server.registerTool( - 'open_electerm_tab_local', - { - description: 'Open a new Local terminal tab directly with connection parameters (no bookmark created)', - inputSchema: localBookmarkSchema - }, - async (args) => { - const result = await self.sendToRenderer('tool-call', { - toolName: 'open_tab', - args: { ...stripDangerousTabProps(args), type: 'local' } - }) - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } - } - ) - - // ==================== Bookmark APIs ==================== - if (this.config.enableBookmarks) { - server.registerTool( - 'list_electerm_bookmarks', - { - description: 'List all electerm SSH/terminal bookmarks', - inputSchema: {} - }, - async (args) => { - let result = await self.sendToRenderer('tool-call', { toolName: 'list_bookmarks', args: {} }) - const keyword = self.config.bookmarkKeyword - if (keyword && Array.isArray(result)) { - const lower = keyword.toLowerCase() - result = result.filter(b => (b.title || '').toLowerCase().includes(lower)) - } - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } - } - ) - - server.registerTool( - 'get_electerm_bookmark', - { - description: 'Get a specific electerm bookmark by ID', - inputSchema: { - id: z.string().describe('Bookmark ID') - } - }, - async ({ id }) => { - const result = await self.sendToRenderer('tool-call', { toolName: 'get_bookmark', args: { id } }) - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } - } - ) - - server.registerTool( - 'add_electerm_bookmark_ssh', - { - description: 'Add a new SSH bookmark to electerm', - inputSchema: sshBookmarkSchema - }, - async (args) => { - const result = await self.sendToRenderer('tool-call', { - toolName: 'add_bookmark', - args: { ...args, type: 'ssh' } - }) - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } - } - ) - - server.registerTool( - 'add_electerm_bookmark_telnet', - { - description: 'Add a new Telnet bookmark to electerm', - inputSchema: telnetBookmarkSchema - }, - async (args) => { - const result = await self.sendToRenderer('tool-call', { - toolName: 'add_bookmark', - args: { ...args, type: 'telnet' } - }) - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } - } - ) - - server.registerTool( - 'add_electerm_bookmark_serial', - { - description: 'Add a new Serial bookmark to electerm', - inputSchema: serialBookmarkSchema - }, - async (args) => { - const result = await self.sendToRenderer('tool-call', { - toolName: 'add_bookmark', - args: { ...args, type: 'serial' } - }) - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } - } - ) - - server.registerTool( - 'add_electerm_bookmark_local', - { - description: 'Add a new Local terminal bookmark to electerm', - inputSchema: localBookmarkSchema - }, - async (args) => { - const result = await self.sendToRenderer('tool-call', { - toolName: 'add_bookmark', - args: { ...args, type: 'local' } - }) - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } - } - ) - - server.registerTool( - 'edit_electerm_bookmark', - { - description: 'Edit an existing electerm bookmark', - inputSchema: { - id: z.string().describe('Bookmark ID to edit'), - updates: z.record(z.any()).describe('Fields to update') - } - }, - async ({ id, updates }) => { - const result = await self.sendToRenderer('tool-call', { toolName: 'edit_bookmark', args: { id, updates } }) - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } - } - ) - - server.registerTool( - 'delete_electerm_bookmark', - { - description: 'Delete an electerm bookmark', - inputSchema: { - id: z.string().describe('Bookmark ID to delete') - } - }, - async ({ id }) => { - const result = await self.sendToRenderer('tool-call', { toolName: 'delete_bookmark', args: { id } }) - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } - } - ) - - server.registerTool( - 'open_electerm_bookmark', - { - description: 'Open an electerm bookmark in a new tab', - inputSchema: { - id: z.string().describe('Bookmark ID to open') - } - }, - async ({ id }) => { - const result = await self.sendToRenderer('tool-call', { toolName: 'open_bookmark', args: { id } }) - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } - } - ) - } - - // ==================== Bookmark Group APIs ==================== - if (this.config.enableBookmarkGroups) { - server.registerTool( - 'list_electerm_bookmark_groups', - { - description: 'List all electerm bookmark groups/folders', - inputSchema: z.object({}) - }, - async () => { - const result = await self.sendToRenderer('tool-call', { toolName: 'list_bookmark_groups', args: {} }) - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } - } - ) - - server.registerTool( - 'add_electerm_bookmark_group', - { - description: 'Add a new electerm bookmark group', - inputSchema: { - title: z.string().describe('Group title'), - parentId: z.string().optional().describe('Optional parent group ID') - } - }, - async ({ title, parentId }) => { - const result = await self.sendToRenderer('tool-call', { toolName: 'add_bookmark_group', args: { title, parentId } }) - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } - } - ) - } - - // ==================== SFTP APIs ==================== - if (this.config.enableSftp) { - server.registerTool( - 'electerm_sftp_list', - { - description: 'List files and folders in a remote directory on the SSH-connected tab', - inputSchema: { - tabId: z.string().optional().describe('SSH tab ID (default: active tab)'), - remotePath: z.string().describe('Remote directory path to list') - } - }, - async ({ tabId, remotePath }) => { - const result = await self.sendToRenderer('tool-call', { toolName: 'sftp_list', args: { tabId, remotePath } }) - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } - } - ) - - server.registerTool( - 'electerm_sftp_stat', - { - description: 'Get file or directory stat/info on the remote SSH server', - inputSchema: { - tabId: z.string().optional().describe('SSH tab ID (default: active tab)'), - remotePath: z.string().describe('Remote file or directory path') - } - }, - async ({ tabId, remotePath }) => { - const result = await self.sendToRenderer('tool-call', { toolName: 'sftp_stat', args: { tabId, remotePath } }) - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } - } - ) - - server.registerTool( - 'electerm_sftp_read_file', - { - description: 'Read the content of a remote file on the SSH server', - inputSchema: { - tabId: z.string().optional().describe('SSH tab ID (default: active tab)'), - remotePath: z.string().describe('Remote file path to read') - } - }, - async ({ tabId, remotePath }) => { - const result = await self.sendToRenderer('tool-call', { toolName: 'sftp_read_file', args: { tabId, remotePath } }) - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } - } - ) - - server.registerTool( - 'electerm_sftp_del_file_or_folder', - { - description: 'Delete a file or folder on the remote SSH server', - inputSchema: { - tabId: z.string().optional().describe('SSH tab ID (default: active tab)'), - remotePath: z.string().describe('Remote file or directory path to delete') - } - }, - async ({ tabId, remotePath }) => { - const result = await self.sendToRenderer('tool-call', { toolName: 'sftp_del', args: { tabId, remotePath } }) - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } - } - ) - - server.registerTool( - 'electerm_sftp_upload', - { - description: 'Upload a local file or folder to the remote SSH server using the SFTP transfer panel', - inputSchema: { - tabId: z.string().optional().describe('SSH tab ID (default: active tab)'), - localPath: z.string().describe('Local file or folder path to upload'), - remotePath: z.string().describe('Remote destination path'), - conflictPolicy: z.enum(['mergeOrOverwriteAll', 'renameAll']).optional().describe('Conflict policy: mergeOrOverwriteAll or renameAll (default: mergeOrOverwriteAll)') - } - }, - async ({ tabId, localPath, remotePath, conflictPolicy }) => { - const result = await self.sendToRenderer('tool-call', { toolName: 'sftp_upload', args: { tabId, localPath, remotePath, conflictPolicy } }) - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } - } - ) - - server.registerTool( - 'electerm_sftp_download', - { - description: 'Download a remote file or folder from the SSH server to a local path using the SFTP transfer panel', - inputSchema: { - tabId: z.string().optional().describe('SSH tab ID (default: active tab)'), - remotePath: z.string().describe('Remote file or directory path to download'), - localPath: z.string().describe('Local destination path'), - conflictPolicy: z.enum(['overwrite', 'rename']).optional().describe('Conflict policy: overwrite or rename (default: overwrite)') - } - }, - async ({ tabId, remotePath, localPath, conflictPolicy }) => { - const result = await self.sendToRenderer('tool-call', { toolName: 'sftp_download', args: { tabId, remotePath, localPath, conflictPolicy } }) - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } - } - ) - - server.registerTool( - 'electerm_zmodem_upload', - { - description: 'Upload local files to the remote SSH server using trzsz (trz) or rzsz (rz). The SSH tab must have the chosen protocol installed.', - inputSchema: { - tabId: z.string().optional().describe('SSH tab ID (default: active tab)'), - files: z.array(z.string()).describe('List of local file paths to upload'), - protocol: z.enum(['trzsz', 'rzsz']).optional().describe('Transfer protocol: trzsz (trz) or rzsz (rz) (default: rzsz)') - } - }, - async ({ tabId, files, protocol }) => { - const result = await self.sendToRenderer('tool-call', { toolName: 'zmodem_upload', args: { tabId, files, protocol } }) - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } - } - ) - - server.registerTool( - 'electerm_zmodem_download', - { - description: 'Download remote files from the SSH server using trzsz (tsz) or rzsz (sz). The SSH tab must have the chosen protocol installed.', - inputSchema: { - tabId: z.string().optional().describe('SSH tab ID (default: active tab)'), - remoteFiles: z.array(z.string()).describe('List of remote file paths to download'), - saveFolder: z.string().describe('Local folder path to save downloaded files'), - protocol: z.enum(['trzsz', 'rzsz']).optional().describe('Transfer protocol: trzsz (tsz) or rzsz (sz) (default: rzsz)') - } - }, - async ({ tabId, remoteFiles, saveFolder, protocol }) => { - const result = await self.sendToRenderer('tool-call', { toolName: 'zmodem_download', args: { tabId, remoteFiles, saveFolder, protocol } }) - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } - } - ) - - server.registerTool( - 'electerm_sftp_transfer_list', - { - description: 'Get the list of all currently active/pending SFTP file transfers', - inputSchema: z.object({}) - }, - async () => { - const result = await self.sendToRenderer('tool-call', { toolName: 'sftp_transfer_list', args: {} }) - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } - } - ) - - server.registerTool( - 'electerm_sftp_transfer_history', - { - description: 'Get the history of completed/failed SFTP file transfers', - inputSchema: z.object({}) - }, - async () => { - const result = await self.sendToRenderer('tool-call', { toolName: 'sftp_transfer_history', args: {} }) - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } - } - ) - } - - // ==================== Settings APIs ==================== - if (this.config.enableSettings) { - server.registerTool( - 'get_electerm_settings', - { - description: 'Get current electerm application settings', - inputSchema: undefined - }, - async () => { - const result = await self.sendToRenderer('tool-call', { toolName: 'get_settings', args: {} }) - return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } - } - ) - } - } - - // Start the MCP server - async start () { - const { host, port } = this.config - - // Set up IPC response handler - this.ipcHandler = (event, response) => { - const { requestId, result, error } = response - const pending = this.pendingRequests.get(requestId) - if (pending) { - clearTimeout(pending.timeout) - this.pendingRequests.delete(requestId) - if (error) { - pending.reject(new Error(error)) - } else { - pending.resolve(result) - } - } - } - ipcMain.on('mcp-response', this.ipcHandler) - - // Create MCP task manager (SEP-2663) when the tasks extension is enabled - if (this.config.enableTasks) { - this.taskManager = new TaskManager({ - ttl: this.config.taskTtlMs > 0 ? this.config.taskTtlMs : 3600000 - }) - this.taskManager.onGet = (task) => this.refreshTask(task) - this.taskManager.onCancel = (task) => this.cancelTaskRemote(task) - this.taskManager.onSweep = (task) => this.sweepTaskRemote(task) - } - - // Create MCP server - this.mcpServer = new McpServer({ - name: 'electerm-mcp-server', - version: widgetInfo.version, - taskManager: this.taskManager - }) - - // Register all tools - this.registerTools() - - // Create Express app - const app = express() - app.use(express.json()) - - // Handle CORS — restrict to same-origin only (no wildcard) - app.use((req, res, next) => { - const allowedOrigin = this.config.allowedOrigin || '' - if (allowedOrigin) { - res.setHeader('Access-Control-Allow-Origin', allowedOrigin) - } - // Do NOT set Access-Control-Allow-Origin when no origin is configured - // This blocks cross-origin browser requests by default - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS') - res.setHeader('Access-Control-Allow-Headers', 'Content-Type, mcp-session-id, Authorization') - if (req.method === 'OPTIONS') { - res.status(204).end() - return - } - next() - }) - - // Authenticate requests with API key (only if apiKey is configured) - if (this.config.apiKey) { - app.use((req, res, next) => { - const authHeader = req.headers.authorization || '' - const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '' - if (!token || token !== this.config.apiKey) { - res.status(401).json({ - jsonrpc: '2.0', - error: { - code: -32600, - message: 'Unauthorized: invalid or missing API key' - }, - id: null - }) - return - } - next() - }) - } - - const self = this - - // Handle MCP requests - app.post('/mcp', async (req, res) => { - const sessionId = req.headers['mcp-session-id'] - - try { - let transport = sessionId ? self.transports[sessionId] : null - - if (!transport) { - // Create new transport for new session - transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => uid(), - onsessioninitialized: (sid) => { - self.transports[sid] = transport - } - }) - - transport.onclose = () => { - const sid = Object.keys(self.transports).find(k => self.transports[k] === transport) - if (sid) { - delete self.transports[sid] - } - } - - await self.mcpServer.connect(transport) - } - - await transport.handleRequest(req, res, req.body) - } catch (error) { - console.error('Error handling MCP request:', error) - if (!res.headersSent) { - res.status(500).json({ - jsonrpc: '2.0', - error: { - code: -32603, - message: 'Internal server error' - }, - id: null - }) - } - } - }) - - // Handle GET requests for SSE streams - app.get('/mcp', async (req, res) => { - const sessionId = req.headers['mcp-session-id'] - if (!sessionId || !self.transports[sessionId]) { - res.status(400).send('Invalid or missing session ID') - return - } - - const transport = self.transports[sessionId] - await transport.handleRequest(req, res) - }) - - // Handle DELETE requests for session termination - app.delete('/mcp', async (req, res) => { - const sessionId = req.headers['mcp-session-id'] - if (!sessionId || !self.transports[sessionId]) { - res.status(400).send('Invalid or missing session ID') - return - } - - const transport = self.transports[sessionId] - await transport.handleRequest(req, res) - }) - - return new Promise((resolve, reject) => { - this.httpServer = app.listen(port, host, (err) => { - if (err) { - console.error('MCP Server error:', err) - reject(err) - return - } - - const serverInfo = { - url: `http://${host}:${port}/mcp`, - protocol: 'mcp', - version: self.mcpServer.supportedProtocolVersions[0], - apiKey: self.config.apiKey - } - const authNote = self.config.apiKey ? '(API key required)' : '(no auth required)' - const msg = `MCP Server is running at ${serverInfo.url} ${authNote}` - resolve({ - serverInfo, - msg, - success: true - }) - }) - - this.httpServer.on('error', (err) => { - console.error('MCP Server error:', err) - reject(err) - }) - }) - } - - // Stop the MCP server - async stop () { - // Remove IPC handler - if (this.ipcHandler) { - ipcMain.removeListener('mcp-response', this.ipcHandler) - this.ipcHandler = null - } - - // Destroy task manager (stops the TTL sweep timer) - if (this.taskManager) { - this.taskManager.destroy() - this.taskManager = null - } - - // Clear pending requests - for (const [, pending] of this.pendingRequests) { - clearTimeout(pending.timeout) - pending.reject(new Error('Server stopping')) - } - this.pendingRequests.clear() - - // Close all transports - for (const sessionId of Object.keys(this.transports)) { - try { - await this.transports[sessionId].close() - } catch (e) { - console.error(`Error closing transport ${sessionId}:`, e) - } - } - this.transports = {} - - // Close MCP server - if (this.mcpServer) { - await this.mcpServer.close() - this.mcpServer = null - } - - // Close HTTP server - return new Promise((resolve, reject) => { - if (this.httpServer) { - this.httpServer.close((err) => { - if (err) { - console.error('Error stopping MCP server:', err) - reject(err) - } else { - this.httpServer = null - resolve() - } - }) - } else { - resolve() - } - }) - } -} - -function widgetRun (instanceConfig) { - const config = { ...getDefaultConfig(), ...instanceConfig } - const mcpServer = new ElectermMCPServer(config) - - return { - instanceId: mcpServer.instanceId, - start: () => mcpServer.start(), - stop: () => mcpServer.stop() - } -} - -module.exports = { - widgetInfo, - widgetRun, - _ElectermMCPServer: ElectermMCPServer -} diff --git a/src/app/widgets/widget-rename.js b/src/app/widgets/widget-rename.js deleted file mode 100644 index 1509e03..0000000 --- a/src/app/widgets/widget-rename.js +++ /dev/null @@ -1,181 +0,0 @@ -const fs = require('fs').promises -const path = require('path') - -// Define defaults in one place -const DEFAULTS = { - directory: '', - template: '{name}-{n}.{ext}', - includeSubfolders: false, - fileTypes: '*', - startNumber: 1, - preserveCase: true -} -const pathSeparatorPattern = /[\\/]/ - -const widgetInfo = { - name: 'File Renamer', - description: 'Batch rename files in a folder using customizable templates', - version: '1.0.0', - author: 'ZHAO Xudong', - type: 'once', - builtin: true, - configs: [ - { - name: 'directory', - type: 'string', - default: DEFAULTS.directory, - description: 'The directory containing files to rename' - }, - { - name: 'template', - type: 'string', - default: DEFAULTS.template, - description: 'Template for new file names. Available tags:\n{n} - Sequential number (e.g., 1, 2, 3)\n{n:padding} - Padded number (e.g., {n:3} => 001, 002)\n{name} - Original filename without extension\n{ext} - File extension\n{date} - File creation date (YYYY-MM-DD)\n{time} - File creation time (HH-mm-ss)\n{random} - Random string' - }, - { - name: 'includeSubfolders', - type: 'boolean', - default: DEFAULTS.includeSubfolders, - description: 'Process files in subfolders' - }, - { - name: 'fileTypes', - type: 'string', - default: DEFAULTS.fileTypes, - description: 'Comma-separated list of file extensions (e.g., jpg,png,gif) or * for all' - }, - { - name: 'startNumber', - type: 'number', - default: DEFAULTS.startNumber, - description: 'Starting number for sequential naming' - }, - { - name: 'preserveCase', - type: 'boolean', - default: DEFAULTS.preserveCase, - description: 'Preserve case of original filenames' - } - ] -} - -async function getFiles (dir, fileTypes, includeSubfolders) { - const files = await fs.readdir(dir, { withFileTypes: true }) - let results = [] - for (const file of files) { - const fullPath = path.join(dir, file.name) - if (file.isDirectory() && includeSubfolders) { - results = results.concat(await getFiles(fullPath, fileTypes, includeSubfolders)) - } else if (file.isFile()) { - const ext = path.extname(file.name).toLowerCase().slice(1) - if (fileTypes === '*' || fileTypes.split(',').map(t => t.trim().toLowerCase()).includes(ext)) { - results.push(fullPath) - } - } - } - return results -} - -async function processTemplate (template, filePath, index, startNumber, preserveCase) { - const stats = await fs.stat(filePath) - const parsedPath = path.parse(filePath) - const date = new Date(stats.birthtime) - const replacements = { - n: (padding) => { - const num = startNumber + index - return padding ? String(num).padStart(parseInt(padding), '0') : String(num) - }, - name: () => preserveCase ? parsedPath.name : parsedPath.name.toLowerCase(), - ext: () => parsedPath.ext.slice(1), - date: () => date.toISOString().split('T')[0], - time: () => date.toTimeString().split(' ')[0].replace(/:/g, '-'), - random: () => Math.random().toString(36).substring(2, 8), - parent: () => parsedPath.dir.split(path.sep).pop() - } - - let result = template - for (const [tag, func] of Object.entries(replacements)) { - // Handle tags with parameters like {n:3} - result = result.replace(new RegExp(`{${tag}(?::([^}]+))?}`, 'g'), (match, param) => func(param)) - } - return result -} - -function resolveRenamePath (dir, newName) { - if (typeof newName !== 'string' || !newName.trim() || newName === '.' || newName === '..') { - throw new Error('Template produced an invalid file name') - } - - if (pathSeparatorPattern.test(newName)) { - throw new Error('Template must not include path separators') - } - - const newPath = path.resolve(dir, newName) - const relativePath = path.relative(dir, newPath) - - if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) { - throw new Error('Template must keep files within the source directory') - } - - return newPath -} - -async function widgetRun (params = {}) { - const config = { - ...DEFAULTS, - ...params - } - - const { - directory, - template, - includeSubfolders, - fileTypes, - startNumber, - preserveCase - } = config - - if (!directory) { - return { - success: false, - error: 'Directory must be specified' - } - } - - try { - const files = await getFiles(directory, fileTypes, includeSubfolders) - const results = [] - - for (let i = 0; i < files.length; i++) { - const filePath = files[i] - const dir = path.dirname(filePath) - const newName = await processTemplate(template, filePath, i, startNumber, preserveCase) - const newPath = resolveRenamePath(dir, newName) - await fs.rename(filePath, newPath) - - results.push({ - oldPath: filePath, - newPath, - success: true - }) - } - - return { - success: true, - totalRenamed: files.length, - msg: `Renamed ${files.length} files successfully`, - details: results - } - } catch (error) { - return { - success: false, - error: error.message, - details: error - } - } -} - -module.exports = { - widgetInfo, - widgetRun -} diff --git a/src/client/entry/basic.js b/src/client/entry/basic.js deleted file mode 100644 index f3e8690..0000000 --- a/src/client/entry/basic.js +++ /dev/null @@ -1,65 +0,0 @@ -/** - * init app data then write main script to html body - */ -import '../electerm-react/css/basic.styl' -import '../electerm-react/css/mobile.styl' -import { get as _get } from 'lodash-es' -import '../electerm-react/common/pre' - -const { isDev } = window.et -const { version } = window.pre.packInfo - -async function loadWorker () { - return new Promise((resolve) => { - const url = !isDev ? `js/worker-${version}.js` : 'js/worker.js' - window.worker = new window.Worker(url) - function onInit (e) { - if (!e || !e.data) { - return false - } - const { - action - } = e.data - if (action === 'worker-init') { - window.worker.removeEventListener('message', onInit) - resolve(1) - } - } - window.worker.addEventListener('message', onInit) - }) -} - -async function load () { - window.capitalizeFirstLetter = (string) => { - return string.charAt(0).toUpperCase() + string.slice(1) - } - function loadScript () { - const rcs = document.createElement('script') - const url = !isDev ? `js/electerm-${version}.js` : 'js/electerm.js' - rcs.src = url - rcs.type = 'module' - rcs.onload = () => { - const loadingEl = document.getElementById('content-loading') - if (loadingEl) { - document.body.removeChild(loadingEl) - } - } - document.body.appendChild(rcs) - } - const initLocale = window.pre.runSync('getInitLocale') || {} - window.langMap = initLocale.langMap - window.initLanguage = initLocale.language - window.getLang = (lang = window.store?.config.language || window.initLanguage || 'en_us') => { - return _get(window.langMap, `[${lang}].lang`) - } - window.translate = txt => { - const lang = window.getLang() - const str = _get(lang, `[${txt}]`) || txt - return window.capitalizeFirstLetter(str) - } - await loadWorker() - loadScript() -} - -// window.addEventListener('load', load) -load() diff --git a/src/client/entry/electerm.jsx b/src/client/entry/electerm.jsx deleted file mode 100644 index 6154609..0000000 --- a/src/client/entry/electerm.jsx +++ /dev/null @@ -1,9 +0,0 @@ -import { createRoot } from 'react-dom/client' -import 'antd/dist/reset.css' -import '@fontsource/maple-mono/index.css' -import Main from '../harmony/main.jsx' - -const rootElement = createRoot(document.getElementById('container')) -rootElement.render( -
-) diff --git a/src/client/entry/worker.js b/src/client/entry/worker.js deleted file mode 100644 index 54d77a9..0000000 --- a/src/client/entry/worker.js +++ /dev/null @@ -1,146 +0,0 @@ -/** - * web worker - */ - -self.insts = {} - -function createWs ( - type, - id, - sftpId = '', - config -) { - // init gloabl ws - const { host, port, tokenElecterm } = config - const wsUrl = `ws://${host}:${port}/${type}/${id}?&sftpId=${sftpId}&token=${tokenElecterm}` - const ws = new WebSocket(wsUrl) - ws.s = msg => { - try { - ws.send(JSON.stringify(msg)) - } catch (e) { - console.error('ws send error', e) - } - } - ws.id = id - ws.once = (callack, id) => { - const func = (evt) => { - const arg = JSON.parse(evt.data) - if (id === arg.id) { - callack(arg) - ws.removeEventListener('message', func) - } - } - ws.addEventListener('message', func) - } - ws.onclose = () => { - if (ws.dup) { - return - } - send({ - id: ws.id, - action: 'close' - }) - delete self.insts[ws.id] - } - return new Promise((resolve) => { - ws.onopen = () => { - if (self.insts[ws.id]) { - ws.dup = true - ws.close() - resolve(null) - } else { - resolve(ws) - } - } - }) -} - -function send (data) { - self.postMessage(data) -} - -async function onMsg (e) { - const { - id, - wsId, - args, - action, - type, - persist - } = e.data - if (action === 'create') { - const inst = self.insts[id] - if (inst instanceof WebSocket) { - return send({ - action, - id, - persist - }, '*') - } else if (inst) { - return false - } else { - const ws = await createWs(...args) - if (ws) { - self.insts[id] = ws - } - } - send({ - action, - persist, - id - }, '*') - } else if (action === 'once') { - const ws = self.insts[wsId] - if (ws) { - const cb = (data) => { - send({ - id, - wsId, - data - }) - } - ws.once(cb, id) - } - } else if (action === 'close') { - const ws = self.insts[wsId] - if (ws) { - ws.close() - } - } else if (action === 's') { - const ws = self.insts[wsId] - if (ws) { - ws.s(...args) - } - } else if (action === 'addEventListener') { - const ws = self.insts[wsId] - if (ws) { - if (!ws.cbs) { - ws.cbs = {} - } - const cb = (e) => { - send({ - wsId, - id, - data: { - data: e.data - } - }) - } - ws.cbs[id] = cb - ws.addEventListener(type, cb) - } - } else if (action === 'removeEventListener') { - const ws = self.insts[wsId] - if (ws && ws.cbs && ws.cbs[id]) { - ws.removeEventListener(type, ws.cbs[id]) - delete ws.cbs[id] - } - } -} - -self.addEventListener('message', onMsg) -setTimeout(() => { - send({ - action: 'worker-init' - }) -}, 10) diff --git a/src/client/harmony/language-select.jsx b/src/client/harmony/language-select.jsx deleted file mode 100644 index 6ebf938..0000000 --- a/src/client/harmony/language-select.jsx +++ /dev/null @@ -1,69 +0,0 @@ -import { useMemo } from 'react' -import { GlobalOutlined } from '@ant-design/icons' -import './language-select.styl' - -// window.localStorage key that records the one-time language pick. -// When absent, we prompt the user to choose a language once. -const STORAGE_KEY = 'locale' - -function getLangs () { - // window.et.langs is the canonical list, but it is only populated - // after the store finishes initApp(). At first mount window.langMap - // (from getInitLocale) is already available, so derive from it as a - // fallback — both expose { id, name }. - if (Array.isArray(window.et?.langs) && window.et.langs.length) { - return window.et.langs - } - return Object.values(window.langMap || {}) -} - -export default function LanguageSelect ({ children }) { - const langs = useMemo(getLangs, []) - // locale is set once after the first pick. Also skip the prompt when - // no language data is available yet, so the user is never trapped - // behind an empty picker. - const selected = !!window.localStorage.getItem(STORAGE_KEY) || !langs.length - - const choose = async langId => { - // 1. mark the choice so we never prompt again - window.localStorage.setItem(STORAGE_KEY, langId) - // 2. persist into user config so the app actually boots in this - // language (saveUserConfig merges, so other settings are kept) - try { - await window.pre.runGlobalAsync('saveUserConfig', { language: langId }) - } catch (err) { - console.error('[language-select] saveUserConfig failed', err) - } - // 3. reboot — language/translate are resolved at load time - window.location.reload() - } - - if (selected) { - return children - } - - return ( -
-
- -
- Select language / 选择语言 -
-
- { - langs.map(l => ( - - )) - } -
-
-
- ) -} diff --git a/src/client/harmony/main.jsx b/src/client/harmony/main.jsx deleted file mode 100644 index e9684a9..0000000 --- a/src/client/harmony/main.jsx +++ /dev/null @@ -1,10 +0,0 @@ -import Entry from '../electerm-react/components/main/index.jsx' -import LanguageSelect from './language-select.jsx' - -export default function Main () { - return ( - - - - ) -} diff --git a/src/client/views/index.pug b/src/client/views/index.pug deleted file mode 100644 index c677059..0000000 --- a/src/client/views/index.pug +++ /dev/null @@ -1,68 +0,0 @@ - -doctype html -html - head - meta(charset='UTF-8') - meta(http-equiv='x-ua-compatible' content='IE=edge') - meta(name='viewport', content='width=device-width, initial-scale=1, shrink-to-fit=no') - title #{siteName} - style. - body { - background: #000; - } - #content-loading { - position: fixed; - left: 0; - top: 0; - width: 100%; - height: 100%; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - } - .electerm-logo-bg { - background: transparent 50% 50% no-repeat url("./images/electerm-watermark.png"); - } - .morph-shape { - background: linear-gradient(45deg, #08c 0%, #09c 100%); - animation: morph 8s ease-in-out infinite; - border-radius: 60% 40% 30% 70% / 60% 30% 70% 40%; - transition: all 1s ease-in-out; - z-index: 5; - } - - - if (!isDev) - link(rel='stylesheet', href='css/style-' + version + '.css') - style(id='theme-css'). - style(id='custom-css'). - body - - if (isDev) - style(id='theme-css'). - style(id='custom-css'). - #container - #content-loading - .morph-shape.iblock.pd3 - img.iblock.logo-filter(src='images/electerm.png', alt='', height=80) - script. - window.et = !{JSON.stringify(_global)} - - var url = '/src/client/entry/basic.js' - - if (isDev) - //- script(src='/external/react.development.js?' + version) - //- script(src='/external/react-dom.development.js?' + version) - script(type='module'). - import RefreshRuntime from '/@react-refresh' - RefreshRuntime.injectIntoGlobalHook(window) - window.$RefreshReg$ = () => {} - window.$RefreshSig$ = () => (type) => type - window.__vite_plugin_react_preamble_installed__ = true - script(src='/@vite/client', type='module') - script(src=url1, type='module') - script(src=url, type='module') - - else - //- script(src='/external/react.production.min.js?' + version) - //- script(src='/external/react-dom.production.min.js?' + version) - - var url = src='/js/basic-' + version + '.js' - script(src=url1, type='module') - script(src=url, type='module') -