commit eb21b65815158be3d23bdbf84cee654589f8c91d Author: wehub-resource-sync Date: Mon Jul 13 12:46:20 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..3a0e64f --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,18 @@ +# CONTRIBUTING + +> First off, thank you for considering contributing to electerm. + +> electerm is an open source project and I love to receive contributions from our community โ€” you! There are many ways to contribute, from writing tutorials or blog posts, improving the documentation, submitting bug reports and feature requests or writing code which can be incorporated into electerm itself. + +> Working on your first Pull Request? You can learn how from this *free* series, [How to Contribute to an Open Source Project on GitHub](https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github). + +Basic process: + +> 1. Create your own fork of the code +> 2. Do the changes in your fork +> 3. If you like the change and think the project could use it: + * Be sure you have followed the code style for the project by running `npm run lint`. + * Send a pull request + +> Want to report a issue or give some suggestions? +[Submit a issue](https://github.com/electerm/electerm/issues/new) diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..63a59a0 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +github: electerm +ko_fi: zhaoxudong +custom: https://paypal.me/zhaoxudongPS \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..e69e4ea --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1 @@ +## ๐Ÿ‘‰ [Please follow one of these issue templates](https://github.com/electerm/electerm/issues/new/choose) ๐Ÿ‘ˆ diff --git a/.github/ISSUE_TEMPLATE/1-bug-report.yml b/.github/ISSUE_TEMPLATE/1-bug-report.yml new file mode 100644 index 0000000..ddcaea3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1-bug-report.yml @@ -0,0 +1,62 @@ +name: "\U0001F41B Bug report(ๆไบคbug)" +description: Create a report to help us improve +body: + - type: markdown + attributes: + value: | + Thank you for reporting an issue. + + This issue tracker is for bugs and issues found within Electerm. + If you require more general support please start a discussion on our discussion board https://github.com/electerm/electerm/discussions + + Please fill in as much of the form below as you're able. + + Before continue, make sure you are using the latest release: check https://github.com/electerm/electerm/releases for latest release, also please + + ไป…็”จๆฅ่ฟฝ่ธชbug, ๅฆ‚ๆžœๆœ‰ๅ…ถไป–็š„่ฎจ่ฎบ่ฏท็งปๆญฅ่ฎจ่ฎบๅŒบ https://github.com/electerm/electerm/discussions, ่ฏทๅŠกๅฟ…็กฎไฟ้—ฎ้ข˜ๆ˜ฏๅŸบไบŽๆœ€ๆ–ฐ็‰ˆๆœฌ: https://github.com/electerm/electerm/releases + + - type: input + attributes: + label: Electerm Version and download file extension(Electerm็‰ˆๆœฌๅ’Œไธ‹่ฝฝๆ–‡ไปถๅŽ็ผ€) + description: | + - electerm-xx.xx.xx-linux-amd64.deb + - electerm-xx.xx.xx-linux-amd64.snap + - electerm-xx.xx.xx-linux-x64.tar.gz + - electerm-xx.xx.xx-linux-x86_64.rpm + - electerm-xx.xx.xx-mac-arm64.dmg + - electerm-xx.xx.xx-mac-x64.dmg + - electerm-xx.xx.xx-win-x64-installer.exe + - electerm-xx.xx.xx-win-x64-loose.tar.gz + - electerm-xx.xx.xx-win-x64.appx + - electerm-xx.xx.xx-win-x64.tar.gz + validations: + required: true + - type: input + attributes: + label: Platform detail (ๅนณๅฐ่ฏฆๆƒ…) + description: | + windows 7/8/10/11, mac os(ARM or x64), or linux + UNIX: output of `uname -a` + Windows: output of `"$([Environment]::OSVersion | ForEach-Object VersionString) $(if ([Environment]::Is64BitOperatingSystem) { "x64" } else { "x86" })"` in PowerShell console + validations: + required: true + - type: textarea + attributes: + label: What steps will reproduce the bug?(้‡็Žฐ้—ฎ้ข˜็š„่ฏฆ็ป†ๆญฅ้ชค) + description: Enter details about your bug, preferably a simple code snippet that can be run using `node` directly without installing third-party dependencies. + validations: + required: true + - type: textarea + attributes: + label: What should have happened?(ๆœŸๆœ›็š„็ป“ๆžœ) + description: If possible please provide screenshots. + validations: + required: true + - type: textarea + attributes: + label: Would this happen in other terminal app(ๆ˜ฏๅฆ่ƒฝๅคŸๅœจๅ…ถไป–ๅŒ็ฑป่ฝฏไปถ้‡็Žฐ่ฟ™ไธช้—ฎ้ข˜) + description: really important. + - type: textarea + attributes: + label: Additional information(ๅ…ถไป–ไปปไฝ•็›ธๅ…ณไฟกๆฏ) + description: Tell us anything else you think we should know. diff --git a/.github/ISSUE_TEMPLATE/2-proposal.yml b/.github/ISSUE_TEMPLATE/2-proposal.yml new file mode 100644 index 0000000..d031011 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/2-proposal.yml @@ -0,0 +1,10 @@ +name: "๐Ÿ’ฅ Proposal / Feature (ๅปบ่ฎฎๅ’Œๆ–ฐๅŠŸ่ƒฝ)" +description: Propose a non-trivial change or new feature for electerm +body: + - type: textarea + attributes: + label: What feature you'd like to see or proposal(ๆœŸๆœ›ไป€ไนˆๆ–ฐๅŠŸ่ƒฝ/็‰นๆ€งๆˆ–่€…ๅปบ่ฎฎ) + description: A clear and concise description of what the proposal or feature is.(่ฏทๅฐฝๅฏ่ƒฝ่ฏฆ็ป†ๆ่ฟฐ๏ผŒไปฅไพฟๅธฎๅŠฉๅ…ถไป–ไบบ่ƒฝๅคŸ็†่งฃ) + validations: + required: true + diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..69aceb5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: โ‰๏ธ Need help with Electerm? + url: https://github.com/electerm/electerm/discussions + about: Please start a discussion before opening a bug. diff --git a/.github/code-of-conduct.md b/.github/code-of-conduct.md new file mode 100644 index 0000000..d084467 --- /dev/null +++ b/.github/code-of-conduct.md @@ -0,0 +1,8 @@ +# Electerm Community Code of Conduct + +1. **Be Respectful**: Treat everyone with kindness. No harassment or discrimination. +2. **Collaborate Constructively**: Critique ideas, not people. +3. **Support Others**: Help newcomers and value diverse perspectives. +4. **Report Responsibly**: Flag issues via [GitHub Issues](https://github.com/electerm/electerm/issues) or contact maintainers privately. + +We foster an open, welcoming environment for all contributors. \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..1230149 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" diff --git a/.github/workflows/build-homepage-trigger.yml b/.github/workflows/build-homepage-trigger.yml new file mode 100644 index 0000000..8a6ca4e --- /dev/null +++ b/.github/workflows/build-homepage-trigger.yml @@ -0,0 +1,24 @@ +name: Trigger Website Update + +on: + release: + types: [published] + workflow_dispatch: + +jobs: + trigger-website-update: + runs-on: ubuntu-latest + environment: build + strategy: + matrix: + repository: + - electerm/electerm.org + - electerm/repos + - electerm/electerm-data-tool + steps: + - name: Trigger ${{ matrix.repository }} repository workflow + uses: peter-evans/repository-dispatch@v2 + with: + token: ${{ secrets.PAT_REPO_TOKEN }} + repository: ${{ matrix.repository }} + event-type: electerm-release \ No newline at end of file diff --git a/.github/workflows/electerm-react-publish.yml b/.github/workflows/electerm-react-publish.yml new file mode 100644 index 0000000..77557a2 --- /dev/null +++ b/.github/workflows/electerm-react-publish.yml @@ -0,0 +1,26 @@ +name: electerm-react-publish +on: + release: + types: [published] + push: + branches: [ electerm-react ] +jobs: + release-npm: + runs-on: ubuntu-24.04 + environment: build + if: ${{ !contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[skip npm]') }} + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup node and npm + uses: actions/setup-node@v4 + with: + node-version: '24' + registry-url: https://registry.npmjs.org/ + - run: npm config set legacy-peer-deps true + - run: npm install shelljs + - run: npm install -g @jsdevtools/npm-publish + - name: build + env: + token: ${{secrets.NPM_TOKEN}} + run: node build/bin/publish-electerm-react.js diff --git a/.github/workflows/linux-arm-legacy.yml b/.github/workflows/linux-arm-legacy.yml new file mode 100644 index 0000000..511e167 --- /dev/null +++ b/.github/workflows/linux-arm-legacy.yml @@ -0,0 +1,99 @@ +# Simplified workflow using custom electerm-builder Docker image +name: linux-arm-legacy-docker + +on: + push: + branches: [ build, test, build-only, linux-arm-legacy, legacy-build ] + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-24.04-arm + container: zxdong262/electerm-builder-legacy:latest # This will be our custom image + environment: build + if: ${{ !contains(github.event.head_commit.message, '[skip build]') && !contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[skip legacy]') && !contains(github.event.head_commit.message, '[skip linux-arm-legacy]') }} + + steps: + - name: Clone repository to /tmp and setup + run: | + cd /tmp + git clone https://github.com/electerm/electerm.git electerm_build + cd electerm_build + git checkout ${{ github.sha }} + + # Configure npm to use /tmp for cache and global packages + mkdir -p /tmp/.npm /tmp/.npm-global + npm config set cache /tmp/.npm + npm config set prefix /tmp/.npm-global + export PATH="/tmp/.npm-global/bin:$PATH" + echo "PATH=/tmp/.npm-global/bin:$PATH" >> $GITHUB_ENV + echo "npm_config_cache=/tmp/.npm" >> $GITHUB_ENV + echo "npm_config_prefix=/tmp/.npm-global" >> $GITHUB_ENV + + - name: Verify pre-installed versions + working-directory: /tmp/electerm_build + run: | + echo "=== Pre-installed versions ===" + node --version + npm --version + yarn --version + python3 --version + python --version + gcc --version + g++ --version + fpm --version + + - name: Clear electron-builder cache + working-directory: /tmp/electerm_build + run: rm -rf ~/.cache/electron-builder + + - name: ci init + working-directory: /tmp/electerm_build + run: npm config set legacy-peer-deps true + + - name: Update electron version and remove package-lock + working-directory: /tmp/electerm_build + run: | + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('package.json')); + pkg.devDependencies.electron = '22.3.27'; + pkg.devDependencies['electron-builder'] = '26.0.20'; + pkg.devDependencies['@electron/rebuild'] = '3.7.2'; + pkg.dependencies['node-pty'] = '0.10.1'; + pkg.dependencies.serialport = '10.5.0'; + pkg.devDependencies.vite = '4'; + pkg.devDependencies['@vitejs/plugin-react'] = '4.7.0'; + fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2)); + " + rm -f package-lock.json + + - name: Install npm dependencies + working-directory: /tmp/electerm_build + run: npm i && npm i -S @electron/rebuild@3.7.2 + + - name: Install R2 dependencies if needed + working-directory: /tmp/electerm_build + if: ${{ contains(github.event.head_commit.message, '[r2]') }} + run: npm i -D @aws-sdk/client-s3 + + - name: Build application + working-directory: /tmp/electerm_build + run: npm run b + + - name: Run pb + working-directory: /tmp/electerm_build + run: npm run pb + + - name: Build legacy packages + working-directory: /tmp/electerm_build + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_NUMBER: ${{ secrets.BUILD_NUMBER }} + USE_SYSTEM_FPM: true + WORKFLOW_NAME: ${{ github.workflow }} + CF_R2_ACCOUNT_ID: ${{ secrets.CF_R2_ACCOUNT_ID }} + CF_R2_BUCKET: ${{ secrets.CF_R2_BUCKET }} + CF_R2_ACCESS_KEY_ID: ${{ secrets.CF_R2_ACCESS_KEY_ID }} + CF_R2_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_SECRET_ACCESS_KEY }} + run: node build/bin/build-linux-arm-legacy diff --git a/.github/workflows/linux-arm.yml b/.github/workflows/linux-arm.yml new file mode 100644 index 0000000..3ef5ae2 --- /dev/null +++ b/.github/workflows/linux-arm.yml @@ -0,0 +1,111 @@ +# Alternative workflow using native ARM64 runners +name: linux-arm-native + +on: + push: + branches: [ build, test, build-only, linux-arm ] + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-24.04-arm + environment: build + if: ${{ !contains(github.event.head_commit.message, '[skip build]') && !contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[skip arm-linux]') && !contains(github.event.head_commit.message, '[skip linux3]') }} + env: + # Force electron-builder to use the system fpm (ARM native) instead of downloading x86 prebuilt bundle + USE_SYSTEM_FPM: true + + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js 16.x + uses: actions/setup-node@v4 + with: + node-version: 16.x + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + python3-dev \ + build-essential \ + libnss3-dev \ + libsecret-1-dev \ + fakeroot \ + rpm \ + libgtk-3-0 \ + libnotify4 \ + libnss3 \ + libxss1 \ + libxtst6 \ + xdg-utils \ + libatspi2.0-0 \ + libuuid1 \ + libsecret-1-0 \ + libappindicator3-1 \ + ruby-full + + - name: Install FPM + run: sudo gem install fpm + + - name: Verify glibc version + run: ldd --version + + - name: Verify fpm + run: | + which fpm + fpm --version + + - name: Install yarn + run: npm install -g yarn + + - name: ci init + run: npm config set legacy-peer-deps true + + - name: Clear electron-builder cache + run: rm -rf ~/.cache/electron-builder + + - name: Update electron version and remove package-lock + run: | + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('package.json')); + pkg.devDependencies.electron = '22.3.27'; + pkg.devDependencies['electron-builder'] = '26.0.20'; + pkg.devDependencies['@electron/rebuild'] = '3.7.2'; + pkg.dependencies['node-pty'] = '1.1.0-beta14'; + pkg.dependencies.serialport = '13.0.0'; + pkg.devDependencies.vite = '4'; + pkg.devDependencies['@vitejs/plugin-react'] = '4.7.0'; + fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2)); + " + rm -f package-lock.json + + - name: Install npm dependencies + run: npm i && npm i -S @electron/rebuild@3.7.2 + + - name: Install R2 dependencies if needed + if: ${{ contains(github.event.head_commit.message, '[r2]') }} + run: npm i -D @aws-sdk/client-s3 + + - name: Build application + run: npm run b + + - name: Run pb + run: npm run pb + + - name: Build ARM64 packages + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CF_R2_ACCOUNT_ID: ${{ secrets.CF_R2_ACCOUNT_ID }} + CF_R2_BUCKET: ${{ secrets.CF_R2_BUCKET }} + CF_R2_ACCESS_KEY_ID: ${{ secrets.CF_R2_ACCESS_KEY_ID }} + CF_R2_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_SECRET_ACCESS_KEY }} + BUILD_NUMBER: ${{ secrets.BUILD_NUMBER }} + WORKFLOW_NAME: ${{ github.workflow }} + run: node build/bin/build-linux-arm \ No newline at end of file diff --git a/.github/workflows/linux-deb-tar.yml b/.github/workflows/linux-deb-tar.yml new file mode 100644 index 0000000..9a4d68d --- /dev/null +++ b/.github/workflows/linux-deb-tar.yml @@ -0,0 +1,57 @@ +# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions + +name: linux-deb-tar + +on: + push: + branches: [ build, test, build-only, linux-deb-tar ] + +jobs: + build: + + runs-on: ubuntu-24.04 + environment: build + if: ${{ !contains(github.event.head_commit.message, '[skip build]') && !contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[skip linux]') && !contains(github.event.head_commit.message, '[skip linux1]') }} + + steps: + - uses: actions/checkout@v4 + - name: Use Node.js 24.x + uses: actions/setup-node@v4 + with: + node-version: 24.x + # - name: xvfb + # uses: GabrielBB/xvfb-action@v1 + + # before_script: + # - run: export DISPLAY=:99.0 + # - run: sh -e /etc/init.d/xvfb start & + # - run: sleep 3 + + # before_install: + - run: npm config set legacy-peer-deps true + - run: npm install -g yarn + - run: npm i + + - name: Install R2 dependencies if needed + if: ${{ contains(github.event.head_commit.message, '[r2]') }} + run: npm i -D @aws-sdk/client-s3 + + # script: + - run: npm run b + + - name: Run pb + run: npm run pb + + - name: build + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_NUMBER: ${{ secrets.BUILD_NUMBER }} + WORKFLOW_NAME: ${{ github.workflow }} + CF_R2_ACCOUNT_ID: ${{ secrets.CF_R2_ACCOUNT_ID }} + CF_R2_BUCKET: ${{ secrets.CF_R2_BUCKET }} + CF_R2_ACCESS_KEY_ID: ${{ secrets.CF_R2_ACCESS_KEY_ID }} + CF_R2_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_SECRET_ACCESS_KEY }} + + run: node build/bin/build-linux-deb-tar + diff --git a/.github/workflows/linux-legacy.yml b/.github/workflows/linux-legacy.yml new file mode 100644 index 0000000..e9d89a6 --- /dev/null +++ b/.github/workflows/linux-legacy.yml @@ -0,0 +1,109 @@ +# Simplified workflow using custom electerm-builder Docker image +name: linux-legacy-docker + +on: + push: + branches: [ build, test, build-only, linux-legacy, legacy-build ] + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + container: zxdong262/electerm-builder-legacy:latest # This will be our custom image + environment: build + if: ${{ !contains(github.event.head_commit.message, '[skip build]') && !contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[skip legacy]') && !contains(github.event.head_commit.message, '[skip linux-legacy]') }} + + steps: + - name: Clone repository to /tmp and setup + run: | + cd /tmp + git clone https://github.com/electerm/electerm.git electerm_build + cd electerm_build + git checkout ${{ github.sha }} + + # Configure npm to use /tmp for cache and global packages + mkdir -p /tmp/.npm /tmp/.npm-global + npm config set cache /tmp/.npm + npm config set prefix /tmp/.npm-global + export PATH="/tmp/.npm-global/bin:$PATH" + echo "PATH=/tmp/.npm-global/bin:$PATH" >> $GITHUB_ENV + echo "npm_config_cache=/tmp/.npm" >> $GITHUB_ENV + echo "npm_config_prefix=/tmp/.npm-global" >> $GITHUB_ENV + + - name: Verify pre-installed versions + working-directory: /tmp/electerm_build + run: | + echo "=== Pre-installed versions ===" + node --version + npm --version + yarn --version + python3 --version + python --version + gcc --version + g++ --version + fpm --version + + - name: Clear electron-builder cache + working-directory: /tmp/electerm_build + run: rm -rf ~/.cache/electron-builder + + - name: ci init + working-directory: /tmp/electerm_build + run: npm config set legacy-peer-deps true + + - name: Update electron version and remove package-lock + working-directory: /tmp/electerm_build + run: | + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('package.json')); + pkg.devDependencies.electron = '22.3.27'; + pkg.devDependencies['electron-builder'] = '26.0.20'; + pkg.devDependencies['@electron/rebuild'] = '3.7.2'; + pkg.dependencies['node-pty'] = '0.10.1'; + pkg.dependencies.serialport = '10.5.0'; + pkg.devDependencies.vite = '4'; + pkg.devDependencies['@vitejs/plugin-react'] = '4.7.0'; + fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2)); + fs.unlinkSync('package-lock.json'); + " + + - name: Install npm dependencies + working-directory: /tmp/electerm_build + run: npm i && npm i -S @electron/rebuild@3.7.2 + + - name: Install R2 dependencies if needed + working-directory: /tmp/electerm_build + if: ${{ contains(github.event.head_commit.message, '[r2]') }} + run: npm i -D @aws-sdk/client-s3 + + - name: Verify updated dependencies + working-directory: /tmp/electerm_build + run: | + echo "Verifying updated dependencies:" + npm list electron --depth=0 + npm list @electron/rebuild --depth=0 + npm list node-pty --depth=0 + npm list serialport --depth=0 + npm list vite --depth=0 + + - name: Build application + working-directory: /tmp/electerm_build + run: npm run b + + - name: Run pb + working-directory: /tmp/electerm_build + run: npm run pb + + - name: Build legacy packages + working-directory: /tmp/electerm_build + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_NUMBER: ${{ secrets.BUILD_NUMBER }} + USE_SYSTEM_FPM: true + WORKFLOW_NAME: ${{ github.workflow }} + CF_R2_ACCOUNT_ID: ${{ secrets.CF_R2_ACCOUNT_ID }} + CF_R2_BUCKET: ${{ secrets.CF_R2_BUCKET }} + CF_R2_ACCESS_KEY_ID: ${{ secrets.CF_R2_ACCESS_KEY_ID }} + CF_R2_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_SECRET_ACCESS_KEY }} + run: node build/bin/build-linux-legacy diff --git a/.github/workflows/linux-loong64-legacy.yml b/.github/workflows/linux-loong64-legacy.yml new file mode 100644 index 0000000..fe3baa7 --- /dev/null +++ b/.github/workflows/linux-loong64-legacy.yml @@ -0,0 +1,142 @@ +# Build electerm for loong64 (LoongArch64) Linux - Legacy (old world) +# +# This workflow: +# 1. Downloads GCC 8 cross-compiler from Loongnix for old-world ABI +# 2. Builds x64 version with legacy deps to get the asar +# 3. Downloads electron loong64 from Loongnix +# 4. Cross-compile native modules for loong64 with legacy deps +# 5. Merges x64 asar with loong64 electron and native modules +# 6. Uploads tar.gz to GitHub release draft +# 7. Builds deb package and uploads to GitHub release draft +# 8. Optionally uploads to R2 + +name: linux-loong64-legacy + +on: + push: + branches: [ build, test, build-only, linux-loong64-legacy ] + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-24.04 + environment: build + if: ${{ !contains(github.event.head_commit.message, '[skip build]') && !contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[skip linux]') && !contains(github.event.head_commit.message, '[skip loong64]') && !contains(github.event.head_commit.message, '[skip legacy]') }} + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js 20.x + uses: actions/setup-node@v4 + with: + node-version: 20.x + + - name: Install loong64 cross-compiler and GCC 8 from Loongnix + run: | + sudo add-apt-repository -y universe + sudo apt-get update + sudo apt-get install -y gcc-14-loongarch64-linux-gnu g++-14-loongarch64-linux-gnu binutils-loongarch64-linux-gnu || \ + sudo apt-get install -y gcc-loongarch64-linux-gnu g++-loongarch64-linux-gnu binutils-loongarch64-linux-gnu || true + which loongarch64-linux-gnu-gcc 2>/dev/null || sudo ln -sf /usr/bin/loongarch64-linux-gnu-gcc-14 /usr/bin/loongarch64-linux-gnu-gcc || true + which loongarch64-linux-gnu-g++ 2>/dev/null || sudo ln -sf /usr/bin/loongarch64-linux-gnu-g++-14 /usr/bin/loongarch64-linux-gnu-g++ || true + + # Download GCC 8 from Loongnix for old-world ABI support + # The Loongnix FTP server is unreliable and drops connections, so we retry with resume + echo "Downloading GCC 8 cross-compiler from Loongnix..." + URL="https://ftp.loongnix.cn/toolchain/gcc/release/loongarch/gcc8/loongson-gnu-toolchain-8.3-x86_64-loongarch64-linux-gnu-rc1.6.tar.xz" + for i in 1 2 3 4 5; do + echo "Attempt $i..." + curl -L --retry 3 --retry-delay 5 -C - -o /tmp/gcc8-loong64.tar.xz "$URL" && break + echo "Attempt $i failed, retrying..." + sleep 10 + done + sudo mkdir -p /opt/gcc8-loong64 + sudo tar xJf /tmp/gcc8-loong64.tar.xz -C /opt/gcc8-loong64 --strip-components=1 + echo "/opt/gcc8-loong64/bin" >> $GITHUB_PATH + export PATH="/opt/gcc8-loong64/bin:$PATH" + loongarch64-linux-gnu-gcc --version + + - run: rm package-lock.json + - run: npm config set legacy-peer-deps true + + - name: Update package.json with legacy deps + run: | + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('package.json')); + pkg.devDependencies.electron = '22.3.27'; + pkg.devDependencies['electron-builder'] = '26.0.20'; + pkg.devDependencies['@electron/rebuild'] = '3.7.2'; + pkg.dependencies['node-pty'] = '1.1.0'; + pkg.dependencies.serialport = '10.5.0'; + pkg.devDependencies.vite = '4'; + pkg.devDependencies['@vitejs/plugin-react'] = '4.7.0'; + console.log('Version:', pkg.version); + fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2)); + " + + - name: Install npm dependencies + run: | + npm config set legacy-peer-deps true + npm install -g yarn + npm i && npm i -S @electron/rebuild@3.7.2 + + - name: Verify updated dependencies + run: | + echo "Verifying updated dependencies:" + npm list electron --depth=0 + npm list @electron/rebuild --depth=0 + npm list node-pty --depth=0 + npm list serialport --depth=0 + npm list vite --depth=0 + + - name: Build loong64 legacy + env: + WORKFLOW_NAME: linux-loong64-legacy + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_NUMBER: ${{ secrets.BUILD_NUMBER }} + run: bash build/bin/build-linux-loong64-legacy.sh + + - name: Verify output + run: | + ls -la dist-loong64-legacy/*.tar.gz dist-loong64-legacy/*.deb + file dist-loong64-legacy/electerm-*/electerm + echo "=== Native modules ===" + find dist-loong64-legacy/electerm-* -name "*.node" -exec sh -c 'echo "--- $1 ---"; file "$1"; readelf -h "$1" 2>/dev/null | grep -E "Machine|Class"; readelf -d "$1" 2>/dev/null | grep NEEDED' _ {} \; + echo "=== pty.node symbols (forkpty/openpty) ===" + find dist-loong64-legacy/electerm-* -name "pty.node" -exec readelf -s {} \; 2>/dev/null | grep -iE "forkpty|openpty|pty" || true + echo "=== Build logs ===" + cat /tmp/node-pty-build.log 2>/dev/null | tail -50 || echo "No node-pty build log" + cat /tmp/serialport-build.log 2>/dev/null | tail -30 || echo "No serialport build log" + + # - name: Upload build logs + # if: always() + # uses: actions/upload-artifact@v4 + # with: + # name: build-logs + # path: /tmp/*-build.log + # retention-days: 7 + + - name: Upload to R2 + if: ${{ contains(github.event.head_commit.message, '[r2]') }} + env: + CF_R2_BUCKET: ${{ secrets.CF_R2_BUCKET }} + run: | + mkdir -p ~/.aws + cat > ~/.aws/credentials << EOF + [default] + aws_access_key_id = ${{ secrets.CF_R2_ACCESS_KEY_ID }} + aws_secret_access_key = ${{ secrets.CF_R2_SECRET_ACCESS_KEY }} + EOF + cat > ~/.aws/config << EOF + [default] + region = auto + endpoint_url = ${{ secrets.CF_R2_ENDPOINT }} + EOF + for file in $(find dist-loong64-legacy \( -name "*.tar.gz" -o -name "*.deb" \) -type f); do + filename=$(basename "$file") + aws s3 cp "$file" "s3://${CF_R2_BUCKET}/${filename}" \ + --acl public-read \ + --cache-control "max-age=31536000" + echo "Uploaded: ${filename}" + done diff --git a/.github/workflows/linux-loong64.yml b/.github/workflows/linux-loong64.yml new file mode 100644 index 0000000..58b5474 --- /dev/null +++ b/.github/workflows/linux-loong64.yml @@ -0,0 +1,94 @@ +# Build electerm for loong64 (LoongArch64) Linux +# +# This workflow: +# 1. Builds x64 version with electron v39.2.7 to get the asar +# 2. Downloads electron loong64 v39.2.7 +# 3. Cross-compiles native modules for loong64 +# 4. Merges x64 asar with loong64 electron and native modules +# 5. Uploads tar.gz to GitHub release draft +# 6. Builds deb package and uploads to GitHub release draft +# 7. Optionally uploads to R2 + +name: linux-loong64 + +on: + push: + branches: [ build, test, build-only, linux-loong64 ] + +jobs: + build: + runs-on: ubuntu-24.04 + environment: build + if: ${{ !contains(github.event.head_commit.message, '[skip build]') && !contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[skip linux]') && !contains(github.event.head_commit.message, '[skip loong64]') }} + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js 24.x + uses: actions/setup-node@v4 + with: + node-version: 24.x + + - name: Install loong64 cross-compiler + run: | + sudo add-apt-repository -y universe + sudo apt-get update + sudo apt-get install -y gcc-14-loongarch64-linux-gnu g++-14-loongarch64-linux-gnu binutils-loongarch64-linux-gnu || \ + sudo apt-get install -y gcc-loongarch64-linux-gnu g++-loongarch64-linux-gnu binutils-loongarch64-linux-gnu + which loongarch64-linux-gnu-gcc 2>/dev/null || sudo ln -sf /usr/bin/loongarch64-linux-gnu-gcc-14 /usr/bin/loongarch64-linux-gnu-gcc || true + which loongarch64-linux-gnu-g++ 2>/dev/null || sudo ln -sf /usr/bin/loongarch64-linux-gnu-g++-14 /usr/bin/loongarch64-linux-gnu-g++ || true + + - run: rm package-lock.json + - run: npm config set legacy-peer-deps true + - name: Update electron version to v39.2.7 + run: | + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('package.json')); + pkg.devDependencies.electron = '39.2.7'; + fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); + console.log('Electron version set to 39.2.7'); + " + + - name: Install dependencies + run: | + npm config set legacy-peer-deps true + npm install -g yarn + npm i + + - name: Build loong64 + env: + WORKFLOW_NAME: linux-loong64 + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_NUMBER: ${{ secrets.BUILD_NUMBER }} + run: bash build/bin/build-linux-loong64.sh + + - name: Verify output + run: | + ls -la dist-loong64/*.tar.gz dist-loong64/*.deb + file dist-loong64/electerm-*/electerm + find dist-loong64/electerm-* -name "*.node" -exec file {} \; | grep LoongArch + + - name: Upload to R2 + if: ${{ contains(github.event.head_commit.message, '[r2]') }} + env: + CF_R2_BUCKET: ${{ secrets.CF_R2_BUCKET }} + run: | + mkdir -p ~/.aws + cat > ~/.aws/credentials << EOF + [default] + aws_access_key_id = ${{ secrets.CF_R2_ACCESS_KEY_ID }} + aws_secret_access_key = ${{ secrets.CF_R2_SECRET_ACCESS_KEY }} + EOF + cat > ~/.aws/config << EOF + [default] + region = auto + endpoint_url = ${{ secrets.CF_R2_ENDPOINT }} + EOF + for file in $(find dist-loong64 \( -name "*.tar.gz" -o -name "*.deb" \) -type f); do + filename=$(basename "$file") + aws s3 cp "$file" "s3://${CF_R2_BUCKET}/${filename}" \ + --acl public-read \ + --cache-control "max-age=31536000" + echo "Uploaded: ${filename}" + done diff --git a/.github/workflows/linux-rpm-snap.yml b/.github/workflows/linux-rpm-snap.yml new file mode 100644 index 0000000..5f7c864 --- /dev/null +++ b/.github/workflows/linux-rpm-snap.yml @@ -0,0 +1,70 @@ +# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions + +name: linux-rpm-snap + +on: + push: + branches: [ build, test, build-only, linux-rpm-snap ] + +jobs: + build: + + runs-on: ubuntu-24.04 + environment: build + if: ${{ !contains(github.event.head_commit.message, '[skip build]') && !contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[skip linux]') && !contains(github.event.head_commit.message, '[skip linux2]') }} + + steps: + - uses: actions/checkout@v4 + - name: Use Node.js 24.x + uses: actions/setup-node@v4 + with: + node-version: 24.x + + # - name: xvfb + # uses: GabrielBB/xvfb-action@v1 + + - name: Install rpm + run: sudo apt-get install rpm -y + + - name: Install Snapcraft + uses: samuelmeuli/action-snapcraft@master + + # before_script: + # - run: export DISPLAY=:99.0 + # - run: sh -e /etc/init.d/xvfb start & + # - run: sleep 3 + + # before_install: + - run: npm config set legacy-peer-deps true + - run: npm install -g yarn + - run: npm i + # - run: npm i -D -E electron-builder@24.9.1 + + - name: Install R2 dependencies if needed + if: ${{ contains(github.event.head_commit.message, '[r2]') }} + run: npm i -D @aws-sdk/client-s3 + + # script: + - run: npm run b + + - name: Run pb + run: npm run pb + + - name: build + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_NUMBER: ${{ secrets.BUILD_NUMBER }} + WORKFLOW_NAME: ${{ github.workflow }} + CF_R2_ACCOUNT_ID: ${{ secrets.CF_R2_ACCOUNT_ID }} + CF_R2_BUCKET: ${{ secrets.CF_R2_BUCKET }} + CF_R2_ACCESS_KEY_ID: ${{ secrets.CF_R2_ACCESS_KEY_ID }} + CF_R2_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_SECRET_ACCESS_KEY }} + + run: node build/bin/build-linux-rpm-snap + + - name: upload to snap store + env: + SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_TOKEN }} + run: snapcraft upload --release=edge dist/*.snap + diff --git a/.github/workflows/mac-10.yml b/.github/workflows/mac-10.yml new file mode 100644 index 0000000..c292c30 --- /dev/null +++ b/.github/workflows/mac-10.yml @@ -0,0 +1,70 @@ +# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions + +name: mac-10 + +on: + push: + branches: [ build, test, build-only, mac-10, legacy-build ] + +jobs: + build: + + runs-on: macos-15-intel + environment: build + if: ${{ !contains(github.event.head_commit.message, '[skip build]') && !contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[skip mac]') && !contains(github.event.head_commit.message, '[skip mac-10]') }} + + steps: + - uses: actions/checkout@v4 + - name: Use Node.js 16.x + uses: actions/setup-node@v4 + with: + node-version: 16.x + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + # before_install: + - run: npm config set legacy-peer-deps true + - run: npm install -g yarn + - name: Update electron version and remove package-lock + run: | + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('package.json')); + pkg.devDependencies.electron = '22.3.27'; + pkg.devDependencies['electron-builder'] = '26.0.20'; + pkg.devDependencies['@electron/rebuild'] = '3.7.2'; + pkg.dependencies['node-pty'] = '1.1.0-beta14'; + pkg.dependencies.serialport = '13.0.0'; + pkg.devDependencies.vite = '4'; + pkg.devDependencies['@vitejs/plugin-react'] = '4.7.0'; + fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2)); + fs.unlinkSync('package-lock.json'); + " + - run: npm i && npm i -S @electron/rebuild@3.7.2 + + - name: Install R2 dependencies if needed + if: ${{ contains(github.event.head_commit.message, '[r2]') }} + run: npm i -D @aws-sdk/client-s3 + + # script: + - run: npm run b + + - name: Run pb + run: npm run pb + + - name: build + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_NUMBER: ${{ secrets.BUILD_NUMBER }} + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_ID: ${{ secrets.APPLEID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLEIDPASS }} + APPLE_TEAM_ID: ${{secrets.APPLE_TEAM_ID}} + WORKFLOW_NAME: ${{ github.workflow }} + CF_R2_ACCOUNT_ID: ${{ secrets.CF_R2_ACCOUNT_ID }} + CF_R2_BUCKET: ${{ secrets.CF_R2_BUCKET }} + CF_R2_ACCESS_KEY_ID: ${{ secrets.CF_R2_ACCESS_KEY_ID }} + CF_R2_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_SECRET_ACCESS_KEY }} + run: node build/bin/build-mac-10 diff --git a/.github/workflows/mac-arm.yml b/.github/workflows/mac-arm.yml new file mode 100644 index 0000000..6086eee --- /dev/null +++ b/.github/workflows/mac-arm.yml @@ -0,0 +1,57 @@ +# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions + +name: mac-arm + +on: + push: + branches: [ build, test, build-only, mac-arm ] + +jobs: + build: + + runs-on: macos-15 + environment: build + if: ${{ !contains(github.event.head_commit.message, '[skip build]') && !contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[skip mac]') && !contains(github.event.head_commit.message, '[skip arm]') }} + + steps: + - uses: actions/checkout@v4 + - name: Use Node.js 24.x + uses: actions/setup-node@v4 + with: + node-version: 24.x + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + # before_install: + - run: pip install setuptools + - run: npm config set legacy-peer-deps true + - run: npm install -g yarn + - run: npm i + + - name: Install R2 dependencies if needed + if: ${{ contains(github.event.head_commit.message, '[r2]') }} + run: npm i -D @aws-sdk/client-s3 + + # script: + - run: npm run b + + - name: Run pb + run: npm run pb + + - name: build + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_NUMBER: ${{ secrets.BUILD_NUMBER }} + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_ID: ${{ secrets.APPLEID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLEIDPASS }} + APPLE_TEAM_ID: ${{secrets.APPLE_TEAM_ID}} + WORKFLOW_NAME: ${{ github.workflow }} + CF_R2_ACCOUNT_ID: ${{ secrets.CF_R2_ACCOUNT_ID }} + CF_R2_BUCKET: ${{ secrets.CF_R2_BUCKET }} + CF_R2_ACCESS_KEY_ID: ${{ secrets.CF_R2_ACCESS_KEY_ID }} + CF_R2_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_SECRET_ACCESS_KEY }} + run: node build/bin/build-mac-arm + diff --git a/.github/workflows/mac-test-1.yml b/.github/workflows/mac-test-1.yml new file mode 100644 index 0000000..0bafb29 --- /dev/null +++ b/.github/workflows/mac-test-1.yml @@ -0,0 +1,51 @@ +# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions + +name: mac-x64-test-1 + +on: + push: + branches: [ build, test, test-only, test1 ] + +jobs: + build: + + runs-on: macos-15-intel + environment: build + if: ${{ !contains(github.event.head_commit.message, '[skip test]') && !contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[skip mac]') && !contains(github.event.head_commit.message, '[skip test1]') }} + + steps: + - uses: actions/checkout@v4 + - name: Use Node.js 24.x + uses: actions/setup-node@v4 + with: + node-version: 24.15.0 + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + # before_install: + - run: pip install setuptools + - run: npm config set legacy-peer-deps true + - run: npm install -g yarn + - run: npm i + - run: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm i -D -E playwright@1.49.1 && npm i -D -E @playwright/test@1.49.1 + + # script: + - run: npm run b + + - name: Run pb + run: npm run pb + - name: test + uses: GabrielBB/xvfb-action@v1 + with: + run: npm run test1 + env: + NODE_TEST: 1 + TEST_HOST: ${{ secrets.TEST_HOST }} + TEST_USER: ${{ secrets.TEST_USER_1 }} + TEST_PASS: ${{ secrets.TEST_PASS_1 }} + GIST_TOKEN: ${{ secrets.GIST_TOKEN }} + GIST_ID: ${{ secrets.GIST_ID }} + GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }} + GITEE_ID: ${{ secrets.GITEE_ID }} + diff --git a/.github/workflows/mac-test-2.yml b/.github/workflows/mac-test-2.yml new file mode 100644 index 0000000..1a226a4 --- /dev/null +++ b/.github/workflows/mac-test-2.yml @@ -0,0 +1,56 @@ +# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions + +name: mac-x64-test-2 + +on: + push: + branches: [ build, test, test-only ] + +jobs: + build: + + runs-on: macos-15-intel + environment: build + if: ${{ !contains(github.event.head_commit.message, '[skip test]') && !contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[skip mac]') && !contains(github.event.head_commit.message, '[skip test2]') }} + + steps: + - uses: actions/checkout@v4 + - name: Use Node.js 24.x + uses: actions/setup-node@v4 + with: + node-version: 24.15.0 + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + # before_install: + - run: pip install setuptools + - run: npm config set legacy-peer-deps true + - run: npm install -g yarn + - run: npm i + - run: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm i -D -E playwright@1.49.1 && npm i -D -E @playwright/test@1.49.1 + + # script: + - run: npm run b + + - name: Run pb + run: npm run pb + + - name: test + uses: GabrielBB/xvfb-action@v1 + with: + run: npm run test2 + env: + NODE_TEST: 1 + TEST_HOST: ${{ secrets.TEST_HOST }} + TEST_USER: ${{ secrets.TEST_USER_2 }} + TEST_PASS: ${{ secrets.TEST_PASS_2 }} + GIST_TOKEN: ${{ secrets.GIST_TOKEN }} + GIST_ID: ${{ secrets.GIST_ID }} + GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }} + GITEE_ID: ${{ secrets.GITEE_ID }} + CUSTOM_SYNC_URL: ${{ secrets.CUSTOM_SYNC_URL }} + CUSTOM_SYNC_USER: ${{ secrets.CUSTOM_SYNC_USER }} + CUSTOM_SYNC_SECRET: ${{ secrets.CUSTOM_SYNC_SECRET }} + CLOUD_TOKEN: ${{ secrets.CLOUD_TOKEN }} + diff --git a/.github/workflows/mac-test-3.yml b/.github/workflows/mac-test-3.yml new file mode 100644 index 0000000..77556b8 --- /dev/null +++ b/.github/workflows/mac-test-3.yml @@ -0,0 +1,56 @@ +# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions + +name: mac-x64-test-3 + +on: + push: + branches: [ build, test, test-only, test3 ] + +jobs: + build: + + runs-on: macos-15-intel + environment: build + if: ${{ !contains(github.event.head_commit.message, '[skip test]') && !contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[skip mac]') && !contains(github.event.head_commit.message, '[skip test3]') }} + + steps: + - uses: actions/checkout@v4 + - name: Use Node.js 24.x + uses: actions/setup-node@v4 + with: + node-version: 24.15.0 + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + # before_install: + - run: pip install setuptools + - run: npm config set legacy-peer-deps true + - run: npm install -g yarn + - run: npm i + - run: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm i -D -E playwright@1.49.1 && npm i -D -E @playwright/test@1.49.1 + + # script: + - run: npm run b + + - name: Run pb + run: npm run pb + + - name: test + uses: GabrielBB/xvfb-action@v1 + with: + run: npm run test3 + env: + NODE_TEST: 1 + TEST_HOST: ${{ secrets.TEST_HOST }} + TEST_USER: ${{ secrets.TEST_USER_3 }} + TEST_PASS: ${{ secrets.TEST_PASS_3 }} + GIST_TOKEN: ${{ secrets.GIST_TOKEN }} + GIST_ID: ${{ secrets.GIST_ID }} + GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }} + GITEE_ID: ${{ secrets.GITEE_ID }} + CUSTOM_SYNC_URL: ${{ secrets.CUSTOM_SYNC_URL }} + CUSTOM_SYNC_USER: ${{ secrets.CUSTOM_SYNC_USER }} + CUSTOM_SYNC_SECRET: ${{ secrets.CUSTOM_SYNC_SECRET }} + CLOUD_TOKEN: ${{ secrets.CLOUD_TOKEN }} + diff --git a/.github/workflows/mac.yml b/.github/workflows/mac.yml new file mode 100644 index 0000000..9745bb2 --- /dev/null +++ b/.github/workflows/mac.yml @@ -0,0 +1,56 @@ +# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions + +name: mac-x64 + +on: + push: + branches: [ build, test, build-only, mac-x64 ] + +jobs: + build: + + runs-on: macos-15-intel + environment: build + if: ${{ !contains(github.event.head_commit.message, '[skip build]') && !contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[skip mac]') }} + + steps: + - uses: actions/checkout@v4 + - name: Use Node.js 24.x + uses: actions/setup-node@v4 + with: + node-version: 24.x + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + # before_install: + - run: npm config set legacy-peer-deps true + - run: npm install -g yarn + - run: npm i + + - name: Install R2 dependencies if needed + if: ${{ contains(github.event.head_commit.message, '[r2]') }} + run: npm i -D @aws-sdk/client-s3 + + # script: + - run: npm run b + + - name: Run pb + run: npm run pb + + - name: build + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_NUMBER: ${{ secrets.BUILD_NUMBER }} + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_ID: ${{ secrets.APPLEID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLEIDPASS }} + APPLE_TEAM_ID: ${{secrets.APPLE_TEAM_ID}} + WORKFLOW_NAME: ${{ github.workflow }} + CF_R2_ACCOUNT_ID: ${{ secrets.CF_R2_ACCOUNT_ID }} + CF_R2_BUCKET: ${{ secrets.CF_R2_BUCKET }} + CF_R2_ACCESS_KEY_ID: ${{ secrets.CF_R2_ACCESS_KEY_ID }} + CF_R2_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_SECRET_ACCESS_KEY }} + run: node build/bin/build-mac + diff --git a/.github/workflows/ms-store-upload.yml b/.github/workflows/ms-store-upload.yml new file mode 100644 index 0000000..5a20dee --- /dev/null +++ b/.github/workflows/ms-store-upload.yml @@ -0,0 +1,504 @@ +# Upload APPX to Microsoft Store after a new release is published. +# Downloads the .appx artifact from the GitHub release and submits +# a new Store submission via the Partner Center Ingestion API. + +name: ms-store-upload + +on: + release: + types: [released] + push: + branches: [ ms ] + +jobs: + upload-to-store: + runs-on: windows-2022 + environment: build + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js 24.x + uses: actions/setup-node@v4 + with: + node-version: 24.x + + - name: Install R2 dependencies if store upload may run + if: ${{ contains(github.event.head_commit.message, '[r2]') }} + run: npm i -D @aws-sdk/client-s3 + + - name: Determine release tag + id: tag + env: + GH_TOKEN: ${{ github.token }} + shell: pwsh + run: | + $tag = "${{ github.event.release.tag_name }}" + if (-not $tag) { + Write-Host "No release tag from event, fetching latest release..." + $tag = (gh release view --repo "${{ github.repository }}" --json tagName -q '.tagName') + if (-not $tag) { throw 'Could not determine release tag.' } + } + Write-Host "Release tag: $tag" + "tag_name=$tag" >> $env:GITHUB_OUTPUT + + - name: Download APPX from release + env: + GH_TOKEN: ${{ github.token }} + shell: pwsh + run: | + $tag = "${{ steps.tag.outputs.tag_name }}" + New-Item -ItemType Directory -Force -Path dist | Out-Null + gh release download "$tag" --repo "${{ github.repository }}" --pattern '*.appx' -D dist + Write-Host 'Downloaded APPX files:' + Get-ChildItem -Path dist -Filter '*.appx' -Recurse | Format-Table FullName, Length + + - name: Setup Microsoft Store Developer CLI + uses: microsoft/microsoft-store-apppublisher@v1.1 + + - name: Configure Microsoft Store Developer CLI + env: + MS_TENANT_ID: ${{ secrets.MS_TENANT_ID }} + MS_SELLER_ID: ${{ secrets.MS_SELLER_ID }} + MS_CLIENT_ID: ${{ secrets.MS_CLIENT_ID }} + MS_CLIENT_SECRET: ${{ secrets.MS_CLIENT_SECRET }} + run: msstore reconfigure --tenantId "$env:MS_TENANT_ID" --sellerId "$env:MS_SELLER_ID" --clientId "$env:MS_CLIENT_ID" --clientSecret "$env:MS_CLIENT_SECRET" + shell: pwsh + + - name: Debug Microsoft Store app access + continue-on-error: true + env: + MS_PRODUCT_ID: ${{ secrets.MS_PRODUCT_ID }} + MS_APP_ID: ${{ secrets.MS_APP_ID }} + MSSTORE_LOG_FILE: dist/msstore-logs/msstore-${{ github.run_id }}-${{ github.run_attempt }}.log + run: | + function Get-VisibleProductIds([string] $appsListOutput) { + $cleanOutput = [regex]::Replace($appsListOutput, '\x1B\[[0-9;?]*[ -/]*[@-~]', '') + return @( + $cleanOutput -split "`r?`n" | + Where-Object { $_.Trim().StartsWith('โ”‚') } | + ForEach-Object { + $columns = $_.Trim().Split('โ”‚') | ForEach-Object { $_.Trim() } + if ($columns.Count -ge 5) { + $productId = $columns[2] + $packageId = $columns[4] + if ($productId -and $productId -ne 'ProductId' -and $packageId -and $packageId -ne 'PackageId') { + $productId + } + } + } | + Select-Object -Unique + ) + } + + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $env:MSSTORE_LOG_FILE) | Out-Null + Set-Content -Path $env:MSSTORE_LOG_FILE -Value "=== Microsoft Store debug log $(Get-Date -Format o) ===" + + function Write-StoreLog([string] $message) { + Add-Content -Path $env:MSSTORE_LOG_FILE -Value $message + } + + $msstoreVersion = & msstore --version 2>&1 | Out-String + Write-Host "msstore CLI version: $msstoreVersion" + Write-StoreLog "msstore CLI version: $msstoreVersion" + + $configuredProductId = "$env:MS_PRODUCT_ID".Trim() + if (-not $configuredProductId) { + $configuredProductId = "$env:MS_APP_ID".Trim() + } + + if ($configuredProductId) { + Write-Host "Configured Store Product ID: $configuredProductId" + } else { + Write-Host 'Configured Store Product ID: ' + } + + Write-Host 'Package identifiers in this repo are not valid Store Product IDs:' + Write-Host ' Electron appId: org.electerm.electerm' + Write-Host ' APPX applicationId: electerm' + Write-Host ' APPX identityName: 64159ZHAOXudong.electerm' + Write-Host 'Accessible Microsoft Store packaged apps for this account:' + Write-StoreLog "Configured Store Product ID: $configuredProductId" + Write-StoreLog 'Package identifiers in this repo are not valid Store Product IDs:' + Write-StoreLog ' Electron appId: org.electerm.electerm' + Write-StoreLog ' APPX applicationId: electerm' + Write-StoreLog ' APPX identityName: 64159ZHAOXudong.electerm' + Write-StoreLog 'Accessible Microsoft Store packaged apps for this account:' + + Write-StoreLog '' + Write-StoreLog "=== msstore apps list $(Get-Date -Format o) ===" + $appsListOutput = (& msstore apps list 2>&1 | Tee-Object -FilePath $env:MSSTORE_LOG_FILE -Append | Out-String) + $appsListExitCode = $LASTEXITCODE + $global:LASTEXITCODE = 0 + Write-Host $appsListOutput + Write-StoreLog "msstore apps list exit code: $appsListExitCode" + + $productIds = Get-VisibleProductIds $appsListOutput + + if ($appsListExitCode -ne 0) { + Write-Host "msstore apps list exited with code $appsListExitCode. This usually means the configured Entra app can authenticate but does not have Partner Center access to packaged apps for this seller account." + } elseif ($productIds.Count -eq 0) { + Write-Host 'msstore apps list succeeded but no packaged Product IDs were detected. This usually means no packaged Store apps are visible to this account.' + } elseif (-not $configuredProductId) { + if ($productIds.Count -eq 1) { + Write-Host "Exactly one packaged Product ID is visible: $($productIds[0])" + } else { + Write-Host "Multiple packaged Product IDs are visible: $($productIds -join ', ')" + } + } elseif ($configuredProductId -notin $productIds) { + Write-Host "Configured Store Product ID '$configuredProductId' was not found in the accessible app list." + Write-Host "Visible Product IDs: $($productIds -join ', ')" + } else { + Write-Host "Configured Store Product ID '$configuredProductId' is visible to this account." + } + shell: pwsh + + - name: Upload APPX to Microsoft Store and submit for certification + env: + MS_PRODUCT_ID: ${{ secrets.MS_PRODUCT_ID }} + MS_APP_ID: ${{ secrets.MS_APP_ID }} + MS_TENANT_ID: ${{ secrets.MS_TENANT_ID }} + MS_CLIENT_ID: ${{ secrets.MS_CLIENT_ID }} + MS_CLIENT_SECRET: ${{ secrets.MS_CLIENT_SECRET }} + MSSTORE_LOG_FILE: dist/msstore-logs/msstore-${{ github.run_id }}-${{ github.run_attempt }}.log + run: | + # โ”€โ”€ Initialise log file โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $env:MSSTORE_LOG_FILE) | Out-Null + Set-Content -Path $env:MSSTORE_LOG_FILE -Value "=== ms-store-upload log $(Get-Date -Format o) ===" + + function Write-StoreLog([string] $message) { + Add-Content -Path $env:MSSTORE_LOG_FILE -Value $message + } + + # Retry wrapper for Invoke-RestMethod with exponential backoff. + # The Partner Center Ingestion API is behind Azure Front Door and can + # return transient 504 / 502 / 503 responses under load. + function Invoke-WithRetry { + param( + [Parameter(Mandatory=$true)] [scriptblock] $Action, + [string] $Description = 'API call', + [int] $MaxRetries = 5, + [int] $BaseDelaySec = 10 + ) + for ($i = 0; $i -lt $MaxRetries; $i++) { + try { + return & $Action + } catch { + $statusCode = $null + if ($_.Exception.Response) { + $statusCode = [int]$_.Exception.Response.StatusCode + } + $isTransient = $false + if ($statusCode -in 408, 429, 500, 502, 503, 504) { $isTransient = $true } + if ($_.Exception.Message -match 'timed?\s*out|gateway|service unavailable|connection was closed|unable to connect') { $isTransient = $true } + $attempt = $i + 1 + if ($isTransient -and $attempt -lt $MaxRetries) { + $delay = $BaseDelaySec * [Math]::Pow(2, $i) + Write-Host " โš  ${Description}: transient error (HTTP $statusCode) on attempt $attempt/$MaxRetries โ€” retrying in ${delay}s..." + Write-StoreLog "${Description}: transient error (HTTP $statusCode) attempt $attempt/$MaxRetries, retry in ${delay}s. $($_.Exception.Message)" + Start-Sleep -Seconds $delay + } else { + Write-StoreLog "${Description}: FAILED on attempt $attempt (HTTP $statusCode). $($_.Exception.Message)" + throw + } + } + } + } + + function Get-VisibleProductIds([string] $appsListOutput) { + $cleanOutput = [regex]::Replace($appsListOutput, '\x1B\[[0-9;?]*[ -/]*[@-~]', '') + return @( + $cleanOutput -split "`r?`n" | + Where-Object { $_.Trim().StartsWith('โ”‚') } | + ForEach-Object { + $columns = $_.Trim().Split('โ”‚') | ForEach-Object { $_.Trim() } + if ($columns.Count -ge 5) { + $productId = $columns[2] + $packageId = $columns[4] + if ($productId -and $productId -ne 'ProductId' -and $packageId -and $packageId -ne 'PackageId') { + $productId + } + } + } | + Select-Object -Unique + ) + } + + try { + + $appx = Get-ChildItem -Path dist -Filter '*.appx' -Recurse | Select-Object -First 1 + if (-not $appx) { throw 'No .appx file found under dist/' } + + Write-StoreLog '' + Write-StoreLog "=== publish preflight $(Get-Date -Format o) ===" + Write-StoreLog "APPX file: $($appx.FullName)" + + # Soft preflight: enumerate accessible packaged apps. + # 'msstore apps list' can fail on its own (e.g. a CLI regression pulled in by + # 'version: latest') even when the Entra app and the configured Product ID are + # perfectly valid. The actual submission below calls the Dev Center Ingestion API + # directly and does not depend on this command, so when MS_PRODUCT_ID is explicitly + # configured we trust it and proceed instead of aborting the whole build. + $appsListOutput = (& msstore apps list 2>&1 | Tee-Object -FilePath $env:MSSTORE_LOG_FILE -Append | Out-String) + $appsListExitCode = $LASTEXITCODE + $global:LASTEXITCODE = 0 + Write-StoreLog "msstore apps list exit code: $appsListExitCode" + $productIds = Get-VisibleProductIds $appsListOutput + + $productId = "$env:MS_PRODUCT_ID".Trim() + if (-not $productId) { + $productId = "$env:MS_APP_ID".Trim() + } + + if (-not $productId) { + # No explicitly configured id: we must rely on the app list. + if ($appsListExitCode -eq 0 -and $productIds.Count -eq 1) { + $productId = $productIds[0] + Write-Host "Resolved MS Product ID from account app list: $productId" + } else { + $visibleProductIds = if ($productIds.Count) { $productIds -join ', ' } else { 'none detected' } + throw "Missing MS_PRODUCT_ID or MS_APP_ID secret and could not resolve a single Product ID from 'msstore apps list' (exit $appsListExitCode). Visible Product IDs for this account: $visibleProductIds" + } + } elseif ($appsListExitCode -ne 0 -or $productIds.Count -eq 0) { + Write-Host "WARNING: 'msstore apps list' failed (exit $appsListExitCode) but MS_PRODUCT_ID is explicitly configured; proceeding with the configured Product ID." + Write-StoreLog "WARNING: 'msstore apps list' failed (exit $appsListExitCode); proceeding with configured Product ID: $productId" + } elseif ($productId -notin $productIds) { + Write-Host "WARNING: Configured Store Product ID '$productId' was not found in the accessible app list; proceeding with the configured value anyway." + Write-StoreLog "WARNING: Configured Store Product ID '$productId' not in accessible app list; proceeding." + } else { + Write-Host "Configured Store Product ID '$productId' is visible to this account." + } + + $knownWrongAppIds = @( + 'org.electerm.electerm', + 'electerm', + '64159ZHAOXudong.electerm' + ) + if ($knownWrongAppIds -contains $productId) { + throw "Configured store identifier '$productId' is a package identifier. Use the Microsoft Store Partner Center product id instead." + } + + Write-Host "Resolved publish Product ID: $productId" + Write-StoreLog "Resolved publish Product ID: $productId" + + # โ”€โ”€ Direct Ingestion API publish โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # msstore publish copies listing features verbatim from the live submission. + # If any locale has >20 features the Ingestion API rejects the update. + # We bypass msstore publish and call the API directly so we can cap features. + + Write-StoreLog '' + Write-StoreLog "=== direct-api publish $(Get-Date -Format o) ===" + + # 1. Get OAuth token for the Partner Center Ingestion API + $tokenParts = @( + "grant_type=client_credentials", + "client_id=$([uri]::EscapeDataString($env:MS_CLIENT_ID))", + "client_secret=$([uri]::EscapeDataString($env:MS_CLIENT_SECRET))", + "scope=$([uri]::EscapeDataString('https://manage.devcenter.microsoft.com/.default'))" + ) + $tokenResp = Invoke-WithRetry -Description 'Get OAuth token' { + Invoke-RestMethod ` + -Uri "https://login.microsoftonline.com/$env:MS_TENANT_ID/oauth2/v2.0/token" ` + -Method Post -ContentType 'application/x-www-form-urlencoded' ` + -Body ($tokenParts -join '&') + } + $token = $tokenResp.access_token + Write-StoreLog "OAuth token acquired." + $h = @{ Authorization = "Bearer $token" } + $apiBase = "https://manage.devcenter.microsoft.com/v1.0/my/applications/$productId" + + # 2. Get app info; delete any pending submission + Write-Host "Getting app info..." + $appInfo = Invoke-WithRetry -Description 'Get app info' { Invoke-RestMethod -Uri $apiBase -Headers $h } + Write-StoreLog "App info fetched. lastPublishedSubmission: $($appInfo.lastPublishedApplicationSubmission.id)" + if ($appInfo.pendingApplicationSubmission.id) { + $pendingId = $appInfo.pendingApplicationSubmission.id + Write-Host "Deleting pending submission $pendingId ..." + Write-StoreLog "Deleting pending submission: $pendingId" + Invoke-WithRetry -Description 'Delete pending submission' { Invoke-RestMethod -Uri "$apiBase/submissions/$pendingId" -Method Delete -Headers $h } | Out-Null + Write-Host "Deleted pending submission." + Write-StoreLog "Deleted pending submission." + } + + # 3. Fetch last published submission to use as the body for POST /submissions. + # The API does NOT auto-clone โ€” it requires the full submission object. + $lastPubSubId = $appInfo.lastPublishedApplicationSubmission.id + if (-not $lastPubSubId) { throw 'No published submission found to clone from.' } + Write-Host "Fetching published submission $lastPubSubId as template..." + Write-StoreLog "Fetching published submission: $lastPubSubId" + $pubSub = Invoke-WithRetry -Description 'Fetch published submission template' { Invoke-RestMethod -Uri "$apiBase/submissions/$lastPubSubId" -Headers $h } + + # 4. Create new submission using the published submission data as the request body. + # A 504/502 gateway timeout can still result in the submission being created + # server-side. A naive retry would then fail with HTTP 409 Conflict + # (InvalidState) because the Ingestion API only allows one pending + # submission at a time. After any failure we re-fetch app info and reuse + # the pending submission if one now exists instead of blindly retrying. + Write-Host "Creating new submission..." + Write-StoreLog "Creating new submission (POST with published data as body)..." + $pubSubBytes = [System.Text.Encoding]::UTF8.GetBytes(($pubSub | ConvertTo-Json -Depth 30 -Compress)) + $newSub = $null + $maxCreateRetries = 5 + $submissionReady = $false + for ($i = 0; $i -lt $maxCreateRetries -and -not $submissionReady; $i++) { + try { + $newSub = Invoke-RestMethod -Uri "$apiBase/submissions" -Method Post -Headers $h -ContentType 'application/json' -Body $pubSubBytes + $submissionReady = $true + } catch { + $statusCode = $null + if ($_.Exception.Response) { + $statusCode = [int]$_.Exception.Response.StatusCode + } + $errBody = '' + if ($_.ErrorDetails -and $_.ErrorDetails.Message) { + $errBody = $_.ErrorDetails.Message + } + $attempt = $i + 1 + $isRecoverable = ($statusCode -in 409, 502, 503, 504) -or + ($errBody -match 'Conflict|InvalidState') -or + ($_.Exception.Message -match 'timed?\s*out|gateway|service unavailable|connection was closed') + if ($isRecoverable -and $attempt -lt $maxCreateRetries) { + $delay = 10 * [Math]::Pow(2, $i) + Write-Host " โš  Create new submission: HTTP $statusCode on attempt $attempt/$maxCreateRetries โ€” checking for pending submission in ${delay}s..." + Write-StoreLog "Create new submission: HTTP $statusCode attempt $attempt/$maxCreateRetries, retry in ${delay}s. $errBody" + Start-Sleep -Seconds $delay + # Re-fetch app info โ€” the failed POST may have created a pending submission. + # Use Invoke-WithRetry because the API may be experiencing widespread 504s. + try { + $appInfoRetry = Invoke-WithRetry -Description 'Re-fetch app info for pending submission' { Invoke-RestMethod -Uri $apiBase -Headers $h } + if ($appInfoRetry.pendingApplicationSubmission.id) { + $pendingSubId = $appInfoRetry.pendingApplicationSubmission.id + Write-Host " Found pending submission $pendingSubId โ€” reusing it." + Write-StoreLog "Reusing existing pending submission: $pendingSubId" + $pendingSub = Invoke-WithRetry -Description 'Fetch pending submission details' { Invoke-RestMethod -Uri "$apiBase/submissions/$pendingSubId" -Headers $h } + $newSub = [PSCustomObject]@{ + id = $pendingSubId + fileUploadUrl = $pendingSub.fileUploadUrl + } + $submissionReady = $true + } + } catch { + Write-Host " Could not re-fetch app info or submission: $($_.Exception.Message)" + Write-StoreLog "Re-fetch failed: $($_.Exception.Message)" + } + } else { + Write-StoreLog "Create new submission: FAILED on attempt $attempt (HTTP $statusCode). $errBody" + throw + } + } + } + if (-not $newSub) { throw 'Failed to create new submission after retries.' } + $subId = $newSub.id + $fileUploadUrl = $newSub.fileUploadUrl + Write-Host "Submission ID: $subId" + Write-StoreLog "Submission ID: $subId" + + # 5. Get full data for the new submission + $subData = Invoke-WithRetry -Description 'Get new submission data' { Invoke-RestMethod -Uri "$apiBase/submissions/$subId" -Headers $h } + + # 6. Replace packages: mark existing for deletion, add new one as PendingUpload + $newPackages = @() + foreach ($pkg in @($subData.applicationPackages)) { + $pkg.fileStatus = 'PendingDelete' + $newPackages += $pkg + } + $newPackages += [PSCustomObject]@{ fileName = $appx.Name; fileStatus = 'PendingUpload' } + $subData.applicationPackages = $newPackages + Write-StoreLog "applicationPackages: $($newPackages.Count) entries (last = new PendingUpload)." + + # Auto-publish after certification passes. + $subData.targetPublishMode = 'Immediate' + + # 7. Push updated submission metadata + Write-Host "Updating submission metadata..." + Write-StoreLog "PUT submission metadata..." + $updateBodyBytes = [System.Text.Encoding]::UTF8.GetBytes(($subData | ConvertTo-Json -Depth 30 -Compress)) + Invoke-WithRetry -Description 'PUT submission metadata' { + Invoke-RestMethod -Uri "$apiBase/submissions/$subId" -Method Put -Headers $h ` + -ContentType 'application/json' -Body $updateBodyBytes + } | Out-Null + Write-Host "Submission metadata updated." + Write-StoreLog "Submission metadata updated." + + # 8. Create ZIP and upload to Azure Blob SAS URL + # (SAS URL carries its own auth โ€” do not include the Bearer token here) + $zipPath = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), 'electerm-appx-upload.zip') + if (Test-Path $zipPath) { Remove-Item $zipPath } + Compress-Archive -Path $appx.FullName -DestinationPath $zipPath + $zipSizeMB = [Math]::Round((Get-Item $zipPath).Length / 1MB, 1) + Write-Host "Uploading package ZIP ($zipSizeMB MB)..." + Write-StoreLog "Uploading package ZIP ($zipSizeMB MB) to Azure Blob SAS URL..." + $blobHeaders = @{ 'x-ms-blob-type' = 'BlockBlob'; 'Content-Type' = 'application/octet-stream' } + Invoke-WithRetry -Description 'Upload package ZIP to Azure Blob' { Invoke-RestMethod -Uri $fileUploadUrl -Method Put -Headers $blobHeaders -InFile $zipPath } | Out-Null + Write-Host "Package uploaded." + Write-StoreLog "Package uploaded." + + # 9. Commit submission to trigger certification. + # targetPublishMode = 'Immediate' means it will go live automatically after passing certification. + Write-Host "Committing submission to trigger certification..." + Write-StoreLog "Committing submission (Immediate publish mode)..." + $commitResp = Invoke-WithRetry -Description 'Commit submission' { Invoke-RestMethod -Uri "$apiBase/submissions/$subId/commit" -Method Post -Headers $h -ContentType 'application/json' } + Write-Host "Submission committed. Certification will start shortly." + Write-StoreLog "Commit response status: $($commitResp.status)" + if ($commitResp.statusDetails) { + $errors = @($commitResp.statusDetails.errors) + $warnings = @($commitResp.statusDetails.warnings) + if ($errors.Count -gt 0) { + foreach ($e in $errors) { + Write-Host " ERROR: $($e.code) - $($e.details)" + Write-StoreLog " Commit error: $($e.code) - $($e.details)" + } + throw "Submission commit returned $($errors.Count) error(s)." + } + if ($warnings.Count -gt 0) { + foreach ($w in $warnings) { + Write-Host " WARNING: $($w.code) - $($w.details)" + Write-StoreLog " Commit warning: $($w.code) - $($w.details)" + } + } + } + Write-StoreLog "Done (committed, Immediate mode). SubmissionId: $subId" + + # 10. Verify submission status after commit + Write-Host "Verifying submission status..." + Start-Sleep -Seconds 10 + $subStatus = Invoke-WithRetry -Description 'Check submission status' { Invoke-RestMethod -Uri "$apiBase/submissions/$subId" -Headers $h } + Write-Host "Submission status: $($subStatus.status)" + Write-StoreLog "Post-commit submission status: $($subStatus.status)" + if ($subStatus.status -eq 'CommitFailed') { + $errDetails = if ($subStatus.statusDetails.errors) { $subStatus.statusDetails.errors | ForEach-Object { "$($_.code): $($_.details)" } | Out-String } else { 'no details' } + throw "Submission commit failed. Status: $($subStatus.status). Details: $errDetails" + } + + } catch { + $errMsg = $_.ToString() + $errDetail = $_.Exception.Message + $errStack = $_.ScriptStackTrace + Write-Host "ERROR: $errMsg" + Write-StoreLog '' + Write-StoreLog "=== FATAL ERROR $(Get-Date -Format o) ===" + Write-StoreLog "Message : $errMsg" + Write-StoreLog "Detail : $errDetail" + Write-StoreLog "Stack :" + Write-StoreLog "$errStack" + throw $_ + } + shell: pwsh + + - name: Upload Microsoft Store raw log to R2 + if: ${{ contains(github.event.head_commit.message, '[r2]') }} + env: + MSSTORE_LOG_FILE: dist/msstore-logs/msstore-${{ github.run_id }}-${{ github.run_attempt }}.log + CF_R2_ACCOUNT_ID: ${{ secrets.CF_R2_ACCOUNT_ID }} + CF_R2_BUCKET: ${{ secrets.CF_R2_BUCKET }} + CF_R2_ACCESS_KEY_ID: ${{ secrets.CF_R2_ACCESS_KEY_ID }} + CF_R2_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_SECRET_ACCESS_KEY }} + run: | + if (-not (Test-Path $env:MSSTORE_LOG_FILE)) { + Write-Host "Microsoft Store raw log file was not created: $env:MSSTORE_LOG_FILE" + exit 0 + } + + $key = "store-logs/ms-store-upload/${{ github.run_id }}-${{ github.run_attempt }}.log" + node build/bin/upload-file-to-r2.js "$env:MSSTORE_LOG_FILE" "$key" + shell: pwsh diff --git a/.github/workflows/npm.yml b/.github/workflows/npm.yml new file mode 100644 index 0000000..5d7ecc4 --- /dev/null +++ b/.github/workflows/npm.yml @@ -0,0 +1,24 @@ +name: publish-to-npm +on: + release: + types: [published] + push: + branches: [ test-npm ] +jobs: + release-npm: + runs-on: ubuntu-latest + environment: build + if: ${{ !contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[skip npm]') }} + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup node and npm + uses: actions/setup-node@v4 + with: + node-version: '24' + registry-url: https://registry.npmjs.org/ + - run: node build/bin/prepublish.js + - run: npm config set legacy-peer-deps true && npm i + - uses: JS-DevTools/npm-publish@v3 + with: + token: ${{secrets.NPM_TOKEN}} \ No newline at end of file diff --git a/.github/workflows/sync-gitee.yml b/.github/workflows/sync-gitee.yml new file mode 100644 index 0000000..722ddc9 --- /dev/null +++ b/.github/workflows/sync-gitee.yml @@ -0,0 +1,52 @@ +name: Sync Master to Gitee + +on: + release: + types: [published] + workflow_dispatch: + +jobs: + sync-master: + runs-on: ubuntu-latest + environment: build + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup SSH + run: | + mkdir -p ~/.ssh + echo "${{ secrets.GITEE_PRIVATE_SSH_TOKEN }}" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + ssh-keyscan gitee.com >> ~/.ssh/known_hosts + + - name: Configure Git + run: | + git config --global user.name "ZHAO Xudong" + git config --global user.email "zxdong@gmail.com" + git remote add gitee git@gitee.com:github-zxdong262/electerm.git + + - name: Push to Gitee + run: git push -f gitee HEAD:master + + - name: Update version.html in gh-pages + run: | + # Get version from package.json using standard grep and sed + VERSION=$(curl -s https://raw.githubusercontent.com/electerm/electerm/master/package.json | grep '"version":' | sed 's/.*"version": "\([^"]*\).*/\1/') + + # Checkout gh-pages branch + git fetch origin gh-pages:gh-pages + git checkout gh-pages + + # Update version.html + echo "v$VERSION" > version.html + + # Commit and push changes + git add version.html + git commit -m "Update version to v$VERSION" + git push origin gh-pages + + # Also push to Gitee + git push gitee gh-pages \ No newline at end of file diff --git a/.github/workflows/upload-release-assets-r2.yml b/.github/workflows/upload-release-assets-r2.yml new file mode 100644 index 0000000..9f512f3 --- /dev/null +++ b/.github/workflows/upload-release-assets-r2.yml @@ -0,0 +1,59 @@ +name: upload-release-assets-to-r2 +on: + release: + types: [published] + push: + branches: [ r2 ] +jobs: + upload-to-r2: + runs-on: ubuntu-latest + environment: build + steps: + - name: Download all release assets + run: | + TAG_NAME="${{ github.event.release.tag_name || github.event.inputs.tag_name }}" + mkdir -p ./release-assets + gh release download "$TAG_NAME" --repo "${{ github.repository }}" -D ./release-assets + env: + GH_TOKEN: ${{ github.token }} + + - name: List downloaded file + run: ls -la ./release-assets + + - name: Configure AWS credentials for R2 + run: | + mkdir -p ~/.aws + cat > ~/.aws/credentials << 'EOF' + [default] + aws_access_key_id = ${{ secrets.CF_R2_ACCESS_KEY_ID }} + aws_secret_access_key = ${{ secrets.CF_R2_SECRET_ACCESS_KEY }} + EOF + cat > ~/.aws/config << 'EOF' + [default] + region = auto + endpoint_url = ${{ secrets.CF_R2_ENDPOINT }} + EOF + + - name: Delete existing r folder + env: + CF_R2_BUCKET: ${{ secrets.CF_R2_BUCKET }} + run: | + aws s3 rm "s3://${CF_R2_BUCKET}/r/" --recursive + + - name: Upload all release assets to R2 + env: + CF_R2_BUCKET: ${{ secrets.CF_R2_BUCKET }} + run: | + for file in $(find ./release-assets -type f); do + rel_path="${file#./release-assets/}" + aws s3 cp "$file" "s3://${CF_R2_BUCKET}/r/${rel_path}" \ + --acl public-read \ + --cache-control "max-age=31536000" + echo "Uploaded: ${rel_path}" + done + + - name: Verify uploads + env: + CF_R2_BUCKET: ${{ secrets.CF_R2_BUCKET }} + run: | + aws s3 ls "s3://${CF_R2_BUCKET}/r/" --recursive \ No newline at end of file diff --git a/.github/workflows/win-appx.yml b/.github/workflows/win-appx.yml new file mode 100644 index 0000000..eccd10d --- /dev/null +++ b/.github/workflows/win-appx.yml @@ -0,0 +1,55 @@ +# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions + +name: win-appx + +on: + push: + branches: [ build, test, build-only, win-appx ] + +jobs: + build: + + runs-on: windows-2022 + environment: build + if: ${{ !contains(github.event.head_commit.message, '[skip build]') && !contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[skip win]') && !contains(github.event.head_commit.message, '[skip appx]') }} + + steps: + - uses: actions/checkout@v4 + - name: Use Node.js 24.x + uses: actions/setup-node@v4 + with: + node-version: 24.x + + - uses: actions/setup-python@v5 + with: + python-version: '3.7' # Version range or exact version of a Python version to use, using SemVer's version range syntax + architecture: 'x64' # optional x64 or x86. Defaults to x64 if not specified + - run: npm config set legacy-peer-deps true + - run: npm install -g node-gyp + + # before_install: + # - run: npm install --global --production windows-build-tools --vs2015 + - run: npm install -g yarn + - run: npm un node-pty serialport + - run: npm i + - run: npm i -S -E node-pty@1.1.0-beta14 + - run: npm i -S -E serialport@13.0.0 + + - name: Install R2 dependencies if store upload may run + if: ${{ contains(github.event.head_commit.message, '[r2]') }} + run: npm i -D @aws-sdk/client-s3 + + # script: + - run: npm run b + + - name: Run pb + run: npm run pb + + - name: build + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_NUMBER: ${{ secrets.BUILD_NUMBER }} + WORKFLOW_NAME: ${{ github.workflow }} + + run: node build/bin/build-win-appx diff --git a/.github/workflows/win-arm.yml b/.github/workflows/win-arm.yml new file mode 100644 index 0000000..c0d5706 --- /dev/null +++ b/.github/workflows/win-arm.yml @@ -0,0 +1,68 @@ +# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions + +name: win-arm + +on: + push: + branches: [ build, test, build-only, win-arm ] + +jobs: + build: + + runs-on: windows-11-arm + environment: build + if: ${{ !contains(github.event.head_commit.message, '[skip build]') && !contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[skip win]') && !contains(github.event.head_commit.message, '[skip win-arm]') }} + + steps: + - uses: actions/checkout@v4 + - name: Use Node.js 24.x + uses: actions/setup-node@v4 + with: + node-version: 24.x + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: npm config set legacy-peer-deps true + - run: npm install -g node-gyp + + # before_install: + # - run: npm install --global --production windows-build-tools --vs2015 + - run: npm install -g yarn + - run: npm un node-pty serialport + - run: npm i + - run: npm i -S -E node-pty@1.1.0-beta14 + - run: npm i -S -E serialport@13.0.0 + + - name: Install R2 dependencies if needed + if: ${{ contains(github.event.head_commit.message, '[r2]') }} + run: npm i -D @aws-sdk/client-s3 + + # script: + - run: npm run b + + - name: Run pb + run: npm run pb + + # check work/app/node_modules size in windows arm + - name: Check node_modules size + shell: powershell + run: | + $size = (Get-ChildItem -Path "work/app/node_modules" -Recurse | Measure-Object -Property Length -Sum).Sum + $sizeInMB = [math]::Round($size / 1MB, 2) + $sizeInGB = [math]::Round($size / 1GB, 2) + Write-Host "work/app/node_modules size: $sizeInMB MB ($sizeInGB GB)" + + - name: build + if: ${{ !contains(github.event.head_commit.message, 'skip build') }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_NUMBER: ${{ secrets.BUILD_NUMBER }} + WORKFLOW_NAME: ${{ github.workflow }} + CF_R2_ACCOUNT_ID: ${{ secrets.CF_R2_ACCOUNT_ID }} + CF_R2_BUCKET: ${{ secrets.CF_R2_BUCKET }} + CF_R2_ACCESS_KEY_ID: ${{ secrets.CF_R2_ACCESS_KEY_ID }} + CF_R2_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_SECRET_ACCESS_KEY }} + run: node build/bin/build-win-arm + diff --git a/.github/workflows/win-loose.yml b/.github/workflows/win-loose.yml new file mode 100644 index 0000000..638ca01 --- /dev/null +++ b/.github/workflows/win-loose.yml @@ -0,0 +1,60 @@ +# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions + +name: win-loose + +on: + push: + branches: [ build, test, build-only, win-loose ] + +jobs: + build: + + runs-on: windows-2022 + environment: build + if: ${{ !contains(github.event.head_commit.message, '[skip build]') && !contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[skip win]') && !contains(github.event.head_commit.message, '[skip loose]') }} + + steps: + - uses: actions/checkout@v4 + - name: Use Node.js 24.x + uses: actions/setup-node@v4 + with: + node-version: 24.x + + - uses: actions/setup-python@v5 + with: + python-version: '3.7' # Version range or exact version of a Python version to use, using SemVer's version range syntax + architecture: 'x64' # optional x64 or x86. Defaults to x64 if not specified + - run: npm config set legacy-peer-deps true + - run: npm install -g node-gyp + + # before_install: + # - run: npm install --global --production windows-build-tools --vs2015 + - run: npm install -g yarn + - run: npm un node-pty serialport + - run: npm i + - run: npm i -S -E node-pty@1.1.0-beta14 + - run: npm i -S -E serialport@13.0.0 + + - name: Install R2 dependencies if needed + if: ${{ contains(github.event.head_commit.message, '[r2]') }} + run: npm i -D @aws-sdk/client-s3 + + # script: + - run: npm run b + + - name: Run pb + run: npm run pb + + - name: build + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_NUMBER: ${{ secrets.BUILD_NUMBER }} + WORKFLOW_NAME: ${{ github.workflow }} + CF_R2_ACCOUNT_ID: ${{ secrets.CF_R2_ACCOUNT_ID }} + CF_R2_BUCKET: ${{ secrets.CF_R2_BUCKET }} + CF_R2_ACCESS_KEY_ID: ${{ secrets.CF_R2_ACCESS_KEY_ID }} + CF_R2_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_SECRET_ACCESS_KEY }} + + run: node build/bin/build-win-loose + diff --git a/.github/workflows/win-nsis-arm.yml b/.github/workflows/win-nsis-arm.yml new file mode 100644 index 0000000..8c691f7 --- /dev/null +++ b/.github/workflows/win-nsis-arm.yml @@ -0,0 +1,68 @@ +# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions + +name: win-arm-nsis + +on: + push: + branches: [ build, test, build-only, win-nsis-arm ] + +jobs: + build: + + runs-on: windows-11-arm + environment: build + if: ${{ !contains(github.event.head_commit.message, '[skip build]') && !contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[skip win]') && !contains(github.event.head_commit.message, '[skip win-nsis-arm]') }} + + steps: + - uses: actions/checkout@v4 + - name: Use Node.js 24.x + uses: actions/setup-node@v4 + with: + node-version: 24.x + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: npm config set legacy-peer-deps true + - run: npm install -g node-gyp + + # before_install: + # - run: npm install --global --production windows-build-tools --vs2015 + - run: npm install -g yarn + - run: npm un node-pty serialport + - run: npm i + - run: npm i -S -E node-pty@1.1.0-beta14 + - run: npm i -S -E serialport@13.0.0 + + - name: Install R2 dependencies if needed + if: ${{ contains(github.event.head_commit.message, '[r2]') }} + run: npm i -D @aws-sdk/client-s3 + + # script: + - run: npm run b + + - name: Run pb + run: npm run pb + + # check work/app/node_modules size in windows arm + - name: Check node_modules size + shell: powershell + run: | + $size = (Get-ChildItem -Path "work/app/node_modules" -Recurse | Measure-Object -Property Length -Sum).Sum + $sizeInMB = [math]::Round($size / 1MB, 2) + $sizeInGB = [math]::Round($size / 1GB, 2) + Write-Host "work/app/node_modules size: $sizeInMB MB ($sizeInGB GB)" + + - name: build + if: ${{ !contains(github.event.head_commit.message, 'skip build') }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_NUMBER: ${{ secrets.BUILD_NUMBER }} + WORKFLOW_NAME: ${{ github.workflow }} + CF_R2_ACCOUNT_ID: ${{ secrets.CF_R2_ACCOUNT_ID }} + CF_R2_BUCKET: ${{ secrets.CF_R2_BUCKET }} + CF_R2_ACCESS_KEY_ID: ${{ secrets.CF_R2_ACCESS_KEY_ID }} + CF_R2_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_SECRET_ACCESS_KEY }} + run: node build/bin/build-win-nsis-arm + diff --git a/.github/workflows/win-nsis.yml b/.github/workflows/win-nsis.yml new file mode 100644 index 0000000..aa2cdb6 --- /dev/null +++ b/.github/workflows/win-nsis.yml @@ -0,0 +1,60 @@ +# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions + +name: win-nsis + +on: + push: + branches: [ build, test, build-only, win-nsis ] + +jobs: + build: + + runs-on: windows-2022 + environment: build + if: ${{ !contains(github.event.head_commit.message, '[skip build]') && !contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[skip win]') && !contains(github.event.head_commit.message, '[skip nsis]') }} + + steps: + - uses: actions/checkout@v4 + - name: Use Node.js 24.x + uses: actions/setup-node@v4 + with: + node-version: 24.x + + - uses: actions/setup-python@v5 + with: + python-version: '3.7' # Version range or exact version of a Python version to use, using SemVer's version range syntax + architecture: 'x64' # optional x64 or x86. Defaults to x64 if not specified + - run: npm config set legacy-peer-deps true + - run: npm install -g node-gyp + + # before_install: + # - run: npm install --global --production windows-build-tools --vs2015 + - run: npm install -g yarn + - run: npm un node-pty serialport + - run: npm i + - run: npm i -S -E node-pty@1.1.0-beta14 + - run: npm i -S -E serialport@13.0.0 + + - name: Install R2 dependencies if needed + if: ${{ contains(github.event.head_commit.message, '[r2]') }} + run: npm i -D @aws-sdk/client-s3 + + # script: + - run: npm run b + + - name: Run pb + run: npm run pb + + - name: build + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_NUMBER: ${{ secrets.BUILD_NUMBER }} + WORKFLOW_NAME: ${{ github.workflow }} + CF_R2_ACCOUNT_ID: ${{ secrets.CF_R2_ACCOUNT_ID }} + CF_R2_BUCKET: ${{ secrets.CF_R2_BUCKET }} + CF_R2_ACCESS_KEY_ID: ${{ secrets.CF_R2_ACCESS_KEY_ID }} + CF_R2_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_SECRET_ACCESS_KEY }} + + run: node build/bin/build-win-nsis + diff --git a/.github/workflows/win-portable.yml b/.github/workflows/win-portable.yml new file mode 100644 index 0000000..65ae2f0 --- /dev/null +++ b/.github/workflows/win-portable.yml @@ -0,0 +1,59 @@ +# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions + +name: win-portable + +on: + push: + branches: [ build, test, build-only, win-portable ] + +jobs: + build: + + runs-on: windows-2022 + environment: build + if: ${{ !contains(github.event.head_commit.message, '[skip build]') && !contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[skip win]') && !contains(github.event.head_commit.message, '[skip portable]') }} + + steps: + - uses: actions/checkout@v4 + - name: Use Node.js 24.x + uses: actions/setup-node@v4 + with: + node-version: 24.x + + - uses: actions/setup-python@v5 + with: + python-version: '3.7' # Version range or exact version of a Python version to use, using SemVer's version range syntax + architecture: 'x64' # optional x64 or x86. Defaults to x64 if not specified + - run: npm config set legacy-peer-deps true + - run: npm install -g node-gyp + + # before_install: + # - run: npm install --global --production windows-build-tools --vs2015 + - run: npm install -g yarn + - run: npm un node-pty serialport + - run: npm i + - run: npm i -S -E node-pty@1.1.0-beta14 + - run: npm i -S -E serialport@13.0.0 + + - name: Install R2 dependencies if needed + if: ${{ contains(github.event.head_commit.message, '[r2]') }} + run: npm i -D @aws-sdk/client-s3 + + # script: + - run: npm run b + + - name: Run pb + run: npm run pb + + - name: build + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_NUMBER: ${{ secrets.BUILD_NUMBER }} + WORKFLOW_NAME: ${{ github.workflow }} + CF_R2_ACCOUNT_ID: ${{ secrets.CF_R2_ACCOUNT_ID }} + CF_R2_BUCKET: ${{ secrets.CF_R2_BUCKET }} + CF_R2_ACCESS_KEY_ID: ${{ secrets.CF_R2_ACCESS_KEY_ID }} + CF_R2_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_SECRET_ACCESS_KEY }} + run: node build/bin/build-win-portable + diff --git a/.github/workflows/win-tar.yml b/.github/workflows/win-tar.yml new file mode 100644 index 0000000..0b58f2d --- /dev/null +++ b/.github/workflows/win-tar.yml @@ -0,0 +1,59 @@ +# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions + +name: win-tar + +on: + push: + branches: [ build, test, build-only, win-tar ] + +jobs: + build: + + runs-on: windows-2022 + environment: build + if: ${{ !contains(github.event.head_commit.message, '[skip build]') && !contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[skip win]') && !contains(github.event.head_commit.message, '[skip win-tar]') }} + + steps: + - uses: actions/checkout@v4 + - name: Use Node.js 24.x + uses: actions/setup-node@v4 + with: + node-version: 24.x + + - uses: actions/setup-python@v5 + with: + python-version: '3.7' # Version range or exact version of a Python version to use, using SemVer's version range syntax + architecture: 'x64' # optional x64 or x86. Defaults to x64 if not specified + - run: npm config set legacy-peer-deps true + - run: npm install -g node-gyp + + # before_install: + # - run: npm install --global --production windows-build-tools --vs2015 + - run: npm install -g yarn + - run: npm un node-pty serialport + - run: npm i + - run: npm i -S -E node-pty@1.1.0-beta14 + - run: npm i -S -E serialport@13.0.0 + + - name: Install R2 dependencies if needed fix + if: ${{ contains(github.event.head_commit.message, '[r2]') }} + run: npm i -D @aws-sdk/client-s3 + + # script: + - run: npm run b + + - name: Run pb + run: npm run pb + + - name: build + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_NUMBER: ${{ secrets.BUILD_NUMBER }} + WORKFLOW_NAME: ${{ github.workflow }} + CF_R2_ACCOUNT_ID: ${{ secrets.CF_R2_ACCOUNT_ID }} + CF_R2_BUCKET: ${{ secrets.CF_R2_BUCKET }} + CF_R2_ACCESS_KEY_ID: ${{ secrets.CF_R2_ACCESS_KEY_ID }} + CF_R2_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_SECRET_ACCESS_KEY }} + run: node build/bin/build-win-tar + diff --git a/.github/workflows/win7-tar.yml b/.github/workflows/win7-tar.yml new file mode 100644 index 0000000..e45d123 --- /dev/null +++ b/.github/workflows/win7-tar.yml @@ -0,0 +1,73 @@ +# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions + +name: win7-tar + +on: + push: + branches: [ build, test, build-only, win7-tar, legacy-build ] + +jobs: + build: + + runs-on: windows-2022 + environment: build + if: ${{ !contains(github.event.head_commit.message, '[skip build]') && !contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[skip win]') && !contains(github.event.head_commit.message, '[skip win7-tar]') }} + + steps: + - uses: actions/checkout@v4 + - name: Use Node.js 16.x + uses: actions/setup-node@v4 + with: + node-version: 16.x + + - uses: actions/setup-python@v5 + with: + python-version: '3.7' # Version range or exact version of a Python version to use, using SemVer's version range syntax + architecture: 'x64' # optional x64 or x86. Defaults to x64 if not specified + - run: npm config set legacy-peer-deps true + - run: npm install -g node-gyp + + # before_install : + # - run: npm install --global --production windows-build-tools --vs2015 + - run: npm install -g yarn + - name: Update electron version and remove package-lock + run: | + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('package.json')); + pkg.devDependencies.electron = '22.3.27'; + pkg.devDependencies['electron-builder'] = '26.0.20'; + pkg.devDependencies['@electron/rebuild'] = '3.7.2'; + pkg.dependencies['node-pty'] = '1.1.0-beta14'; + pkg.dependencies.serialport = '13.0.0'; + pkg.devDependencies.vite = '4'; + pkg.devDependencies['@vitejs/plugin-react'] = '4.7.0'; + pkg.dependencies['os-locale-s'] = '1.0.8'; + fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2)); + fs.unlinkSync('package-lock.json'); + " + - run: npm i + - run: npm i -S @electron/rebuild@3.7.2 + + - name: Install R2 dependencies if needed + if: ${{ contains(github.event.head_commit.message, '[r2]') }} + run: npm i -D @aws-sdk/client-s3 + + # script: + - run: npm run b + + - name: Run pb + run: npm run pb + + - name: build + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_NUMBER: ${{ secrets.BUILD_NUMBER }} + WORKFLOW_NAME: ${{ github.workflow }} + CF_R2_ACCOUNT_ID: ${{ secrets.CF_R2_ACCOUNT_ID }} + CF_R2_BUCKET: ${{ secrets.CF_R2_BUCKET }} + CF_R2_ACCESS_KEY_ID: ${{ secrets.CF_R2_ACCESS_KEY_ID }} + CF_R2_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_SECRET_ACCESS_KEY }} + run: node build/bin/build-win7-tar + diff --git a/.github/workflows/winget.yml b/.github/workflows/winget.yml new file mode 100644 index 0000000..64ced4f --- /dev/null +++ b/.github/workflows/winget.yml @@ -0,0 +1,17 @@ +name: Publish to WinGet +on: + release: + types: [released] + push: + branches: [ test-winget ] +jobs: + publish: + runs-on: ubuntu-24.04 + environment: build + steps: + - uses: vedantmgoyal9/winget-releaser@main + with: + identifier: electerm.electerm + installers-regex: 'win-x64-installer.exe$' + max-versions-to-keep: 30 + token: ${{ secrets.WINGET_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..021aba1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,127 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test + +# parcel-bundler cache (https://parceljs.org/) +.cache + +# Next.js build output +.next + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and *not* Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ +.DS_Store + +# TernJS port file +.tern-port + +# custom +/temp/ +/dist/ +/work/ +/version +/*.txt +/build/web-app/client +/*.tar +/*.gz +.vscode +/test-results + +ftp-root +*.asc +/electron-builder.json + +/dist-loong64 +/work-loong64 +/.claude +/AGENTS.md +/.workbuddy \ No newline at end of file diff --git a/.sample.env b/.sample.env new file mode 100644 index 0000000..93fa971 --- /dev/null +++ b/.sample.env @@ -0,0 +1,16 @@ +# run `cp .sample.env .env` to create your local env + +## test env + +## test ssh ip or host, required +TEST_HOST=0.0.0.0 + +## test ssh username, required +TEST_USER=xxx + +## test ssh port, optional, default 22 +# TEST_PORT=22 + +## test ssh password, required +TEST_PASS=xxx + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1ada745 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) since 2017~ ZHAO Xudong + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3ab4587 --- /dev/null +++ b/README.md @@ -0,0 +1,229 @@ +

+ + + +

+ +[![GitHub version](https://badgers.space/github/release/electerm/electerm?corner_radius=m)](https://github.com/electerm/electerm/releases) +[![Build Status](https://github.com/electerm/electerm/actions/workflows/mac-test-1.yml/badge.svg)](https://github.com/electerm/electerm/actions) +[![license](https://img.shields.io/github/license/electerm/electerm)](https://github.com/electerm/electerm/blob/master/LICENSE) +[![Get it from the Snap Store](https://img.shields.io/badge/Snap-Store-green)](https://snapcraft.io/electerm) +[![Get it from the Microsoft Store](https://img.shields.io/badge/Microsoft-Store-blue)](https://www.microsoft.com/store/apps/9NCN7272GTFF) +[![GitHub Sponsors](https://img.shields.io/github/sponsors/electerm?label=Sponsors)](https://github.com/sponsors/electerm) +[![star](https://atomgit.com/electerm/electerm/star/badge.svg)](https://atomgit.com/electerm/electerm) + +[![English](https://img.shields.io/badge/English-EN-blue)](README.md) [![ไธญๆ–‡](https://img.shields.io/badge/ไธญๆ–‡-Chinese-blue)](README_cn.md) + +Open-sourced terminal/ssh/sftp/telnet/serialport/RDP/VNC/Spice/ftp client(linux, mac, win). + +- [electerm.org](https://electerm.org): Homepage, downloads, videos, etc +- [electerm-web](https://github.com/electerm/electerm-web): Web app version running in browser(including mobile device) +- [electerm-web-docker](https://github.com/electerm/electerm-web-docker): Docker image for electerm-web +- [electerm online](https://cloud.electerm.org): Public free online electerm app +- [electerm demo](https://demo.electerm.org): Online demo of electerm +- [electerm AI](https://ai.electerm.org): Free AI for electerm users +- [electerm deb repo](https://repos.electerm.org/deb): Debian repo of electerm +- [electerm rpm repo](https://repos.electerm.org/rpm): RPM repo of electerm + +## Atlas Cloud + +
+ + Atlas Cloud + +
+ +[Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=electerm) provides OpenAI-compatible AI APIs and model access for AI-powered workflows in electerm. + +[![DigitalOcean Referral Badge](https://web-platforms.sfo2.cdn.digitaloceanspaces.com/WWW/Badge%202.svg)](https://www.digitalocean.com/?refcode=c10bcb28b846&utm_campaign=Referral_Invite&utm_medium=Referral_Program&utm_source=badge) + +[![Vercel OSS Program](https://github.com/electerm/electerm-resource/blob/master/static/images/vercel-oss-2005.png?raw=true)](https://oss-directory.vercel.app) + +
+ +
+ +## Features + +- Works as a terminal/file manager or ssh/sftp/ftp/telnet/serialport/RDP/VNC/Spice client +- Support Window 7+(X64/ARM64), Mac OS 10.15+(x64/arm64), Linux(x64/arm64/Loong64), even old Linux with glibc 2.17+ like UOS/Kylin/Ubuntu 18.04 etc +- Global hotkey to toggle window visibility (similar to guake, default is `ctrl + 2`) +- Multi platform(linux, mac, win) +- ๐Ÿ‡บ๐Ÿ‡ธ ๐Ÿ‡จ๐Ÿ‡ณ ๐Ÿ‡ง๐Ÿ‡ท ๐Ÿ‡ท๐Ÿ‡บ ๐Ÿ‡ช๐Ÿ‡ธ ๐Ÿ‡ซ๐Ÿ‡ท ๐Ÿ‡น๐Ÿ‡ท ๐Ÿ‡ญ๐Ÿ‡ฐ ๐Ÿ‡ฏ๐Ÿ‡ต ๐Ÿ‡ธ๐Ÿ‡ฆ ๐Ÿ‡ฉ๐Ÿ‡ช ๐Ÿ‡ฐ๐Ÿ‡ท ๐Ÿ‡ฎ๐Ÿ‡ฉ ๐Ÿ‡ต๐Ÿ‡ฑ Multi-language support([electerm-locales](https://github.com/electerm/electerm-locales), contributions/fixes welcome) +- Double click to directly edit (small) remote files. +- Auth with publicKey + password. +- Support Zmodem(rz, sz). +- Support ssh tunnel. +- Support [Trzsz](https://github.com/trzsz/trzsz)(trz/tsz), similar to rz/sz, and compatible with tmux. +- Transparent window(Mac, win). +- Terminal background image. +- Global/session proxy. +- Quick commands +- UI/terminal theme +- Sync bookmarks/themes/quick commands to github/gitee secret gist/webdav/custom server/electerm cloud +- Quick input to one or all terminals. +- AI assistant integration (supporting [DeepSeek](https://www.deepseek.com), OpenAI, and any other AI APIs) to help with command suggestions, script writing, and explaining selected terminal content, create bookmarks +- MCP (Model Context Protocol) widget for AI assistants and external tools integration - see [MCP Widget Usage Guide](https://github.com/electerm/electerm/wiki/MCP-Widget-Usage-Guide) +- Deep link support: Open connections with URLs like `telnet://192.168.2.31:34554` or `ssh://user@host:22` - see [Deep link support wiki](https://github.com/electerm/electerm/wiki/Deep-link-support) +- Command line usage: check [wiki](https://github.com/electerm/electerm/wiki/Command-line-usage) + +## Install + +- For Mac user: `brew install --cask electerm` +- With snap: `sudo snap install electerm --classic` +- For some Linux distribution, you can find it from OS default App store(Ubuntu, Deepin, Mint...). +- For some linux OS, the `rpm`, `deb`, or `snap` release may not work, you can try the `tar.gz` or `.appImage` release. +- For Windows users, you can install it from [windows store](https://www.microsoft.com/store/apps/9NCN7272GTFF), command-line installer [winget](https://github.com/microsoft/winget-cli) and [scoop](https://github.com/lukesampson/scoop) is also recommended: + +```powershell +# winget https://github.com/microsoft/winget-cli +winget install electerm.electerm + +# scoop https://github.com/lukesampson/scoop +scoop bucket add dorado https://github.com/chawyehsu/dorado +scoop install dorado/electerm +``` + +- Install from Debian repository (for Debian/Ubuntu-based systems) with `apt` command + +Check [https://repos.electerm.org/deb](https://repos.electerm.org/deb) + +- Install from npm + +```bash +npm i -g electerm + +``` + +## Upgrade + +- Auto upgrade: When a new version is released, you will get an upgrade notification after you start electerm again. You can then click the upgrade button to upgrade. +- Download: Just download the latest edition, reinstall. +- Npm: If you install from npm, just run `npm i -g electerm` again. +- If use Snap or some other distribution system, these systems may provide upgrades. + +## Known issues + +[https://github.com/electerm/electerm/wiki/Know-issues](https://github.com/electerm/electerm/wiki/Know-issues) + +## Troubleshoot + +[https://github.com/electerm/electerm/wiki/Troubleshoot](https://github.com/electerm/electerm/wiki/Troubleshoot) + +## Discussion + +[![Discord](https://img.shields.io/badge/Discord-Join-blue?logo=discord)](https://discord.gg/855W7g8EVd) + +[Discussion board](https://github.com/electerm/electerm/discussions) + +![electerm-wechat-group-qr.jpg](https://electerm.org/electerm-wechat-group-qr.jpg) + +## Support + +Would love to hear from you, please tell me what you think, [submit an issue](https://github.com/electerm/electerm/issues), [Start a new discussion](https://github.com/electerm/electerm/discussions/new), [create/fix language files](https://github.com/electerm/electerm-locales) or create pull requests, all welcome. + +## Sponsor this project + +github sponsor + +[https://github.com/sponsors/electerm](https://github.com/sponsors/electerm) + +kofi + +[https://ko-fi.com/zhaoxudong](https://ko-fi.com/zhaoxudong) + +wechat donate + +[![wechat donate](https://electerm.org/electerm-wechat-donate.png)](https://github.com/electerm) + +TRON TRN20 + +[![TRN20 donate](https://github.com/electerm/electerm-resource/blob/master/static/images/trn20.png?raw=true)] + +Address: TXk3pQNmQu1vihH76RaEFnK9wg13x4LLCZ + +## Dev + +```bash +# May only works in Linux +# needs nodejs/npm, suggest using nvm to install nodejs/npm +# with nodejs 24.x + +git clone git@github.com:electerm/electerm.git +cd electerm +npm config set legacy-peer-deps true +npm i + +# start vite dev server, requires port 5570 +npm start + +# in a separate terminal session run app +npm run app + +# code format check +npm run lint + +# code format fix +npm run fix +``` + +## Test + +```bash +npm run b +npm run prepare-test +cp .sample.env .env + +# edit .env, fill your test host/username/password, may only works in mac OS +npm run test +``` + +## Test build + +```bash +# May only works in Linux +# Install yarn first(to do yarn autoclean) +# See https://yarnpkg.com/en/docs/install + +# Build linux only with -l +npm i +npm run b +npm run pb +./node_modules/.bin/electron-builder --linux tar.gz +# or replace tar.gz to rpm/deb/AppImage +# check dist/ folder + +# build for linux arm/ +./node_modules/.bin/electron-builder --linux --arm64 +``` + +## Video guide + +- [https://electerm.org/videos](https://electerm.org/videos) + +## Change log + +Visit [Releases](https://github.com/electerm/electerm/releases). + +## Contact author + +[zxdong@gmail.com](mailto:zxdong@gmail.com) + + +## License + +MIT + +## Star History + +

+ Star History Rank +

+ + + + + + Star History Chart + + \ No newline at end of file diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..b1f6a78 --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub ๆฅๆบ่ฏดๆ˜Ž + +- ๅŽŸๅง‹้กน็›ฎ๏ผš`electerm/electerm` +- ๅŽŸๅง‹ไป“ๅบ“๏ผšhttps://github.com/electerm/electerm +- ๅฏผๅ…ฅๆ–นๅผ๏ผšไธŠๆธธ้ป˜่ฎคๅˆ†ๆ”ฏ็š„ๆœ€ๆ–ฐๅฟซ็…ง +- ๅŽŸไฝœ่€…ใ€็‰ˆๆƒๅ’Œ่ฎธๅฏ่ฏไฟกๆฏไปฅๅŽŸๅง‹ไป“ๅบ“ๅŠๆœฌไป“ๅบ“ LICENSE ไธบๅ‡† +- ๆœฌๆ–‡ไปถไป…็”จไบŽ่ฎฐๅฝ•ๆฅๆบ๏ผŒไธไปฃ่กจ WeHub ๆ˜ฏๅŽŸ้กน็›ฎไฝœ่€… diff --git a/README_cn.md b/README_cn.md new file mode 100644 index 0000000..a521416 --- /dev/null +++ b/README_cn.md @@ -0,0 +1,229 @@ +

+ + + +

+ +[![GitHub version](https://badgers.space/github/release/electerm/electerm?corner_radius=m)](https://github.com/electerm/electerm/releases) +[![Build Status](https://github.com/electerm/electerm/actions/workflows/mac-test-1.yml/badge.svg)](https://github.com/electerm/electerm/actions) +[![license](https://img.shields.io/github/license/electerm/electerm)](https://github.com/electerm/electerm/blob/master/LICENSE) +[![Get it from the Snap Store](https://img.shields.io/badge/Snap-Store-green)](https://snapcraft.io/electerm) +[![Get it from the Microsoft Store](https://img.shields.io/badge/Microsoft-Store-blue)](https://www.microsoft.com/store/apps/9NCN7272GTFF) +[![GitHub Sponsors](https://img.shields.io/github/sponsors/electerm?label=Sponsors)](https://github.com/sponsors/electerm) +[![star](https://atomgit.com/electerm/electerm/star/badge.svg)](https://atomgit.com/electerm/electerm) + +[![English](https://img.shields.io/badge/English-EN-blue)](README.md) [![ไธญๆ–‡](https://img.shields.io/badge/ไธญๆ–‡-Chinese-blue)](README_cn.md) + + +ๅผ€ๆบ็ปˆ็ซฏ/ssh/telnet/serialport/RDP/VNC/Spice/sftp/ftpๅฎขๆˆท็ซฏ(linux, mac, win)ใ€‚ + +- [electerm.org](https://electerm.org): ไธป้กต๏ผŒไธ‹่ฝฝ๏ผŒ่ง†้ข‘็ญ‰ +- [electerm-web](https://github.com/electerm/electerm-web): ่ฟ่กŒไบŽๆต่งˆๅ™จ(ๆ”ฏๆŒ็งปๅŠจ่ฎพๅค‡)็š„web app็‰ˆๆœฌ +- [electerm-web-docker](https://github.com/electerm/electerm-web-docker): electerm-web็š„docker้•œๅƒ +- [electerm online](https://cloud.electerm.org): ๅ…ฌๅ…ฑๅ…่ดนๅœจ็บฟelectermๅบ”็”จ +- [electerm demo](https://demo.electerm.org): ๅœจ็บฟๆผ”็คบ +- [electerm AI](https://ai.electerm.org): ๅ…่ดนไธบ electerm ็”จๆˆทๆไพ› AI +- [electerm deb repo](https://repos.electerm.org/deb): Debian repo of electerm +- [electerm rpm repo](https://repos.electerm.org/rpm): RPM repo of electerm + +## Atlas Cloud + +
+ + Atlas Cloud + +
+ +[Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=electerm) ๆไพ›ไธŽ OpenAI ๅ…ผๅฎน็š„ AI API ๅ’Œๆจกๅž‹่ฎฟ้—ฎ๏ผŒ็”จไบŽๅœจ electerm ไธญๅฎž็Žฐ AI ้ฉฑๅŠจ็š„ๅทฅไฝœๆต็จ‹ใ€‚ + +[![DigitalOcean Referral Badge](https://web-platforms.sfo2.cdn.digitaloceanspaces.com/WWW/Badge%202.svg)](https://www.digitalocean.com/?refcode=c10bcb28b846&utm_campaign=Referral_Invite&utm_medium=Referral_Program&utm_source=badge) + +[![Vercel OSS Program](https://github.com/electerm/electerm-resource/blob/master/static/images/vercel-oss-2005.png?raw=true)](https://oss-directory.vercel.app) + +
+ +
+ +## ๅŠŸ่ƒฝ็‰นๆ€ง + +- ๆ”ฏๆŒssh,telnet,serialport,RDP,VNC,Spice,ๆœฌๅœฐๅ’Œ่ฟœ็จ‹ๆ–‡ไปถ็ฎก็†๏ผŒsftp/ftpๆ–‡ไปถไผ ่พ“๏ผŒไปฅๅŠไฝœไธบๆœฌๅœฐ็ปˆ็ซฏไฝฟ็”จ +- ๆ”ฏๆŒWindow 7+(X64/ARM64), Mac OS 10.15+(x64/arm64), Linux(x64/arm64/Loong64), ไปฅๅŠLinux with glibc 2.17+ like UOS/Kylin/Ubuntu 18.04 etc +- ๅ…จๅฑ€ๅฟซๆท้”ฎๅˆ‡ๆข้š่—ๆ˜พ็คบ็ช—ๅฃ(็ฑปไผผguake, ้ป˜่ฎคๅฟซๆท้”ฎ`ctrl + 2`) +- ๅคšๅนณๅฐๆ”ฏๆŒ(linux, mac, win) +- ๐Ÿ‡บ๐Ÿ‡ธ ๐Ÿ‡จ๐Ÿ‡ณ ๐Ÿ‡ง๐Ÿ‡ท ๐Ÿ‡ท๐Ÿ‡บ ๐Ÿ‡ช๐Ÿ‡ธ ๐Ÿ‡ซ๐Ÿ‡ท ๐Ÿ‡น๐Ÿ‡ท ๐Ÿ‡ญ๐Ÿ‡ฐ ๐Ÿ‡ฏ๐Ÿ‡ต ๐Ÿ‡ธ๐Ÿ‡ฆ ๐Ÿ‡ฉ๐Ÿ‡ช ๐Ÿ‡ฐ๐Ÿ‡ท ๐Ÿ‡ฎ๐Ÿ‡ฉ ๐Ÿ‡ต๐Ÿ‡ฑ ๅคšๅ›ฝ่ฏญ่จ€ๆ”ฏๆŒ([electerm-locales](https://github.com/electerm/electerm-locales), ๆฌข่ฟŽๆไบคไปฃ็ ) +- ๅŒๅ‡ป็›ดๆŽฅ็ผ–่พ‘่ฟœ็จ‹ๆ–‡ไปถ. +- ๆ”ฏๆŒๅฏ†็ ๆˆ–่€…ๅฏ†ๅŒ™็™ปๅฝ•. +- ๆ”ฏๆŒZmodem(rz, sz). +- ๆ”ฏๆŒssh้šง้“ +- ๆ”ฏๆŒ[Trzsz](https://github.com/trzsz/trzsz)(trz/tsz), ็ฑปไผผrz/sz, ๅ…ผๅฎนtmux. +- ๆ”ฏๆŒ้€ๆ˜Ž็ช—ๅฃ(Mac, win). +- ๆ”ฏๆŒ่ฎพ็ฝฎ็ปˆ็ซฏ่ƒŒๆ™ฏๅ›พ็‰‡. +- ๆ”ฏๆŒไปฃ็†ๆœๅŠกๅ™จ. +- ๆ”ฏๆŒ้ข„่ฎพๅฟซๆทๅ‘ฝไปค +- ๆ”ฏๆŒไธป้ข˜ +- ๆ”ฏๆŒๅŒๆญฅไนฆ็ญพ็ญ‰ๆ•ฐๆฎๅˆฐgithub/gitee็งไบบgist, webdav/custom server/electerm cloud +- ๆ”ฏๆŒๅฟซ้€Ÿ่พ“ๅ…ฅๅ‘ฝไปคๅˆฐไธ€ไธชๆˆ–่€…ๅคšไธช็ปˆ็ซฏ +- AIๅŠฉๆ‰‹้›†ๆˆ๏ผˆๆ”ฏๆŒ[DeepSeek](https://www.deepseek.com)ใ€OpenAI็ญ‰AI API๏ผ‰๏ผŒๅๅŠฉๅ‘ฝไปคๅปบ่ฎฎใ€่„šๆœฌ็ผ–ๅ†™ใ€ไปฅๅŠ่งฃ้‡Šๆ‰€้€‰็ปˆ็ซฏๅ†…ๅฎน, ๅˆ›ๅปบไนฆ็ญพ +- MCP (Model Context Protocol) ็ป„ไปถ๏ผŒ็”จไบŽAIๅŠฉๆ‰‹ๅ’Œๅค–้ƒจๅทฅๅ…ท้›†ๆˆ - ่ฏฆ่ง [MCP Widget Usage Guide](https://github.com/electerm/electerm/wiki/MCP-Widget-Usage-Guide) +- ๆ”ฏๆŒๅ‘ฝไปค่กŒไฝฟ็”จ: ่ฏทๅ‚้˜…[wiki](https://github.com/electerm/electerm/wiki/Command-line-usage) +- ๆทฑๅบฆ้“พๆŽฅๆ”ฏๆŒ: ไฝฟ็”จ `telnet://192.168.2.31:34554` ๆˆ– `ssh://user@host:22` ็ญ‰URLๆ‰“ๅผ€่ฟžๆŽฅ - ่ฏฆ่ง [ๆทฑๅบฆ้“พๆŽฅๆ”ฏๆŒ wiki](https://github.com/electerm/electerm/wiki/Deep-link-support) + + +## ๅฎ‰่ฃ… + +- Mac OS็”จๆˆท: `brew install --cask electerm` +- Snap: `sudo snap install electerm --classic` +- ไธ€ไบ›Linuxๅ‘่กŒ็‰ˆ็š„ๅ†…็ฝฎ่ฝฏไปถๅ•†ๅบ—(Ubuntu, Deepin, Mint...). +- ไธๆ”ฏๆŒ`rpm`, `deb`, or `snap` ็š„Linuxๅ‘่กŒ็‰ˆๅฏไปฅๅฐ่ฏ• `tar.gz`็‰ˆๆœฌ. +- Windows็”จๆˆทๅฏไปฅไปŽ[windows store](https://www.microsoft.com/store/apps/9NCN7272GTFF), ๅ‘ฝไปค่กŒๅฎ‰่ฃ…ๅทฅๅ…ท [winget](https://github.com/microsoft/winget-cli)๏ผŒไปฅๅŠ[scoop](https://github.com/lukesampson/scoop) : + +```powershell +# winget https://github.com/microsoft/winget-cli +winget install electerm.electerm + +# scoop https://github.com/lukesampson/scoop +scoop bucket add dorado https://github.com/chawyehsu/dorado +scoop install dorado/electerm +``` + +- ไปŽDebian่ฝฏไปถๆบๅฎ‰่ฃ… (้€‚็”จไบŽDebian/Ubuntu็ณป็ปŸ) ไฝฟ็”จ `apt` ๅ‘ฝไปค + +ๆŸฅ็œ‹ [https://repos.electerm.org/deb](https://repos.electerm.org/deb) + +- ไปŽnpmๅฎ‰่ฃ… + +```bash +npm i -g electerm +``` + +## ๅ‡็บง + +- ่‡ชๅŠจๅ‡็บง: ็‚นๅ‡ปๆ–ฐ็‰ˆๆœฌ็š„ๅ‡็บงๆŒ‰้’ฎ. +- ไธ‹่ฝฝ: ้‡ๆ–ฐไธ‹่ฝฝๆœ€ๆ–ฐ็‰ˆๅฎ‰่ฃ…. +- Npm: ๅฆ‚ๆžœๆ˜ฏไปŽnpmๅฎ‰่ฃ… ๅ†ๆฌก่ฟ่กŒ`npm i -g electerm` ๅณๅฏ. +- ๅฆ‚ๆžœไปŽLinuxๅ‘่กŒ็‰ˆ่ฝฏไปถๅ•†ๅบ—ๆˆ–่€…Snapๅฎ‰่ฃ…๏ผŒๅฏ่ƒฝๆœ‰่‡ชๅŠจๅ‡็บงๆœบๅˆถ. + +## ๅทฒ็Ÿฅ้—ฎ้ข˜ + +[https://github.com/electerm/electerm/wiki/Know-issues](https://github.com/electerm/electerm/wiki/Know-issues) + +## ็–‘้šพ่งฃ็ญ” + +[https://github.com/electerm/electerm/wiki/Troubleshoot](https://github.com/electerm/electerm/wiki/Troubleshoot) + +## ่ฎจ่ฎบๅŒบ + +[![Discord](https://img.shields.io/badge/Discord-Join-blue?logo=discord)](https://discord.gg/855W7g8EVd) + +[Discussion board](https://github.com/electerm/electerm/discussions) + +![electerm-wechat-group-qr.jpg](https://electerm.org/electerm-wechat-group-qr.jpg) + +## ๆ”ฏๆŒ + +ๆฌข่ฟŽ[ๆไบค้—ฎ้ข˜/ๅปบ่ฎฎ](https://github.com/electerm/electerm/issues), [ๅฑ•ๅผ€่ฎจ่ฎบ](https://github.com/electerm/electerm/discussions/new), [ไฟฎๅคๆˆ–่€…ๅˆ›ๅปบ่ฏญ่จ€ๆ–‡ไปถ](https://github.com/electerm/electerm-locales)ๆˆ–่€…่ดก็Œฎไปฃ็ ใ€‚ + +## ่ตžๅŠฉ้กน็›ฎ + +github sponsor + +[https://github.com/sponsors/electerm](https://github.com/sponsors/electerm) + +kofi + +[https://ko-fi.com/zhaoxudong](https://ko-fi.com/zhaoxudong) + +ๅพฎไฟก่ตž่ต็  + +[![wechat donate](https://electerm.org/electerm-wechat-donate.png)](https://github.com/electerm) + +TRON TRN20 + +[![TRN20 donate](https://github.com/electerm/electerm-resource/blob/master/static/images/trn20.png?raw=true)] + +ๅœฐๅ€: TXk3pQNmQu1vihH76RaEFnK9wg13x4LLCZ + +## ๅผ€ๅ‘ + +```bash +# May only works in Linux +# needs nodejs/npm, suggest using nvm to install nodejs/npm +# with nodejs 24.x + +git clone git@github.com:electerm/electerm.git +cd electerm +npm config set legacy-peer-deps true +npm i + +# start vite dev server, requires port 5570 +npm start + +# in a separate terminal session run app +npm run app + +# code format check +npm run lint + +# code format fix +npm run fix +``` + +## ๆต‹่ฏ• + +```bash +npm run b +npm run prepare-test +cp .sample.env .env + +# edit .env, fill your test host/username/password, may only works in mac OS +npm run test +``` + +## ๆต‹่ฏ•ๆž„ๅปบ + +```bash +# May only works in Linux +# Install yarn first(to do yarn autoclean) +# See https://yarnpkg.com/en/docs/install + +# Build linux only with -l +npm i +npm run b +npm run pb +./node_modules/.bin/electron-builder --linux tar.gz +# or replace tar.gz to rpm/deb/AppImage +# check dist/ folder + +# build for linux arm/ +./node_modules/.bin/electron-builder --linux --arm64 +``` + +## ไฝฟ็”จ่ง†้ข‘ + +- [https://electerm.org/videos](https://electerm.org/videos) + +## ๅ˜ๆ›ดๅކๅฒ + +Visit [Releases](https://github.com/electerm/electerm/releases). + +## ่”็ณปไฝœ่€… + +[zxdong@gmail.com](mailto:zxdong@gmail.com) + +## ่ฎธๅฏ่ฏ + +MIT + +## ๆ”ถ่—ๅކๅฒ + +

+ Star History Rank +

+ + + + + + Star History Chart + + \ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..56a8274 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,6 @@ +## **Reporting Security Issues** + + +If you believe you have found a security vulnerability in electerm, we encourage you to report it immediately. + +We review all legitimate reports and work quickly to fix any issues to ensure the safety, reliability, and stability of our services diff --git a/build/bin/.yarnclean b/build/bin/.yarnclean new file mode 100644 index 0000000..bf77825 --- /dev/null +++ b/build/bin/.yarnclean @@ -0,0 +1,70 @@ +# files +Makefile +Gulpfile.js +Gruntfile.js +.tern-project +.gitattributes +.editorconfig +.eslintrc +.jshintrc +.flowconfig +.documentup.json +.yarn-metadata.json +.travis.yml +appveyor.yml +LICENSE.txt +LICENSE +AUTHORS +CONTRIBUTORS +.yarn-integrity +*.md +*.ts +*.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__ +test +tests +powered-test +docs +doc +website +images +assets +example +examples +coverage +.nyc_output +dist/esm +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 diff --git a/build/bin/app.js b/build/bin/app.js new file mode 100644 index 0000000..9285836 --- /dev/null +++ b/build/bin/app.js @@ -0,0 +1,8 @@ +const { exec } = require('shelljs') +const os = require('os') +const platform = os.platform() +console.log('platform:', platform) +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) diff --git a/build/bin/appimage-integration.js b/build/bin/appimage-integration.js new file mode 100644 index 0000000..5d63abe --- /dev/null +++ b/build/bin/appimage-integration.js @@ -0,0 +1,100 @@ +/** + * Install .desktop file and icon for AppImage on Linux + * so that taskbars (e.g. UOS/Deepin dde-dock) can find the icon. + * + * This is standard practice for AppImage โ€” the format uses a volatile + * FUSE mount path, so the embedded .desktop file and icon are never + * found by desktop environments that look in XDG standard locations. + */ + +const fs = require('fs') +const path = require('path') +const os = require('os') +const log = require('../common/log') + +function installDesktopFile () { + if (process.platform !== 'linux' || !process.env.APPIMAGE) { + return + } + + const appImagePath = process.env.APPIMAGE + const home = os.homedir() + const desktopId = 'electerm' + const appsDir = path.join(home, '.local', 'share', 'applications') + const iconsDir = path.join( + home, '.local', 'share', 'icons', + 'hicolor', '128x128', 'apps' + ) + const desktopFilePath = path.join(appsDir, `${desktopId}.desktop`) + const iconFilePath = path.join(iconsDir, `${desktopId}.png`) + + // Icon source inside the AppImage (asar-aware path โ€” Electron's fs + // module transparently reads from inside app.asar) + const srcIconPath = path.join( + process.resourcesPath, + 'app.asar', 'assets', 'images', 'electerm-round-128x128.png' + ) + + const desktopContent = [ + '[Desktop Entry]', + 'Name=electerm', + 'Comment=Terminal/SSH/SFTP client', + `Exec=${appImagePath} %U`, + `Icon=${iconFilePath}`, + 'Terminal=false', + 'Type=Application', + 'Categories=Development;System;TerminalEmulator;', + 'StartupWMClass=electerm', + 'MimeType=x-scheme-handler/ssh;x-scheme-handler/telnet;' + + 'x-scheme-handler/rdp;x-scheme-handler/vnc;' + + 'x-scheme-handler/serial;x-scheme-handler/spice;' + + 'x-scheme-handler/electerm;', + '' + ].join('\n') + + try { + // Skip if a system-level .desktop file exists (deb/rpm installs one + // to /usr/share/applications/) โ€” don't override it. + // Also clean up any leftover user-level file from a previous AppImage run. + const sysDesktop = '/usr/share/applications/electerm.desktop' + if (fs.existsSync(sysDesktop)) { + try { + fs.unlinkSync(desktopFilePath) + fs.unlinkSync(iconFilePath) + } catch (_) {} + return + } + + // Skip if .desktop file already has the correct content + let existing = '' + try { + existing = fs.readFileSync(desktopFilePath, 'utf8') + } catch (_) {} + if (existing === desktopContent) { + return + } + + fs.mkdirSync(appsDir, { recursive: true }) + fs.mkdirSync(iconsDir, { recursive: true }) + + // Copy icon once (doesn't change between versions) + if (!fs.existsSync(iconFilePath) && fs.existsSync(srcIconPath)) { + fs.copyFileSync(srcIconPath, iconFilePath) + } + + fs.writeFileSync(desktopFilePath, desktopContent) + + // Refresh desktop database so the DE picks up the new entry + try { + require('child_process').execSync( + 'update-desktop-database ~/.local/share/applications 2>/dev/null' + ) + } catch (_) {} + + log.info(`[appimage] Installed .desktop file to ${desktopFilePath}`) + } catch (e) { + log.error('[appimage] Failed to install desktop file:', e.message) + } +} + +module.exports = { installDesktopFile } diff --git a/build/bin/build-common.js b/build/bin/build-common.js new file mode 100644 index 0000000..e47f502 --- /dev/null +++ b/build/bin/build-common.js @@ -0,0 +1,342 @@ +/** + * common functions for build + */ + +const { exec } = require('child_process') +const { resolve } = require('path') +const { writeFileSync, readFileSync } = require('fs') +const replace = require('replace-in-file') +const { rm, mv } = require('shelljs') + +exports.run = function (cmd) { + return new Promise((resolve, reject) => { + console.log('Executing command:', cmd) + const childProcess = exec(cmd, { + env: { + ...process.env, + DEBUG: 'electron-builder:*', + ELECTRON_BUILDER_CACHE: process.env.ELECTRON_BUILDER_CACHE || '' + }, + maxBuffer: 1024 * 1024 * 50 // 50MB buffer for large debug output + }, (err, stdout, stderr) => { + // Always log stdout and stderr regardless of success/failure + if (stdout) { + console.log('=== STDOUT ===') + console.log(stdout) + } + if (stderr) { + console.log('=== STDERR ===') + console.log(stderr) + } + + if (err) { + console.error('=== COMMAND FAILED ===') + console.error('Command:', cmd) + console.error('Exit code:', err.code) + console.error('Signal:', err.signal) + console.error('Error message:', err.message) + + // Create a more detailed error message + const detailedError = new Error(`Command failed with exit code ${err.code}: ${cmd}`) + detailedError.originalError = err + detailedError.stdout = stdout + detailedError.stderr = stderr + detailedError.command = cmd + return reject(detailedError) + } + + resolve(stdout) + }) + + // Also pipe output in real-time for long-running commands + childProcess.stdout.on('data', (data) => { + process.stdout.write(data) + }) + + childProcess.stderr.on('data', (data) => { + process.stderr.write(data) + }) + }) +} + +exports.writeSrc = function (src) { + const p = resolve(__dirname, '../../work/app/lib/install-src.js') + writeFileSync(p, `module.exports = '${src}'`) + if (src.includes('AppImage')) { + exports.patchAppImage() + } +} + +/** + * Patch work/app to include AppImage desktop integration. + * Copies the integration module and wires it into create-app.js + * so the .desktop file + icon are installed on first run. + * Only called for AppImage builds โ€” zero overhead for other targets. + */ +exports.patchAppImage = function patchAppImage () { + const fs = require('fs') + const workLib = resolve(__dirname, '../../work/app/lib') + const workAssets = resolve(__dirname, '../../work/app/assets/images') + + // Copy integration module to work/app/lib/ + const modSrc = resolve(__dirname, 'appimage-integration.js') + const modDst = resolve(workLib, 'appimage-integration.js') + fs.copyFileSync(modSrc, modDst) + + // Copy icon for desktop integration (outside asar, accessible at runtime) + const iconSrc = resolve( + __dirname, '../../node_modules/@electerm/electerm-resource/res/imgs/electerm-round-128x128.png' + ) + const iconDst = resolve(workAssets, 'electerm-round-128x128.png') + if (fs.existsSync(iconSrc)) { + fs.copyFileSync(iconSrc, iconDst) + } + + // Patch create-app.js to import and call installDesktopFile + const createAppPath = resolve(workLib, 'create-app.js') + let code = fs.readFileSync(createAppPath, 'utf8') + if (!code.includes('installDesktopFile')) { + code = code.replace( + "const log = require('../common/log')", + "const log = require('../common/log')\n" + + "const { installDesktopFile } = require('./appimage-integration')" + ) + code = code.replace( + 'app.setName(packInfo.name)', + 'app.setName(packInfo.name)\n installDesktopFile()' + ) + fs.writeFileSync(createAppPath, code) + } + console.log('[appimage] Patched work/app for desktop integration') +} + +exports.builder = resolve( + __dirname, '../../node_modules/.bin/electron-builder' +) + +exports.reBuild = resolve( + __dirname, '../../node_modules/.bin/electron-rebuild' +) + +exports.replaceArr = function (froms, tos) { + const pth = resolve(__dirname, '../../electron-builder.json') + console.log('electron-builder', pth) + let str = readFileSync(pth, 'utf8') + for (let i = 0; i < froms.length; i++) { + str = str.replace(froms[i], tos[i]) + } + writeFileSync(pth, str) +} + +exports.replaceJSON = function (func) { + const pth = resolve(__dirname, '../../electron-builder.json') + const js = require(pth) + func(js) + writeFileSync(pth, JSON.stringify(js, null, 2)) +} + +exports.setChannel = function (suffix) { + exports.replaceJSON((data) => { + if (!data.linux) { data.linux = {} } + if (!data.linux.publish) { data.linux.publish = {} } + data.linux.publish.channel = '${env.WORKFLOW_NAME}-' + suffix // eslint-disable-line + }) +} + +const options = { + files: require('path').resolve(__dirname, '../../electron-builder.json'), + from: ['"asar": true', '${productName}-${version}-${os}-${arch}.${ext}', ', "appx", "nsis"'], // eslint-disable-line + to: ['"asar": false', '${productName}-${version}-${os}-${arch}-loose.${ext}', ''] // eslint-disable-line +} + +exports.replaceRun = function () { + return new Promise((resolve, reject) => { + replace(options, (err) => { + if (err) { + return reject(err) + } + console.log('start build loose file(no asar)') + resolve() + }) + }) +} + +const shouldKeepFile = !!process.env.KEEP_FILE + +exports.renameDist = function renameDist () { + if (!shouldKeepFile) { + return rm('-rf', 'dist') + } + mv('dist', 'dist' + new Date().getTime()) +} + +exports.uploadToR2 = async function uploadToR2 (src) { + // Must be running in CI + if (!process.env.CI) { + console.log('[r2] Skipping upload: not in CI environment') + return + } + + // Check commit message contains "[r2]" + const { execSync } = require('child_process') + let commitMsg = '' + try { + commitMsg = execSync('git log -1 --format=%s', { encoding: 'utf8' }).trim() + } catch (e) { + console.log('[r2] Skipping upload: could not read commit message') + return + } + if (!commitMsg.includes('[r2]')) { + console.log('[r2] Skipping upload: commit message does not contain "[r2]"') + return + } + + // Check required env vars + const accountId = process.env.CF_R2_ACCOUNT_ID + const bucket = process.env.CF_R2_BUCKET + const accessKeyId = process.env.CF_R2_ACCESS_KEY_ID + const secretAccessKey = process.env.CF_R2_SECRET_ACCESS_KEY + + if (!accountId || !bucket || !accessKeyId || !secretAccessKey) { + console.error('[r2] Missing required env vars: CF_R2_ACCOUNT_ID, CF_R2_BUCKET, CF_R2_ACCESS_KEY_ID, CF_R2_SECRET_ACCESS_KEY') + return + } + + // Find the built file in dist/ + const fs = require('fs') + const path = require('path') + const distDir = resolve(__dirname, '../../dist') + let filePath = null + if (fs.existsSync(distDir)) { + const files = fs.readdirSync(distDir) + const match = files.find(f => f.endsWith(src)) + if (match) { + filePath = path.join(distDir, match) + } + } + if (!filePath) { + // Try to find recursively one level deeper + if (fs.existsSync(distDir)) { + const entries = fs.readdirSync(distDir, { withFileTypes: true }) + for (const entry of entries) { + if (entry.isDirectory()) { + const subDir = path.join(distDir, entry.name) + const subFiles = fs.readdirSync(subDir) + const match = subFiles.find(f => f.endsWith(src)) + if (match) { + filePath = path.join(subDir, match) + break + } + } + } + } + } + if (!filePath) { + console.error(`[r2] Could not find built file matching "${src}" in dist/`) + return + } + + console.log(`[r2] Uploading ${filePath} to R2 bucket "${bucket}" as "${path.basename(filePath)}"`) + + // Polyfill crypto.getRandomValues for Node < 19. + // @smithy/core reads getRandomValues from require('node:crypto') directly + // (line: const _getRandomValues = node_crypto.getRandomValues), so we must + // patch the crypto module itself, not just globalThis.crypto. + const nodeCrypto = require('crypto') + if (!nodeCrypto.getRandomValues) { + nodeCrypto.getRandomValues = (buf) => nodeCrypto.randomFillSync(buf) + } + if (!globalThis.crypto) { + globalThis.crypto = nodeCrypto + } + + let S3Client, PutObjectCommand + try { + const s3Module = require('@aws-sdk/client-s3') + S3Client = s3Module.S3Client + PutObjectCommand = s3Module.PutObjectCommand + } catch (e) { + console.error('[r2] @aws-sdk/client-s3 is not installed. Run: npm install --save-dev @aws-sdk/client-s3') + throw e + } + + const client = new S3Client({ + region: 'auto', + endpoint: `https://${accountId}.r2.cloudflarestorage.com`, + credentials: { + accessKeyId, + secretAccessKey + } + }) + + const fileStream = fs.createReadStream(filePath) + const key = path.basename(filePath) + const command = new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: fileStream, + ContentLength: fs.statSync(filePath).size + }) + + await client.send(command) + console.log(`[r2] Successfully uploaded ${key} to R2`) +} + +/** + * Patch the electron-builder NSIS template so that the installer always tries + * to keep shortcuts (desktop / start-menu / taskbar pins) when upgrading. + * + * By default, when `allowToChangeInstallationDirectory` is set, the template + * sets `isTryToKeepShortcuts = "false"` for manual (non-auto-update) installs, + * which causes `WinShell::UninstShortcut` / `UninstAppUserModelId` to be called + * and the taskbar pin to be silently removed. Removing the guard makes the + * installer always pass `--keep-shortcuts` to the old uninstaller when the + * previous install wrote `KeepShortcuts = "true"` to the registry. + */ +exports.patchNsisKeepShortcuts = function patchNsisKeepShortcuts () { + const fs = require('fs') + const path = require('path') + const templatePath = path.join( + require.resolve('app-builder-lib/package.json'), + '../templates/nsis/include/installUtil.nsh' + ) + const original = fs.readFileSync(templatePath, 'utf-8') + const patched = original.replace( + /(!macro setIsTryToKeepShortcuts\s+StrCpy \$isTryToKeepShortcuts "true"\s*)!ifdef allowToChangeInstallationDirectory[\s\S]*?!endif(\s*!macroend)/, + '$1$2' + ) + if (patched === original) { + console.log('NSIS keep-shortcuts patch: already applied or pattern not found, skipping') + } else { + fs.writeFileSync(templatePath, patched, 'utf-8') + console.log('NSIS keep-shortcuts patch: applied successfully') + } +} + +/** + * Patch electron-builder's Snap target so classic confinement builds keep the + * Chromium sandbox and do not force --no-sandbox into the generated launcher. + * + * electerm's packaged entrypoint rejects the --no-sandbox CLI flag, and classic + * snaps do not need the browser-support plug path used by strict confinement. + */ +exports.patchSnapClassicSandbox = function patchSnapClassicSandbox () { + const fs = require('fs') + const path = require('path') + const targetPath = path.join( + require.resolve('app-builder-lib/package.json'), + '../out/targets/snap.js' + ) + const original = fs.readFileSync(targetPath, 'utf-8') + const patched = original.replace( + ' if (this.isElectronVersionGreaterOrEqualThan("5.0.0") && !isBrowserSandboxAllowed(snap)) {', + ' if (this.isElectronVersionGreaterOrEqualThan("5.0.0") && snap.confinement !== "classic" && !isBrowserSandboxAllowed(snap)) {' + ) + + if (patched === original) { + console.log('Snap classic sandbox patch: already applied or pattern not found, skipping') + } else { + fs.writeFileSync(targetPath, patched, 'utf-8') + console.log('Snap classic sandbox patch: applied successfully') + } +} diff --git a/build/bin/build-linux-arm-legacy.js b/build/bin/build-linux-arm-legacy.js new file mode 100644 index 0000000..322d6c7 --- /dev/null +++ b/build/bin/build-linux-arm-legacy.js @@ -0,0 +1,183 @@ +const { echo } = require('shelljs') +const { + run, + writeSrc, + uploadToR2, + builder: pb, + renameDist, + reBuild, + replaceJSON, + setChannel +} = require('./build-common') + +// Function to add "-legacy" suffix to artifact names in electron-builder.json +function addLegacySuffix () { + echo('============') + echo('Updating electron-builder.json to add -legacy suffix') + replaceJSON((data) => { + // Update main artifactName + if (data.artifactName) { + data.artifactName = data.artifactName.replace('${productName}-${version}-${os}-${arch}.${ext}', '${productName}-${version}-${os}-${arch}-legacy.${ext}') // eslint-disable-line + } + }) + echo('Updated artifact names with -legacy suffix') +} + +async function main () { + echo('============================================') + echo('==== Start: running build for linux part 3 arm64/armv7l legacy ====') + echo('============================================') + + // Add legacy suffix to names + addLegacySuffix() + + echo('============================================') + echo('==== Start: build linux.arm64.tar.gz ====') + echo('============================================') + let src = 'linux-arm64-legacy.tar.gz' + renameDist() + writeSrc(src) + replaceJSON( + (data) => { + data.linux.target = ['tar.gz'] + } + ) + setChannel(src) + // await run(`${reBuild} --arch arm64 -f work/app`) + await run(`${pb} --linux --arm64`).catch(error => { + echo('โŒ Fatal error in build linux.arm64.tar.gz:') + console.error(error) + }) + await uploadToR2(src) + + echo('============================================') + echo('==== Start: build linux.arm64.deb ====') + echo('============================================') + src = 'linux-arm64-legacy.deb' + renameDist() + writeSrc(src) + replaceJSON( + (data) => { + data.linux.target = ['deb'] + } + ) + setChannel(src) + await run(`${pb} --linux --arm64`).catch(error => { + echo('โŒ Fatal error in build linux.arm64.deb:') + console.error(error) + }) + await uploadToR2(src) + + echo('============================================') + echo('==== Start: build linux.aarch64.rpm ====') + echo('============================================') + src = 'linux-aarch64-legacy.rpm' + renameDist() + writeSrc(src) + replaceJSON( + (data) => { + data.linux.target = ['rpm'] + } + ) + setChannel(src) + await run(`${pb} --linux --arm64`).catch(error => { + echo('โŒ Fatal error in build linux.aarch64.rpm:') + console.error(error) + }) + await uploadToR2(src) + + echo('============================================') + echo('==== Start: build linux.arm64.AppImage ====') + echo('============================================') + src = 'linux-arm64-legacy.AppImage' + renameDist() + writeSrc(src) + replaceJSON( + (data) => { + data.linux.target = ['AppImage'] + } + ) + setChannel(src) + await run(`${pb} --linux --arm64`).catch(error => { + echo('โŒ Fatal error in build linux.arm64.AppImage:') + console.error(error) + }) + await uploadToR2(src) + + echo('============================================') + echo('==== Start: build linux.armv7l.tar.gz ====') + echo('============================================') + src = 'linux-armv7l-legacy.tar.gz' + renameDist() + writeSrc(src) + replaceJSON( + (data) => { + data.linux.target = ['tar.gz'] + } + ) + setChannel(src) + await run(`${reBuild} --arch armv7l -f work/app`) + await run(`${pb} --linux --armv7l`).catch(error => { + echo('โŒ Fatal error in build linux.armv7l:') + console.error(error) + }) + await uploadToR2(src) + + echo('============================================') + echo('==== Start: build linux.armv7l.deb ====') + echo('============================================') + src = 'linux-armv7l-legacy.deb' + renameDist() + writeSrc(src) + replaceJSON( + (data) => { + data.linux.target = ['deb'] + } + ) + setChannel(src) + await run(`${pb} --linux --armv7l`).catch(error => { + echo('โŒ Fatal error in build linux.armv7l.deb:') + console.error(error) + }) + await uploadToR2(src) + + echo('============================================') + echo('==== Start: build linux.armv7l.rpm ====') + echo('============================================') + src = 'linux-armv7l-legacy.rpm' + renameDist() + writeSrc(src) + replaceJSON( + (data) => { + data.linux.target = ['rpm'] + } + ) + setChannel(src) + await run(`${pb} --linux --armv7l`).catch(error => { + echo('โŒ Fatal error in build linux.armv7l.rpm:') + console.error(error) + }) + await uploadToR2(src) + + echo('============================================') + echo('==== Start: build linux.armv7l.AppImage ====') + echo('============================================') + src = 'linux-armv7l-legacy.AppImage' + renameDist() + writeSrc(src) + replaceJSON( + (data) => { + data.linux.target = ['AppImage'] + } + ) + setChannel(src) + await run(`${pb} --linux --armv7l`).catch(error => { + echo('โŒ Fatal error in build linux.armv7l.AppImage:') + console.error(error) + }) + await uploadToR2(src) + + echo('โœ… All Linux ARM builds completed successfully') +} + +main() diff --git a/build/bin/build-linux-arm.js b/build/bin/build-linux-arm.js new file mode 100644 index 0000000..9260518 --- /dev/null +++ b/build/bin/build-linux-arm.js @@ -0,0 +1,158 @@ +const { echo } = require('shelljs') +const { + run, + writeSrc, + uploadToR2, + builder: pb, + renameDist, + reBuild, + replaceJSON +} = require('./build-common') + +async function main () { + echo('============================================') + echo('==== Start: running build for linux part 3 arm64/armv7l ====') + echo('============================================') + + echo('============================================') + echo('==== Start: build linux.arm64.tar.gz ====') + echo('============================================') + let src = 'linux-arm64.tar.gz' + renameDist() + writeSrc(src) + replaceJSON( + (data) => { + data.linux.target = ['tar.gz'] + } + ) + // await run(`${reBuild} --arch arm64 -f work/app`) + await run(`${pb} --linux --arm64`).catch(error => { + echo('โŒ Fatal error in build linux.arm64.tar.gz:') + console.error(error) + }) + await uploadToR2(src) + + echo('============================================') + echo('==== Start: build linux.arm64.deb ====') + echo('============================================') + src = 'linux-arm64.deb' + renameDist() + writeSrc(src) + replaceJSON( + (data) => { + data.linux.target = ['deb'] + } + ) + await run(`${pb} --linux --arm64`).catch(error => { + echo('โŒ Fatal error in build linux.arm64.deb:') + console.error(error) + }) + await uploadToR2(src) + + echo('============================================') + echo('==== Start: build linux.aarch64.rpm ====') + echo('============================================') + src = 'linux-aarch64.rpm' + renameDist() + writeSrc(src) + replaceJSON( + (data) => { + data.linux.target = ['rpm'] + } + ) + await run(`${pb} --linux --arm64`).catch(error => { + echo('โŒ Fatal error in build linux.aarch64.rpm:') + console.error(error) + }) + await uploadToR2(src) + + echo('============================================') + echo('==== Start: build linux.arm64.AppImage ====') + echo('============================================') + src = 'linux-arm64.AppImage' + renameDist() + writeSrc(src) + replaceJSON( + (data) => { + data.linux.target = ['AppImage'] + } + ) + await run(`${pb} --linux --arm64`).catch(error => { + echo('โŒ Fatal error in build linux.arm64.AppImage:') + console.error(error) + }) + await uploadToR2(src) + + echo('============================================') + echo('==== Start: build linux.armv7l.tar.gz ====') + echo('============================================') + src = 'linux-armv7l.tar.gz' + renameDist() + writeSrc(src) + replaceJSON( + (data) => { + data.linux.target = ['tar.gz'] + } + ) + await run(`${reBuild} --arch armv7l -f work/app`) + await run(`${pb} --linux --armv7l`).catch(error => { + echo('โŒ Fatal error in build linux.armv7l:') + console.error(error) + }) + await uploadToR2(src) + + echo('============================================') + echo('==== Start: build linux.armv7l.deb ====') + echo('============================================') + src = 'linux-armv7l.deb' + renameDist() + writeSrc(src) + replaceJSON( + (data) => { + data.linux.target = ['deb'] + } + ) + await run(`${pb} --linux --armv7l`).catch(error => { + echo('โŒ Fatal error in build linux.armv7l.deb:') + console.error(error) + }) + await uploadToR2(src) + + echo('============================================') + echo('==== Start: build linux.armv7l.rpm ====') + echo('============================================') + src = 'linux-armv7l.rpm' + renameDist() + writeSrc(src) + replaceJSON( + (data) => { + data.linux.target = ['rpm'] + } + ) + await run(`${pb} --linux --armv7l`).catch(error => { + echo('โŒ Fatal error in build linux.armv7l.rpm:') + console.error(error) + }) + await uploadToR2(src) + + echo('============================================') + echo('==== Start: build linux.armv7l.AppImage ====') + echo('============================================') + src = 'linux-armv7l.AppImage' + renameDist() + writeSrc(src) + replaceJSON( + (data) => { + data.linux.target = ['AppImage'] + } + ) + await run(`${pb} --linux --armv7l`).catch(error => { + echo('โŒ Fatal error in build linux.armv7l.AppImage:') + console.error(error) + }) + await uploadToR2(src) + + echo('โœ… All Linux ARM builds completed successfully') +} + +main() diff --git a/build/bin/build-linux-deb-tar.js b/build/bin/build-linux-deb-tar.js new file mode 100644 index 0000000..1a384ef --- /dev/null +++ b/build/bin/build-linux-deb-tar.js @@ -0,0 +1,44 @@ +const { echo, rm } = require('shelljs') +const { + run, + writeSrc, + uploadToR2, + builder: pb, + replaceJSON, + renameDist +} = require('./build-common') + +async function main () { + echo('running build for linux part 1') + + echo('build tar.gz') + rm('-rf', 'dist') + let src = 'linux-x64.tar.gz' + writeSrc(src) + await run(`${pb} --linux tar.gz`) + await uploadToR2(src) + renameDist() + + echo('build deb') + rm('-rf', 'dist') + src = 'linux-amd64.deb' + writeSrc(src) + await run(`${pb} --linux deb`) + await uploadToR2(src) + renameDist() + + echo('build linux-x86_64.AppImage') + rm('-rf', 'dist') + src = 'linux-x86_64.AppImage' + writeSrc(src) + replaceJSON( + (data) => { + data.linux.target = ['AppImage'] + } + ) + await run(`${pb} --linux`) + await uploadToR2(src) + renameDist() +} + +main() diff --git a/build/bin/build-linux-legacy.js b/build/bin/build-linux-legacy.js new file mode 100644 index 0000000..06d0009 --- /dev/null +++ b/build/bin/build-linux-legacy.js @@ -0,0 +1,88 @@ +const { echo, rm } = require('shelljs') +const { + run, + writeSrc, + uploadToR2, + builder: pb, + replaceJSON, + renameDist, + setChannel +} = require('./build-common') + +// Function to add "-legacy" suffix to artifact names in electron-builder.json +function addLegacySuffix () { + echo('============') + echo('Updating electron-builder.json to add -legacy suffix') + replaceJSON((data) => { + // Update main artifactName + if (data.artifactName) { + data.artifactName = data.artifactName.replace('${productName}-${version}-${os}-${arch}.${ext}', '${productName}-${version}-${os}-${arch}-legacy.${ext}') // eslint-disable-line + } + }) + echo('Updated artifact names with -legacy suffix') +} + +async function main () { + echo('============') + echo('Starting legacy Linux build process') + echo('============') + + // Add legacy suffix to names + addLegacySuffix() + + echo('============') + echo('Building tar.gz package') + echo('============') + rm('-rf', 'dist') + let src = 'linux-x64-legacy.tar.gz' + writeSrc(src) + setChannel(src) + await run(`${pb} --linux tar.gz`) + await uploadToR2(src) + renameDist() + + echo('============') + echo('Building deb package') + echo('============') + rm('-rf', 'dist') + src = 'linux-amd64-legacy.deb' + writeSrc(src) + setChannel(src) + await run(`${pb} --linux deb`) + await uploadToR2(src) + renameDist() + + echo('============') + echo('Building rpm package') + echo('============') + rm('-rf', 'dist') + src = 'linux-x86_64-legacy.rpm' + writeSrc(src) + replaceJSON((data) => { + data.linux.target = ['rpm'] + }) + setChannel(src) + await run(`${pb} --linux rpm`) + await uploadToR2(src) + renameDist() + + echo('============') + echo('Building AppImage package') + echo('============') + rm('-rf', 'dist') + src = 'linux-x86_64-legacy.AppImage' + writeSrc(src) + replaceJSON((data) => { + data.linux.target = ['AppImage'] + }) + setChannel(src) + await run(`${pb} --linux`) + await uploadToR2(src) + renameDist() + + echo('============') + echo('Legacy Linux build process completed') + echo('============') +} + +main() diff --git a/build/bin/build-linux-loong64-legacy.js b/build/bin/build-linux-loong64-legacy.js new file mode 100644 index 0000000..1be106c --- /dev/null +++ b/build/bin/build-linux-loong64-legacy.js @@ -0,0 +1,31 @@ +/** + * Build electerm for loong64 (LoongArch64) Linux - Legacy (old world) + * + * This script orchestrates the loong64 legacy build process: + * 1. Build x64 version with legacy deps to get the asar + * 2. Download electron loong64 from Loongnix FTP + * 3. Cross-compile native modules for loong64 (node-pty@0.10.1, serialport@10.5.0) + * 4. Merge x64 asar with loong64 electron and native modules + * 5. Upload tar.gz to GitHub release draft + * 6. Build deb package and upload to GitHub release draft + */ + +const { resolve } = require('path') +const { echo } = require('shelljs') +const { run } = require('./build-common') + +const SCRIPT_PATH = resolve(__dirname, 'build-linux-loong64-legacy.sh') + +async function main () { + echo('Starting electerm loong64-legacy build...') + + try { + await run(`bash ${SCRIPT_PATH}`) + echo('Loong64-legacy build completed successfully!') + } catch (err) { + console.error('Loong64-legacy build failed:', err.message) + process.exit(1) + } +} + +main() diff --git a/build/bin/build-linux-loong64-legacy.sh b/build/bin/build-linux-loong64-legacy.sh new file mode 100755 index 0000000..da36c81 --- /dev/null +++ b/build/bin/build-linux-loong64-legacy.sh @@ -0,0 +1,559 @@ +#!/bin/bash +# Build electerm for loong64 (LoongArch64) Linux - Legacy (old world) +# +# Strategy: +# 1. Build x64 version with legacy deps to get the asar +# 2. Download electron loong64 from Loongnix FTP +# 3. Cross-compile native modules for loong64 (node-pty@0.10.1, serialport@10.5.0) +# 4. Merge x64 asar with loong64 electron and native modules +# 5. Upload tar.gz to GitHub release draft +# 6. Build deb package and upload to GitHub release draft +# +# Prerequisites: +# - loongarch64-linux-gnu-g++ (cross-compiler, preferably GCC 8 from Loongnix) +# - curl, tar, unzip + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +WORK_DIR="$PROJECT_ROOT/work-loong64-legacy" +ELECTRON_VERSION="22.3.27" +# Loongnix FTP electron download URL - update version as needed +ELECTRON_LOONG64_URL="https://ftp.loongnix.cn/electron/LoongArch/v${ELECTRON_VERSION}/electron-v${ELECTRON_VERSION}-linux-loong64.zip" +OUTPUT_DIR="$PROJECT_ROOT/dist-loong64-legacy" +SKIP_NATIVE="${SKIP_NATIVE:-0}" +WORKFLOW_NAME="${WORKFLOW_NAME:-linux-loong64-legacy}" + +# Legacy native module versions +NODE_PTY_VERSION="1.1.0" +SERIALPORT_VERSION="10.5.0" + +# GCC 8 from Loongnix (old-world ABI) +GCC8_PREFIX="${GCC8_PREFIX:-/opt/gcc8-loong64}" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +log_info() { echo -e "${GREEN}[INFO]${NC} $*"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +log_error() { echo -e "${RED}[ERROR]${NC} $*"; } + +# ============================================================================ +# Helpers +# ============================================================================ +get_version() { + node -e "console.log(require('$PROJECT_ROOT/package.json').version)" +} + +write_install_src() { + local src="$1" + node -e " + const { writeFileSync } = require('fs'); + writeFileSync('$PROJECT_ROOT/work/app/lib/install-src.js', \"module.exports = '${src}'\"); + " +} + +# ============================================================================ +# Step 0: Install prerequisites +# ============================================================================ +install_prerequisites() { + log_info "Checking prerequisites..." + + local SUDO="sudo" + if [ "$(id -u)" -eq 0 ]; then + SUDO="" + fi + + for cmd in curl tar unzip; do + if ! command -v "$cmd" &>/dev/null; then + $SUDO apt-get install -y "$cmd" + fi + done + + # Check for GCC 8 from Loongnix + if [ -d "$GCC8_PREFIX/bin" ]; then + log_info "Using GCC 8 from Loongnix at $GCC8_PREFIX" + export PATH="$GCC8_PREFIX/bin:$PATH" + loongarch64-linux-gnu-gcc --version | head -1 + fi + + log_info "Prerequisites ready." +} + +# ============================================================================ +# Step 1: Build x64 version with legacy deps +# ============================================================================ +build_x64() { + log_info "Step 1: Building x64 version with electron v${ELECTRON_VERSION}..." + + cd "$PROJECT_ROOT" + + # Save original electron version + local original_electron_version + original_electron_version=$(node -e "console.log(require('./package.json').devDependencies.electron)") + + # Temporarily set electron version + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); + pkg.devDependencies.electron = '${ELECTRON_VERSION}'; + fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); + " + + log_info "Running npm run b..." + npm run b + npm run pb + + # Build x64 tar.gz + rm -rf dist + local builder="./node_modules/.bin/electron-builder" + write_install_src "linux-loong64-legacy.tar.gz" + + export WORKFLOW_NAME + $builder --linux tar.gz --x64 --publish=never + + # Find the built tar.gz + local tar_gz + tar_gz=$(find dist -name "*.tar.gz" -type f | head -1) + if [ -z "$tar_gz" ]; then + log_error "Could not find built tar.gz in dist/" + exit 1 + fi + log_info "x64 build complete: $tar_gz" + + # Extract asar and full app directory from tar.gz + mkdir -p "$WORK_DIR/x64-extract" + tar xzf "$tar_gz" -C "$WORK_DIR/x64-extract" + + local asar_file + asar_file=$(find "$WORK_DIR/x64-extract" -name "app.asar" -type f | head -1) + if [ -z "$asar_file" ]; then + log_error "Could not find app.asar in extracted tar.gz" + exit 1 + fi + cp "$asar_file" "$WORK_DIR/app.asar" + + local app_dir + app_dir=$(find "$WORK_DIR/x64-extract" -maxdepth 1 -name "electerm*" -type d | head -1) + if [ -n "$app_dir" ]; then + cp -r "$app_dir" "$WORK_DIR/x64-app" + fi + + # Restore original electron version + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); + pkg.devDependencies.electron = '${original_electron_version}'; + fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); + " + + rm -rf "$WORK_DIR/x64-extract" +} + +# ============================================================================ +# Step 2: Download electron loong64 from Loongnix +# ============================================================================ +download_electron_loong64() { + log_info "Step 2: Downloading electron v${ELECTRON_VERSION} for loong64 from Loongnix..." + + mkdir -p "$WORK_DIR/electron-loong64" + local zip_file="$WORK_DIR/electron-loong64.zip" + + if [ ! -f "$zip_file" ]; then + curl -L -o "$zip_file" "$ELECTRON_LOONG64_URL" + fi + + # Try to unzip, the archive may contain electron directly or in a subdirectory + unzip -q -o "$zip_file" -d "$WORK_DIR/electron-loong64-raw" + + # Find electron binary - may be at root or in a subdirectory + if [ -f "$WORK_DIR/electron-loong64-raw/electron" ]; then + cp -r "$WORK_DIR/electron-loong64-raw"/* "$WORK_DIR/electron-loong64/" + else + # Look for electron binary in subdirectory + local electron_bin + electron_bin=$(find "$WORK_DIR/electron-loong64-raw" -name "electron" -type f | head -1) + if [ -n "$electron_bin" ]; then + local subdir + subdir=$(dirname "$electron_bin") + cp -r "$subdir"/* "$WORK_DIR/electron-loong64/" + fi + fi + rm -rf "$WORK_DIR/electron-loong64-raw" + + if [ ! -f "$WORK_DIR/electron-loong64/electron" ]; then + log_error "Could not find electron binary in extracted zip" + exit 1 + fi + + log_info "Electron loong64 downloaded and extracted." +} + +# ============================================================================ +# Step 3: Rebuild native modules for loong64 using cross-compilation +# ============================================================================ +rebuild_native_modules() { + if [ "$SKIP_NATIVE" = "1" ]; then + log_warn "Step 3: Skipping native module rebuild (SKIP_NATIVE=1)" + return 0 + fi + + if ! command -v loongarch64-linux-gnu-g++ &>/dev/null; then + log_warn "Step 3: loongarch64-linux-gnu-g++ not found, skipping native module rebuild" + log_warn "Install with: sudo apt-get install g++-loongarch64-linux-gnu" + return 0 + fi + + log_info "Step 3: Cross-compiling native modules for loong64 (legacy deps)..." + log_info " node-pty@${NODE_PTY_VERSION}, serialport@${SERIALPORT_VERSION}" + + local native_modules_dir="$WORK_DIR/native-modules-loong64" + mkdir -p "$native_modules_dir" + + local cross_build_dir="$WORK_DIR/cross-build" + rm -rf "$cross_build_dir" + mkdir -p "$cross_build_dir" + cd "$cross_build_dir" + + # Use Electron 22.3.27 headers which have: + # - NODE_MODULE_VERSION 110 (matching Loongnix Electron) + # - v8 10.8 headers (matching Electron's Chromium-based v8) + # Standard Node 16 headers have v8 9.4 and ABI 93 - wrong for Electron. + local headers_dir="$WORK_DIR/electron-headers" + + log_info "Downloading Electron v${ELECTRON_VERSION} headers..." + curl -sL "https://artifacts.electronjs.org/headers/dist/v${ELECTRON_VERSION}/node-v${ELECTRON_VERSION}-headers.tar.gz" -o "$WORK_DIR/electron-headers.tar.gz" + mkdir -p "$headers_dir" + tar xzf "$WORK_DIR/electron-headers.tar.gz" -C "$headers_dir" --strip-components=1 + log_info "Electron headers NODE_MODULE_VERSION: $(grep NODE_MODULE_VERSION "$headers_dir/include/node/node_version.h" | grep define | head -1)" + + # Build node-pty (legacy version) + log_info "Building node-pty@${NODE_PTY_VERSION} for loong64..." + log_info "CC=loongarch64-linux-gnu-gcc CXX=loongarch64-linux-gnu-g++" + log_info "npm_config_nodedir=$headers_dir" + log_info "npm_config_arch=loong64 npm_config_target_arch=loong64" + mkdir -p build-node-pty && cd build-node-pty + npm init -y 2>/dev/null + npm_config_nodedir="$headers_dir" npm_config_arch=loong64 npm_config_target_arch=loong64 CC=loongarch64-linux-gnu-gcc CXX=loongarch64-linux-gnu-g++ npm install node-pty@${NODE_PTY_VERSION} --build-from-source 2>&1 | tee /tmp/node-pty-build.log | tail -30 + log_info "Full node-pty build log saved to /tmp/node-pty-build.log" + find . -name "pty.node" -type f | while read f; do + log_info "Found pty.node: $f" + file "$f" + log_info "ELF headers:" + readelf -h "$f" 2>/dev/null | grep -E "Machine|Class" || true + log_info "Dynamic dependencies:" + readelf -d "$f" 2>/dev/null | grep NEEDED || true + log_info "Undefined symbols:" + readelf -s "$f" 2>/dev/null | grep "UND" | grep -v "UND$" | head -20 || true + cp "$f" "$native_modules_dir/node-pty.node" + done + cd "$cross_build_dir" + + # Build @serialport/bindings-cpp (legacy serialport@10.5.0) + log_info "Building serialport@${SERIALPORT_VERSION} bindings for loong64..." + mkdir -p build-serialport && cd build-serialport + npm init -y 2>/dev/null + npm_config_nodedir="$headers_dir" npm_config_arch=loong64 npm_config_target_arch=loong64 CC=loongarch64-linux-gnu-gcc CXX=loongarch64-linux-gnu-g++ npm install @serialport/bindings-cpp --build-from-source 2>&1 | tee /tmp/serialport-build.log | tail -30 + find . -path "*/build/Release/bindings.node" -type f | while read f; do + log_info "Found bindings.node: $f" + file "$f" + log_info "ELF headers:" + readelf -h "$f" 2>/dev/null | grep -E "Machine|Class" || true + cp "$f" "$native_modules_dir/serialport-bindings.node" + done + cd "$cross_build_dir" + + unset CC CXX AR RANLIB LINK GYP_DEFINES npm_config_arch npm_config_target_arch npm_config_nodedir + + # Verify native modules are LoongArch64 ELF binaries + if [ -d "$native_modules_dir" ] && [ "$(ls -A "$native_modules_dir" 2>/dev/null)" ]; then + log_info "Native modules built successfully:" + ls -la "$native_modules_dir/" + for mod in "$native_modules_dir"/*.node; do + log_info "Verifying $mod:" + file "$mod" + if ! file "$mod" | grep -q "LoongArch"; then + log_error "WARNING: $mod does not appear to be a LoongArch binary!" + fi + done + else + log_warn "No native modules were built." + fi + + if [ -d "$native_modules_dir" ] && [ "$(ls -A "$native_modules_dir" 2>/dev/null)" ]; then + log_info "Native modules built successfully:" + ls -la "$native_modules_dir/" + else + log_warn "No native modules were built." + fi +} + +# ============================================================================ +# Step 4: Merge x64 asar with loong64 electron and native modules +# ============================================================================ +merge_loong64() { + log_info "Step 4: Merging x64 asar with loong64 electron..." + + local electerm_version + electerm_version=$(get_version) + + mkdir -p "$OUTPUT_DIR" + local output_name="electerm-${electerm_version}-linux-loong64-legacy" + local output_dir="$OUTPUT_DIR/$output_name" + rm -rf "$output_dir" + mkdir -p "$output_dir" + + # Start with x64 app directory + if [ -d "$WORK_DIR/x64-app" ]; then + cp -r "$WORK_DIR/x64-app"/* "$output_dir/" + fi + + # Replace x64 binary with loong64 electron + rm -f "$output_dir/electerm" + cp "$WORK_DIR/electron-loong64/electron" "$output_dir/electerm" + chmod +x "$output_dir/electerm" + + # Copy loong64 electron runtime files (libraries, locales, etc.) + for f in "$WORK_DIR/electron-loong64"/*; do + local base + base=$(basename "$f") + if [ "$base" = "electron" ]; then + continue + fi + if [ -d "$f" ] && [ "$base" != "locales" ]; then + continue + fi + if [ -d "$f" ]; then + cp -r "$f" "$output_dir/" + else + cp "$f" "$output_dir/" + fi + done + + # Replace native modules with loong64 versions + local native_modules_dir="$WORK_DIR/native-modules-loong64" + if [ -d "$native_modules_dir" ] && [ "$(ls -A "$native_modules_dir" 2>/dev/null)" ]; then + log_info "Replacing native modules with loong64 versions..." + if [ -f "$native_modules_dir/node-pty.node" ]; then + find "$output_dir" -path "*/node-pty/build/Release/*.node" -exec cp "$native_modules_dir/node-pty.node" {} \; 2>/dev/null || true + fi + if [ -f "$native_modules_dir/serialport-bindings.node" ]; then + find "$output_dir" -path "*@serialport/bindings-cpp*" -name "*.node" -exec cp "$native_modules_dir/serialport-bindings.node" {} \; 2>/dev/null || true + fi + if [ -f "$native_modules_dir/ssh2-crypto.node" ]; then + find "$output_dir" -path "*@electerm/ssh2*" -name "*.node" -exec cp "$native_modules_dir/ssh2-crypto.node" {} \; 2>/dev/null || true + fi + fi + + # Ensure asar is in place + if [ -f "$WORK_DIR/app.asar" ] && [ ! -f "$output_dir/resources/app.asar" ]; then + mkdir -p "$output_dir/resources" + cp "$WORK_DIR/app.asar" "$output_dir/resources/" + fi + + # Create tar.gz + log_info "Creating loong64-legacy tar.gz..." + cd "$OUTPUT_DIR" + tar czf "${output_name}.tar.gz" "$output_name" + + log_info "Tar.gz complete: $OUTPUT_DIR/${output_name}.tar.gz" +} + +# ============================================================================ +# Step 5: Build deb package +# ============================================================================ +patch_asar_install_src() { + local asar_file="$1" + local new_src="$2" + + log_info "Patching asar install-src to '${new_src}'..." + + local asar_bin="$PROJECT_ROOT/node_modules/@electron/asar/bin/asar.js" + local tmp_dir + tmp_dir=$(mktemp -d) + + # Extract, patch, repack using CLI + node "$asar_bin" extract "$asar_file" "$tmp_dir" + echo "module.exports = '${new_src}'" > "$tmp_dir/lib/install-src.js" + node "$asar_bin" pack "$tmp_dir" "$asar_file" + + rm -rf "$tmp_dir" + log_info "Asar patched successfully." +} + +build_deb() { + local install_src="${1:-linux-loong64-legacy.deb}" + local deb_arch="${2:-loong64}" + local suffix="$deb_arch" + log_info "Step 5: Building ${suffix} deb package..." + + local electerm_version + electerm_version=$(get_version) + + local output_dir="$OUTPUT_DIR/electerm-${electerm_version}-linux-loong64-legacy" + local deb_build="$OUTPUT_DIR/deb-build-${suffix}" + local deb_name="electerm_${electerm_version}_loongarch64" + local deb_dir="$deb_build/$deb_name" + + if [ ! -d "$output_dir" ]; then + log_error "Output directory not found: $output_dir" + return 1 + fi + + # Patch asar to use deb install-src + local asar_file="$output_dir/resources/app.asar" + if [ -f "$asar_file" ]; then + patch_asar_install_src "$asar_file" "$install_src" + else + log_warn "app.asar not found at $asar_file, skipping install-src patch" + fi + + rm -rf "$deb_build" + mkdir -p "$deb_dir/DEBIAN" + mkdir -p "$deb_dir/opt/electerm" + mkdir -p "$deb_dir/usr/share/applications" + mkdir -p "$deb_dir/usr/share/icons/hicolor/128x128/apps" + + cp -r "$output_dir"/* "$deb_dir/opt/electerm/" + + # Install icon + local icon_src="$PROJECT_ROOT/node_modules/@electerm/electerm-resource/res/imgs/electerm-round-128x128.png" + if [ -f "$icon_src" ]; then + cp "$icon_src" "$deb_dir/usr/share/icons/hicolor/128x128/apps/electerm.png" + fi + + # Install desktop file + cat > "$deb_dir/usr/share/applications/electerm.desktop" <<'DESKTOP' +[Desktop Entry] +Name=electerm +Comment=Terminal/SSH/SFTP client +Exec=/opt/electerm/electerm %U +Icon=electerm +Terminal=false +Type=Application +Categories=Development;System;TerminalEmulator; +StartupWMClass=electerm +MimeType=x-scheme-handler/ssh;x-scheme-handler/telnet;x-scheme-handler/rdp;x-scheme-handler/vnc;x-scheme-handler/serial;x-scheme-handler/spice;x-scheme-handler/electerm; +DESKTOP + + cat > "$deb_dir/DEBIAN/control" < +Description: Open-sourced terminal/ssh/sftp/telnet/serialport/RDP/VNC/Spice/ftp client +CTRL + + cat > "$deb_dir/DEBIAN/postinst" <<'POSTINST' +#!/bin/bash +chown root:root /opt/electerm/chrome-sandbox +chmod 4755 /opt/electerm/chrome-sandbox +update-desktop-database /usr/share/applications/ 2>/dev/null || true +gtk-update-icon-cache /usr/share/icons/hicolor/ 2>/dev/null || true +POSTINST + chmod 755 "$deb_dir/DEBIAN/postinst" + + local deb_file="$OUTPUT_DIR/electerm-${electerm_version}-linux-${suffix}-legacy.deb" + # Use gzip compression for compatibility with dpkg < 1.21.18 (e.g. Loongnix 20 has dpkg 1.19.7) + if command -v fakeroot &>/dev/null; then + fakeroot dpkg-deb -Zgzip --root-owner-group --build "$deb_dir" "$deb_file" + else + dpkg-deb -Zgzip --root-owner-group --build "$deb_dir" "$deb_file" + fi + + if [ ! -f "$deb_file" ]; then + log_error "Deb build failed: $deb_file not created" + return 1 + fi + + log_info "Deb complete: $deb_file" + + rm -rf "$deb_build" +} + +# ============================================================================ +# Step 6: Upload to GitHub release draft +# ============================================================================ +upload_to_github() { + local file="$1" + local filename + filename=$(basename "$file") + + if [ ! -f "$file" ]; then + log_warn "Upload skipped: file not found: $file" + return 0 + fi + + if [ -z "${GH_TOKEN:-}" ]; then + log_warn "Upload skipped: GH_TOKEN not set" + return 0 + fi + + local electerm_version + electerm_version=$(get_version) + + local release_name="v${electerm_version}" + + log_info "Uploading $filename to GitHub release draft '${release_name}'..." + + if ! gh release view "$release_name" >/dev/null 2>&1; then + gh release create "$release_name" \ + --draft \ + --title "$release_name" \ + --generate-notes \ + "$file" + else + gh release upload "$release_name" "$file" --clobber + fi + + log_info "Uploaded $filename." +} + +# ============================================================================ +# Main +# ============================================================================ +main() { + log_info "Starting electerm loong64-legacy build..." + log_info "Project root: $PROJECT_ROOT" + log_info "Work directory: $WORK_DIR" + log_info "Output directory: $OUTPUT_DIR" + log_info "Electron version: $ELECTRON_VERSION" + log_info "node-pty: $NODE_PTY_VERSION, serialport: $SERIALPORT_VERSION" + + rm -rf "$WORK_DIR" + mkdir -p "$WORK_DIR" + + install_prerequisites + build_x64 + download_electron_loong64 + rebuild_native_modules + merge_loong64 + + local electerm_version + electerm_version=$(get_version) + + # Upload tar.gz + upload_to_github "$OUTPUT_DIR/electerm-${electerm_version}-linux-loong64-legacy.tar.gz" + + # Build and upload debs + build_deb "linux-loong64-legacy.deb" "loong64" + upload_to_github "$OUTPUT_DIR/electerm-${electerm_version}-linux-loong64-legacy.deb" + + build_deb "linux-loongarch64-legacy.deb" "loongarch64" + upload_to_github "$OUTPUT_DIR/electerm-${electerm_version}-linux-loongarch64-legacy.deb" + + log_info "==========================================" + log_info "Build complete!" + log_info " $OUTPUT_DIR/electerm-${electerm_version}-linux-loong64-legacy.tar.gz" + log_info " $OUTPUT_DIR/electerm-${electerm_version}-linux-loong64-legacy.deb" + log_info " $OUTPUT_DIR/electerm-${electerm_version}-linux-loongarch64-legacy.deb" + log_info "==========================================" +} + +main "$@" diff --git a/build/bin/build-linux-loong64.js b/build/bin/build-linux-loong64.js new file mode 100644 index 0000000..8959d0e --- /dev/null +++ b/build/bin/build-linux-loong64.js @@ -0,0 +1,31 @@ +/** + * Build electerm for loong64 (LoongArch64) Linux + * + * This script orchestrates the loong64 build process: + * 1. Build x64 version with electron v39.2.7 to get the asar + * 2. Download electron loong64 v39.2.7 + * 3. Cross-compile native modules for loong64 + * 4. Merge x64 asar with loong64 electron and native modules + * 5. Upload tar.gz to GitHub release draft + * 6. Build deb package (patched asar) and upload to GitHub release draft + */ + +const { resolve } = require('path') +const { echo } = require('shelljs') +const { run } = require('./build-common') + +const SCRIPT_PATH = resolve(__dirname, 'build-linux-loong64.sh') + +async function main () { + echo('Starting electerm loong64 build...') + + try { + await run(`bash ${SCRIPT_PATH}`) + echo('Loong64 build completed successfully!') + } catch (err) { + console.error('Loong64 build failed:', err.message) + process.exit(1) + } +} + +main() diff --git a/build/bin/build-linux-loong64.sh b/build/bin/build-linux-loong64.sh new file mode 100755 index 0000000..3ce94d8 --- /dev/null +++ b/build/bin/build-linux-loong64.sh @@ -0,0 +1,481 @@ +#!/bin/bash +# Build electerm for loong64 (LoongArch64) Linux +# +# Strategy: +# 1. Build x64 version with electron v39.2.7 to get the asar +# 2. Download electron loong64 v39.2.7 from darkyzhou/electron-loong64 +# 3. Cross-compile native modules for loong64 +# 4. Merge x64 asar with loong64 electron and native modules +# 5. Upload tar.gz to GitHub release draft +# 6. Build deb packages (loong64 and loongarch64, patched asar) and upload to GitHub release draft +# +# Prerequisites: +# - loongarch64-linux-gnu-g++ (for native module cross-compilation) +# - curl, tar, unzip + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +WORK_DIR="$PROJECT_ROOT/work-loong64" +ELECTRON_VERSION="39.2.7" +ELECTRON_LOONG64_URL="https://github.com/darkyzhou/electron-loong64/releases/download/v${ELECTRON_VERSION}/electron-v${ELECTRON_VERSION}-linux-loong64.zip" +OUTPUT_DIR="$PROJECT_ROOT/dist-loong64" +SKIP_NATIVE="${SKIP_NATIVE:-0}" # Set to 1 to skip native module rebuild +WORKFLOW_NAME="${WORKFLOW_NAME:-linux-loong64}" # For electron-builder publish channel + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +log_info() { echo -e "${GREEN}[INFO]${NC} $*"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +log_error() { echo -e "${RED}[ERROR]${NC} $*"; } + +# ============================================================================ +# Helpers +# ============================================================================ +get_version() { + node -e "console.log(require('$PROJECT_ROOT/package.json').version)" +} + +write_install_src() { + local src="$1" + node -e " + const { writeFileSync } = require('fs'); + writeFileSync('$PROJECT_ROOT/work/app/lib/install-src.js', \"module.exports = '${src}'\"); + " +} + +# ============================================================================ +# Step 0: Install prerequisites +# ============================================================================ +install_prerequisites() { + log_info "Checking prerequisites..." + + local SUDO="sudo" + if [ "$(id -u)" -eq 0 ]; then + SUDO="" + fi + + for cmd in curl tar unzip; do + if ! command -v "$cmd" &>/dev/null; then + $SUDO apt-get install -y "$cmd" + fi + done + + log_info "Prerequisites ready." +} + +# ============================================================================ +# Step 1: Build x64 version with electron v39.2.7 +# ============================================================================ +build_x64() { + log_info "Step 1: Building x64 version with electron v${ELECTRON_VERSION}..." + + cd "$PROJECT_ROOT" + + # Save original electron version + local original_electron_version + original_electron_version=$(node -e "console.log(require('./package.json').devDependencies.electron)") + + # Temporarily set electron version + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); + pkg.devDependencies.electron = '${ELECTRON_VERSION}'; + fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); + " + + log_info "Running npm run b..." + npm run b + npm run pb + + # Build x64 tar.gz + rm -rf dist + local builder="./node_modules/.bin/electron-builder" + write_install_src "linux-loong64.tar.gz" + + export WORKFLOW_NAME + $builder --linux tar.gz --x64 --publish=never + + # Find the built tar.gz + local tar_gz + tar_gz=$(find dist -name "*.tar.gz" -type f | head -1) + if [ -z "$tar_gz" ]; then + log_error "Could not find built tar.gz in dist/" + exit 1 + fi + log_info "x64 build complete: $tar_gz" + + # Extract asar and full app directory from tar.gz + mkdir -p "$WORK_DIR/x64-extract" + tar xzf "$tar_gz" -C "$WORK_DIR/x64-extract" + + local asar_file + asar_file=$(find "$WORK_DIR/x64-extract" -name "app.asar" -type f | head -1) + if [ -z "$asar_file" ]; then + log_error "Could not find app.asar in extracted tar.gz" + exit 1 + fi + cp "$asar_file" "$WORK_DIR/app.asar" + + local app_dir + app_dir=$(find "$WORK_DIR/x64-extract" -maxdepth 1 -name "electerm*" -type d | head -1) + if [ -n "$app_dir" ]; then + cp -r "$app_dir" "$WORK_DIR/x64-app" + fi + + # Restore original electron version + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); + pkg.devDependencies.electron = '${original_electron_version}'; + fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); + " + + rm -rf "$WORK_DIR/x64-extract" +} + +# ============================================================================ +# Step 2: Download electron loong64 +# ============================================================================ +download_electron_loong64() { + log_info "Step 2: Downloading electron v${ELECTRON_VERSION} for loong64..." + + mkdir -p "$WORK_DIR/electron-loong64" + local zip_file="$WORK_DIR/electron-loong64.zip" + + if [ ! -f "$zip_file" ]; then + curl -L -o "$zip_file" "$ELECTRON_LOONG64_URL" + fi + + unzip -q -o "$zip_file" -d "$WORK_DIR/electron-loong64" + + if [ ! -f "$WORK_DIR/electron-loong64/electron" ]; then + log_error "Could not find electron binary in extracted zip" + exit 1 + fi + + log_info "Electron loong64 downloaded and extracted." +} + +# ============================================================================ +# Step 3: Rebuild native modules for loong64 using cross-compilation +# ============================================================================ +rebuild_native_modules() { + if [ "$SKIP_NATIVE" = "1" ]; then + log_warn "Step 3: Skipping native module rebuild (SKIP_NATIVE=1)" + return 0 + fi + + if ! command -v loongarch64-linux-gnu-g++ &>/dev/null; then + log_warn "Step 3: loongarch64-linux-gnu-g++ not found, skipping native module rebuild" + log_warn "Install with: sudo apt-get install g++-loongarch64-linux-gnu" + return 0 + fi + + log_info "Step 3: Cross-compiling native modules for loong64..." + + local native_modules_dir="$WORK_DIR/native-modules-loong64" + mkdir -p "$native_modules_dir" + + local cross_build_dir="$WORK_DIR/cross-build" + rm -rf "$cross_build_dir" + mkdir -p "$cross_build_dir" + cd "$cross_build_dir" + + # Build node-pty + log_info "Building node-pty for loong64..." + mkdir -p build-node-pty && cd build-node-pty + npm init -y 2>/dev/null + npm_config_arch=loong64 npm_config_target_arch=loong64 CC=loongarch64-linux-gnu-gcc CXX=loongarch64-linux-gnu-g++ npm install node-pty@1.1.0-beta34 --build-from-source 2>&1 | tail -5 + find . -name "pty.node" -type f | while read f; do + cp "$f" "$native_modules_dir/node-pty.node" + done + cd "$cross_build_dir" + + # Build @serialport/bindings-cpp + log_info "Building @serialport/bindings-cpp for loong64..." + mkdir -p build-serialport && cd build-serialport + npm init -y 2>/dev/null + npm_config_arch=loong64 npm_config_target_arch=loong64 CC=loongarch64-linux-gnu-gcc CXX=loongarch64-linux-gnu-g++ npm install @serialport/bindings-cpp --build-from-source 2>&1 | tail -5 + find . -path "*/build/Release/bindings.node" -type f | while read f; do + cp "$f" "$native_modules_dir/serialport-bindings.node" + done + cd "$cross_build_dir" + + unset CC CXX AR RANLIB LINK GYP_DEFINES npm_config_arch npm_config_target_arch + + if [ -d "$native_modules_dir" ] && [ "$(ls -A "$native_modules_dir" 2>/dev/null)" ]; then + log_info "Native modules built successfully:" + ls -la "$native_modules_dir/" + else + log_warn "No native modules were built." + fi +} + +# ============================================================================ +# Step 4: Merge x64 asar with loong64 electron and native modules +# ============================================================================ +merge_loong64() { + log_info "Step 4: Merging x64 asar with loong64 electron..." + + local electerm_version + electerm_version=$(get_version) + + mkdir -p "$OUTPUT_DIR" + local output_name="electerm-${electerm_version}-linux-loong64" + local output_dir="$OUTPUT_DIR/$output_name" + rm -rf "$output_dir" + mkdir -p "$output_dir" + + # Start with x64 app directory + if [ -d "$WORK_DIR/x64-app" ]; then + cp -r "$WORK_DIR/x64-app"/* "$output_dir/" + fi + + # Replace x64 binary with loong64 electron + rm -f "$output_dir/electerm" + cp "$WORK_DIR/electron-loong64/electron" "$output_dir/electerm" + chmod +x "$output_dir/electerm" + + # Copy loong64 electron runtime files (libraries, locales, etc.) + for f in "$WORK_DIR/electron-loong64"/*; do + local base + base=$(basename "$f") + if [ "$base" = "electron" ]; then + continue + fi + if [ -d "$f" ] && [ "$base" != "locales" ]; then + continue + fi + if [ -d "$f" ]; then + cp -r "$f" "$output_dir/" + else + cp "$f" "$output_dir/" + fi + done + + # Replace native modules with loong64 versions + local native_modules_dir="$WORK_DIR/native-modules-loong64" + if [ -d "$native_modules_dir" ] && [ "$(ls -A "$native_modules_dir" 2>/dev/null)" ]; then + log_info "Replacing native modules with loong64 versions..." + if [ -f "$native_modules_dir/node-pty.node" ]; then + find "$output_dir" -path "*/node-pty/build/Release/*.node" -exec cp "$native_modules_dir/node-pty.node" {} \; 2>/dev/null || true + fi + if [ -f "$native_modules_dir/serialport-bindings.node" ]; then + find "$output_dir" -path "*@serialport/bindings-cpp*" -name "*.node" -exec cp "$native_modules_dir/serialport-bindings.node" {} \; 2>/dev/null || true + fi + if [ -f "$native_modules_dir/ssh2-crypto.node" ]; then + find "$output_dir" -path "*@electerm/ssh2*" -name "*.node" -exec cp "$native_modules_dir/ssh2-crypto.node" {} \; 2>/dev/null || true + fi + fi + + # Ensure asar is in place + if [ -f "$WORK_DIR/app.asar" ] && [ ! -f "$output_dir/resources/app.asar" ]; then + mkdir -p "$output_dir/resources" + cp "$WORK_DIR/app.asar" "$output_dir/resources/" + fi + + # Create tar.gz + log_info "Creating loong64 tar.gz..." + cd "$OUTPUT_DIR" + tar czf "${output_name}.tar.gz" "$output_name" + + log_info "Tar.gz complete: $OUTPUT_DIR/${output_name}.tar.gz" +} + +# ============================================================================ +# Step 5: Build deb package +# ============================================================================ +patch_asar_install_src() { + local asar_file="$1" + local new_src="$2" + + log_info "Patching asar install-src to '${new_src}'..." + + local asar_bin="$PROJECT_ROOT/node_modules/@electron/asar/bin/asar.js" + local tmp_dir + tmp_dir=$(mktemp -d) + + # Extract, patch, repack using CLI + node "$asar_bin" extract "$asar_file" "$tmp_dir" + echo "module.exports = '${new_src}'" > "$tmp_dir/lib/install-src.js" + node "$asar_bin" pack "$tmp_dir" "$asar_file" + + rm -rf "$tmp_dir" + log_info "Asar patched successfully." +} + +build_deb() { + local install_src="${1:-linux-loong64.deb}" + local deb_arch="${2:-loong64}" + local suffix="$deb_arch" + log_info "Step 5: Building ${suffix} deb package..." + + local electerm_version + electerm_version=$(get_version) + + local output_dir="$OUTPUT_DIR/electerm-${electerm_version}-linux-loong64" + local deb_build="$OUTPUT_DIR/deb-build-${suffix}" + local deb_name="electerm_${electerm_version}_loongarch64" + local deb_dir="$deb_build/$deb_name" + + if [ ! -d "$output_dir" ]; then + log_error "Output directory not found: $output_dir" + return 1 + fi + + # Patch asar to use deb install-src + local asar_file="$output_dir/resources/app.asar" + if [ -f "$asar_file" ]; then + patch_asar_install_src "$asar_file" "$install_src" + else + log_warn "app.asar not found at $asar_file, skipping install-src patch" + fi + + rm -rf "$deb_build" + mkdir -p "$deb_dir/DEBIAN" + mkdir -p "$deb_dir/opt/electerm" + mkdir -p "$deb_dir/usr/share/applications" + mkdir -p "$deb_dir/usr/share/icons/hicolor/128x128/apps" + + cp -r "$output_dir"/* "$deb_dir/opt/electerm/" + + # Install icon + local icon_src="$PROJECT_ROOT/node_modules/@electerm/electerm-resource/res/imgs/electerm-round-128x128.png" + if [ -f "$icon_src" ]; then + cp "$icon_src" "$deb_dir/usr/share/icons/hicolor/128x128/apps/electerm.png" + fi + + # Install desktop file + cat > "$deb_dir/usr/share/applications/electerm.desktop" <<'DESKTOP' +[Desktop Entry] +Name=electerm +Comment=Terminal/SSH/SFTP client +Exec=/opt/electerm/electerm %U +Icon=electerm +Terminal=false +Type=Application +Categories=Development;System;TerminalEmulator; +StartupWMClass=electerm +MimeType=x-scheme-handler/ssh;x-scheme-handler/telnet;x-scheme-handler/rdp;x-scheme-handler/vnc;x-scheme-handler/serial;x-scheme-handler/spice;x-scheme-handler/electerm; +DESKTOP + + cat > "$deb_dir/DEBIAN/control" < +Description: Open-sourced terminal/ssh/sftp/telnet/serialport/RDP/VNC/Spice/ftp client +CTRL + + cat > "$deb_dir/DEBIAN/postinst" <<'POSTINST' +#!/bin/bash +chown root:root /opt/electerm/chrome-sandbox +chmod 4755 /opt/electerm/chrome-sandbox +update-desktop-database /usr/share/applications/ 2>/dev/null || true +gtk-update-icon-cache /usr/share/icons/hicolor/ 2>/dev/null || true +POSTINST + chmod 755 "$deb_dir/DEBIAN/postinst" + + local deb_file="$OUTPUT_DIR/electerm-${electerm_version}-linux-${suffix}.deb" + if command -v fakeroot &>/dev/null; then + fakeroot dpkg-deb --build "$deb_dir" "$deb_file" + else + dpkg-deb --build "$deb_dir" "$deb_file" + fi + + if [ ! -f "$deb_file" ]; then + log_error "Deb build failed: $deb_file not created" + return 1 + fi + + log_info "Deb complete: $deb_file" + + rm -rf "$deb_build" +} + +# ============================================================================ +# Step 6: Upload to GitHub release draft +# ============================================================================ +upload_to_github() { + local file="$1" + local filename + filename=$(basename "$file") + + if [ ! -f "$file" ]; then + log_warn "Upload skipped: file not found: $file" + return 0 + fi + + if [ -z "${GH_TOKEN:-}" ]; then + log_warn "Upload skipped: GH_TOKEN not set" + return 0 + fi + + local electerm_version + electerm_version=$(get_version) + + local release_name="v${electerm_version}" + + log_info "Uploading $filename to GitHub release draft '${release_name}'..." + + if ! gh release view "$release_name" >/dev/null 2>&1; then + gh release create "$release_name" \ + --draft \ + --title "$release_name" \ + --generate-notes \ + "$file" + else + gh release upload "$release_name" "$file" --clobber + fi + + log_info "Uploaded $filename." +} + +# ============================================================================ +# Main +# ============================================================================ +main() { + log_info "Starting electerm loong64 build..." + log_info "Project root: $PROJECT_ROOT" + log_info "Work directory: $WORK_DIR" + log_info "Output directory: $OUTPUT_DIR" + + rm -rf "$WORK_DIR" + mkdir -p "$WORK_DIR" + + install_prerequisites + build_x64 + download_electron_loong64 + rebuild_native_modules + merge_loong64 + + local electerm_version + electerm_version=$(get_version) + + # Upload tar.gz + upload_to_github "$OUTPUT_DIR/electerm-${electerm_version}-linux-loong64.tar.gz" + + # Build and upload debs + build_deb "linux-loong64.deb" "loong64" + upload_to_github "$OUTPUT_DIR/electerm-${electerm_version}-linux-loong64.deb" + + build_deb "linux-loongarch64.deb" "loongarch64" + upload_to_github "$OUTPUT_DIR/electerm-${electerm_version}-linux-loongarch64.deb" + + log_info "==========================================" + log_info "Build complete!" + log_info " $OUTPUT_DIR/electerm-${electerm_version}-linux-loong64.tar.gz" + log_info " $OUTPUT_DIR/electerm-${electerm_version}-linux-loong64.deb" + log_info " $OUTPUT_DIR/electerm-${electerm_version}-linux-loongarch64.deb" + log_info "==========================================" +} + +main "$@" diff --git a/build/bin/build-linux-rpm-snap.js b/build/bin/build-linux-rpm-snap.js new file mode 100644 index 0000000..d493602 --- /dev/null +++ b/build/bin/build-linux-rpm-snap.js @@ -0,0 +1,29 @@ +const { echo, rm } = require('shelljs') +const { + run, + writeSrc, + uploadToR2, + builder: pb, + patchSnapClassicSandbox +} = require('./build-common') + +async function main () { + echo('running build for linux part 2') + + echo('build rpm') + rm('-rf', 'dist') + let src = 'linux-x86_64.rpm' + writeSrc(src) + await run(`${pb} --linux rpm`) + await uploadToR2(src) + + echo('build snap') + rm('-rf', 'dist') + src = 'linux-amd64.snap' + writeSrc(src) + patchSnapClassicSandbox() + await run(`${pb} --linux snap -p always`) + await uploadToR2(src) +} + +main() diff --git a/build/bin/build-mac-10.js b/build/bin/build-mac-10.js new file mode 100644 index 0000000..85281d5 --- /dev/null +++ b/build/bin/build-mac-10.js @@ -0,0 +1,41 @@ +const { rm, echo } = require('shelljs') +const replace = require('replace-in-file') +const { + run, + writeSrc, + uploadToR2, + builder +} = require('./build-common') + +const options = { + files: require('path').resolve(__dirname, '../../electron-builder.json'), + from: ['${productName}-${version}-${os}-${arch}.${ext}'], // eslint-disable-line + to: ['${productName}-${version}-${os}10-${arch}.${ext}'] // eslint-disable-line +} + +function replaceRun () { + return new Promise((resolve, reject) => { + replace(options, (err) => { + if (err) { + return reject(err) + } + console.log('start build mac10 file') + resolve() + }) + }) +} + +async function main () { + const pb = builder + echo('running build for mac10 (macOS 10.15 Catalina compatible)') + + echo('build dmg') + await replaceRun() + rm('-rf', 'dist') + const src = 'mac10-x64.dmg' + writeSrc(src) + await run(`${pb} --mac --x64`) + await uploadToR2(src) +} + +main() diff --git a/build/bin/build-mac-arm.js b/build/bin/build-mac-arm.js new file mode 100644 index 0000000..0e0bb9d --- /dev/null +++ b/build/bin/build-mac-arm.js @@ -0,0 +1,22 @@ +const { echo, rm } = require('shelljs') +const { + run, + writeSrc, + uploadToR2, + builder: pb, + reBuild +} = require('./build-common') + +async function main () { + echo('running build for mac arm') + + echo('build dmg') + const src = 'mac-arm64.dmg' + rm('-rf', 'dist') + writeSrc(src) + await run(`${reBuild} --arch arm64 -f work/app`) + await run(`${pb} --mac --arm64`) + await uploadToR2(src) +} + +main() diff --git a/build/bin/build-mac.js b/build/bin/build-mac.js new file mode 100644 index 0000000..4eaeff1 --- /dev/null +++ b/build/bin/build-mac.js @@ -0,0 +1,28 @@ +// const cmd = 'rm -rf dist && ' + +// 'echo "build x64 mac" && ' + +// './node_modules/.bin/electron-builder --mac --x64' +// 'echo "build arm64 mac" && ' + +// './node_modules/.bin/electron-rebuild --arch arm64 -f -p work/app && ' + +// './node_modules/.bin/electron-builder --mac --arm64' +// const cmd = './node_modules/.bin/electron-builder --mac' + +const { echo, rm } = require('shelljs') +const { + run, + writeSrc, + uploadToR2, + builder: pb +} = require('./build-common') + +async function main () { + echo('running build for mac') + + echo('build dmg') + const src = 'mac-x64.dmg' + rm('-rf', 'dist') + writeSrc(src) + await run(`${pb} --mac`) + await uploadToR2(src) +} + +main() diff --git a/build/bin/build-win-appx.js b/build/bin/build-win-appx.js new file mode 100644 index 0000000..74a884d --- /dev/null +++ b/build/bin/build-win-appx.js @@ -0,0 +1,25 @@ +const { rm, echo } = require('shelljs') +const { resolve } = require('path') +const fs = require('fs') +const { + run, + writeSrc, + uploadToR2, + builder +} = require('./build-common') + +async function main () { + const pb = builder + echo('build appx') + const p = resolve(__dirname, '../../electron-builder.json') + const txt = require('../../electron-builder.json') + delete txt.electronLanguages + fs.writeFileSync(p, JSON.stringify(txt, null, 2)) + rm('-rf', 'dist') + const src = 'win-x64.appx' + writeSrc(src) + await run(`${pb} --win appx`) + await uploadToR2(src) +} + +main() diff --git a/build/bin/build-win-arm.js b/build/bin/build-win-arm.js new file mode 100644 index 0000000..d8a5637 --- /dev/null +++ b/build/bin/build-win-arm.js @@ -0,0 +1,28 @@ +const { echo, rm } = require('shelljs') +const { + run, + writeSrc, + uploadToR2, + builder: pb, + reBuild, + replaceJSON +} = require('./build-common') + +async function main () { + echo('running build for Windows ARM64') + + echo('build tar.gz for Windows ARM64') + const src = 'win-arm64.tar.gz' + rm('-rf', 'dist') + writeSrc(src) + replaceJSON( + (data) => { + data.win.target = ['tar.gz'] + } + ) + await run(`${reBuild} --arch arm64 -f work/app`) + await run(`${pb} --win --arm64`) + await uploadToR2(src) +} + +main() diff --git a/build/bin/build-win-loose.js b/build/bin/build-win-loose.js new file mode 100644 index 0000000..9dc14e9 --- /dev/null +++ b/build/bin/build-win-loose.js @@ -0,0 +1,21 @@ +const { rm, echo } = require('shelljs') +const { + run, + writeSrc, + uploadToR2, + builder, + replaceRun +} = require('./build-common') + +async function main () { + const pb = builder + echo('build loose tar.gz') + await replaceRun() + rm('-rf', 'dist') + const src = 'win-x64-loose.tar.gz' + writeSrc(src) + await run(`${pb} --win tar.gz`) + await uploadToR2(src) +} + +main() diff --git a/build/bin/build-win-nsis-arm.js b/build/bin/build-win-nsis-arm.js new file mode 100644 index 0000000..cbfb84b --- /dev/null +++ b/build/bin/build-win-nsis-arm.js @@ -0,0 +1,32 @@ +const { echo, rm } = require('shelljs') +const { + run, + writeSrc, + uploadToR2, + builder: pb, + reBuild, + replaceJSON, + patchNsisKeepShortcuts +} = require('./build-common') + +async function main () { + echo('running build for Windows ARM64') + + patchNsisKeepShortcuts() + + echo('build tar.gz for Windows ARM64') + const src = 'win-arm64-installer.exe' + rm('-rf', 'dist') + writeSrc(src) + replaceJSON( + (data) => { + data.win.target = ['nsis'] + data.afterAllArtifactBuild = 'build/bin/clean-yml.js' + } + ) + await run(`${reBuild} --arch arm64 -f work/app`) + await run(`${pb} --win --arm64`) + await uploadToR2(src) +} + +main() diff --git a/build/bin/build-win-nsis.js b/build/bin/build-win-nsis.js new file mode 100644 index 0000000..e665ec7 --- /dev/null +++ b/build/bin/build-win-nsis.js @@ -0,0 +1,24 @@ +const { rm, echo } = require('shelljs') +const { + run, + writeSrc, + uploadToR2, + builder, + patchNsisKeepShortcuts +} = require('./build-common') + +async function main () { + const pb = builder + echo('running build for win part nsis installer') + + patchNsisKeepShortcuts() + + echo('build nsis') + const src = 'win-x64-installer.exe' + rm('-rf', 'dist') + writeSrc(src) + await run(`${pb} --win nsis`) + await uploadToR2(src) +} + +main() diff --git a/build/bin/build-win-portable.js b/build/bin/build-win-portable.js new file mode 100644 index 0000000..338dee6 --- /dev/null +++ b/build/bin/build-win-portable.js @@ -0,0 +1,40 @@ +const replace = require('replace-in-file') +const { rm, echo } = require('shelljs') +const { + run, + writeSrc, + uploadToR2, + builder +} = require('./build-common') +const options = { + files: require('path').resolve(__dirname, '../../electron-builder.json'), + from: ['${productName}-${version}-${os}-${arch}.${ext}', ', "appx", "nsis"'], // eslint-disable-line + to: ['${productName}-${version}-${os}-${arch}-portable.${ext}', ''] // eslint-disable-line +} + +function replaceRun () { + return new Promise((resolve, reject) => { + replace(options, (err) => { + if (err) { + return reject(err) + } + console.log('start build portable file') + resolve() + }) + }) +} + +async function main () { + const pb = builder + echo('running build for win part 3') + + echo('build portable tar.gz') + await replaceRun() + rm('-rf', 'dist') + const src = 'win-x64-portable.tar.gz' + writeSrc(src) + await run(`${pb} --win tar.gz`) + await uploadToR2(src) +} + +main() diff --git a/build/bin/build-win-tar.js b/build/bin/build-win-tar.js new file mode 100644 index 0000000..4e0b797 --- /dev/null +++ b/build/bin/build-win-tar.js @@ -0,0 +1,21 @@ +const { rm, echo } = require('shelljs') +const { + run, + writeSrc, + uploadToR2, + builder +} = require('./build-common') + +async function main () { + const pb = builder + echo('running build for win part 1') + + echo('build tar.gz') + rm('-rf', 'dist') + const src = 'win-x64.tar.gz' + writeSrc(src) + await run(`${pb} --win tar.gz`) + await uploadToR2(src) +} + +main() diff --git a/build/bin/build-win7-tar.js b/build/bin/build-win7-tar.js new file mode 100644 index 0000000..bdc8c62 --- /dev/null +++ b/build/bin/build-win7-tar.js @@ -0,0 +1,39 @@ +const { rm, echo } = require('shelljs') +const replace = require('replace-in-file') +const { + run, + writeSrc, + uploadToR2, + builder +} = require('./build-common') +const options = { + files: require('path').resolve(__dirname, '../../electron-builder.json'), + from: ['${productName}-${version}-${os}-${arch}.${ext}', ', "appx", "nsis"'], // eslint-disable-line + to: ['${productName}-${version}-${os}7.${ext}', ''] // eslint-disable-line +} +function replaceRun () { + return new Promise((resolve, reject) => { + replace(options, (err) => { + if (err) { + return reject(err) + } + console.log('start build win7 file') + resolve() + }) + }) +} + +async function main () { + const pb = builder + echo('running build for win7') + + echo('build tar.gz') + await replaceRun() + rm('-rf', 'dist') + const src = 'win7.tar.gz' + writeSrc(src) + await run(`${pb} --win tar.gz`) + await uploadToR2(src) +} + +main() diff --git a/build/bin/build.js b/build/bin/build.js new file mode 100644 index 0000000..28f708a --- /dev/null +++ b/build/bin/build.js @@ -0,0 +1,22 @@ +/** + * build + */ + +const { exec, echo } = require('shelljs') + +echo('start build') + +const timeStart = +new Date() + +// 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() +echo(`done build in ${(endTime - timeStart) / 1000} s`) diff --git a/build/bin/clean-empty-folders.js b/build/bin/clean-empty-folders.js new file mode 100644 index 0000000..1f94ebb --- /dev/null +++ b/build/bin/clean-empty-folders.js @@ -0,0 +1,83 @@ +#!/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-yml.js b/build/bin/clean-yml.js new file mode 100644 index 0000000..fecd8c2 --- /dev/null +++ b/build/bin/clean-yml.js @@ -0,0 +1,16 @@ +// delete-yml.js +const fs = require('fs') +const path = require('path') + +exports.default = async function (context) { + // context.outDir is the path to the build output directory + const ymlFiles = fs.readdirSync(context.outDir).filter(file => { + return path.extname(file) === '.yml' + }) + + ymlFiles.forEach(file => { + const filePath = path.join(context.outDir, file) + console.log(`Deleting auto-update file: ${filePath}`) + fs.unlinkSync(filePath) + }) +} diff --git a/build/bin/clean.js b/build/bin/clean.js new file mode 100644 index 0000000..0319c06 --- /dev/null +++ b/build/bin/clean.js @@ -0,0 +1,5 @@ +const { rm } = require('shelljs') + +rm('-rf', [ + 'work' +]) diff --git a/build/bin/copy.js b/build/bin/copy.js new file mode 100644 index 0000000..39fd6fb --- /dev/null +++ b/build/bin/copy.js @@ -0,0 +1,39 @@ +const { resolve } = require('path') +const { cp } = require('shelljs') +const from = resolve( + __dirname, + '../../node_modules/@electerm/electerm-resource/tray-icons/*' +) +const from0 = resolve( + __dirname, + '../../node_modules/electerm-icons/icons' +) +const to1 = resolve( + __dirname, + '../../work/app/assets/images/' +) +const to2 = resolve( + __dirname, + '../../work/app/assets/icons' +) +const arr = [ + { + from, + to: to1, + file: true + }, { + from: from0, + to: to2 + } +] + +for (const obj of arr) { + const { + file, from, to + } = obj + if (file) { + cp(from, to) + } else { + cp('-r', from, to) + } +} diff --git a/build/bin/linux-deb-after-build.sh b/build/bin/linux-deb-after-build.sh new file mode 100755 index 0000000..2a2b196 --- /dev/null +++ b/build/bin/linux-deb-after-build.sh @@ -0,0 +1,3 @@ +#!/bin/bash +chown root:root /opt/electerm/chrome-sandbox +chmod 4755 /opt/electerm/chrome-sandbox \ No newline at end of file diff --git a/build/bin/npm-publish b/build/bin/npm-publish new file mode 100755 index 0000000..407dd98 --- /dev/null +++ b/build/bin/npm-publish @@ -0,0 +1,10 @@ +#!/bin/bash +cd `dirname $0` +cd ../.. +git co master +git pull +git pull +git delete-branch test-npm +git create-branch test-npm +git push origin test-npm -u +git co - diff --git a/build/bin/post-install.js b/build/bin/post-install.js new file mode 100644 index 0000000..dcab002 --- /dev/null +++ b/build/bin/post-install.js @@ -0,0 +1,42 @@ +/** + * post install script + */ +const { cp, exec, rm } = require('shelljs') +const { existsSync } = require('fs') +const { resolve } = require('path') +const prePushPath = resolve(__dirname, '../../.git/hooks/pre-push') +const prePushPathFrom = resolve(__dirname, 'pre-push') +const os = require('os') + +const platform = os.platform() +const isWin = platform === 'win32' +// const rest = '' +if (isWin && process.env.CI) { + exec('npm cache clear -f') + exec('npm uninstall node-gyp -g') + exec('npm install node-gyp -g') +} + +// Remove optional native module that may fail to rebuild +try { + // Check multiple potential locations for cpu-features + const cpuFeaturesPaths = [ + resolve(__dirname, '../../node_modules/cpu-features'), + resolve(__dirname, '../../work/app/node_modules/cpu-features') + ] + + cpuFeaturesPaths.forEach(cpuFeaturesPath => { + if (existsSync(cpuFeaturesPath)) { + rm('-rf', cpuFeaturesPath) + console.log('Removed optional module:', cpuFeaturesPath) + } + }) +} catch (e) { + console.warn('Failed to remove cpu-features:', e?.message || e) +} + +exec(resolve('./node_modules/.bin/electron-rebuild')) + +if (!existsSync(prePushPath)) { + cp(prePushPathFrom, prePushPath) +} diff --git a/build/bin/postpublish.js b/build/bin/postpublish.js new file mode 100644 index 0000000..84c87d0 --- /dev/null +++ b/build/bin/postpublish.js @@ -0,0 +1,7 @@ +const { resolve } = require('path') +const { cp, rm } = require('shelljs') + +const from = resolve(__dirname, '../../package.json') +const to = resolve(__dirname, '../../package-bak.json') +cp(to, from) +rm(to) 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-electron-build.js b/build/bin/prepare-electron-build.js new file mode 100644 index 0000000..db42b02 --- /dev/null +++ b/build/bin/prepare-electron-build.js @@ -0,0 +1,3 @@ +const { cp } = require('shelljs') + +cp('-r', 'build/electron-builder.json', './') diff --git a/build/bin/prepare.js b/build/bin/prepare.js new file mode 100644 index 0000000..f436459 --- /dev/null +++ b/build/bin/prepare.js @@ -0,0 +1,111 @@ +/** + * 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/prepublish.js b/build/bin/prepublish.js new file mode 100644 index 0000000..ba27bf6 --- /dev/null +++ b/build/bin/prepublish.js @@ -0,0 +1,28 @@ +const savedPackage = [ + 'shelljs', + 'tar' +] +const pack = require('../../package.json') +const fs = require('fs') +const { resolve } = require('path') + +delete pack.devDependencies +pack.dependencies = savedPackage.reduce((prev, p) => { + prev[p] = '*' + return prev +}, {}) +pack.scripts = { + postinstall: 'node npm/install.js', + postpublish: 'node bin/postpublish.js' +} +delete pack.langugeRepo +delete pack.privacyNoticeLink +delete pack.knownIssuesLink +delete pack.sponsorLink +delete pack.releases +delete pack.standard +const from = resolve(__dirname, '../../package.json') +const to = resolve(__dirname, '../../package-bak.json') +fs.copyFileSync(from, to) +fs.unlinkSync(from) +fs.writeFileSync(from, JSON.stringify(pack, null, 2)) diff --git a/build/bin/publish-electerm-react.js b/build/bin/publish-electerm-react.js new file mode 100644 index 0000000..a01d204 --- /dev/null +++ b/build/bin/publish-electerm-react.js @@ -0,0 +1,16 @@ +const { rm, echo, cd, cp, exec } = require('shelljs') +const { resolve } = require('path') +const fs = require('fs') +const pack = require('../../package.json') +const tpack = require('../web-app/package.json') +tpack.version = pack.version + +const to = resolve(__dirname, '../web-app/') +const from = resolve(__dirname, '../../src/client') + +echo('start build electerm-react pack and publish') +fs.writeFileSync(resolve(to, 'package.json'), JSON.stringify(tpack, null, 2)) +rm('-rf', resolve(to, 'client')) +cp('-r', from, to) +cd(to) +exec(`npm-publish --token ${process.env.token} --access public`) diff --git a/build/bin/pug.js b/build/bin/pug.js new file mode 100644 index 0000000..6852f10 --- /dev/null +++ b/build/bin/pug.js @@ -0,0 +1,40 @@ +// build html +/** + * build common files with react module in it + */ +const fs = require('fs') +const pug = require('pug') +const { resolve } = require('path') +const pack = require('../../package.json') +const deepCopy = require('json-deep-copy') + +const entryPug = resolve( + __dirname, + '../../src/client/views/index.pug' +) +const targetFilePath = resolve( + __dirname, + '../../work/app/assets/index.html' +) +const pugContent = fs.readFileSync(entryPug, 'utf-8') +const defaultAIPreset = { + baseURLAI: 'https://ai.electerm.org/api/ai', + apiPathAI: '/chat/completions', + modelAI: 'mistral-small-latest', + authHeaderNameAI: 'Authorization: Bearer', + id: 'ai.electerm.org', + nameAI: 'ai.electerm.org(default free)' +} + +const data = { + version: pack.version, + siteName: pack.name, + isDev: false, + defaultAIPreset +} +const htmlContent = pug.render(pugContent, { + filename: entryPug, + ...data, + _global: deepCopy(data) +}) +fs.writeFileSync(targetFilePath, htmlContent, 'utf8') diff --git a/build/bin/release b/build/bin/release new file mode 100755 index 0000000..dfd9acf --- /dev/null +++ b/build/bin/release @@ -0,0 +1,10 @@ +#!/bin/bash +cd `dirname $0` +cd ../.. +git co master +git pull +git pull +git delete-branch build +git create-branch build +git push origin build -u +git co - diff --git a/build/bin/start.js b/build/bin/start.js new file mode 100644 index 0000000..b49311b --- /dev/null +++ b/build/bin/start.js @@ -0,0 +1,6 @@ +#!/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/upload-file-to-r2.js b/build/bin/upload-file-to-r2.js new file mode 100644 index 0000000..6389d1a --- /dev/null +++ b/build/bin/upload-file-to-r2.js @@ -0,0 +1,53 @@ +const fs = require('fs') +const path = require('path') + +async function main () { + const filePath = process.argv[2] + const key = process.argv[3] || path.basename(filePath || '') + + if (!filePath) { + throw new Error('Usage: node build/bin/upload-file-to-r2.js [key]') + } + + if (!fs.existsSync(filePath)) { + throw new Error(`File does not exist: ${filePath}`) + } + + const accountId = process.env.CF_R2_ACCOUNT_ID + const bucket = process.env.CF_R2_BUCKET + const accessKeyId = process.env.CF_R2_ACCESS_KEY_ID + const secretAccessKey = process.env.CF_R2_SECRET_ACCESS_KEY + + if (!accountId || !bucket || !accessKeyId || !secretAccessKey) { + throw new Error('Missing required env vars: CF_R2_ACCOUNT_ID, CF_R2_BUCKET, CF_R2_ACCESS_KEY_ID, CF_R2_SECRET_ACCESS_KEY') + } + + const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3') + + const client = new S3Client({ + region: 'auto', + endpoint: `https://${accountId}.r2.cloudflarestorage.com`, + credentials: { + accessKeyId, + secretAccessKey + } + }) + + const fileStream = fs.createReadStream(filePath) + const command = new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: fileStream, + ContentLength: fs.statSync(filePath).size, + ContentType: 'text/plain; charset=utf-8', + CacheControl: 'no-store' + }) + + await client.send(command) + console.log(`[r2] Uploaded ${filePath} to ${bucket}/${key}`) +} + +main().catch(err => { + console.error(err) + process.exit(1) +}) diff --git a/build/bin/vite-build.js b/build/bin/vite-build.js new file mode 100755 index 0000000..fefd460 --- /dev/null +++ b/build/bin/vite-build.js @@ -0,0 +1,7 @@ +#!/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/bin/web-release b/build/bin/web-release new file mode 100755 index 0000000..e0c9810 --- /dev/null +++ b/build/bin/web-release @@ -0,0 +1,10 @@ +#!/bin/bash +cd `dirname $0` +cd ../.. +git co master +git pull +git pull +git delete-branch electerm-react +git create-branch electerm-react +git push origin electerm-react -u +git co - diff --git a/build/electron-builder.json b/build/electron-builder.json new file mode 100644 index 0000000..026b4bb --- /dev/null +++ b/build/electron-builder.json @@ -0,0 +1,137 @@ +{ + "appId": "org.electerm.electerm", + "directories": { + "app": "work/app", + "output": "dist", + "buildResources": "node_modules/@electerm/electerm-resource/build-res" + }, + "electronLanguages": [ + "en-US" + ], + "asar": true, + "artifactName": "${productName}-${version}-${os}-${arch}.${ext}", + "win": { + "target": [ + "tar.gz", + "appx", + "nsis" + ], + "icon": "node_modules/@electerm/electerm-resource/build/icons-win.ico", + "publish": { + "provider": "github", + "channel": "${env.WORKFLOW_NAME}" + } + }, + "protocols": [ + { + "name": "SSH Protocol", + "schemes": [ + "ssh" + ] + }, + { + "name": "Telnet Protocol", + "schemes": [ + "telnet" + ] + }, + { + "name": "RDP Protocol", + "schemes": [ + "rdp" + ] + }, + { + "name": "VNC Protocol", + "schemes": [ + "vnc" + ] + }, + { + "name": "Serial Protocol", + "schemes": [ + "serial" + ] + }, + { + "name": "Spice Protocol", + "schemes": [ + "spice" + ] + }, + { + "name": "Electerm Protocol", + "schemes": [ + "electerm" + ] + } + ], + "appx": { + "applicationId": "electerm", + "identityName": "64159ZHAOXudong.electerm", + "publisher": "CN=9F481654-170A-434C-8259-984D2EB1FD7F", + "publisherDisplayName": "ZHAO Xudong" + }, + "nsis": { + "oneClick": false, + "artifactName": "${productName}-${version}-${os}-${arch}-installer.${ext}", + "allowToChangeInstallationDirectory": true + }, + "mac": { + "category": "public.app-category.developer-tools", + "target": "dmg", + "icon": "node_modules/@electerm/electerm-resource/build/icons.icns", + "publish": { + "provider": "github", + "channel": "${env.WORKFLOW_NAME}" + }, + "hardenedRuntime": true, + "gatekeeperAssess": false, + "entitlements": "./build/entitlements.plist", + "entitlementsInherit": "./build/entitlements.plist", + "notarize": true + }, + "dmg": { + "contents": [ + { + "x": 110, + "y": 190 + }, + { + "x": 440, + "y": 190, + "type": "link", + "path": "/Applications" + } + ] + }, + "linux": { + "category": "Development;System;TerminalEmulator", + "icon": "node_modules/@electerm/electerm-resource/res/imgs", + "target": [ + "rpm", + "deb", + "tar.gz", + "snap" + ], + "publish": { + "provider": "github", + "channel": "${env.WORKFLOW_NAME}" + }, + "desktop": { + "entry": { + "StartupWMClass": "electerm" + } + } + }, + "snap": { + "confinement": "classic", + "publish": { + "provider": "github", + "channel": "${env.WORKFLOW_NAME}" + } + }, + "deb": { + "afterInstall": "build/bin/linux-deb-after-build.sh" + } +} diff --git a/build/entitlements.plist b/build/entitlements.plist new file mode 100644 index 0000000..8b23942 --- /dev/null +++ b/build/entitlements.plist @@ -0,0 +1,10 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-dyld-environment-variables + + + diff --git a/build/vite/.sample.env b/build/vite/.sample.env new file mode 100644 index 0000000..006af8a --- /dev/null +++ b/build/vite/.sample.env @@ -0,0 +1,5 @@ +# 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 new file mode 100644 index 0000000..79ae145 --- /dev/null +++ b/build/vite/common.js @@ -0,0 +1,31 @@ +import { config as conf } from 'dotenv' +import { readFileSync } from 'fs' +import { resolve } from 'path' + +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 pack = JSON.parse(readFileSync(packPath).toString()) +export const version = pack.version +export const viewPath = resolve(cwd, '../../src/client/views') +export const staticPaths = [ + { + dir: resolve(cwd, '../../node_modules/electerm-icons/icons'), + path: '/icons' + }, + { + dir: resolve(cwd, '../../node_modules/@electerm/electerm-resource/tray-icons'), + path: '/images' + }, + { + dir: resolve(cwd, '../../node_modules/@electerm/electerm-resource/res/imgs'), + path: '/images' + }, + { + dir: resolve(cwd, '../../src/client/entry'), + path: '/' + } +] diff --git a/build/vite/conf.js b/build/vite/conf.js new file mode 100644 index 0000000..6cce64d --- /dev/null +++ b/build/vite/conf.js @@ -0,0 +1,87 @@ +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 + } + } +} + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [ + react({ include: /\.(mdx|js|jsx|ts|tsx|mjs)$/ }), + replaceWebAppPlugin() + ], + resolve: { + alias: { + 'ironrdp-wasm': resolve(cwd, '../../node_modules/ironrdp-wasm/pkg/rdp_client.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') + } + }, + optimizeDeps: { + exclude: ['ironrdp-wasm'] + }, + define: def, + publicDir: false, + legacy: { + inconsistentCjsInterop: true + }, + root: resolve(cwd, '../..'), + build: { + target: 'esnext', + cssCodeSplit: false, + codeSplitting: false, + emptyOutDir: false, + outDir: resolve(cwd, '../../work/app/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)) { + return `images/${name}` + } else if (name && name.endsWith('.css')) { + return `css/style-${version}[extname]` + } else { + return 'assets/[name]-[hash][extname]' + } + } + } + } + } +}) diff --git a/build/vite/def.js b/build/vite/def.js new file mode 100644 index 0000000..9be6654 --- /dev/null +++ b/build/vite/def.js @@ -0,0 +1,6 @@ +import { version } from './common.js' + +export default { + 'process.env.VER': JSON.stringify(version), + __DEFINES__: JSON.stringify('some value') +} diff --git a/build/vite/dev-server.js b/build/vite/dev-server.js new file mode 100644 index 0000000..784fd1d --- /dev/null +++ b/build/vite/dev-server.js @@ -0,0 +1,150 @@ +import logger from 'morgan' +import { viewPath, env, staticPaths, pack, isProd, cwd } from './common.js' +import express from 'express' +import { createServer as createViteServer } from 'vite' +import conf from './conf.js' +import copy from 'json-deep-copy' +import fs from 'fs' +import path from 'path' +import { spawn } from 'child_process' +import multer from 'multer' + +const devPort = env.DEV_PORT || 5570 +const host = env.DEV_HOST || '127.0.0.1' +const h = `http://${host}:${devPort}` +const defaultAIPreset = { + baseURLAI: 'https://ai.electerm.org/api/ai', + apiPathAI: '/chat/completions', + modelAI: 'mistral-small-latest', + authHeaderNameAI: 'Authorization: Bearer', + id: 'ai.electerm.org', + nameAI: 'ai.electerm.org(default free)' +} + +const base = { + version: pack.version, + isDev: !isProd, + siteName: pack.name, + defaultAIPreset +} + +function handleIndex (req, res) { + const view = 'index' + res.render(view, { + ...base, + _global: copy(base) + }) +} + +function redirect (req, res) { + const { + name + } = req.params + const mapper = { + electerm: '/src/client/entry/electerm.jsx', + worker: '/src/client/entry/worker.js' + } + res.redirect(mapper[name]) +} + +async function createServer () { + const app = express() + + // Create Vite server in middleware mode and configure the app type as + // 'custom', disabling Vite's own HTML serving logic so parent server + // can take control + const vite = await createViteServer({ + ...conf, + server: { + middlewareMode: true, + hmr: { + port: 30085, + overlay: true + } + }, + appType: 'custom' + }) + + app.use( + logger('dev') + ) + app.use(express.json()) + app.use(express.urlencoded({ + extended: true + })) + staticPaths.forEach(({ path, dir }) => { + app.use( + path, + express.static(dir, { maxAge: '170d' }) + ) + }) + + 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') + + // Use vite's connect instance as middleware. If you use your own + // express router (express.Router()), you should use router.use + app.use(vite.middlewares) + app.get(['/', '/index.html'], handleIndex) + app.get('/:dir/:name.:ext', redirect) + app.listen(devPort, host, () => { + console.log('cwd:', cwd) + console.log(`server started at ${h}`) + }) +} + +createServer() diff --git a/build/vite/diagnostics-channel-stub.js b/build/vite/diagnostics-channel-stub.js new file mode 100644 index 0000000..c09ab70 --- /dev/null +++ b/build/vite/diagnostics-channel-stub.js @@ -0,0 +1,55 @@ +// Browser-safe stub for Node's `node:diagnostics_channel` module. +// +// Why this exists: +// @xterm/addon-ligatures (beta line) bundles lru-cache@11, which imports +// `channel`/`tracingChannel` from `node:diagnostics_channel` and calls them at +// module-load time (for optional metrics). In the Electron renderer / Vite dev +// server this is a browser context: Vite stubs Node builtins with a +// `browser-external` shim that throws on use, so `channel()` is undefined and +// the addon crashes on import. +// +// lru-cache only publishes metrics when `hasSubscribers` is true, which never +// happens here (we never subscribe). So all of these can be silent no-ops. + +function makeChannel () { + return { + publish () {}, + subscribe () { + return () => {} + }, + unsubscribe () {}, + bindStore (store) { + return store + }, + unbindStore () {}, + hasSubscribers: false + } +} + +export function channel () { + return makeChannel() +} + +export function tracingChannel () { + const ch = makeChannel() + return { + start: ch, + end: ch, + asyncStart: ch, + asyncEnd: ch, + error: ch, + trace (fn) { + return fn() + } + } +} + +export function hasSubscribers () { + return false +} + +export default { + channel, + tracingChannel, + hasSubscribers +} diff --git a/build/vite/package.json b/build/vite/package.json new file mode 100644 index 0000000..e7b6715 --- /dev/null +++ b/build/vite/package.json @@ -0,0 +1,11 @@ +{ + "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-app/.npmignore b/build/web-app/.npmignore new file mode 100644 index 0000000..24bcafe --- /dev/null +++ b/build/web-app/.npmignore @@ -0,0 +1,2 @@ +/client/views +/client/entry \ No newline at end of file diff --git a/build/web-app/LICENSE b/build/web-app/LICENSE new file mode 100644 index 0000000..1ada745 --- /dev/null +++ b/build/web-app/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) since 2017~ ZHAO Xudong + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/build/web-app/README.md b/build/web-app/README.md new file mode 100644 index 0000000..7a72be3 --- /dev/null +++ b/build/web-app/README.md @@ -0,0 +1,31 @@ +

+ + + +

+ +# electerm [![Tweet](https://img.shields.io/twitter/url/http/shields.io.svg?style=social)](https://twitter.com/intent/tweet?text=Open%20sourced%20terminal%2Fssh%2Fsftp%20client(linux%2C%20mac%2C%20win)&url=https%3A%2F%2Fgithub.com%2Felecterm%2Felecterm&hashtags=electerm,ssh,terminal,sftp) + +[![GitHub version](https://img.shields.io/github/release/electerm/electerm/all.svg)](https://github.com/electerm/electerm/releases) +[![Build Status](https://github.com/electerm/electerm/actions/workflows/mac-test-1.yml/badge.svg)](https://github.com/electerm/electerm/actions) +[![license](https://img.shields.io/github/license/electerm/electerm.svg)](https://github.com/electerm/electerm/blob/master/LICENSE) +[![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com) +[![Get it from the Snap Store](https://img.shields.io/badge/Snap-Store-green)](https://snapcraft.io/electerm) +[![Get it from the Microsoft Store](https://img.shields.io/badge/Microsoft-Store-blue)](https://www.microsoft.com/store/apps/9NCN7272GTFF) +[![GitHub Sponsors](https://img.shields.io/github/sponsors/electerm?label=Sponsors)](https://github.com/sponsors/electerm) + +Open-sourced terminal/ssh/telnet/serialport/sftp client(linux, mac, win). + +This is react components src for electerm + +## Use + +todo + +## Change log + +Visit [Releases](https://github.com/electerm/electerm/releases). + +## License + +MIT diff --git a/build/web-app/package.json b/build/web-app/package.json new file mode 100644 index 0000000..32f4c94 --- /dev/null +++ b/build/web-app/package.json @@ -0,0 +1,33 @@ +{ + "name": "@electerm/electerm-react", + "version": "1.34.30", + "description": "react components src for electerm", + "main": "./client/components/main/main.jsx", + "license": "MIT", + "langugeRepo": "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.html", + "repository": { + "type": "git", + "url": "git+https://github.com/electerm/electerm.git" + }, + "author": { + "name": "ZHAO Xudong", + "email": "zxdong@gmail.com", + "url": "https://github.com/zxdong262" + }, + "bugs": { + "url": "https://github.com/electerm/electerm/issues" + }, + "homepage": "https://electerm.org", + "releases": "https://github.com/electerm/electerm/releases", + "engines": { + "node": ">=18.0.0" + }, + "files": [ + "client", + "README.md", + "LICENSE" + ] +} diff --git a/npm/electerm b/npm/electerm new file mode 100755 index 0000000..5f1239d --- /dev/null +++ b/npm/electerm @@ -0,0 +1,93 @@ +#!/usr/bin/env node +/** + * electerm CLI launcher (cross-platform) + * After npm i -g electerm, the postinstall script downloads and installs the binary. + * This script simply finds and launches the installed binary. + * + * Binary locations: + * macOS: /Applications/electerm.app/Contents/MacOS/electerm + * Windows: /electerm/electerm.exe + * Linux: /electerm/electerm + */ + +const path = require('path') +const fs = require('fs') +const { spawn } = require('child_process') +const os = require('os') + +const plat = os.platform() +const packageRoot = path.resolve(__dirname, '..') + +function getElectermExePath () { + if (plat === 'darwin') { + const appBinary = '/Applications/electerm.app/Contents/MacOS/electerm' + if (fs.existsSync(appBinary)) { + return appBinary + } + return path.join(packageRoot, 'electerm', 'electerm') + } + + if (plat === 'win32') { + return path.join(packageRoot, 'electerm', 'electerm.exe') + } + + return path.join(packageRoot, 'electerm', 'electerm') +} + +function isSandboxReady () { + // chrome-sandbox must be owned by root (uid 0) and have setuid bit set (mode 4755) + // This requires root during install, which npm global install does not provide. + try { + const sandboxPath = path.join(packageRoot, 'electerm', 'chrome-sandbox') + if (!fs.existsSync(sandboxPath)) return false + const stat = fs.statSync(sandboxPath) + const hasSetuid = (stat.mode & 0o4000) !== 0 + const ownedByRoot = stat.uid === 0 + return hasSetuid && ownedByRoot + } catch (e) { + return false + } +} + +function launchElecterm () { + const exePath = getElectermExePath() + + if (!fs.existsSync(exePath)) { + console.error('electerm binary not found at:', exePath) + console.error('') + console.error('The binary may not have been installed properly.') + console.error('Try running manually:') + console.error(' node', path.join(packageRoot, 'npm', 'install.js')) + process.exit(1) + } + + const extraArgs = [] + if (plat === 'linux' && !isSandboxReady()) { + extraArgs.push('--no-sandbox') + } + + const child = spawn(exePath, [...extraArgs, ...process.argv.slice(2)], { + stdio: 'inherit', + detached: plat !== 'win32', + windowsHide: false + }) + + if (plat !== 'win32') { + child.unref() + } + + child.on('error', (err) => { + console.error('Failed to start electerm:', err.message) + process.exit(1) + }) + + child.on('exit', (code) => { + process.exit(code || 0) + }) +} + +if (require.main === module) { + launchElecterm() +} + +module.exports = { launchElecterm } diff --git a/npm/install.js b/npm/install.js new file mode 100644 index 0000000..b38ba9f --- /dev/null +++ b/npm/install.js @@ -0,0 +1,558 @@ +/** + * install electerm from binary + * After npm i -g electerm, running `electerm` command will: + * 1. Download the appropriate binary for the platform + * 2. Extract it to the package directory (electerm/) + * 3. The bash script (npm/electerm) then launches the extracted binary + * + * This script only downloads and extracts. Launching is handled by the bash script. + */ + +const os = require('os') +const { resolve, join } = require('path') +const { execSync, rm, mv } = require('shelljs') +const { execFile } = require('child_process') +const fs = require('fs') +const { phin, download, extractTarGz, GITHUB_PROXY, applyProxy } = require('./utils') + +const plat = os.platform() +const arch = os.arch() +const { homepage } = require('../package.json') + +const releaseInfoUrl = `${homepage}/data/electerm-github-release.json?_=${+new Date()}` +const versionUrl = `${homepage}/version.html?_=${+new Date()}` + +// Directory where electerm package is installed +const packageRoot = resolve(__dirname, '..') +// Directory where the extracted binary will live +const extractDir = join(packageRoot, 'electerm') + +// --------------------------------------------------------------------------- +// Security helpers +// --------------------------------------------------------------------------- + +function sanitizeVersion (ver) { + const clean = String(ver).trim().replace(/^v/, '') + if (!/^\d+\.\d+\.\d+$/.test(clean)) { + throw new Error( + `Refusing to continue: remote version string failed validation: "${ver}"` + ) + } + return clean +} + +function sanitizeFilename (name) { + const clean = String(name).trim() + if (!/^[\w.-]+$/.test(clean)) { + throw new Error( + `Refusing to continue: remote filename failed validation: "${name}"` + ) + } + return clean +} + +// --------------------------------------------------------------------------- +// Core helpers +// --------------------------------------------------------------------------- + +function getVer () { + return phin({ + url: versionUrl, + timeout: 15000 + }) + .then(res => res.body.toString()) +} + +function getReleaseInfo (filter) { + return phin({ + url: releaseInfoUrl, + timeout: 15000 + }) + .then((res) => { + return JSON.parse(res.body.toString()) + .release + .assets + .filter(filter)[0] + }) +} + +function showFinalMessage () { + console.log('') + console.log('========================================') + console.log('electerm installation complete!') + console.log('========================================') + console.log('') + console.log('For more information, documentation, and updates, please visit:') + console.log('https://electerm.org') + console.log('') + console.log('Thank you for using electerm!') + console.log('========================================') + console.log('') +} + +// --------------------------------------------------------------------------- +// Platform detection helpers +// --------------------------------------------------------------------------- + +function isWindows7OrEarlier (platform, release) { + if (platform !== 'win32') return false + const [major, minor] = release.split('.').map(Number) + return major < 10 && (major < 6 || (major === 6 && minor <= 1)) +} + +function isMacOS10 (platform, release) { + if (platform !== 'darwin') return false + const majorVersion = parseInt(release.split('.')[0], 10) + return majorVersion < 20 +} + +function isLinuxLegacy (platform) { + if (platform !== 'linux') return false + try { + const result = execSync('ldd --version 2>&1 | head -n1', { encoding: 'utf8' }) + const match = result.match(/(\d+\.\d+)\s*$/) + if (match) { + return parseFloat(match[1]) < 2.34 + } + return false + } catch (e) { + return false + } +} + +// --------------------------------------------------------------------------- +// Launch the extracted binary +// --------------------------------------------------------------------------- + +/** + * Get the path to the extracted electerm executable + */ +function getElectermExePath () { + if (plat === 'win32') { + return join(extractDir, 'electerm.exe') + } + // Linux and macOS (if extracted) + return join(extractDir, 'electerm') +} + +/** + * Check if the electerm binary has been extracted already + */ +function isElectermExtracted () { + const exePath = getElectermExePath() + return fs.existsSync(exePath) +} + +// --------------------------------------------------------------------------- +// Platform installers +// --------------------------------------------------------------------------- + +async function runLinux (folderName, filePattern) { + const rawVer = await getVer() + const ver = sanitizeVersion(rawVer) + + console.log(` Version: ${ver}`) + console.log(` Target: ${folderName}`) + + const target = join(packageRoot, `electerm-${ver}-${folderName}`) + + // Clean up old installations + rm('-rf', [target, extractDir]) + + console.log(' Fetching release info...') + const releaseInfo = await getReleaseInfo(r => r.name.includes(filePattern)) + if (!releaseInfo) { + throw new Error(`No release found for pattern: ${filePattern}`) + } + + // Download without extracting to packageRoot directly + // We'll extract to a temp location first + const tmpDir = join(packageRoot, '.electerm-tmp') + rm('-rf', tmpDir) + fs.mkdirSync(tmpDir, { recursive: true }) + + const proxyUrl = applyProxy(releaseInfo.browser_download_url) + console.log(` URL: ${proxyUrl}`) + + const { filepath } = await download(releaseInfo.browser_download_url, tmpDir, { extract: false, displayName: releaseInfo.name }) + + // Extract to tmpDir (keeps top-level folder name) + await extractTarGz(filepath, tmpDir) + + // Find the extracted folder (should be the only directory) + const entries = fs.readdirSync(tmpDir) + const extractedFolder = entries.find(e => fs.statSync(join(tmpDir, e)).isDirectory()) + + if (!extractedFolder) { + throw new Error('No folder found in extracted archive') + } + + // Move to extractDir + console.log(` Installing to: ${extractDir}`) + mv(join(tmpDir, extractedFolder), extractDir) + + // Fix chrome-sandbox permissions on Linux (Electron requires specific permissions) + // Note: setting the setuid bit requires root ownership, which npm install cannot provide. + // The launcher handles this by passing --no-sandbox when the sandbox is not root-owned. + if (plat === 'linux') { + const chromeSandboxPath = join(extractDir, 'chrome-sandbox') + if (fs.existsSync(chromeSandboxPath)) { + console.log(' Note: To enable the Electron sandbox, run:') + console.log(` sudo chown root:root "${chromeSandboxPath}"`) + console.log(` sudo chmod 4755 "${chromeSandboxPath}"`) + console.log(' Otherwise, electerm will launch with --no-sandbox automatically.') + } + } + + // Clean up temp files + rm('-rf', tmpDir) + + showFinalMessage() +} + +async function runWin (archName) { + const rawVer = await getVer() + const ver = sanitizeVersion(rawVer) + + console.log(` Version: ${ver}`) + console.log(` Target: win-${archName}`) + + const target = join(packageRoot, `electerm-${ver}-win-${archName}`) + + rm('-rf', [target, extractDir]) + fs.mkdirSync(extractDir, { recursive: true }) + + const pattern = new RegExp(`electerm-\\d+\\.\\d+\\.\\d+-win-${archName}\\.tar\\.gz$`) + console.log(' Fetching release info...') + const releaseInfo = await getReleaseInfo(r => pattern.test(r.name)) + if (!releaseInfo) { + throw new Error(`No release found for Windows ${archName}`) + } + + // Download to a temp file, then extract directly to extractDir with strip:1 + // (avoids a rename which can fail on Windows when AV has file locks) + const tmpDir = join(packageRoot, '.electerm-tmp') + rm('-rf', tmpDir) + fs.mkdirSync(tmpDir, { recursive: true }) + + const proxyUrl = applyProxy(releaseInfo.browser_download_url) + console.log(` URL: ${proxyUrl}`) + + const { filepath } = await download(releaseInfo.browser_download_url, tmpDir, { extract: false, displayName: releaseInfo.name }) + + console.log(` Installing to: ${extractDir}`) + await extractTarGz(filepath, extractDir, 1) + + rm('-rf', tmpDir) + + const exePath = getElectermExePath() + if (!fs.existsSync(exePath)) { + throw new Error(`electerm.exe not found at ${exePath} after extraction. Archive may have an unexpected structure.`) + } + + showFinalMessage() +} + +async function runWin7 () { + const rawVer = await getVer() + const ver = sanitizeVersion(rawVer) + + console.log(` Version: ${ver}`) + console.log(' Target: win7') + + const target = join(packageRoot, `electerm-${ver}-win7`) + + rm('-rf', [target, extractDir]) + fs.mkdirSync(extractDir, { recursive: true }) + + console.log(' Fetching release info...') + const releaseInfo = await getReleaseInfo(r => /electerm-\d+\.\d+\.\d+-win7\.tar\.gz$/.test(r.name)) + if (!releaseInfo) { + throw new Error('No release found for Windows 7') + } + + const tmpDir = join(packageRoot, '.electerm-tmp') + rm('-rf', tmpDir) + fs.mkdirSync(tmpDir, { recursive: true }) + + const proxyUrl = applyProxy(releaseInfo.browser_download_url) + console.log(` URL: ${proxyUrl}`) + + const { filepath } = await download(releaseInfo.browser_download_url, tmpDir, { extract: false, displayName: releaseInfo.name }) + + console.log(` Installing to: ${extractDir}`) + await extractTarGz(filepath, extractDir, 1) + + rm('-rf', tmpDir) + + const exePath = getElectermExePath() + if (!fs.existsSync(exePath)) { + throw new Error(`electerm.exe not found at ${exePath} after extraction. Archive may have an unexpected structure.`) + } + + showFinalMessage() +} + +/** + * Mount a DMG, copy the .app to /Applications, then detach + * @param {string} dmgPath - Path to the DMG file + * @returns {Promise} - Path to the installed app + */ +function installFromDmg (dmgPath) { + return new Promise((resolve, reject) => { + // Step 1: Mount the DMG + console.log(' Mounting DMG...') + execFile('hdiutil', ['attach', dmgPath, '-nobrowse', '-readonly'], (err, stdout) => { + if (err) { + reject(new Error(`Failed to mount DMG: ${err.message}`)) + return + } + + // Parse mount point + const mountMatch = stdout.match(/(\/Volumes\/[^\n]+)/) + if (!mountMatch) { + reject(new Error('Could not find mount point')) + return + } + + const mountPoint = mountMatch[1].trim() + console.log(` Mounted at: ${mountPoint}`) + + // Step 2: Find the .app bundle + try { + const entries = fs.readdirSync(mountPoint) + const appFile = entries.find(e => e.endsWith('.app')) + + if (!appFile) { + // Try to detach before rejecting + execFileSyncIgnore('hdiutil', ['detach', mountPoint]) + reject(new Error('No .app bundle found in DMG')) + return + } + + const appSource = join(mountPoint, appFile) + const appDest = `/Applications/${appFile}` + + // Check if app already exists + if (fs.existsSync(appDest)) { + console.log(` Existing app found at ${appDest}, replacing...`) + // Remove existing app + rm('-rf', appDest) + } + + // Step 3: Copy the app to /Applications + console.log(` Installing ${appFile} to /Applications...`) + execFile('cp', ['-R', appSource, appDest], (cpErr) => { + // Step 4: Detach the DMG (always, regardless of copy result) + console.log(' Detaching DMG...') + execFile('hdiutil', ['detach', mountPoint], (detachErr) => { + if (detachErr) { + console.log(' Warning: Failed to detach DMG:', detachErr.message) + } else { + console.log(' DMG detached') + } + + if (cpErr) { + reject(new Error(`Failed to copy app: ${cpErr.message}`)) + return + } + + console.log(` App installed to: ${appDest}`) + resolve(appDest) + }) + }) + } catch (e) { + // Try to detach before rejecting + execFileSyncIgnore('hdiutil', ['detach', mountPoint]) + reject(e) + } + }) + }) +} + +/** + * Execute a file synchronously, ignoring errors + */ +function execFileSyncIgnore (cmd, args) { + try { + execSync(cmd, args, { stdio: 'ignore' }) + } catch (e) { + // Ignore + } +} + +async function runMac (archName) { + const pattern = new RegExp(`mac-${archName}\\.dmg$`) + console.log(' Fetching release info...') + const releaseInfo = await getReleaseInfo(r => pattern.test(r.name)) + if (!releaseInfo) { + throw new Error(`No release found for Mac ${archName}`) + } + + const safeName = sanitizeFilename(releaseInfo.name) + const proxyUrl = applyProxy(releaseInfo.browser_download_url) + console.log(` URL: ${proxyUrl}`) + + await download(releaseInfo.browser_download_url, packageRoot, { extract: false, displayName: releaseInfo.name }) + + const dmgPath = join(packageRoot, safeName) + showFinalMessage() + + // Install from DMG automatically + try { + await installFromDmg(dmgPath) + + // Clean up DMG + try { + fs.unlinkSync(dmgPath) + console.log(' Cleaned up DMG file') + } catch (e) { + // Ignore cleanup errors + } + + console.log('') + console.log(' Installation complete! You can now launch electerm from /Applications') + } catch (err) { + console.error('') + console.error(' Warning: Automatic installation failed:', err.message) + console.error(' Please manually copy the app from the DMG to /Applications') + console.error('') + console.log(' Opening DMG for manual installation...') + execFile('open', [dmgPath]) + } +} + +async function runMac10 () { + console.log(' Fetching release info...') + const releaseInfo = await getReleaseInfo(r => /mac10-x64\.dmg$/.test(r.name)) + if (!releaseInfo) { + throw new Error('No release found for macOS 10.x') + } + + const safeName = sanitizeFilename(releaseInfo.name) + const proxyUrl = applyProxy(releaseInfo.browser_download_url) + console.log(` URL: ${proxyUrl}`) + + await download(releaseInfo.browser_download_url, packageRoot, { extract: false, displayName: releaseInfo.name }) + + const dmgPath = join(packageRoot, safeName) + showFinalMessage() + + // Install from DMG automatically + try { + await installFromDmg(dmgPath) + + // Clean up DMG + try { + fs.unlinkSync(dmgPath) + console.log(' Cleaned up DMG file') + } catch (e) { + // Ignore cleanup errors + } + + console.log('') + console.log(' Installation complete! You can now launch electerm from /Applications') + } catch (err) { + console.error('') + console.error(' Warning: Automatic installation failed:', err.message) + console.error(' Please manually copy the app from the DMG to /Applications') + console.error('') + console.log(' Opening DMG for manual installation...') + execFile('open', [dmgPath]) + } +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main () { + console.log('') + console.log('========================================') + console.log('electerm binary installer') + console.log('========================================') + console.log(`Platform: ${plat}, Architecture: ${arch}`) + + if (GITHUB_PROXY) { + console.log(`GitHub Proxy: ${GITHUB_PROXY}`) + } + + console.log('') + + // Check for legacy systems + const win7 = isWindows7OrEarlier(plat, os.release()) + const mac10 = isMacOS10(plat, os.release()) + const linuxLegacy = isLinuxLegacy(plat) + + if (win7) console.log(' Detected: Windows 7 or earlier') + if (mac10) console.log(' Detected: macOS 10.x') + if (linuxLegacy) console.log(' Detected: Linux with glibc < 2.34 (legacy)') + + console.log(' Fetching release information...') + + try { + if (plat === 'win32') { + if (win7) { + await runWin7() + } else if (arch === 'arm64') { + await runWin('arm64') + } else { + await runWin('x64') + } + } else if (plat === 'darwin') { + if (mac10) { + await runMac10() + } else if (arch === 'arm64') { + await runMac('arm64') + } else { + await runMac('x64') + } + } else if (plat === 'linux') { + const suffix = linuxLegacy ? '-legacy' : '' + if (arch === 'arm64' || arch === 'aarch64') { + await runLinux(`linux-arm64${suffix}`, `linux-arm64${suffix}.tar.gz`) + } else if (arch === 'arm') { + await runLinux(`linux-armv7l${suffix}`, `linux-armv7l${suffix}.tar.gz`) + } else if (arch.includes('loong')) { + await runLinux(`linux-loong64${suffix}`, `linux-loong64${suffix}.tar.gz`) + } else { + await runLinux(`linux-x64${suffix}`, `linux-x64${suffix}.tar.gz`) + } + } else { + throw new Error(`Platform "${plat}" is not supported.`) + } + } catch (err) { + console.error('') + console.error('========================================') + console.error('Installation failed!') + console.error('========================================') + console.error(`Error: ${err.message}`) + console.error(`Platform: ${plat}, Architecture: ${arch}`) + console.error('') + console.error('Please visit https://electerm.org for manual download options.') + console.error('========================================') + console.error('') + process.exit(1) + } +} + +// --------------------------------------------------------------------------- +// Exports for testing +// --------------------------------------------------------------------------- + +module.exports = { + isWindows7OrEarlier, + isMacOS10, + isLinuxLegacy, + sanitizeVersion, + sanitizeFilename, + getElectermExePath, + isElectermExtracted, + // Expose for test injection + _packageRoot: packageRoot, + _extractDir: extractDir +} + +if (require.main === module) { + main() +} diff --git a/npm/utils.js b/npm/utils.js new file mode 100644 index 0000000..1416066 --- /dev/null +++ b/npm/utils.js @@ -0,0 +1,263 @@ +/** + * Utility functions for npm installer + * Replaces download and phin packages with native Node.js http/https and tar + * Supports Node.js 16+ + * Supports GITHUB_PROXY environment variable for proxying GitHub URLs + */ + +const https = require('https') +const http = require('http') +const fs = require('fs') +const path = require('path') +const tar = require('tar') + +// GitHub proxy support +const GITHUB_PROXY = process.env.GITHUB_PROXY || '' + +/** + * Apply GitHub proxy to URLs if configured + * @param {string} url - Original URL + * @returns {string} - Proxy URL or original URL + */ +function applyProxy (url) { + if (!GITHUB_PROXY) return url + if (!url.includes('github.com')) return url + + // Remove trailing slash from proxy if present + const proxy = GITHUB_PROXY.replace(/\/+$/, '') + // Ensure url has protocol + const urlWithProto = url.startsWith('http') ? url : `https://${url}` + + return `${proxy}/${urlWithProto}` +} + +/** + * Format bytes to human readable + */ +function formatBytes (bytes) { + if (bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i] +} + +/** + * Make an HTTP GET request and download to a file with progress + * @param {string} url - URL to fetch + * @param {string} filepath - Destination file path + * @param {number} timeout - Request timeout in milliseconds (default: 300000 = 5min) + * @param {function} onProgress - Progress callback (received, total, percent) + * @returns {Promise} Path to downloaded file + */ +function httpDownload (url, filepath, timeout = 300000, onProgress) { + return new Promise((resolve, reject) => { + const client = url.startsWith('https') ? https : http + + const req = client.get(url, { timeout }, (res) => { + // Handle redirects + if (res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 307) { + if (res.headers.location) { + // Handle relative URLs + let redirectUrl = res.headers.location + if (!redirectUrl.startsWith('http://') && !redirectUrl.startsWith('https://')) { + const parsedUrl = new URL(url) + redirectUrl = `${parsedUrl.protocol}//${parsedUrl.host}${redirectUrl}` + } + resolve(httpDownload(redirectUrl, filepath, timeout, onProgress)) + return + } + } + + if (res.statusCode !== 200) { + reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage || 'Unknown error'}`)) + return + } + + const total = parseInt(res.headers['content-length'] || '0', 10) + let received = 0 + let lastPercent = -1 + + const fileStream = fs.createWriteStream(filepath) + + res.on('data', (chunk) => { + received += chunk.length + if (onProgress && total > 0) { + const percent = Math.round((received / total) * 100) + if (percent !== lastPercent) { + lastPercent = percent + onProgress(received, total, percent) + } + } + }) + + res.pipe(fileStream) + fileStream.on('finish', () => { + fileStream.close() + resolve(filepath) + }) + fileStream.on('error', (err) => { + fs.unlink(filepath, () => {}) // Clean up partial download + reject(err) + }) + }) + + req.on('error', reject) + req.on('timeout', () => { + req.destroy() + reject(new Error(`Request timeout after ${timeout}ms`)) + }) + }) +} + +/** + * Make an HTTP GET request and return response body as string + * @param {string} url - URL to fetch + * @param {number} timeout - Request timeout in milliseconds (default: 15000) + * @returns {Promise} Response body as string + */ +function httpGet (url, timeout = 15000) { + return new Promise((resolve, reject) => { + const client = url.startsWith('https') ? https : http + + const req = client.get(url, { timeout }, (res) => { + // Handle redirects + if (res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 307) { + if (res.headers.location) { + // Handle relative URLs + let redirectUrl = res.headers.location + if (!redirectUrl.startsWith('http://') && !redirectUrl.startsWith('https://')) { + const parsedUrl = new URL(url) + redirectUrl = `${parsedUrl.protocol}//${parsedUrl.host}${redirectUrl}` + } + resolve(httpGet(redirectUrl, timeout)) + return + } + } + + if (res.statusCode !== 200) { + reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage || 'Unknown error'}`)) + return + } + + const chunks = [] + res.on('data', (chunk) => chunks.push(chunk)) + res.on('end', () => { + const buffer = Buffer.concat(chunks) + resolve(buffer.toString()) + }) + res.on('error', reject) + }) + + req.on('error', reject) + req.on('timeout', () => { + req.destroy() + reject(new Error(`Request timeout after ${timeout}ms`)) + }) + }) +} + +/** + * Extract a tar.gz file to destination directory + * @param {string} filepath - Path to tar.gz file + * @param {string} dest - Destination directory + * @param {number} strip - Number of leading path components to strip (default: 0) + * @returns {Promise} + */ +function extractTarGz (filepath, dest, strip = 0) { + return tar.extract({ + file: filepath, + cwd: dest, + strip + }) +} + +/** + * Download and optionally extract a file with progress + * @param {string} url - URL to download from + * @param {string} dest - Destination directory + * @param {object} options - Options + * @param {boolean} options.extract - Whether to extract the file (default: true) + * @param {string} options.displayName - Display name for progress output + * @returns {Promise<{filepath: string, extracted: boolean}>} + */ +async function download (url, dest, { extract: doExtract = true, displayName } = {}) { + // Ensure dest directory exists + if (!fs.existsSync(dest)) { + fs.mkdirSync(dest, { recursive: true }) + } + + // Extract filename from URL + const urlParts = url.split('/') + const filename = urlParts[urlParts.length - 1].split('?')[0] || 'download' + const filepath = path.join(dest, filename) + + // Apply proxy if configured + const downloadUrl = applyProxy(url) + + const label = displayName || filename + const proxyInfo = GITHUB_PROXY ? ' [via proxy]' : '' + + console.log('') + console.log(` Downloading: ${label}${proxyInfo}`) + + let lastPercent = -1 + await httpDownload(downloadUrl, filepath, 300000, (received, total, percent) => { + if (percent !== lastPercent && percent % 10 === 0) { + lastPercent = percent + const receivedStr = formatBytes(received) + const totalStr = formatBytes(total) + process.stdout.write(` Progress: ${percent}% (${receivedStr} / ${totalStr})\n`) + } + }) + + console.log(` Progress: 100% (${formatBytes(fs.statSync(filepath).size)})`) + console.log(' Download complete!') + + let extracted = false + if (doExtract && (filepath.endsWith('.tar.gz') || filepath.endsWith('.tgz'))) { + console.log(' Extracting archive...') + await extractTarGz(filepath, dest) + // Clean up the downloaded archive + try { + fs.unlinkSync(filepath) + } catch (err) { + // Ignore cleanup errors + } + extracted = true + console.log(' Extraction complete!') + } + + return { filepath, extracted } +} + +/** + * Phin replacement - simple promisified HTTP client + * @param {object} options - Request options + * @param {string} options.url - URL to fetch + * @param {number} options.timeout - Request timeout (default: 15000) + * @returns {Promise<{body: Buffer, statusCode: number, headers: object}>} + */ +async function phin (options) { + const { url, timeout = 15000 } = options + const body = await httpGet(url, timeout) + + return { + body: Buffer.from(body), + statusCode: 200, + headers: {} + } +} + +phin.promisified = phin + +module.exports = { + httpGet, + httpDownload, + extractTarGz, + download, + phin, + applyProxy, + formatBytes, + GITHUB_PROXY +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..518a2b7 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,13775 @@ +{ + "name": "electerm", + "version": "3.15.128", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "electerm", + "version": "3.15.128", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@electerm/electerm-locales": "2.2.16", + "@electerm/electerm-themes": "^1.0.1", + "@electerm/ftp-srv": "1.0.5", + "@electerm/nedb": "2.0.0", + "@electerm/ssh2": "1.19.0", + "@xterm/headless": "6.1.0-beta.288", + "axios": "1.18.1", + "basic-ftp": "6.0.1", + "commander": "12.1.0", + "diffie-hellman": "^5.0.3", + "electerm-sync": "2.0.0", + "electron-log": "4.3.5", + "express": "5.2.1", + "express-ws": "5.0.2", + "fast-deep-equal": "3.1.3", + "find-free-port": "2.0.0", + "font-list": "1.5.1", + "https-proxy-agent": "7.0.1", + "iconv-lite": "^0.7.2", + "json-deep-copy": "1.3.1", + "jsonwebtoken": "^9.0.1", + "nanoid": "3.3.8", + "node-bash": "5.0.1", + "node-forge": "1.4.0", + "node-pty": "1.1.0-beta34", + "os-locale-s": "1.1.3", + "serialport": "13.0.0", + "socks": "2.8.9", + "socks-proxy-agent": "8.0.1", + "socksv5-server": "^1.0.2", + "ssh-config-loader": "1.1.2", + "ssh2-scp": "3.2.0", + "tar": "7.5.17", + "trzsz2": "1.2.0", + "zmodem2": "1.4.0" + }, + "bin": { + "electerm": "npm/electerm" + }, + "devDependencies": { + "@ant-design/icons": "6.2.5", + "@electerm/electerm-resource": "2.1.7", + "@fontsource/maple-mono": "^5.2.5", + "@novnc/novnc": "1.7.0", + "@types/node": "22.9.3", + "@vitejs/plugin-react": "^5.2.0", + "@xterm/addon-attach": "0.13.0-beta.288", + "@xterm/addon-fit": "0.12.0-beta.288", + "@xterm/addon-image": "0.10.0-beta.288", + "@xterm/addon-ligatures": "0.11.0-beta.288", + "@xterm/addon-search": "0.17.0-beta.288", + "@xterm/addon-unicode11": "0.10.0-beta.288", + "@xterm/addon-web-links": "0.13.0-beta.288", + "@xterm/addon-webgl": "0.20.0-beta.287", + "@xterm/xterm": "6.1.0-beta.288", + "antd": "6.4.5", + "classnames": "2.5.1", + "cross-env": "7.0.3", + "dotenv": "16.4.5", + "electerm-icons": "1.0.1", + "electron": "41.2.0", + "electron-builder": "26.9.0", + "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", + "react-dom": "19.2.7", + "react-markdown": "9.0.1", + "replace-in-file": "6.3.5", + "shelljs": "0.8.5", + "spice-client": "1.2.0", + "standard": "^17.1.2", + "stylus": "^0.64.0", + "vite": "8.1.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/@adobe/css-tools/-/css-tools-4.3.3.tgz", + "integrity": "sha512-rE0Pygv0sEZ4vBWHlAgJLGDU7Pm8xoO6p3wsEceb7GYAjScrOHpEo8KK/eVkAcnSM+slAEtXjA2JpdjLp4fJQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ant-design/colors": { + "version": "8.0.1", + "resolved": "https://registry.npmmirror.com/@ant-design/colors/-/colors-8.0.1.tgz", + "integrity": "sha512-foPVl0+SWIslGUtD/xBr1p9U4AKzPhNYEseXYRRo5QSzGACYZrQbe11AYJbYfAWnWSpGBx6JjBmSeugUsD9vqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^3.0.0" + } + }, + "node_modules/@ant-design/cssinjs": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/@ant-design/cssinjs/-/cssinjs-2.1.2.tgz", + "integrity": "sha512-2Hy8BnCEH31xPeSLbhhB2ctCPXE2ZnASdi+KbSeS79BNbUhL9hAEe20SkUk+BR8aKTmqb6+FKFruk7w8z0VoRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@emotion/hash": "^0.8.0", + "@emotion/unitless": "^0.7.5", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "stylis": "^4.3.4" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/cssinjs-utils": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/@ant-design/cssinjs-utils/-/cssinjs-utils-2.1.2.tgz", + "integrity": "sha512-5fTHQ158jJJ5dC/ECeyIdZUzKxE/mpEMRZxthyG1sw/AKRHKgJBg00Yi6ACVXgycdje7KahRNvNET/uBccwCnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ant-design/cssinjs": "^2.1.2", + "@babel/runtime": "^7.23.2", + "@rc-component/util": "^1.4.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/@ant-design/fast-color": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/@ant-design/fast-color/-/fast-color-3.0.1.tgz", + "integrity": "sha512-esKJegpW4nckh0o6kV3Tkb7NPIZYbPnnFxmQDUmL08ukXZAvV85TZBr70eGuke/CIArLaP6aw8lt9KILjnWuOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.x" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^8.0.1", + "@ant-design/icons-svg": "^4.4.2", + "@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/@ant-design/icons-svg": { + "version": "4.4.2", + "resolved": "https://registry.npmmirror.com/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz", + "integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ant-design/react-slick": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/@ant-design/react-slick/-/react-slick-2.0.0.tgz", + "integrity": "sha512-HMS9sRoEmZey8LsE/Yo6+klhlzU12PisjrVcydW3So7RdklyEd2qehyU6a7Yp+OYN72mgsYs3NFCyP2lCPFVqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "clsx": "^2.1.1", + "json2mq": "^0.2.0", + "throttle-debounce": "^5.0.0" + }, + "peerDependencies": { + "react": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@develar/schema-utils": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", + "integrity": "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.0", + "ajv-keywords": "^3.4.1" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/@electerm/electerm-locales": { + "version": "2.2.16", + "resolved": "https://registry.npmjs.org/@electerm/electerm-locales/-/electerm-locales-2.2.16.tgz", + "integrity": "sha512-IwTRtgwe3XAOsWcCZIJob21vlKXBrs1oPxnze/x6IxY9m8WK2h0i/Cp+0QlE0eQdo0ktleK58yyxruEGxjtoYQ==", + "license": "MIT" + }, + "node_modules/@electerm/electerm-resource": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@electerm/electerm-resource/-/electerm-resource-2.1.7.tgz", + "integrity": "sha512-c9mF1cdC7A7Ej2Rk8CqBUeX4vXLZ/oPikHB3QgCCL8A7C+R+804ey9MiEPzgnoetDiKKPvukQyzTMs2HUl4QBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electerm/electerm-themes": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@electerm/electerm-themes/-/electerm-themes-1.0.1.tgz", + "integrity": "sha512-Xx9SW+iXad/LODpeEK79Hs7z3AFergW/oFbVMZY/Wa2XCtcxhb8iEjrCLSkPX/wWjpq28Wm2nrJGChAls2mXMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@electerm/ftp-srv": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@electerm/ftp-srv/-/ftp-srv-1.0.5.tgz", + "integrity": "sha512-WgW7/HJWAPkZytXNbnc5KUVdoJSArmOpd4Ga+pP2S+xx5wiPyceyQ+H0iwZFKO3djGFHWGG2t4gChpIjSIcOrQ==", + "license": "MIT", + "dependencies": { + "commander": "^14.0.3" + }, + "bin": { + "electermftpsrv": "bin/index.cjs" + }, + "engines": { + "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.npmmirror.com/@electerm/nedb/-/nedb-2.0.0.tgz", + "integrity": "sha512-3u60Dnjs4OFMsAmBhFZ4JsMrpVetY+S0LcGRSW0/15LAiI6+DqAwvxiY4HxC1S5I21nMHWMjgV228U3zb/F78w==", + "license": "MIT", + "dependencies": { + "@yetzt/binary-search-tree": "^0.2.6", + "mkdirp": "^1.0.4" + } + }, + "node_modules/@electerm/ssh2": { + "version": "1.19.0", + "resolved": "https://registry.npmmirror.com/@electerm/ssh2/-/ssh2-1.19.0.tgz", + "integrity": "sha512-niyOA2dx6D5EVUvF4q7HV/EmzeA6jKblP08FVOLhrCgndiWmkv9jJi1mHcnRc9TcjT9SFDbtP7+h58nZzSrL5w==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2", + "iconv-lite": "^0.7.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.20.0" + } + }, + "node_modules/@electron/asar": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", + "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" + }, + "bin": { + "asar": "bin/asar.js" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@electron/asar/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/asar/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/asar/node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/@electron/asar/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@electron/asar/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/fuses": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-1.8.0.tgz", + "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.1", + "fs-extra": "^9.0.1", + "minimist": "^1.2.5" + }, + "bin": { + "electron-fuses": "dist/bin.js" + } + }, + "node_modules/@electron/fuses/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/fuses/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/fuses/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/get": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/@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/@electron/notarize": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", + "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/notarize/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/notarize/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/osx-sign": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.3.tgz", + "integrity": "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "compare-version": "^0.1.2", + "debug": "^4.3.4", + "fs-extra": "^10.0.0", + "isbinaryfile": "^4.0.8", + "minimist": "^1.2.6", + "plist": "^3.0.5" + }, + "bin": { + "electron-osx-flat": "bin/electron-osx-flat.js", + "electron-osx-sign": "bin/electron-osx-sign.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@electron/osx-sign/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/@electron/osx-sign/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/osx-sign/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/rebuild": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.0.4.tgz", + "integrity": "sha512-Rzc39XPdk/+/wBG8MfwAHohXflep0ITUfulb6Rgz3R0NeSB1noE+E9/M/cb8ftCAiyDD9PPhLuuWgE1GaInbKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.1.1", + "node-abi": "^4.2.0", + "node-api-version": "^0.2.1", + "node-gyp": "^12.2.0", + "read-binary-file-arch": "^1.0.6" + }, + "bin": { + "electron-rebuild": "lib/cli.js" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/universal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.3.tgz", + "integrity": "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "^3.3.1", + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.3.1", + "dir-compare": "^4.2.0", + "fs-extra": "^11.1.1", + "minimatch": "^9.0.3", + "plist": "^3.1.0" + }, + "engines": { + "node": ">=16.4" + } + }, + "node_modules/@electron/universal/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/universal/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@electron/universal/node_modules/fs-extra": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", + "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/universal/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/universal/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@electron/universal/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmmirror.com/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.7.5", + "resolved": "https://registry.npmmirror.com/@emotion/unitless/-/unitless-0.7.5.tgz", + "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmmirror.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmmirror.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmmirror.com/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmmirror.com/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@fontsource/maple-mono": { + "version": "5.2.6", + "resolved": "https://registry.npmmirror.com/@fontsource/maple-mono/-/maple-mono-5.2.6.tgz", + "integrity": "sha512-+VAD7z8nyTkaiz2/Ww639Z5Kp/YJIL4dNMQxydqSMafNb5nKxFeoRq6W3g9WJ04IcBjdBmn0dKFNk90lyz7K+w==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmmirror.com/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmmirror.com/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@malept/cross-spawn-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", + "integrity": "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "Apache-2.0", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/@malept/flatpak-bundler": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", + "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.0", + "lodash": "^4.17.15", + "tmp-promise": "^3.0.2" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmmirror.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@novnc/novnc": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@novnc/novnc/-/novnc-1.7.0.tgz", + "integrity": "sha512-ucEJOx4T2avIRCleodk7YobZj5O2Ga2AeLfQ69A/yjG9HHba2+PDgwSkN3FttrmG+70ZGx21sElNFouK13RzyA==", + "dev": true, + "license": "MPL-2.0" + }, + "node_modules/@oxc-project/types": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", + "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmmirror.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@rc-component/async-validator": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-6.0.0.tgz", + "integrity": "sha512-D3AGQwdyE58gmvx6waVSXJ80JGO+IY5L2O8HDnSOex7JNlzB3GuN/4hyHNTdhy2qtOhkpbIjmeAN3tL993wKbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.4" + }, + "engines": { + "node": ">=14.x" + } + }, + "node_modules/@rc-component/cascader": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/@rc-component/cascader/-/cascader-1.16.1.tgz", + "integrity": "sha512-wxLopwM+EBed0zNNGdnGE4coYoqcO+XD42fHgn+pDvO+XzhNFbdgSlSNXdKocIYqccvqgWvoxDPNb0OVRdi59A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rc-component/select": "~1.7.1", + "@rc-component/tree": "~1.3.2", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/checkbox": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/@rc-component/checkbox/-/checkbox-2.0.0.tgz", + "integrity": "sha512-3CXGPpAR9gsPKeO2N78HAPOzU30UdemD6HGJoWVJOpa6WleaGB5kzZj3v6bdTZab31YuWgY/RxV3VKPctn0DwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/collapse": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/@rc-component/collapse/-/collapse-1.2.0.tgz", + "integrity": "sha512-ZRYSKSS39qsFx93p26bde7JUZJshsUBEQRlRXPuJYlAiNX0vyYlF5TsAm8JZN3LcF8XvKikdzPbgAtXSbkLUkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/motion": "^1.1.4", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/color-picker": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/@rc-component/color-picker/-/color-picker-3.1.1.tgz", + "integrity": "sha512-OHaCHLHszCegdXmIq2ZRIZBN/EtpT6Wm8SG/gpzLATHbVKc/avvuKi+zlOuk05FTWvgaMmpxAko44uRJ3M+2pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^3.0.1", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/context": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@rc-component/context/-/context-2.0.2.tgz", + "integrity": "sha512-uiGpAlblCNlziHPwj4S4Iy/oemeuz/hR03mbiEjTCXwsqOIN3BOzsRMyDwpyO5Fm0vIEEJRUf9ZtbRLbhksuTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.0" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/dialog": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@rc-component/dialog/-/dialog-1.9.0.tgz", + "integrity": "sha512-zbAAogkg4kkKum79sLE6M+vq1jSAW25zdkafrahgcTP9t9S//SD634Znd1A4c8F2Gc12ZKnehGLsVaaOvZzD2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.3", + "@rc-component/portal": "^2.1.0", + "@rc-component/util": "^1.9.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/drawer": { + "version": "1.4.2", + "resolved": "https://registry.npmmirror.com/@rc-component/drawer/-/drawer-1.4.2.tgz", + "integrity": "sha512-1ib+fZEp6FBu+YvcIktm+nCQ+Q+qIpwpoaJH6opGr4ofh2QMq+qdr5DLC4oCf5qf3pcWX9lUWPYX652k4ini8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/portal": "^2.1.3", + "@rc-component/util": "^1.9.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/dropdown": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rc-component/dropdown/-/dropdown-1.0.2.tgz", + "integrity": "sha512-6PY2ecUSYhDPhkNHHb4wfeAya04WhpmUSKzdR60G+kMNVUCX2vjT/AgTS0Lz0I/K6xrPMJ3enQbwVpeN3sHCgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.2.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.11.0", + "react-dom": ">=16.11.0" + } + }, + "node_modules/@rc-component/form": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@rc-component/form/-/form-1.8.5.tgz", + "integrity": "sha512-d24EYtvUOBhxEtSd/EqIu9DaMuqrWF2IRIvAFCTM6NQ/GJIYNr8DvEpUSUlv2uPxEJ0ZPwYQ+wwlGIAaiHvdrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rc-component/async-validator": "^6.0.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/image": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@rc-component/image/-/image-1.9.0.tgz", + "integrity": "sha512-khF7w7xkBH5B1bsBcI1FSUZdkyd1aqpl2eYyILCqCzzQH3XdfehGUaZTnptyaJJfs09/R5hv9jXWyazOMFIClQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.0.0", + "@rc-component/portal": "^2.1.2", + "@rc-component/util": "^1.10.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/input": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@rc-component/input/-/input-1.3.1.tgz", + "integrity": "sha512-iFvTUT9W+JC/MSin2aGAk8NqsVlTzcExNC9DZariON1IWirju9NoNeEk47an4Q8iHazkoVI/y1LnDi88+CPcig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rc-component/resize-observer": "^1.1.1", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@rc-component/input-number": { + "version": "1.6.2", + "resolved": "https://registry.npmmirror.com/@rc-component/input-number/-/input-number-1.6.2.tgz", + "integrity": "sha512-Gjcq7meZlCOiWN1t1xCC+7/s85humHVokTBI7PJgTfoyw5OWF74y3e6P8PHX104g9+b54jsodFIzyaj6p8LI9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rc-component/mini-decimal": "^1.0.1", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mentions": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@rc-component/mentions/-/mentions-1.9.0.tgz", + "integrity": "sha512-WUwfFKDSOF5S9UPsNsXcLYtzjTxBGsftTXWRbZuxX6BYrsySISTnujfJNgaaQ6qVzaCDJ35QUkZKvsYxip1C5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rc-component/input": "~1.3.0", + "@rc-component/menu": "~1.3.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/menu": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@rc-component/menu/-/menu-1.3.1.tgz", + "integrity": "sha512-pSZl9nBPgKgxN0aaW7NilIBEwWsc+43S+ulGdWAg9afak96dNOGWsGx0DLLBB1VQsAJvo6bQMTDzXoPlEHsBEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/overflow": "^1.0.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mini-decimal": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@rc-component/mini-decimal/-/mini-decimal-1.1.3.tgz", + "integrity": "sha512-bk/FJ09fLf+NLODMAFll6CfYrHPBioTedhW6lxDBuuWucJEqFUd4l/D/5JgIi3dina6sYahB8iuPAZTNz2pMxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@rc-component/motion": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@rc-component/motion/-/motion-1.3.3.tgz", + "integrity": "sha512-Xh3IszxvlSv3/PLYFyC2UZi9LNB83yOnkB/LNmRzaypZLvkhqUIPS7MQpGZcCMWrNsXV2p6YTSWbSGvFpEle9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mutate-observer": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/@rc-component/mutate-observer/-/mutate-observer-2.0.1.tgz", + "integrity": "sha512-AyarjoLU5YlxuValRi+w8JRH2Z84TBbFO2RoGWz9d8bSu0FqT8DtugH3xC3BV7mUwlmROFauyWuXFuq4IFbH+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/notification": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@rc-component/notification/-/notification-2.0.7.tgz", + "integrity": "sha512-nqZzpf6BPdaj+3ILx7si79LLmqPKyUmQoXa+/9gg0SkH0v1DbD66oJgRMSBEVnd/zUT3D4gwxWIHUKebYf2ZXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/util": "^1.11.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/overflow": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/overflow/-/overflow-1.0.1.tgz", + "integrity": "sha512-syfmgAABaHCnCDzPwHZ/2tuvIcpOO3jefYZMmfkN+pmo8HKTzsfhS57vxo4ksPdN0By+uWVJhJWNFozNBxi2eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@rc-component/resize-observer": "^1.0.1", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/pagination": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@rc-component/pagination/-/pagination-1.3.0.tgz", + "integrity": "sha512-12ahTY+HPITg1L2bjWKXUqBJe/oOnpA2QsChdCjthqLVf/e19StiCsv8OLKpWoHbc+8PFEkNjRqRqrLoRBHjFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/picker": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@rc-component/picker/-/picker-1.10.0.tgz", + "integrity": "sha512-vVOXP2RVWozwpERGUFAehVH1Jz6o/uRrAb9qSZm1LC+iJs8rvEwFo1bzz2jlOYV+uWwu0dIuG86tnDui14Ea0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rc-component/overflow": "^1.0.0", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/trigger": "^3.6.15", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=12.x" + }, + "peerDependencies": { + "date-fns": ">= 2.x", + "dayjs": ">= 1.x", + "luxon": ">= 3.x", + "moment": ">= 2.x", + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + }, + "peerDependenciesMeta": { + "date-fns": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + } + } + }, + "node_modules/@rc-component/portal": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/@rc-component/portal/-/portal-2.2.0.tgz", + "integrity": "sha512-oc6FlA+uXCMiwArHsJyHcIkX4q6uKyndrPol2eWX8YPkAnztHOPsFIRtmWG4BMlGE5h7YIRE3NiaJ5VS8Lb1QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=12.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/progress": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/@rc-component/progress/-/progress-1.0.2.tgz", + "integrity": "sha512-WZUnH9eGxH1+xodZKqdrHke59uyGZSWgj5HBM5Kwk5BrTMuAORO7VJ2IP5Qbm9aH3n9x3IcesqHHR0NWPBC7fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/qrcode": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@rc-component/qrcode/-/qrcode-2.0.0.tgz", + "integrity": "sha512-aAv3QhPP1xyafuTZOxub6a54pCeBnN3IwQkpETrBtthq4BL5IgxnCbuoBWPDpdLw1y1j6BgBUCAKV92+yX06Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/rate": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@rc-component/rate/-/rate-1.0.1.tgz", + "integrity": "sha512-bkXxeBqDpl5IOC7yL7GcSYjQx9G8H+6kLYQnNZWeBYq2OYIv1MONd6mqKTjnnJYpV0cQIU2z3atdW0j1kttpTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/resize-observer": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@rc-component/resize-observer/-/resize-observer-1.1.2.tgz", + "integrity": "sha512-t/Bb0W8uvL4PYKAB3YcChC+DlHh0Wt5kM7q/J+0qpVEUMLe7Hk5zuvc9km0hMnTFPSx5Z7Wu/fzCLN6erVLE8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/segmented": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/@rc-component/segmented/-/segmented-1.3.0.tgz", + "integrity": "sha512-5J/bJ01mbDnoA6P/FW8SxUvKn+OgUSTZJPzCNnTBntG50tzoP7DydGhqxp7ggZXZls7me3mc2EQDXakU3iTVFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@rc-component/motion": "^1.1.4", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@rc-component/select": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@rc-component/select/-/select-1.7.1.tgz", + "integrity": "sha512-GZ1cMJk2xQh0VHyOQjjG8drYL4iu24NcbkXioUcReQOCUr+ub/3fmRonZe6cRPEZhWMbJdeHsqnEltogDaZ5Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rc-component/overflow": "^1.0.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.11.1", + "@rc-component/virtual-list": "^1.2.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@rc-component/slider": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@rc-component/slider/-/slider-1.0.1.tgz", + "integrity": "sha512-uDhEPU1z3WDfCJhaL9jfd2ha/Eqpdfxsn0Zb0Xcq1NGQAman0TWaR37OWp2vVXEOdV2y0njSILTMpTfPV1454g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/steps": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/@rc-component/steps/-/steps-1.2.2.tgz", + "integrity": "sha512-/yVIZ00gDYYPHSY0JP+M+s3ZvuXLu2f9rEjQqiUDs7EcYsUYrpJ/1bLj9aI9R7MBR3fu/NGh6RM9u2qGfqp+Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/switch": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/@rc-component/switch/-/switch-1.0.3.tgz", + "integrity": "sha512-Jgi+EbOBquje/XNdofr7xbJQZPYJP+BlPfR0h+WN4zFkdtB2EWqEfvkXJWeipflwjWip0/17rNbxEAqs8hVHfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/table": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/@rc-component/table/-/table-1.10.2.tgz", + "integrity": "sha512-b3PjqB9Gp25p5t/zq+9QrbXbodkptT8/zvLmwgd2FNPUUtaYyDnQqfxeD5a7ao8E8lpinLHsi2u2vdfPhyNvAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rc-component/context": "^2.0.1", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/util": "^1.11.1", + "@rc-component/virtual-list": "^1.0.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/tabs": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@rc-component/tabs/-/tabs-1.9.1.tgz", + "integrity": "sha512-6mY08Fce6aNOHuGsxbzT+f2ekgL9mg1cGGHkittMlVGymjGg+kGupu5v90sRxcUd/paRU9jclLLXtF/PkK1FUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rc-component/dropdown": "~1.0.0", + "@rc-component/menu": "~1.3.0", + "@rc-component/motion": "^1.1.3", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tooltip": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/@rc-component/tooltip/-/tooltip-1.4.0.tgz", + "integrity": "sha512-8Rx5DCctIlLI4raR0I0xHjVTf1aF48+gKCNeAAo5bmF5VoR5YED+A/XEqzXv9KKqrJDRcd3Wndpxh2hyzrTtSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rc-component/trigger": "^3.7.1", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/tour": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@rc-component/tour/-/tour-2.4.0.tgz", + "integrity": "sha512-aui4r4TqmTzwaBgcQxHYep8kM8PTjZFufjokObpy35KfFeZ0k9ArquWFZqegQlH24P14t+F0qO0mGTgzlav1yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rc-component/portal": "^2.2.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.7.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tree": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@rc-component/tree/-/tree-1.3.2.tgz", + "integrity": "sha512-bJFj46wEkpBPnWyTm18XmgAgNQ/4YvprxMOPPY2a6rmhGJYxLuNKEFiL5Qej4Qctu9wHJm8WW+v2SYskafE0kA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.0.0", + "@rc-component/util": "^1.11.1", + "@rc-component/virtual-list": "^1.2.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=10.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@rc-component/tree-select": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@rc-component/tree-select/-/tree-select-1.10.0.tgz", + "integrity": "sha512-E1U4pn2LAbXEhLJdzIzid7WYbIuFbkTIctuFoeC6weppf8UbPR3+YYB6/ay0c0ksand4gXMRQpa1Z60Auo7VJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rc-component/select": "~1.7.0", + "@rc-component/tree": "~1.3.0", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@rc-component/trigger": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-3.9.1.tgz", + "integrity": "sha512-LNsYvz60mrLJ/kRvKcHE7boUvcQfVMCfRqZ71x3Fo9AOiZ1KKIEqkzMA8DNvz2V3Bcvir/vwQNn7JF1NPODQ7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/portal": "^2.2.0", + "@rc-component/resize-observer": "^1.1.1", + "@rc-component/util": "^1.2.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/upload": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@rc-component/upload/-/upload-1.1.1.tgz", + "integrity": "sha512-GvYWSKeaJTOxxC5p6+nOSadzfvXA1h8C/iHFPFZX+szH3JUXrvs+DLiW8YUTBgvMh8m63mJeHrlYlJzAlg+pDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/util": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@rc-component/util/-/util-1.11.1.tgz", + "integrity": "sha512-awVlI3ub2vqfqkYxOBc/uQ0efm3jw0wcrhtO/YWLyZfxiKXczKwNbVuhlnyxytDt7H9pbbVQiqr+O6MLATtRYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-mobile": "^5.0.0", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/virtual-list": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rc-component/virtual-list/-/virtual-list-1.2.0.tgz", + "integrity": "sha512-iavRm1Jo4GDbASQwdGa7jFyk93RvSOo9xHyBT4QL1pgFJj/Fdf1G+3RErH7/7BmAMvx2AkF62mjGYxDbXsK9TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.0", + "@rc-component/resize-observer": "^1.0.1", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", + "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz", + "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz", + "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz", + "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz", + "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz", + "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz", + "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz", + "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz", + "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz", + "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz", + "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz", + "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz", + "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", + "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz", + "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@serialport/binding-mock": { + "version": "10.2.2", + "resolved": "https://registry.npmmirror.com/@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": ">=12.0.0" + } + }, + "node_modules/@serialport/bindings-cpp": { + "version": "13.0.0", + "resolved": "https://registry.npmmirror.com/@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://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/bindings-cpp/node_modules/@serialport/parser-delimiter": { + "version": "12.0.0", + "resolved": "https://registry.npmmirror.com/@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.npmmirror.com/@serialport/parser-readline/-/parser-readline-12.0.0.tgz", + "integrity": "sha512-O7cywCWC8PiOMvo/gglEBfAkLjp/SENEML46BXDykfKP5mTPM46XMaX1L0waWU6DXJpBgjaL7+yX6VriVPbN4w==", + "license": "MIT", + "dependencies": { + "@serialport/parser-delimiter": "12.0.0" + }, + "engines": { + "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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/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/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmmirror.com/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmmirror.com/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmmirror.com/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmmirror.com/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmmirror.com/@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.npmmirror.com/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/fs-extra": { + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", + "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@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.npmmirror.com/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmmirror.com/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.9.3", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-22.9.3.tgz", + "integrity": "sha512-F3u1fs/fce3FFk+DAxbxc78DF8x0cY09RRL8GnXLmkJ1jvx3TtPdWoTT5/NiYfI5ASqXBmfqJi9dZ3gxMx4lzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.8" + } + }, + "node_modules/@types/plist": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz", + "integrity": "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*", + "xmlbuilder": ">=11.0.1" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/@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.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/verror": { + "version": "1.10.11", + "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", + "integrity": "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmmirror.com/@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.0", + "resolved": "https://registry.npmmirror.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@xterm/addon-attach": { + "version": "0.13.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-attach/-/addon-attach-0.13.0-beta.288.tgz", + "integrity": "sha512-4vr1a6lN8Bdriqwm6uGd7fHB9gDqdgw6fFm1mOKRG32Morg9pbouxeyk2Wj4sOIKOgSJvPckyLwnkmXUJhvxkQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^6.1.0-beta.288" + } + }, + "node_modules/@xterm/addon-fit": { + "version": "0.12.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.12.0-beta.288.tgz", + "integrity": "sha512-fiPUx+IKzr+LRzjxkPQ+PHmbzxYqyCxIwk4H0Nf5+TDpP9tSyvxvcZgThfzjL/dBi/fhuHw8UrsiObtpahxCyg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^6.1.0-beta.288" + } + }, + "node_modules/@xterm/addon-image": { + "version": "0.10.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.288.tgz", + "integrity": "sha512-zlgz0IIG3Te/aP4Kg1xRgWvCzgMgAk3LOP+RvGJAfP2g8tMFZBqcVVdX9BWTsp6hZQ4XaBPiiW+bORms/0Y3OQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^6.1.0-beta.288" + } + }, + "node_modules/@xterm/addon-ligatures": { + "version": "0.11.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.288.tgz", + "integrity": "sha512-82bXLLGHPvyrLF6FByXOJEJEmqHBMH0sIao9kkWBLL3tBSAHty25xo0ulWNUgP6O6sxAZX8Qbfi/zVhrw0A56g==", + "dev": true, + "license": "MIT", + "dependencies": { + "lru-cache": "^11.3.6", + "opentype.js": "^2.0.0" + }, + "engines": { + "node": ">8.0.0" + }, + "peerDependencies": { + "@xterm/xterm": "^6.1.0-beta.288" + } + }, + "node_modules/@xterm/addon-ligatures/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@xterm/addon-search": { + "version": "0.17.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.288.tgz", + "integrity": "sha512-JQk4n83Zs0A3K2VjQGbfhGAxoQZaMZtgBMSLKOx2Zg2HuikIsjIjUsPpOoJHwAfhVXKXmgd9QmVp61DxJj8tyw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^6.1.0-beta.288" + } + }, + "node_modules/@xterm/addon-unicode11": { + "version": "0.10.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.288.tgz", + "integrity": "sha512-nFQvOBqQEtaPdpiZ2m3A5dkSW+EQGytnyQjMond81bIv3MDRkDKTy8FhAI9fEsZSq2wTbB91tfV5FLjAkSSQ7A==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^6.1.0-beta.288" + } + }, + "node_modules/@xterm/addon-web-links": { + "version": "0.13.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.13.0-beta.288.tgz", + "integrity": "sha512-HMZtBptsnogJC/xIUl7UNZ+XchnKzlEEkEMKsfDsNE7Z/2wWABWZawFD7SdD5OvpoZlOXgg5MVRp5sVpzzaWcA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^6.1.0-beta.288" + } + }, + "node_modules/@xterm/addon-webgl": { + "version": "0.20.0-beta.287", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.287.tgz", + "integrity": "sha512-ADDuXRHLfhMk3y+BFvaI1ibGCGxPMNRonzH5M/qTUnigsVxdnEMsYODG6pWAYkfDMPoCRBF8Zf/hj0efyEBjzQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^6.1.0-beta.288" + } + }, + "node_modules/@xterm/headless": { + "version": "6.1.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.288.tgz", + "integrity": "sha512-b7lKSC8eW2Zk2iwy0BQja4XbBHcai7aJBdVq8jZmPS9GqY8qo6k/rjZiOs5pgqTR9S+/MXYq0c+4szY+0sPBLw==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, + "node_modules/@xterm/xterm": { + "version": "6.1.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.288.tgz", + "integrity": "sha512-XLtX5kO0bgvjOEypUtO5xYUykE+vbJuQPuRK2cIx7oBFwlSYXGCVKPC7pvkLOYsfJWqJ9yusIaeJQqJvrYT1kQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, + "node_modules/@yetzt/binary-search-tree": { + "version": "0.2.6", + "resolved": "https://registry.npmmirror.com/@yetzt/binary-search-tree/-/binary-search-tree-0.2.6.tgz", + "integrity": "sha512-e/8wt8AAumI8VK5sv09b3IgWuRoblXJ5z0SQYfrL2nap89oKihvVaP1zy3FzD5NaeRi1X0gdXZA9lB3QAZILBg==", + "license": "MIT" + }, + "node_modules/7zip-bin": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", + "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/abbrev": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accumulate-stream": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/accumulate-stream/-/accumulate-stream-5.0.0.tgz", + "integrity": "sha512-a1pAtAy+LFCAQ6EpAwDeHERf99nadIXa8UApaddabeF18IsL8cD67WL67E/cRJf4EkZyR9z4F+9g+OCl3Cqi4w==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.0", + "ms": "^2.1.3" + } + }, + "node_modules/acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmmirror.com/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/antd": { + "version": "6.4.5", + "resolved": "https://registry.npmjs.org/antd/-/antd-6.4.5.tgz", + "integrity": "sha512-xyAgX/sqF/CRS1G95oM4ql0+3TBG+tE58aRJqdUPVv4yMZcQrnnkA4cU7Uc5Rny2yK2TrusDVargHzzXUrlJ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^8.0.1", + "@ant-design/cssinjs": "^2.1.2", + "@ant-design/cssinjs-utils": "^2.1.2", + "@ant-design/fast-color": "^3.0.1", + "@ant-design/icons": "^6.2.5", + "@ant-design/react-slick": "~2.0.0", + "@babel/runtime": "^7.29.2", + "@rc-component/cascader": "~1.16.1", + "@rc-component/checkbox": "~2.0.0", + "@rc-component/collapse": "~1.2.0", + "@rc-component/color-picker": "~3.1.1", + "@rc-component/dialog": "~1.9.0", + "@rc-component/drawer": "~1.4.2", + "@rc-component/dropdown": "~1.0.2", + "@rc-component/form": "~1.8.5", + "@rc-component/image": "~1.9.0", + "@rc-component/input": "~1.3.1", + "@rc-component/input-number": "~1.6.2", + "@rc-component/mentions": "~1.9.0", + "@rc-component/menu": "~1.3.1", + "@rc-component/motion": "^1.3.3", + "@rc-component/mutate-observer": "^2.0.1", + "@rc-component/notification": "~2.0.7", + "@rc-component/pagination": "~1.3.0", + "@rc-component/picker": "~1.10.0", + "@rc-component/progress": "~1.0.2", + "@rc-component/qrcode": "~2.0.0", + "@rc-component/rate": "~1.0.1", + "@rc-component/resize-observer": "^1.1.2", + "@rc-component/segmented": "~1.3.0", + "@rc-component/select": "~1.7.1", + "@rc-component/slider": "~1.0.1", + "@rc-component/steps": "~1.2.2", + "@rc-component/switch": "~1.0.3", + "@rc-component/table": "~1.10.2", + "@rc-component/tabs": "~1.9.1", + "@rc-component/tooltip": "~1.4.0", + "@rc-component/tour": "~2.4.0", + "@rc-component/tree": "~1.3.2", + "@rc-component/tree-select": "~1.10.0", + "@rc-component/trigger": "^3.9.1", + "@rc-component/upload": "~1.1.1", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1", + "dayjs": "^1.11.11", + "scroll-into-view-if-needed": "^3.1.0", + "throttle-debounce": "^5.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ant-design" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/app-builder-bin": { + "version": "5.0.0-alpha.12", + "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-5.0.0-alpha.12.tgz", + "integrity": "sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/app-builder-lib": { + "version": "26.9.0", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.9.0.tgz", + "integrity": "sha512-f/1GhVrDfBH7sSzhAwiXa1rpR/7pB7Av5woUjmt+QYE0QyNvrOAiY05rngtR/PK4/1BzS6/zVoYobIwDAsrtBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@develar/schema-utils": "~2.6.5", + "@electron/asar": "3.4.1", + "@electron/fuses": "^1.8.0", + "@electron/get": "^3.0.0", + "@electron/notarize": "2.5.0", + "@electron/osx-sign": "1.3.3", + "@electron/rebuild": "^4.0.3", + "@electron/universal": "2.0.3", + "@malept/flatpak-bundler": "^0.4.0", + "@types/fs-extra": "9.0.13", + "async-exit-hook": "^2.0.1", + "builder-util": "26.9.0", + "builder-util-runtime": "9.6.0", + "chromium-pickle-js": "^0.2.0", + "ci-info": "4.3.1", + "debug": "^4.3.4", + "dotenv": "^16.4.5", + "dotenv-expand": "^11.0.6", + "ejs": "^3.1.8", + "electron-publish": "26.9.0", + "fs-extra": "^10.1.0", + "hosted-git-info": "^4.1.0", + "isbinaryfile": "^5.0.0", + "jiti": "^2.4.2", + "js-yaml": "^4.1.0", + "json5": "^2.2.3", + "lazy-val": "^1.0.5", + "minimatch": "^10.0.3", + "plist": "3.1.0", + "proper-lockfile": "^4.1.2", + "resedit": "^1.7.0", + "semver": "~7.7.3", + "tar": "^7.5.7", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0", + "which": "^5.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "dmg-builder": "26.9.0", + "electron-builder-squirrel-windows": "26.9.0" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-3.1.0.tgz", + "integrity": "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==", + "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": ">=14" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/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_modules/app-builder-lib/node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/app-builder-lib/node_modules/ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/app-builder-lib/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/app-builder-lib/node_modules/fs-extra/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/app-builder-lib/node_modules/fs-extra/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/app-builder-lib/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/app-builder-lib/node_modules/which": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.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": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmmirror.com/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmmirror.com/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmmirror.com/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmmirror.com/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmmirror.com/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/assert-never": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/assert-never/-/assert-never-1.4.0.tgz", + "integrity": "sha512-5oJg84os6NMQNl27T9LnZkvvqzvAnHu03ShCnoj6bsJwS7L8AO4lf+C/XjK/nvzEqQB744moC6V128RucQd1jA==", + "dev": true, + "license": "MIT" + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-exit-hook": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", + "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmmirror.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/axios/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axios/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/babel-walk": { + "version": "3.0.0-canary-5", + "resolved": "https://registry.npmmirror.com/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" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.21", + "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.21.tgz", + "integrity": "sha512-Q+rUQ7Uz8AHM7DEaNdwvfFCTq7a43lNTzuS94eiWqwyxfV/wJv+oUivef51T91mmRY4d4A1u9rcSvkeufCVXlA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/basic-ftp": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-6.0.1.tgz", + "integrity": "sha512-3ilxa3n4276wGQp/ImRAuz4ALdsj/2Wd3FqoZBZlajDYnByCZ0JMb4+26Rde0wGXIbM0G2HWSfr/Fi8b21KX8g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmmirror.com/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/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/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==", + "license": "MIT" + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmmirror.com/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/builder-util": { + "version": "26.9.0", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.9.0.tgz", + "integrity": "sha512-+eocmbdisnyb40B9nAp/KREfYLXvGagxV50KZv/Zh4aflsr1fdY9Qxs6QG1Jtx1vH5d5NQ3hIcemUi4RSlFK/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.6", + "7zip-bin": "~5.2.0", + "app-builder-bin": "5.0.0-alpha.12", + "builder-util-runtime": "9.6.0", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.6", + "debug": "^4.3.4", + "fs-extra": "^10.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "js-yaml": "^4.1.0", + "sanitize-filename": "^1.6.3", + "source-map-support": "^0.5.19", + "stat-mode": "^1.0.0", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0" + } + }, + "node_modules/builder-util-runtime": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.6.0.tgz", + "integrity": "sha512-k9V5I18PXLepLZ8jVmPzsH+gVCVZ+9aMVLyCZ0ZLOkT2KSyiBblDCCN8WxDbjOpfLGNHZqqJPBmW0HYeqxlgkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/builder-util/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/builder-util/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/builder-util/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/builtins": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/builtins/-/builtins-5.1.0.tgz", + "integrity": "sha512-SW9lzGTLvWTP1AY8xeAMZimqDrIaSdLQUcVr9DMef51niJ022Ri87SwRRKYm4A6iHfkPaiVUu/Duw2Wc4J7kKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.0.0" + } + }, + "node_modules/builtins/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/busboy": { + "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" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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.npmmirror.com/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001790", + "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001790.tgz", + "integrity": "sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/character-parser/-/character-parser-2.2.0.tgz", + "integrity": "sha512-+UqJQjFEFaTAs3bNsF2j2kEN1baG/zghZbdqoYEDxGZtJo9LBzl1A+m0D4n3qKx8N2FNv8/Xp6yV9mQmBuptaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-regex": "^1.0.3" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/child-shell": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/child-shell/-/child-shell-5.0.0.tgz", + "integrity": "sha512-mNki2AmChI8IQM+MQlbh1/+wuqWtosqMrWRv3+RVY19M2IX0cOlGCfaC6DXRgqwnLASsFsZcvUhSJoOVc12mHQ==", + "license": "MIT", + "dependencies": { + "accumulate-stream": "^5.0.0", + "debug": "^4.3.2", + "kind-of": "^6.0.3", + "nanoid": "^3.1.30", + "p-queue": "6.6.2", + "p-timeout": "4.1.0", + "trim-buffer": "^5.0.0" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/chromium-pickle-js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", + "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmmirror.com/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-truncate": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", + "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "slice-ansi": "^3.0.0", + "string-width": "^4.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmmirror.com/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmmirror.com/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/compare-version": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", + "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/compute-scroll-into-view": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", + "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-stream": { + "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" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/constantinople": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/constantinople/-/constantinople-4.0.1.tgz", + "integrity": "sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.6.0", + "@babel/types": "^7.6.1" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmmirror.com/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmmirror.com/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/crc": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", + "integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.1.0" + } + }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmmirror.com/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/diffie-hellman": { + "version": "5.0.3", + "resolved": "https://registry.npmmirror.com/diffie-hellman/-/diffie-hellman-5.0.3.tgz", + "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.1.0", + "miller-rabin": "^4.0.0", + "randombytes": "^2.0.0" + } + }, + "node_modules/dir-compare": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", + "integrity": "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.5", + "p-limit": "^3.1.0 " + } + }, + "node_modules/dir-compare/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/dir-compare/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/dir-compare/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/dmg-builder": { + "version": "26.9.0", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.9.0.tgz", + "integrity": "sha512-5uSyg0yY+JRMMGwFjIweaukYkCGcZSZwvH5VPlBHiKkTEP1a1/uV+bs4y+VAxPMgzg67YB4CF1vD4U/2d3chfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.9.0", + "builder-util": "26.9.0", + "fs-extra": "^10.1.0", + "iconv-lite": "^0.6.2", + "js-yaml": "^4.1.0" + }, + "optionalDependencies": { + "dmg-license": "^1.0.11" + } + }, + "node_modules/dmg-builder/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dmg-builder/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dmg-builder/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/dmg-builder/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/dmg-license": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/dmg-license/-/dmg-license-1.0.11.tgz", + "integrity": "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "@types/plist": "^3.0.1", + "@types/verror": "^1.10.3", + "ajv": "^6.10.0", + "crc": "^3.8.0", + "iconv-corefoundation": "^1.1.7", + "plist": "^3.0.4", + "smart-buffer": "^4.0.2", + "verror": "^1.10.0" + }, + "bin": { + "dmg-license": "bin/dmg-license.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/doctypes": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/doctypes/-/doctypes-1.1.0.tgz", + "integrity": "sha512-LLBi6pEqS6Do3EKQ3J0NqHWV5hhb78Pi8vvESYwyOy2c31ZEZVdtitdzsQsKb7878PEERhzUk0ftqGhG6Mz+pQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "16.4.5", + "resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-16.4.5.tgz", + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", + "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electerm-icons": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/electerm-icons/-/electerm-icons-1.0.1.tgz", + "integrity": "sha512-H02wmSBVu15gYr/dujteePXrX0UvJgomdJugf5C/cIheYNCkq0aIpW82/quy0R/N4u86f8ytqsg/VaFu1BlJ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/electerm-sync": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/electerm-sync/-/electerm-sync-2.0.0.tgz", + "integrity": "sha512-OPYuSkBJ7PgW1uEmF8kQ1EQzYvVVRatmoy/QULUQMdrQj2ndwGoWrBC1aaz0XkvVygqeFNnUEuKm5ydtXxhU7w==", + "license": "MIT", + "dependencies": { + "jsonwebtoken": "^9.0.3" + } + }, + "node_modules/electron": { + "version": "41.2.0", + "resolved": "https://registry.npmmirror.com/electron/-/electron-41.2.0.tgz", + "integrity": "sha512-0OKLiymqfV0WK68RBXqAm3Myad2TpI5wwxLCBEUcH5Nugo3YfSk7p1Js/AL9266qTz5xZioUnxt9hG8FFwax0g==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@electron/get": "^2.0.0", + "@types/node": "^24.9.0", + "extract-zip": "^2.0.1" + }, + "bin": { + "electron": "cli.js" + }, + "engines": { + "node": ">= 12.20.55" + } + }, + "node_modules/electron-builder": { + "version": "26.9.0", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.9.0.tgz", + "integrity": "sha512-7BEeGz8Xlk7Qvitj70nGAQaq7WQIZ2amsDvsyKSZbxgtL2pM9fm/woZrtn0hoID6Fl7wkn456dK3OmtFNzgiOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.9.0", + "builder-util": "26.9.0", + "builder-util-runtime": "9.6.0", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "dmg-builder": "26.9.0", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "simple-update-notifier": "2.0.0", + "yargs": "^17.6.2" + }, + "bin": { + "electron-builder": "cli.js", + "install-app-deps": "install-app-deps.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/electron-builder/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-builder/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmmirror.com/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-builder/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-log": { + "version": "4.3.5", + "resolved": "https://registry.npmmirror.com/electron-log/-/electron-log-4.3.5.tgz", + "integrity": "sha512-J5Ew3axdk7W4jzzxKLSAi1sqbcAoo9CzHuBVsG0tT47j256xKulNrWFf3lZmHJ1KDXOQUcuwOngQF0jjmpEdpw==", + "license": "MIT" + }, + "node_modules/electron-publish": { + "version": "26.9.0", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.9.0.tgz", + "integrity": "sha512-gsy+U7JfDuD1lPOrCXeECDQoUsWjFah3s1Fv3pqKnjdJuKYDDvGdvC74kLHVG6nl5G0uQ7YN0eftCQ4rUmhvVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^9.0.11", + "builder-util": "26.9.0", + "builder-util-runtime": "9.6.0", + "chalk": "^4.1.2", + "form-data": "^4.0.5", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "mime": "^2.5.2" + } + }, + "node_modules/electron-publish/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-publish/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-publish/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.344", + "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", + "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==", + "dev": true, + "license": "ISC" + }, + "node_modules/electron/node_modules/@types/node": { + "version": "24.12.2", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/electron/node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmmirror.com/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmmirror.com/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.3.2", + "resolved": "https://registry.npmmirror.com/es-iterator-helpers/-/es-iterator-helpers-1.3.2.tgz", + "integrity": "sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/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": { + "version": "8.57.1", + "resolved": "https://registry.npmmirror.com/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-standard": { + "version": "17.1.0", + "resolved": "https://registry.npmmirror.com/eslint-config-standard/-/eslint-config-standard-17.1.0.tgz", + "integrity": "sha512-IwHwmaBNtDK4zDHQukFDW5u/aTb8+meQWZvNFWkiGmbWjD6bqyuSSBxxXKkCftCUzc1zwCH2m/baCNDLGmuO5Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "eslint": "^8.0.1", + "eslint-plugin-import": "^2.25.2", + "eslint-plugin-n": "^15.0.0 || ^16.0.0 ", + "eslint-plugin-promise": "^6.0.0" + } + }, + "node_modules/eslint-config-standard-jsx": { + "version": "11.0.0", + "resolved": "https://registry.npmmirror.com/eslint-config-standard-jsx/-/eslint-config-standard-jsx-11.0.0.tgz", + "integrity": "sha512-+1EV/R0JxEK1L0NGolAr8Iktm3Rgotx3BKwgaX+eAuSX8D952LULKtjgZD3F+e6SvibONnhLwoTi9DPxN5LvvQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "peerDependencies": { + "eslint": "^8.8.0", + "eslint-plugin-react": "^7.28.0" + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.10", + "resolved": "https://registry.npmmirror.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", + "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.16.1", + "resolve": "^2.0.0-next.6" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmmirror.com/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/resolve": { + "version": "2.0.0-next.6", + "resolved": "https://registry.npmmirror.com/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmmirror.com/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmmirror.com/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-es": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/eslint-plugin-es/-/eslint-plugin-es-4.1.0.tgz", + "integrity": "sha512-GILhQTnjYE2WorX5Jyi5i4dz5ALWxBIdQECVQavL6s7cI76IZTDWleTHkxz/QT3kvcs2QlGHvKLYsSlPOlPXnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-utils": "^2.0.0", + "regexpp": "^3.0.0" + }, + "engines": { + "node": ">=8.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": ">=4.19.1" + } + }, + "node_modules/eslint-plugin-es/node_modules/eslint-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/eslint-utils/-/eslint-utils-2.1.0.tgz", + "integrity": "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^1.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + } + }, + "node_modules/eslint-plugin-es/node_modules/eslint-visitor-keys": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", + "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmmirror.com/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-import/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmmirror.com/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-import/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-n": { + "version": "15.7.0", + "resolved": "https://registry.npmmirror.com/eslint-plugin-n/-/eslint-plugin-n-15.7.0.tgz", + "integrity": "sha512-jDex9s7D/Qial8AGVIHq4W7NswpUD5DPDL2RH8Lzd9EloWUuvUkHfv4FRLMipH5q2UtyurorBkPeNi1wVWNh3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "builtins": "^5.0.1", + "eslint-plugin-es": "^4.1.0", + "eslint-utils": "^3.0.0", + "ignore": "^5.1.1", + "is-core-module": "^2.11.0", + "minimatch": "^3.1.2", + "resolve": "^1.22.1", + "semver": "^7.3.8" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-n/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-n/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-n/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-n/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/eslint-plugin-promise": { + "version": "6.6.0", + "resolved": "https://registry.npmmirror.com/eslint-plugin-promise/-/eslint-plugin-promise-6.6.0.tgz", + "integrity": "sha512-57Zzfw8G6+Gq7axm2Pdo3gW/Rx3h9Yywgn61uE/3elTCOePEHVrn2i5CdfBwA1BLK0Q0WqctICIUSqXZW/VprQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmmirror.com/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-react/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-react/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.6", + "resolved": "https://registry.npmmirror.com/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmmirror.com/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-utils": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/eslint-utils/-/eslint-utils-3.0.0.tgz", + "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^2.0.0" + }, + "engines": { + "node": "^10.0.0 || ^12.0.0 || >= 14.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": ">=5" + } + }, + "node_modules/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", + "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmmirror.com/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmmirror.com/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmmirror.com/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmmirror.com/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" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-ws": { + "version": "5.0.2", + "resolved": "https://registry.npmmirror.com/express-ws/-/express-ws-5.0.2.tgz", + "integrity": "sha512-0uvmuk61O9HXgLhGl3QhNSEtRsQevtmbL94/eILaliEADZBHZOQUAiHFrGPrgsjikohyrmSG5g+sCfASTt0lkQ==", + "license": "BSD-2-Clause", + "dependencies": { + "ws": "^7.4.6" + }, + "engines": { + "node": ">=4.5.0" + }, + "peerDependencies": { + "express": "^4.0.0 || ^5.0.0-alpha.1" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/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/extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "optional": true + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmmirror.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/filesize": { + "version": "10.1.6", + "resolved": "https://registry.npmmirror.com/filesize/-/filesize-10.1.6.tgz", + "integrity": "sha512-sJslQKU2uM33qH5nqewAwVB2QgR6w1aMNsYUp3aN5rMRyXEwJGmZvaWzeJFNTOXWlHQyBFCWrdj3fV/fsTOX8w==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 10.4.0" + } + }, + "node_modules/filesize-parser": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/filesize-parser/-/filesize-parser-1.5.1.tgz", + "integrity": "sha512-wRjdlQ5JM3WHZp6xpakIHQbkcGig8ANglYQDPcQSgZUN5kcDGOgmAwB0396BxzHxcl+kr+GLuusxBnsjdO6x9A==", + "dev": true, + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "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" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-free-port": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/find-free-port/-/find-free-port-2.0.0.tgz", + "integrity": "sha512-J1j8gfEVf5FN4PR5w5wrZZ7NYs2IvqsHcd03cAeQx3Ec/mo+lKceaVNhpsRKoZpZKbId88o8qh+dwUwzBV6WCg==", + "license": "ISC" + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmmirror.com/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/font-list": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/font-list/-/font-list-1.5.1.tgz", + "integrity": "sha512-Hr5V0dsSv91wH3FgirXd7qh1PydqA/vMQyWjFFWn+lUPJtC+3i2tzgVqbLRcvQh87TGdbTGbAR3mEo4VlwC1jw==", + "license": "MIT" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmmirror.com/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmmirror.com/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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.npmmirror.com/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmmirror.com/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_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmmirror.com/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stdin": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/get-stdin/-/get-stdin-8.0.0.tgz", + "integrity": "sha512-sY22aA6xchAzprjyqmSEQv4UbAAzRN0L2dQB0NlN5acTTK9Don6nhoc3eAbUnpZiCANAMfd/+40kVdKfFygohg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmmirror.com/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/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.7.4", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmmirror.com/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globals/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmmirror.com/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/https-proxy-agent/-/https-proxy-agent-7.0.1.tgz", + "integrity": "sha512-Eun8zV0kcYS1g19r78osiQLEFIRspRUDd9tIfBCTBPBeMieF/EsJNL8VI3xOIdYRDEkjQnqOYPsZ2DsWsVsFwQ==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.0.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-corefoundation": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", + "integrity": "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "cli-truncate": "^2.1.0", + "node-addon-api": "^1.6.3" + }, + "engines": { + "node": "^8.11.2 || >=10" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmmirror.com/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmmirror.com/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "dev": true, + "license": "MIT" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/interpret": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/interpret/-/interpret-1.4.0.tgz", + "integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/ironrdp-wasm": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/ironrdp-wasm/-/ironrdp-wasm-1.1.0.tgz", + "integrity": "sha512-AKF9Du3DeFIO3ej3pRQmAXAnwNAU8/rLsep1od3NW9WRYqmslQMA7skjJW/E/+BH3EtNHnjxyAgAkvtyucSRQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmmirror.com/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmmirror.com/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmmirror.com/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-expression": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/is-expression/-/is-expression-4.0.0.tgz", + "integrity": "sha512-zMIXX63sxzG3XrkHkrAPvm/OVZVSCPNkwMHU8oTX7/U3AL78I0QXCEICXUM13BIa8TYGZ68PiTKfQz3yaTNr4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^7.1.1", + "object-assign": "^4.1.1" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-mobile": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/is-mobile/-/is-mobile-5.0.0.tgz", + "integrity": "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmmirror.com/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isbinaryfile": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", + "integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmmirror.com/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-stringify": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/js-stringify/-/js-stringify-1.0.2.tgz", + "integrity": "sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-deep-copy": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/json-deep-copy/-/json-deep-copy-1.3.1.tgz", + "integrity": "sha512-DXkycXRtYFALaySPLuy0yVXJLuo5+GBC9O/lvpRZE3u6qk43w/liIlQMhFv/tGwe05MG03RSeXRRCy1cHf3TCw==", + "license": "MIT" + }, + "node_modules/json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/json2mq/-/json2mq-0.2.0.tgz", + "integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-convert": "^0.2.0" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jstransformer": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/jstransformer/-/jstransformer-1.0.0.tgz", + "integrity": "sha512-C9YK3Rf8q6VAPDCCU9fnqo3mAfOH6vUGnMcP4AQAYIEpWtfGLpwOTmZ+igtdK5y+VvI2n3CyYSzy4Qh34eq24A==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-promise": "^2.0.0", + "promise": "^7.0.1" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmmirror.com/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmmirror.com/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/lazy-val": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", + "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "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.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/load-json-file": { + "version": "5.3.0", + "resolved": "https://registry.npmmirror.com/load-json-file/-/load-json-file-5.3.0.tgz", + "integrity": "sha512-cJGP40Jc/VXUsp8/OrnyKyTZ1y6v/dphm3bioS+RrKXjK2BB6wHUd6JptZEFDGgGahMT+InnZO5i1Ei9mpC8Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.15", + "parse-json": "^4.0.0", + "pify": "^4.0.1", + "strip-bom": "^3.0.0", + "type-fest": "^0.3.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/load-json-file/node_modules/type-fest": { + "version": "0.3.1", + "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-0.3.1.tgz", + "integrity": "sha512-cUGJnCdr4STbePCgqNFbpVNCepa+kAVohJs1sLhxzdH+gnEoOd8VhbYa7pD3zZYGiURWM2xzEII3fQcRizDkYQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=6" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmmirror.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/manate": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/manate/-/manate-2.0.3.tgz", + "integrity": "sha512-w3PpN9UcyAZspKVMAYVtITBkydlZo0WuzLbzcCTb2f0Hz8tKUC0LmYb2eB00CEKLUTWmh4erb+qZLlNkflZA6Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmmirror.com/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/miller-rabin": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/miller-rabin/-/miller-rabin-4.0.1.tgz", + "integrity": "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "brorand": "^1.0.1" + }, + "bin": { + "miller-rabin": "bin/miller-rabin" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmmirror.com/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" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmmirror.com/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/morgan": { + "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", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.4.1", + "on-headers": "~1.1.0" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/morgan/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/morgan/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/multer": { + "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", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "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" + } + }, + "node_modules/nan": { + "version": "2.26.2", + "resolved": "https://registry.npmmirror.com/nan/-/nan-2.26.2.tgz", + "integrity": "sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==", + "license": "MIT", + "optional": true + }, + "node_modules/nanoid": { + "version": "3.3.8", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", + "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/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "4.31.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.31.0.tgz", + "integrity": "sha512-Erq5w/t3syw3s4sDsUaX4QttIdBPsGKTT1DTRsCkTonGggczhlDKm/wDX3o+HPJpQ41EjXCbcmXf0tgr5YZJXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.6.3" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz", + "integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-api-version": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", + "integrity": "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + } + }, + "node_modules/node-api-version/node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-bash": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/node-bash/-/node-bash-5.0.1.tgz", + "integrity": "sha512-a6cCJGtgDal9gGrv+N7IaAvHX98Yn+jEUVa5H6ycKWmmgM0SW1609YVA+fP7IU9Efu8fJi82cR8G1RxL9RwqWg==", + "license": "MIT", + "dependencies": { + "child-shell": "^5.0.0" + } + }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-forge": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/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": { + "version": "12.3.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.3.0.tgz", + "integrity": "sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^6.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmmirror.com/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-gyp/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/node-gyp/node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-pty": { + "version": "1.1.0-beta34", + "resolved": "https://registry.npmmirror.com/node-pty/-/node-pty-1.1.0-beta34.tgz", + "integrity": "sha512-RraDtX9RS1G1I5iO7e4YIOIA4arzd4ZVCD4mZr7+szaNupoTg9fxDCRr0EanqS0Qlzgm3PIdHNbPmblJguJuyg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.1.0" + } + }, + "node_modules/node-pty/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nopt": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmmirror.com/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmmirror.com/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmmirror.com/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/opentype.js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-2.0.0.tgz", + "integrity": "sha512-kCyjv6xdDY1W/jLWZ/L3QhhTlKUqDZMQ5+Jdlw12b3dXkKNpYBqqlMMj0YDQPShWFTMwgZI1hG14kN3XUDSg/A==", + "dev": true, + "license": "MIT", + "bin": { + "ot": "bin/ot" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/os-locale-s": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/os-locale-s/-/os-locale-s-1.1.3.tgz", + "integrity": "sha512-qwwsGBlE9+qMnrCYk6UHKNklRA10E099h0uqH/7sSJzStTrJifz10MjmbDEoTg5oe2fL8boZBULOjxnBdS+63Q==", + "license": "MIT", + "engines": { + "node": ">=14.14", + "yarn": "^1.22.4" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue": { + "version": "6.6.2", + "resolved": "https://registry.npmmirror.com/p-queue/-/p-queue-6.6.2.tgz", + "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.4", + "p-timeout": "^3.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue/node_modules/p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "license": "MIT", + "dependencies": { + "p-finally": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-timeout": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/p-timeout/-/p-timeout-4.1.0.tgz", + "integrity": "sha512-+/wmHtzJuWii1sXn3HCuH/FTwGhrp4tmJTxSKJbfS+vkipci6osxXM5mY0jUiRzWKMTgUT8l7HFbeSwZAynqHw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmmirror.com/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.3.5", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmmirror.com/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" + } + }, + "node_modules/pe-library": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", + "integrity": "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-conf": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/pkg-conf/-/pkg-conf-3.1.0.tgz", + "integrity": "sha512-m0OTbR/5VPNPqO1ph6Fqbj7Hv6QU7gR/tQW40ZqrL1rjgCU85W6C1bJn0BItuJqnR98PWzw7Z8hHeChD1WrgdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^3.0.0", + "load-json-file": "^5.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-conf/node_modules/find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-conf/node_modules/locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-conf/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-conf/node_modules/p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-conf/node_modules/path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/plist": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", + "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "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/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/promise/-/promise-7.3.1.tgz", + "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "asap": "~2.0.3" + } + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmmirror.com/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmmirror.com/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/pug": { + "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", + "pug-filters": "^4.0.0", + "pug-lexer": "^5.0.1", + "pug-linker": "^4.0.0", + "pug-load": "^3.0.0", + "pug-parser": "^6.0.0", + "pug-runtime": "^3.0.1", + "pug-strip-comments": "^2.0.0" + } + }, + "node_modules/pug-attrs": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/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", + "js-stringify": "^1.0.2", + "pug-runtime": "^3.0.0" + } + }, + "node_modules/pug-code-gen": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/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", + "doctypes": "^1.1.0", + "js-stringify": "^1.0.2", + "pug-attrs": "^3.0.0", + "pug-error": "^2.1.0", + "pug-runtime": "^3.0.1", + "void-elements": "^3.1.0", + "with": "^7.0.0" + } + }, + "node_modules/pug-error": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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", + "jstransformer": "1.0.0", + "pug-error": "^2.0.0", + "pug-walk": "^2.0.0", + "resolve": "^1.15.1" + } + }, + "node_modules/pug-lexer": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/pug-lexer/-/pug-lexer-5.0.1.tgz", + "integrity": "sha512-0I6C62+keXlZPZkOJeVam9aBLVP2EnbeDw3An+k0/QlqdwH6rv8284nko14Na7c0TtqtogfWXcRoFE4O4Ff20w==", + "dev": true, + "license": "MIT", + "dependencies": { + "character-parser": "^2.2.0", + "is-expression": "^4.0.0", + "pug-error": "^2.0.0" + } + }, + "node_modules/pug-linker": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/pug-linker/-/pug-linker-4.0.0.tgz", + "integrity": "sha512-gjD1yzp0yxbQqnzBAdlhbgoJL5qIFJw78juN1NpTLt/mfPJ5VgC4BvkoD3G23qKzJtIIXBbcCt6FioLSFLOHdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pug-error": "^2.0.0", + "pug-walk": "^2.0.0" + } + }, + "node_modules/pug-load": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/pug-load/-/pug-load-3.0.0.tgz", + "integrity": "sha512-OCjTEnhLWZBvS4zni/WUMjH2YSUosnsmjGBB1An7CsKQarYSWQ0GCVyd4eQPMFJqZ8w9xgs01QdiZXKVjk92EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4.1.1", + "pug-walk": "^2.0.0" + } + }, + "node_modules/pug-parser": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/pug-parser/-/pug-parser-6.0.0.tgz", + "integrity": "sha512-ukiYM/9cH6Cml+AOl5kETtM9NR3WulyVP2y4HOU45DyMim1IeP/OOiyEWRr6qk5I5klpsBnbuHpwKmTx6WURnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pug-error": "^2.0.0", + "token-stream": "1.0.0" + } + }, + "node_modules/pug-runtime": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/pug-strip-comments/-/pug-strip-comments-2.0.0.tgz", + "integrity": "sha512-zo8DsDpH7eTkPHCXFeAk1xZXJbyoTfdPlNR0bK7rpOMuhBYb0f5qUVCO1xlsitYd3w5FQTK7zpNVKb3rZoUrrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "pug-error": "^2.0.0" + } + }, + "node_modules/pug-walk": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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.npmmirror.com/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-markdown": { + "version": "9.0.1", + "resolved": "https://registry.npmmirror.com/react-markdown/-/react-markdown-9.0.1.tgz", + "integrity": "sha512-186Gw/vF1uRkydbsOIkcGXw7aHq0sZOCRFFjGrr7b9+nVZg4UfA4enXCaxm4fUzecU38sWfrNDitGhshuU7rdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/read-binary-file-arch": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", + "integrity": "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "bin": { + "read-binary-file-arch": "cli.js" + } + }, + "node_modules/readable-stream": { + "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", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/rechoir": { + "version": "0.6.2", + "resolved": "https://registry.npmmirror.com/rechoir/-/rechoir-0.6.2.tgz", + "integrity": "sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==", + "dev": true, + "dependencies": { + "resolve": "^1.1.6" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmmirror.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmmirror.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexpp": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/regexpp/-/regexpp-3.2.0.tgz", + "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmmirror.com/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmmirror.com/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/replace-in-file": { + "version": "6.3.5", + "resolved": "https://registry.npmmirror.com/replace-in-file/-/replace-in-file-6.3.5.tgz", + "integrity": "sha512-arB9d3ENdKva2fxRnSjwBEXfK1npgyci7ZZuwysgAp7ORjHSyxz6oqIjTEv8R0Ydl4Ll7uOAZXL4vbkhGIizCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "glob": "^7.2.0", + "yargs": "^17.2.1" + }, + "bin": { + "replace-in-file": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/replace-in-file/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/replace-in-file/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/replace-in-file/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/replace-in-file/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resedit": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", + "integrity": "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pe-library": "^0.4.1" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/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/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmmirror.com/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.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", + "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.137.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.3", + "@rolldown/binding-darwin-arm64": "1.1.3", + "@rolldown/binding-darwin-x64": "1.1.3", + "@rolldown/binding-freebsd-x64": "1.1.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", + "@rolldown/binding-linux-arm64-gnu": "1.1.3", + "@rolldown/binding-linux-arm64-musl": "1.1.3", + "@rolldown/binding-linux-ppc64-gnu": "1.1.3", + "@rolldown/binding-linux-s390x-gnu": "1.1.3", + "@rolldown/binding-linux-x64-gnu": "1.1.3", + "@rolldown/binding-linux-x64-musl": "1.1.3", + "@rolldown/binding-openharmony-arm64": "1.1.3", + "@rolldown/binding-wasm32-wasi": "1.1.3", + "@rolldown/binding-win32-arm64-msvc": "1.1.3", + "@rolldown/binding-win32-x64-msvc": "1.1.3" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/sanitize-filename": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz", + "integrity": "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==", + "dev": true, + "license": "WTFPL OR ISC", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/scroll-into-view-if-needed": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", + "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "compute-scroll-into-view": "^3.0.2" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "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" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/serialport": { + "version": "13.0.0", + "resolved": "https://registry.npmmirror.com/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": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/serialport/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmmirror.com/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/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmmirror.com/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shelljs": { + "version": "0.8.5", + "resolved": "https://registry.npmmirror.com/shelljs/-/shelljs-0.8.5.tgz", + "integrity": "sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "glob": "^7.0.0", + "interpret": "^1.0.0", + "rechoir": "^0.6.2" + }, + "bin": { + "shjs": "bin/shjs" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/shelljs/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/shelljs/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/shelljs/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/shelljs/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/slice-ansi": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", + "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.1", + "resolved": "https://registry.npmmirror.com/socks-proxy-agent/-/socks-proxy-agent-8.0.1.tgz", + "integrity": "sha512-59EjPbbgg8U3x62hhKOFVAmySQUcfRQ4C7Q/D5sEHnZTQRrQlNKINks44DMR1gwXp0p4LaVIeccX2KHTTcHVqQ==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.0.1", + "debug": "^4.3.4", + "socks": "^2.7.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/socksv5-server": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/socksv5-server/-/socksv5-server-1.0.2.tgz", + "integrity": "sha512-XHBXcUdiTYNtykkFm/29iDiuLz6U2620AaEQGDR5Y9izPVGZQJC3huFj0WaPeVDZf8BvnVcrUmem0nF62zlUMA==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/spice-client": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/spice-client/-/spice-client-1.2.0.tgz", + "integrity": "sha512-FOAkyvK0mSZmrzY1ueB9cbQKcgkyReNpyko6nnnpy3sWKtrRJGJkmwm5Hw4DaZAa0EzLfqu4m7tCmWCxorP0GQ==", + "dev": true, + "license": "LGPL-3.0-or-later" + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/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/ssh-config-loader": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/ssh-config-loader/-/ssh-config-loader-1.1.2.tgz", + "integrity": "sha512-az9CX2S8zJjDVHwLMIh2wOY0HSY5LFcMIQQw0y+PfTNmDfC35uoEYOgh+OqxpWZcPwDcwf9IO+1uQnOjehbZVA==", + "license": "MIT" + }, + "node_modules/ssh2-scp": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/ssh2-scp/-/ssh2-scp-3.2.0.tgz", + "integrity": "sha512-JOJzQJtKHhxXXyh3ksjHnPWUKS7GZzGMK2d9CL9pHbBzeyyfOsjpYa1CVcFWxsHQf8dBdcUBGOKtNReSsl9CVQ==", + "license": "MIT", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/standard": { + "version": "17.1.2", + "resolved": "https://registry.npmmirror.com/standard/-/standard-17.1.2.tgz", + "integrity": "sha512-WLm12WoXveKkvnPnPnaFUUHuOB2cUdAsJ4AiGHL2G0UNMrcRAWY2WriQaV8IQ3oRmYr0AWUbLNr94ekYFAHOrA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "eslint": "^8.41.0", + "eslint-config-standard": "17.1.0", + "eslint-config-standard-jsx": "^11.0.0", + "eslint-plugin-import": "^2.27.5", + "eslint-plugin-n": "^15.7.0", + "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-react": "^7.36.1", + "standard-engine": "^15.1.0", + "version-guard": "^1.1.1" + }, + "bin": { + "standard": "bin/cmd.cjs" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/standard-engine": { + "version": "15.1.0", + "resolved": "https://registry.npmmirror.com/standard-engine/-/standard-engine-15.1.0.tgz", + "integrity": "sha512-VHysfoyxFu/ukT+9v49d4BRXIokFRZuH3z1VRxzFArZdjSCFpro6rEIU3ji7e4AoAtuSfKBkiOmsrDqKW5ZSRw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "get-stdin": "^8.0.0", + "minimist": "^1.2.6", + "pkg-conf": "^3.1.0", + "xdg-basedir": "^4.0.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/stat-mode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", + "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/streamsearch": { + "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" + } + }, + "node_modules/string_decoder": { + "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" + } + }, + "node_modules/string-convert": { + "version": "0.2.1", + "resolved": "https://registry.npmmirror.com/string-convert/-/string-convert-0.2.1.tgz", + "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmmirror.com/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmmirror.com/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "dev": true, + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmmirror.com/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmmirror.com/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmmirror.com/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "dev": true, + "license": "MIT" + }, + "node_modules/stylus": { + "version": "0.64.0", + "resolved": "https://registry.npmmirror.com/stylus/-/stylus-0.64.0.tgz", + "integrity": "sha512-ZIdT8eUv8tegmqy1tTIdJv9We2DumkNZFdCF5mz/Kpq3OcTaxSuCAYZge6HKK2CmNC02G1eJig2RV7XTw5hQrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "~4.3.3", + "debug": "^4.3.2", + "glob": "^10.4.5", + "sax": "~1.4.1", + "source-map": "^0.7.3" + }, + "bin": { + "stylus": "bin/stylus" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://opencollective.com/stylus" + } + }, + "node_modules/stylus/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/stylus/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/stylus/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmmirror.com/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/stylus/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/stylus/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/stylus/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmmirror.com/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/stylus/node_modules/sax": { + "version": "1.4.4", + "resolved": "https://registry.npmmirror.com/sax/-/sax-1.4.4.tgz", + "integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/stylus/node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tar": { + "version": "7.5.17", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.17.tgz", + "integrity": "sha512-wPEBwzapC+2PaTYPH6e2L+cNOEE227S47wUYFqlegcs8zlLLmeb9Fcff1HVZY4Fwku/1Eyv38n7GYwB2aaS71g==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/temp-file": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", + "integrity": "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-exit-hook": "^2.0.1", + "fs-extra": "^10.0.0" + } + }, + "node_modules/temp-file/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/temp-file/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/temp-file/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/throttle-debounce": { + "version": "5.0.2", + "resolved": "https://registry.npmmirror.com/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.22" + } + }, + "node_modules/tiny-async-pool": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", + "integrity": "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^5.5.0" + } + }, + "node_modules/tiny-async-pool/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", + "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tmp": "^0.2.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/token-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/token-stream/-/token-stream-1.0.0.tgz", + "integrity": "sha512-VSsyNPPW74RpHwR8Fc21uubwHY7wMDeJLys2IX5zJNih+OnAnaifKHo+1LHT7DAdloQ7apeaaWg8l7qnf/TnEg==", + "dev": true, + "license": "MIT" + }, + "node_modules/trim-buffer": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/trim-buffer/-/trim-buffer-5.0.0.tgz", + "integrity": "sha512-PqdsRmhir3lUwraK/O6sdZ9QcmtUK0c6F1utX/WNquJsL4Jpdck+DSe9Mqy8Wk7uAQD/SlVn6cyGF5bj6VF33Q==", + "license": "MIT" + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, + "node_modules/trzsz2": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/trzsz2/-/trzsz2-1.2.0.tgz", + "integrity": "sha512-wJ/Z2CVaY0nnPG6Ev4IxzRyceya1o77V08zRsxSgNNeYxZ1kTaeQxHonLxsO+edaE/KhAiJZ404CI0xO3CrCWQ==", + "license": "MIT", + "dependencies": { + "pako": "^2.1.0", + "ts-md5": "^1.3.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/ts-md5": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/ts-md5/-/ts-md5-1.3.1.tgz", + "integrity": "sha512-DiwiXfwvcTeZ5wCE0z+2A9EseZsztaiZtGrtSaY5JOD7ekPnR/GoIVD5gXZAlK9Na9Kvpo9Waz5rW64WKAWApg==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmmirror.com/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmmirror.com/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmmirror.com/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typedarray": { + "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": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.26.0.tgz", + "integrity": "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmmirror.com/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/util-deprecate": { + "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/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/verror": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/version-guard": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/version-guard/-/version-guard-1.1.3.tgz", + "integrity": "sha512-JwPr6erhX53EWH/HCSzfy1tTFrtPXUe927wdM1jqBBeYp1OM+qPHjWbsvv6pIBduqdgxxS+ScfG7S28pzyr2DQ==", + "dev": true, + "license": "0BSD", + "engines": { + "node": ">=0.10.48" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmmirror.com/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", + "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "~1.1.2", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/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" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmmirror.com/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/with": { + "version": "7.0.2", + "resolved": "https://registry.npmmirror.com/with/-/with-7.0.2.tgz", + "integrity": "sha512-RNGKj82nUPg3g5ygxkQl0R937xLyho1J24ItRCBTr/m1YnZkzJy1hUiHUJrc/VlsDQzsCnInEGSg3bci0Lmd4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.9.6", + "@babel/types": "^7.9.6", + "assert-never": "^1.2.1", + "babel-walk": "3.0.0-canary-5" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmmirror.com/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xdg-basedir": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/xdg-basedir/-/xdg-basedir-4.0.0.tgz", + "integrity": "sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmmirror.com/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zmodem2": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/zmodem2/-/zmodem2-1.4.0.tgz", + "integrity": "sha512-2PG/XvD43I5LKKG09GSsC1irZW5pDTq/5yA29JP118gRv2o3vu+N69x1MzTjFi++O4Nm5nSRdpdZn3Z6D5EneA==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e23ffa9 --- /dev/null +++ b/package.json @@ -0,0 +1,158 @@ +{ + "name": "electerm", + "version": "3.15.128", + "description": "Terminal/ssh/telnet/serialport/sftp client(linux, mac, win)", + "main": "app.js", + "bin": "npm/electerm", + "scripts": { + "app": "node build/bin/app", + "build": "npm run vite-build", + "start": "node build/bin/start.js", + "icon": "node build/bin/icon", + "clean": "node build/bin/clean", + "compile": "node build/bin/build", + "vite-build": "node build/bin/vite-build.js", + "postpublish": "node build/bin/postpublish", + "prepare-file": "node build/bin/prepare", + "pb": "node build/bin/prepare-electron-build.js", + "prepare-test": "PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm i -E playwright@1.28.1 --no-save && npm i -E @playwright/test@1.28.1 --no-save", + "postinstall": "node build/bin/post-install", + "lint": "./node_modules/.bin/standard --verbose", + "fix": "./node_modules/.bin/standard --fix", + "test": "npm run test1 && npm run test2", + "test-unit-ci": "node --test test/unit-ci/*.spec.js", + "test1": "./node_modules/.bin/playwright test test/e2e/00*.js --workers=1", + "test2": "./node_modules/.bin/playwright test test/e2e/01*.js --workers=1", + "test3": "./node_modules/.bin/playwright test test/e2e/02*.js --workers=1 && npm run test-unit-ci", + "b": "npm run clean && npm run compile && npm run prepare-file", + "r": "./build/bin/release", + "w": "./build/bin/web-release", + "t": "./node_modules/.bin/electron work/app/app.js", + "lock": "npm i --package-lock-only" + }, + "license": "MIT", + "langugeRepo": "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", + "repository": { + "type": "git", + "url": "git+https://github.com/electerm/electerm.git" + }, + "author": { + "name": "ZHAO Xudong", + "email": "zxdong@gmail.com", + "url": "https://github.com/zxdong262" + }, + "bugs": { + "url": "https://github.com/electerm/electerm/issues" + }, + "homepage": "https://electerm.org", + "releases": "https://github.com/electerm/electerm/releases", + "engines": { + "node": ">=16.0.0" + }, + "preferGlobal": true, + "devDependencies": { + "@ant-design/icons": "6.2.5", + "@electerm/electerm-resource": "2.1.7", + "@fontsource/maple-mono": "^5.2.5", + "@novnc/novnc": "1.7.0", + "@types/node": "22.9.3", + "@vitejs/plugin-react": "^5.2.0", + "@xterm/addon-attach": "0.13.0-beta.288", + "@xterm/addon-fit": "0.12.0-beta.288", + "@xterm/addon-image": "0.10.0-beta.288", + "@xterm/addon-ligatures": "0.11.0-beta.288", + "@xterm/addon-search": "0.17.0-beta.288", + "@xterm/addon-unicode11": "0.10.0-beta.288", + "@xterm/addon-web-links": "0.13.0-beta.288", + "@xterm/addon-webgl": "0.20.0-beta.287", + "@xterm/xterm": "6.1.0-beta.288", + "antd": "6.4.5", + "classnames": "2.5.1", + "cross-env": "7.0.3", + "dotenv": "16.4.5", + "electerm-icons": "1.0.1", + "electron": "41.2.0", + "electron-builder": "26.9.0", + "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", + "react-dom": "19.2.7", + "react-markdown": "9.0.1", + "replace-in-file": "6.3.5", + "shelljs": "0.8.5", + "spice-client": "1.2.0", + "standard": "^17.1.2", + "stylus": "^0.64.0", + "vite": "8.1.0" + }, + "dependencies": { + "@electerm/electerm-locales": "2.2.16", + "@electerm/electerm-themes": "^1.0.1", + "@electerm/ftp-srv": "1.0.5", + "@electerm/nedb": "2.0.0", + "@electerm/ssh2": "1.19.0", + "@xterm/headless": "6.1.0-beta.288", + "axios": "1.18.1", + "basic-ftp": "6.0.1", + "commander": "12.1.0", + "diffie-hellman": "^5.0.3", + "electerm-sync": "2.0.0", + "electron-log": "4.3.5", + "express": "5.2.1", + "express-ws": "5.0.2", + "fast-deep-equal": "3.1.3", + "find-free-port": "2.0.0", + "font-list": "1.5.1", + "https-proxy-agent": "7.0.1", + "iconv-lite": "^0.7.2", + "json-deep-copy": "1.3.1", + "jsonwebtoken": "^9.0.1", + "nanoid": "3.3.8", + "node-bash": "5.0.1", + "node-forge": "1.4.0", + "node-pty": "1.1.0-beta34", + "os-locale-s": "1.1.3", + "serialport": "13.0.0", + "socks": "2.8.9", + "socks-proxy-agent": "8.0.1", + "socksv5-server": "^1.0.2", + "ssh-config-loader": "1.1.2", + "ssh2-scp": "3.2.0", + "tar": "7.5.17", + "trzsz2": "1.2.0", + "zmodem2": "1.4.0" + }, + "files": [ + "npm", + "README.md", + "LICENSE" + ], + "standard": { + "sourceType": "module", + "ignore": [ + "work", + "temp", + "dist" + ], + "globals": [ + "log", + "MouseEvent", + "WebSocket", + "FileReader", + "CustomEvent", + "onmessage", + "requestAnimationFrame", + "self" + ] + } +} diff --git a/src/app/app.js b/src/app/app.js new file mode 100644 index 0000000..e2a1bf6 --- /dev/null +++ b/src/app/app.js @@ -0,0 +1,13 @@ +/** + * 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/common/app-props.js b/src/app/common/app-props.js new file mode 100644 index 0000000..b1dea75 --- /dev/null +++ b/src/app/common/app-props.js @@ -0,0 +1,44 @@ +/** + * app path + */ +const { app } = require('electron') +const { resolve } = require('path') +const constants = require('./runtime-constants') +const installSrc = require('../lib/install-src') + +function getDataPath () { + const defaultValue = { + appPath: app.getPath('appData'), + isPortable: false + } + if (!constants.isWin) { + return defaultValue + } + const exePath = app.getPath('exe').replace('\\electerm.exe', '') + const p = exePath + '\\' + 'electerm' + if ( + installSrc === 'win-x64-portable.tar.gz' || + require('fs').existsSync( + p + ) + ) { + return { + appPath: exePath, + exePath, + isPortable: true + } + } + return { + ...defaultValue, + exePath + } +} + +module.exports = { + ...getDataPath(), + sshKeysPath: resolve( + app.getPath('home'), + '.ssh' + ), + ...constants +} diff --git a/src/app/common/bookmark-zod-schemas.js b/src/app/common/bookmark-zod-schemas.js new file mode 100644 index 0000000..b1eb0b7 --- /dev/null +++ b/src/app/common/bookmark-zod-schemas.js @@ -0,0 +1,128 @@ +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)'), + 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 new file mode 100644 index 0000000..2510ef0 --- /dev/null +++ b/src/app/common/build-run-scripts.js @@ -0,0 +1,6 @@ +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 new file mode 100644 index 0000000..4b1e587 --- /dev/null +++ b/src/app/common/build-ssh-tunnel.js @@ -0,0 +1,8 @@ +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 new file mode 100644 index 0000000..077709f --- /dev/null +++ b/src/app/common/config-default.js @@ -0,0 +1,23 @@ +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 new file mode 100644 index 0000000..dec3442 --- /dev/null +++ b/src/app/common/constants.js @@ -0,0 +1,27 @@ +/** + * 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 new file mode 100644 index 0000000..b283941 --- /dev/null +++ b/src/app/common/create-session-log-file-path.js @@ -0,0 +1,7 @@ +/** + * 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 new file mode 100644 index 0000000..c30bd7d --- /dev/null +++ b/src/app/common/default-setting.js @@ -0,0 +1,76 @@ +/** + * 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' +} diff --git a/src/app/common/default-user-name.js b/src/app/common/default-user-name.js new file mode 100644 index 0000000..9182796 --- /dev/null +++ b/src/app/common/default-user-name.js @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000..db84926 --- /dev/null +++ b/src/app/common/get-folder-size-and-file-count.js @@ -0,0 +1,44 @@ +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 new file mode 100644 index 0000000..450b17f --- /dev/null +++ b/src/app/common/log.js @@ -0,0 +1,11 @@ +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 new file mode 100644 index 0000000..2b99ab5 --- /dev/null +++ b/src/app/common/lookup.js @@ -0,0 +1,28 @@ +/** + * 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 new file mode 100644 index 0000000..d316fa2 --- /dev/null +++ b/src/app/common/parse-quick-connect.js @@ -0,0 +1,454 @@ +/** + * 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, + 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 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 new file mode 100644 index 0000000..69a097a --- /dev/null +++ b/src/app/common/pass-enc.js @@ -0,0 +1,17 @@ +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 new file mode 100644 index 0000000..903bed3 --- /dev/null +++ b/src/app/common/runtime-constants.js @@ -0,0 +1,56 @@ +/** + * run time contants + */ + +const os = require('os') +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') + +module.exports = { + isTest: !!NODE_TEST, + isDev, + isWin, + isMac, + isArm, + isLinux, + iconPath, + trayIconPath, + extIconPath, + defaultUserName, + minWindowWidth: 590, + minWindowHeight: 400, + defaultLang: 'en_us', + tempDir: require('os').tmpdir(), + homeOrTmp: os.homedir() || os.tmpdir(), + packInfo: require(isDev ? '../../../package.json' : '../package.json') +} diff --git a/src/app/common/sanitize-filename.js b/src/app/common/sanitize-filename.js new file mode 100644 index 0000000..dfe0141 --- /dev/null +++ b/src/app/common/sanitize-filename.js @@ -0,0 +1,73 @@ +/** + * 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 new file mode 100644 index 0000000..f5c4d31 --- /dev/null +++ b/src/app/common/time.js @@ -0,0 +1,19 @@ +/** + * 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 new file mode 100644 index 0000000..b0463d3 --- /dev/null +++ b/src/app/common/uid.js @@ -0,0 +1,4 @@ +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 new file mode 100644 index 0000000..64bc3d2 --- /dev/null +++ b/src/app/common/version-compare.js @@ -0,0 +1,25 @@ +/** + * 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 new file mode 100644 index 0000000..aeabcfe --- /dev/null +++ b/src/app/lib/ai.js @@ -0,0 +1,230 @@ +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.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 +) => { + 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 + + const requestData = { + model, + messages: [ + { + role: 'system', + content: role + }, + { + role: 'user', + content: prompt + } + ], + 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 new file mode 100644 index 0000000..7f03db6 --- /dev/null +++ b/src/app/lib/auth.js @@ -0,0 +1,64 @@ +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 new file mode 100644 index 0000000..6715b37 --- /dev/null +++ b/src/app/lib/build-proxy.js @@ -0,0 +1,18 @@ +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 new file mode 100644 index 0000000..5c8ddfe --- /dev/null +++ b/src/app/lib/command-line.js @@ -0,0 +1,98 @@ +/** + * 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 new file mode 100644 index 0000000..dbd6f82 --- /dev/null +++ b/src/app/lib/create-app.js @@ -0,0 +1,162 @@ +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: + set DISABLE_GPU=1 + +Example: + electerm.exe --no-sandbox + or + set DISABLE_GPU=1 && electerm.exe +================================================================================ +` + +// 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) => { + 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) + } +}) + +exports.createApp = async function () { + app.setName(packInfo.name) + // Set desktop name so Linux taskbars (e.g. UOS/Deepin dde-dock) can match + // the window to the .desktop file embedded in the AppImage. + if (process.platform === 'linux' && app.setDesktopName) { + app.setDesktopName(packInfo.name) + } + // Handle GPU issues on Linux + // On Linux, disable GPU for compatibility + if (process.platform === 'linux' || process.env.DISABLE_GPU) { + app.commandLine.appendSwitch('--disable-gpu') + } + if (process.platform === 'linux') { + app.commandLine.appendSwitch('--enable-transparent-visuals') + app.commandLine.appendSwitch('--in-process-gpu') + } + if (process.platform === 'linux' || process.env.DISABLE_HARDWARE_ACCELERATION) { + 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 () => { + conf = await getDbConfig() + createWindow(conf) + }) + 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 new file mode 100644 index 0000000..bbd1dba --- /dev/null +++ b/src/app/lib/create-window.js @@ -0,0 +1,111 @@ +const { + BrowserWindow +} = require('electron') +const { resolve } = require('path') +const { + isDev, packInfo, iconPath, isMac, + minWindowWidth, minWindowHeight +} = require('../common/runtime-constants') +const defaults = require('../common/default-setting') +const { + getWindowSize, + setWindowPos +} = require('./window-control') +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') + +exports.createWindow = async function (userConfig) { + globalState.set('closeAction', 'closeApp') + globalState.set('requireAuth', !!userConfig.hashedPassword) + const { width, height, x, y } = await getWindowSize() + const { useSystemTitleBar = defaults.useSystemTitleBar } = userConfig + const win = new BrowserWindow({ + width, + height, + x, + y, + fullscreenable: true, + minWidth: minWindowWidth, + minHeight: minWindowHeight, + title: packInfo.name, + frame: useSystemTitleBar, + transparent: !useSystemTitleBar, + backgroundColor: '#333333', + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + enableRemoteModule: false, + preload: resolve(__dirname, '../preload/preload.js'), + webviewTag: true, + devTools: !userConfig.disableDeveloperTool, + spellcheck: false + }, + titleBarStyle: useSystemTitleBar ? 'default' : 'hidden', + icon: iconPath + }) + // hides the traffic lights + if (isMac) { + win.setWindowButtonVisibility(true) + } + + win.webContents.session.setSpellCheckerDictionaryDownloadURL('https://00.00/') + + webviewHandler.init(win) + + globalState.set('win', win) + + await initAppServer() + initIpc() + const port = isDev + ? process.env.devPort || 5570 + : await getPort() + const opts = `http://127.0.0.1:${port}/index.html?v=${packInfo.version}` + // If loading the URL fails (e.g. proxy/firewall interference), show error page + win.webContents.once('did-fail-load', (event, errorCode, errorDescription) => { + console.error('Failed to load app URL:', 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', () => { + 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 new file mode 100644 index 0000000..9c65c1e --- /dev/null +++ b/src/app/lib/custom-require.js @@ -0,0 +1,35 @@ +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 new file mode 100644 index 0000000..6be7890 --- /dev/null +++ b/src/app/lib/db.js @@ -0,0 +1,17 @@ +/** + * db loader + * Provides electron-related environment to nedb/sqlite modules + */ + +const { appPath, defaultUserName } = require('../common/app-props') +const { safeEncrypt, safeDecrypt } = require('./safe-storage') + +const encOpts = { enc: safeEncrypt, dec: safeDecrypt } + +if (process.versions.node < '22.0.0') { + const { createDb } = require('./nedb') + module.exports = createDb(appPath, defaultUserName, encOpts) +} else { + const { createDb } = require('./sqlite') + module.exports = createDb(appPath, defaultUserName, encOpts) +} diff --git a/src/app/lib/deep-link.js b/src/app/lib/deep-link.js new file mode 100644 index 0000000..06aea18 --- /dev/null +++ b/src/app/lib/deep-link.js @@ -0,0 +1,185 @@ +/** + * Deep link support for electerm + * Handles protocol URLs like ssh://, telnet://, rdp://, vnc://, etc. + */ + +const { app } = require('electron') +const log = require('../common/log') +const { + isMac +} = require('../common/runtime-constants') +const globalState = require('./glob-state') +const { parseQuickConnect, SUPPORTED_PROTOCOLS } = require('../common/parse-quick-connect') +/** + * 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 = SUPPORTED_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 = SUPPORTED_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 = SUPPORTED_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 () { + // Handle deep links on macOS (open-url event) + if (isMac) { + app.on('open-url', (event, url) => { + event.preventDefault() + log.info('open-url event:', url) + handleDeepLink(url) + }) + } + + // Handle deep links via second-instance (when app is already running) + // (already handled in create-app.js, but we can add additional parsing here) + app.on('second-instance', (event, commandLine, workingDirectory) => { + log.info('second-instance event:', commandLine) + + // Look for protocol URLs in command line arguments + const protocolUrl = commandLine.find(arg => + /^(ssh|telnet|rdp|vnc|serial|spice|ftp|electerm):\/\//i.test(arg) + ) + + if (protocolUrl) { + handleDeepLink(protocolUrl) + } + }) + + // Handle protocol URLs passed as command line arguments at startup (all platforms) + // Note: macOS also fires 'open-url' event when using `open` command or clicking links, + // but when running `electerm ssh://...` directly in terminal, it comes through argv + if (process.argv.length >= 2) { + const protocolUrl = process.argv.find(arg => + /^(ssh|telnet|rdp|vnc|serial|spice|ftp|electerm):\/\//i.test(arg) + ) + + if (protocolUrl) { + log.info('Startup with protocol URL:', protocolUrl) + // Store it to be handled after window is ready + globalState.set('pendingDeepLink', parseQuickConnect(protocolUrl)) + } + } +} + +module.exports = { + registerDeepLink, + unregisterDeepLink, + checkProtocolRegistration, + handleDeepLink, + getPendingDeepLink, + setupDeepLinkHandlers +} diff --git a/src/app/lib/enc.js b/src/app/lib/enc.js new file mode 100644 index 0000000..b889766 --- /dev/null +++ b/src/app/lib/enc.js @@ -0,0 +1,120 @@ +/** + * 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 new file mode 100644 index 0000000..4d6e3cc --- /dev/null +++ b/src/app/lib/error-page.js @@ -0,0 +1,70 @@ +// 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 new file mode 100644 index 0000000..3e5f471 --- /dev/null +++ b/src/app/lib/file-server.js @@ -0,0 +1,25 @@ +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 new file mode 100644 index 0000000..d9d6e3f --- /dev/null +++ b/src/app/lib/font-list.js @@ -0,0 +1,17 @@ +/** + * 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 new file mode 100644 index 0000000..c26ea47 --- /dev/null +++ b/src/app/lib/fs.js @@ -0,0 +1,432 @@ +const fss = require('fs/promises') +const fs = require('fs') +const log = require('../common/log') +const path = require('path') +const { isWin, isMac, 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, getSizeCountWin } = require('../common/get-folder-size-and-file-count.js') + +const ROOT_PATH = '/' + +function encodeUtf8Base64 (value) { + return Buffer.from(String(value), 'utf8').toString('base64') +} + +// Encoding function +function encodeUint8Array (uint8Arr) { + return Buffer.from(uint8Arr).toString('base64') +} + +// Decoding function +function decodeBase64String (base64String) { + return new Uint8Array(Buffer.from(base64String, 'base64')) +} + +const isWinDrive = function (path) { + return /^\w+:$/.test(path) +} + +/** + * 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, "'\\''") +} + +/** + * Escape a string for safe use inside PowerShell single-quoted strings. + * Single quotes are escaped by doubling them: ' -> '' + */ +function escapePowerShellArg (value) { + return String(value).replace(/'/g, "''") +} + +function getFolderSizeWin (folderPath) { + const safePath = escapePowerShellArg(folderPath) + return runWinCmd( + `Get-ChildItem -Path '${safePath}' -Recurse | Where-Object { ! $_.PSIsContainer } | Measure-Object -Property Length -Sum` + ).then(res => getSizeCountWin(res.stdout)) +} + +function getFolderSize (folderPath) { + if (isWin) { + return getFolderSizeWin(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) => { + if (isWin) { + const script = '$path = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($env:ELECTERM_OPEN_FILE_PATH_B64)); Invoke-Item -LiteralPath $path' + return spawnDetachedCommand('powershell.exe', [ + '-NoLogo', + '-NonInteractive', + '-Command', + script + ], { + windowsHide: true, + env: { + ...process.env, + ELECTERM_OPEN_FILE_PATH_B64: encodeUtf8Base64(localFilePath) + } + }) + } + return spawnDetachedCommand(isMac ? 'open' : '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) +} + +const handleWindowsDrive = async (localFilePath, targetFolderPath) => { + const tar = require('tar') + const tempExtractDir = path.join(tempDir, `electerm-unzip-${uid()}`) + await fss.mkdir(tempExtractDir, { recursive: true }) + + try { + await tar.x({ file: localFilePath, C: tempExtractDir }) + const items = await fss.readdir(tempExtractDir) + + await Promise.all(items.map(async (item) => { + const from = path.join(tempExtractDir, item) + const to = path.join(targetFolderPath, item) + await mv(from, to) + })) + } finally { + await rmrf(tempExtractDir).catch(log.error) + } +} + +/** + * 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') + if (isWin && isWinDrive(targetFolderPath)) { + await handleWindowsDrive(localFilePath, targetFolderPath) + } else { + await tar.x({ file: localFilePath, C: targetFolderPath }) + } + return 1 +} + +async function listWindowsRootPath () { + const drives = await new Promise((resolve, reject) => { + const { exec } = require('child_process') + const command = 'powershell.exe -Command "Get-PSDrive -PSProvider FileSystem | Select-Object -ExpandProperty Root"' + + exec(command, { encoding: 'utf8' }, (error, stdout, stderr) => { + if (error) { + reject(error) + return + } + if (stderr) { + reject(new Error(stderr)) + return + } + const drives = stdout.split('\r\n') + .map(line => line.trim()) + // Accept any valid Windows path that ends with backslash + .filter(line => /^[^<>:"/\\|?*]+:\\$/.test(line)) + .map(drive => drive.slice(0, -1)) // Remove trailing backslash + resolve(drives) + }) + }) + const distros = await listWslDistros() + return [...drives, ...distros] +} + +async function listWslDistros () { + try { + const { stdout } = await execAsync('wsl.exe -l -q', { encoding: 'buffer' }) + const output = Buffer.from(stdout).toString('utf16le').replace(/^\uFEFF/, '') + const distros = output.split(/\r?\n/) + .map(line => line.trim()) + .filter(Boolean) + .map(name => '\\\\wsl.localhost\\' + name) + return distros + } catch { + return [] + } +} + +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) => { + if (_path === ROOT_PATH && isWin) { + return listWindowsRootPath() + } + let path = _path + if (isWin && isWinDrive(path)) { + path = 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 new file mode 100644 index 0000000..20734bf --- /dev/null +++ b/src/app/lib/get-config.js @@ -0,0 +1,47 @@ +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() + } + 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 new file mode 100644 index 0000000..1877c12 --- /dev/null +++ b/src/app/lib/get-port.js @@ -0,0 +1,38 @@ +/** + * 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 new file mode 100644 index 0000000..53c954a --- /dev/null +++ b/src/app/lib/glob-state.js @@ -0,0 +1,41 @@ +// 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 new file mode 100644 index 0000000..0967ebf --- /dev/null +++ b/src/app/lib/init-app.js @@ -0,0 +1,48 @@ +/** + * after data loaded, init menu and other things + */ + +const { + Menu, + Notification +} = require('electron') +const globalState = require('./glob-state') +const { + packInfo +} = require('../common/runtime-constants') +const buildMenu = require('./menu') + +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 + }) + const menu = buildMenu() + Menu.setApplicationMenu(menu) + 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 new file mode 100644 index 0000000..4dc6ff8 --- /dev/null +++ b/src/app/lib/init-server.js @@ -0,0 +1,21 @@ +/** + * server init script + */ + +const createChildServer = require('../server/child-process') +const globalState = require('./glob-state') + +module.exports = async (config, env, sysLocale) => { + return new Promise((resolve) => { + const child = createChildServer(config, env, sysLocale) + child.on('exit', () => { + globalState.set('childPid', null) + }) + globalState.set('childPid', child.pid) + child.on('message', (m) => { + if (m && m.serverInited) { + resolve(child) + } + }) + }) +} diff --git a/src/app/lib/install-src.js b/src/app/lib/install-src.js new file mode 100644 index 0000000..6374597 --- /dev/null +++ b/src/app/lib/install-src.js @@ -0,0 +1,16 @@ +// export install src +const { + isWin, + isMac, + isArm +} = require('../common/runtime-constants') + +let init = 'not-inited' +if (isWin) { + init = 'win-x64.tar.gz' +} else if (isMac || isArm) { + init = 'mac-x64.dmg' +} else { + init = 'linux-x64.tar.gz' +} +module.exports = init diff --git a/src/app/lib/ipc-sync.js b/src/app/lib/ipc-sync.js new file mode 100644 index 0000000..0d8bb9e --- /dev/null +++ b/src/app/lib/ipc-sync.js @@ -0,0 +1,151 @@ +/** + * ipc main + */ + +const { + shell, + clipboard +} = require('electron') +const log = require('../common/log') +const constants = require('../common/runtime-constants') +const windowMove = require('./window-drag-move.js') +const globalState = require('./glob-state') +const { transferKeys } = require('../server/transfer') +const os = require('os') +const { + isTest +} = require('../common/app-props') +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: () => { + 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 || 'en_us', + langMap: langMap || {} + } + }, + getConstants: () => { + return { + sep: require('path').sep, + ...constants, + 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 new file mode 100644 index 0000000..6839e92 --- /dev/null +++ b/src/app/lib/ipc.js @@ -0,0 +1,259 @@ +/** + * 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 { + checkMigrate, + migrate +} = require('../migrate/migrate-1-to-2') +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, + isMac, + 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' +] + +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) { + if (!isMac) { + 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 }) => { + event.returnValue = ipcSyncFuncs[name](...args) + }) + const asyncGlobals = { + confirmExit: () => { + globalState.set('confirmExit', true) + }, + setPassword, + checkPassword, + lookup, + loadSshConfig, + init, + listSerialPorts, + loadFontList, + doUpgrade, + checkDbUpgrade, + checkMigrate, + migrate, + 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, + 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 }) => { + 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 new file mode 100644 index 0000000..1ac089e --- /dev/null +++ b/src/app/lib/iterm-theme.js @@ -0,0 +1,11 @@ +/** + * 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 new file mode 100644 index 0000000..f70650b --- /dev/null +++ b/src/app/lib/key-bind.js @@ -0,0 +1,19 @@ +/** + * disable some default keyboard shortcuts + */ + +const { isMac } = require('../common/runtime-constants') + +exports.disableShortCuts = function (win) { + win.webContents.on('before-input-event', (event, input) => { + if ( + input.key.toLowerCase() === 'r' && + ( + (isMac && input.meta) || + (!isMac && input.control && input.shift) + ) + ) { + event.preventDefault() + } + }) +} diff --git a/src/app/lib/last-state.js b/src/app/lib/last-state.js new file mode 100644 index 0000000..8ea1985 --- /dev/null +++ b/src/app/lib/last-state.js @@ -0,0 +1,46 @@ +/** + * 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 new file mode 100644 index 0000000..b17f156 --- /dev/null +++ b/src/app/lib/locales.js @@ -0,0 +1,67 @@ +/** + * multi language support + */ + +const { isDev, defaultLang } = require('../common/runtime-constants') +const { resolve } = require('path') + +function getOsLocale () { + return require('os-locale-s') + .osLocale() + .catch(() => '') +} + +async function loadLocales () { + const sysLocale = await getOsLocale() || 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 new file mode 100644 index 0000000..8becf8b --- /dev/null +++ b/src/app/lib/lodash.js @@ -0,0 +1,115 @@ +/** + * 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/menu.js b/src/app/lib/menu.js new file mode 100644 index 0000000..b10d186 --- /dev/null +++ b/src/app/lib/menu.js @@ -0,0 +1,272 @@ +/** + * app system menu config + */ +const { + app, + Menu, + shell +} = require('electron') +const { packInfo } = require('../common/runtime-constants') +const globalState = require('./glob-state') + +function buildMenu () { + const e = globalState.get('translate') + + const template = [ + { + label: e('edit'), + submenu: [ + { + label: e('newBookmark'), + click () { + globalState.get('win').webContents.send('new-ssh', null) + } + }, + { + role: 'undo', + label: e('undo') + }, + { + role: 'redo', + label: e('redo') + }, + { + type: 'separator' + }, + { + role: 'cut', + label: e('cut') + }, + { + role: 'copy', + label: e('copy'), + accelerator: 'CmdOrCtrl+C' + }, + { + role: 'paste', + label: e('paste'), + accelerator: 'CmdOrCtrl+V' + }, + { + role: 'pasteandmatchstyle', + label: e('pasteandmatchstyle') + }, + { + role: 'delete', + label: e('del') + }, + { + label: e('selectall'), + accelerator: 'CmdOrCtrl+A', + click () { + globalState.get('win').webContents.send('selectall', null) + } + }, + { + type: 'separator' + }, + { + label: e('settings'), + click () { + globalState.get('win').webContents.send('openSettings', null) + } + } + ] + }, + { + label: e('view'), + submenu: [ + { + role: 'forcereload', + label: e('forcereload') + }, + { + role: 'toggledevtools', + label: e('toggledevtools') + }, + { + type: 'separator' + }, + { + click () { + globalState.get('win').webContents.send('zoom-reset', null) + }, + label: e('resetzoom') + }, + { + click () { + globalState.get('win').webContents.send('zoomin', null) + }, + label: e('zoomin') + }, + { + click () { + globalState.get('win').webContents.send('zoomout', null) + }, + label: e('zoomout') + }, + { + type: 'separator' + }, + { + role: 'togglefullscreen', + label: e('togglefullscreen') + } + ] + }, + { + role: 'window', + label: e('window'), + submenu: [ + { + role: 'minimize', + label: e('minimize') + }, + { + label: e('maximize'), + click () { + globalState.get('win').maximize() + } + }, + { + label: e('restart'), + click () { + globalState.get('win').close() + app.relaunch() + } + } + ] + }, + { + role: 'help', + label: e('help'), + submenu: [ + { + label: e('about'), + click () { + globalState.get('win').webContents.send('open-about', null) + } + }, + { + label: e('checkUpdate'), + click () { + globalState.get('win').webContents.send('checkupdate', null) + } + }, + { + label: e('reportIssue'), + click () { + shell + .openExternal('https://github.com/electerm/electerm/issues/new') + } + }, + { + label: 'github', + click () { + shell + .openExternal('https://github.com/electerm/electerm') + } + }, + { + label: e('homepage'), + click () { + shell + .openExternal(packInfo.homepage) + } + }, + { + label: e('toggledevtools'), + click () { + globalState.get('win').webContents.openDevTools() + } + } + ] + } + ] + + if (process.platform === 'darwin') { + template.unshift({ + label: app.name, + submenu: [ + { + role: 'services', + submenu: [] + }, + { + type: 'separator' + }, + { + role: 'hide', + label: e('hide') + }, + { + role: 'hideothers', + label: e('hideothers') + }, + { + role: 'unhide', + label: e('unhide') + }, + { + type: 'separator' + }, + { + role: 'quit', + label: e('quit') + } + ] + }) + + template[2].submenu = template[2].submenu + .filter(d => d.role !== 'togglefullscreen') + + // Edit menu + template[1].submenu.push({ + type: 'separator' + }, { + label: 'Speech', + submenu: [ + { + role: 'startspeaking', + label: e('startspeaking') + }, + { + role: 'stopspeaking', + label: e('stopspeaking') + } + ] + }) + + // Window menu + template[3].submenu = [ + ...template[3].submenu, + { + role: 'zoom', + label: e('zoom') + }, + { + type: 'separator' + }, + { + role: 'front', + label: e('front') + } + ] + + const dockMenuTemplate = [ + { + label: e('newWindow'), + click () { + require('child_process').exec(`"${process.execPath}" --new-window`) + } + } + ] + const dockMenu = Menu.buildFromTemplate(dockMenuTemplate) + app.dock.setMenu(dockMenu) + } + + const menu = Menu.buildFromTemplate(template) + return menu +} + +module.exports = buildMenu diff --git a/src/app/lib/nedb.js b/src/app/lib/nedb.js new file mode 100644 index 0000000..9923a1b --- /dev/null +++ b/src/app/lib/nedb.js @@ -0,0 +1,204 @@ +/** + * 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') + +// 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 reso = (name) => { + return resolve(appDataPath, 'users', defaultUserName, `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 + } + 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 new file mode 100644 index 0000000..1304f86 --- /dev/null +++ b/src/app/lib/npm.js @@ -0,0 +1,64 @@ +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 new file mode 100644 index 0000000..8eb0c55 --- /dev/null +++ b/src/app/lib/on-close.js @@ -0,0 +1,58 @@ +/** + * 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') + const childPid = globalState.get('childPid') + childPid && process.kill(childPid) + globalState.set('serverInited', false) + process.on('uncaughtException', function () { + const childPid = globalState.get('childPid') + childPid && process.kill(childPid) + process.exit(0) + }) + log.debug('Child process killed') + // 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 new file mode 100644 index 0000000..d8d12cc --- /dev/null +++ b/src/app/lib/open-file-with-editor.js @@ -0,0 +1,147 @@ +const { spawn } = require('child_process') + +function encodeUtf8Base64 (value) { + return Buffer.from(String(value), 'utf8').toString('base64') +} + +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) + + if (process.platform === 'win32') { + const script = [ + '$editor = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($env:ELECTERM_EDITOR_COMMAND_B64))', + '$editorArgsJson = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($env:ELECTERM_EDITOR_ARGS_B64))', + '$editorArgs = @((ConvertFrom-Json -InputObject $editorArgsJson))', + '& $editor @editorArgs', + 'if ($LASTEXITCODE -ne $null) { exit $LASTEXITCODE }' + ].join('; ') + + return spawnDetachedEditor('powershell.exe', [ + '-NoLogo', + '-Command', + script + ], { + windowsHide: true, + env: { + ...process.env, + ELECTERM_EDITOR_COMMAND_B64: encodeUtf8Base64(parsed.command), + ELECTERM_EDITOR_ARGS_B64: encodeUtf8Base64(JSON.stringify([...parsed.args, filePath])) + } + }) + } + + 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 new file mode 100644 index 0000000..3c3f496 --- /dev/null +++ b/src/app/lib/proxy-agent.js @@ -0,0 +1,15 @@ +// 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 new file mode 100644 index 0000000..76d88c2 --- /dev/null +++ b/src/app/lib/safe-storage.js @@ -0,0 +1,66 @@ +/** + * Safe storage wrapper using Electron's safeStorage API. + * Provides OS-level encryption: + * - macOS: Keychain + * - Windows: DPAPI (Data Protection API, bound to current user account) + * - Linux: libsecret / gnome-keyring (falls back to an internal key) + * + * Encrypted values are stored as base64 strings prefixed with SAFE_PREFIX so + * they can be distinguished from plain-text or legacy-encrypted values. + */ + +const SAFE_PREFIX = 'v2:safe:' + +let _ss = null + +function getSS () { + if (_ss === null) { + try { + const { safeStorage } = require('electron') + _ss = safeStorage + } catch (_) { + _ss = undefined + } + } + return _ss +} + +/** + * Encrypt a string using the OS-level secure storage. + * Returns the original string unchanged when safeStorage is unavailable. + * @param {string} str + * @returns {string} + */ +exports.safeEncrypt = function (str) { + if (typeof str !== 'string' || !str) return str + const ss = getSS() + if (!ss) return str + try { + const buf = ss.encryptString(str) + return SAFE_PREFIX + buf.toString('base64') + } 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 + const ss = getSS() + if (!ss) return str + try { + const base64 = str.slice(SAFE_PREFIX.length) + const buf = Buffer.from(base64, 'base64') + return ss.decryptString(buf) + } 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 new file mode 100644 index 0000000..712520b --- /dev/null +++ b/src/app/lib/serial-port.js @@ -0,0 +1,18 @@ +/** + * serial port lib + */ + +exports.listSerialPorts = async function () { + 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 new file mode 100644 index 0000000..3c383b8 --- /dev/null +++ b/src/app/lib/shortcut.js @@ -0,0 +1,51 @@ +/** + * 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 new file mode 100644 index 0000000..29cffa7 --- /dev/null +++ b/src/app/lib/single-instance.js @@ -0,0 +1,128 @@ +/** + * Single instance lock with socket-based IPC fallback + * for Electron versions where additionalData doesn't work (e.g., Electron 22) + */ + +const net = require('net') +const fs = require('fs') +const path = require('path') +const { app } = require('electron') +const globalState = require('./glob-state') + +// Get socket path based on platform +function getSocketPath () { + const appName = app.getName() + if (process.platform === 'win32') { + return `\\\\.\\pipe\\${appName}-instance-lock` + } + // Unix socket + const tmpDir = app.getPath('temp') + return path.join(tmpDir, `${appName}-instance.sock`) +} + +const socketPath = getSocketPath() + +// Clean up stale socket file on Unix +function cleanupSocket () { + if (process.platform !== 'win32' && 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) { + 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) { + return new Promise((resolve) => { + const client = net.createConnection(socketPath, () => { + client.write(JSON.stringify(data)) + client.end() + }) + + client.on('error', () => { + // No server listening, we are the first instance + resolve(false) + }) + + client.on('close', () => { + resolve(true) + }) + }) +} + +/** + * Handle second instance connection with socket fallback + * @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/sqlite.js b/src/app/lib/sqlite.js new file mode 100644 index 0000000..7d36cfc --- /dev/null +++ b/src/app/lib/sqlite.js @@ -0,0 +1,198 @@ +/** + * sqlite api wrapper + * Updated to use two database files: one for 'data' table, one for others + * Accepts appPath and defaultUserName as parameters to avoid electron dependency + */ + +const { resolve } = require('path') +const fs = require('fs') +const uid = require('../common/uid') +const { DatabaseSync } = require('node:sqlite') + +// 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 appDataPath = process.env.DATA_PATH || resolve(appPath, 'electerm') + + if (!fs.existsSync(appDataPath)) { + fs.mkdirSync(appDataPath, { recursive: true }) + } + + // Define database folder and paths for two database files + const dbFolder = resolve(appDataPath, 'users', defaultUserName) + const mainDbPath = resolve(dbFolder, 'electerm.db') + const dataDbPath = resolve(dbFolder, 'electerm_data.db') + + // Ensure parent directory exists + if (!fs.existsSync(dbFolder)) { + fs.mkdirSync(dbFolder, { recursive: true }) + } + // Create two database instances + const mainDb = new DatabaseSync(mainDbPath) + const dataDb = new DatabaseSync(dataDbPath) + + const tables = [ + 'bookmarks', + 'bookmarkGroups', + 'addressBookmarks', + 'terminalThemes', + 'lastStates', + 'data', + 'quickCommands', + 'log', + 'dbUpgradeLog', + 'profiles', + 'workspaces', + 'history', + 'terminalCommandHistory', + 'aiChatHistory', + 'autoRunWidgets' + ] + + // Create tables in appropriate databases + for (const table of tables) { + if (table === 'data') { + dataDb.exec(`CREATE TABLE IF NOT EXISTS \`${table}\` (_id TEXT PRIMARY KEY, data TEXT)`) + } else { + mainDb.exec(`CREATE TABLE IF NOT EXISTS \`${table}\` (_id TEXT PRIMARY KEY, data TEXT)`) + } + } + + // Helper function to get the appropriate database for a table + function getDatabase (dbName) { + return dbName === 'data' ? dataDb : mainDb + } + + /** + * 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)) + } + + function shouldEncForRow (dbName, id) { + if (dbName === 'data') return id === DATA_ENC_ID + return ENC_TABLES.has(dbName) + } + + function toDoc (row, dbName) { + if (!row) return null + const shouldDec = dec && shouldEncForRow(dbName, row._id) + const raw = shouldDec ? decryptData(row.data) : row.data + let r = {} + try { + r = JSON.parse(raw || '{}') + } catch (e) { + console.error(`Error parsing JSON for row ${row._id}:`, e.message) + } + return { + ...r, + _id: row._id + } + } + + function toRow (doc, dbName) { + const _id = doc._id || doc.id || uid() + const copy = { ...doc } + delete copy._id + delete copy.id + const jsonStr = JSON.stringify(copy) + return { + _id, + data: enc && shouldEncForRow(dbName, _id) ? encryptData(jsonStr) : jsonStr + } + } + + async function dbAction (dbName, op, ...args) { + if (op === 'compactDatafile') { + return + } + if (!tables.includes(dbName)) { + throw new Error(`Table ${dbName} does not exist`) + } + + // Get the appropriate database for this table + const db = getDatabase(dbName) + + if (op === 'find') { + const sql = `SELECT * FROM \`${dbName}\`` + const stmt = db.prepare(sql) + const rows = stmt.all() + return (rows || []).map(row => toDoc(row, dbName)).filter(Boolean) + } else if (op === 'findOne') { + const query = args[0] || {} + const sql = `SELECT * FROM \`${dbName}\` WHERE _id = ? LIMIT 1` + const params = [query._id] + const stmt = db.prepare(sql) + const row = stmt.get(...params) + return toDoc(row, dbName) + } else if (op === 'insert') { + const inserts = Array.isArray(args[0]) ? args[0] : [args[0]] + const inserted = [] + for (const doc of inserts) { + const { _id, data } = toRow(doc, dbName) + const stmt = db.prepare(`INSERT OR REPLACE INTO \`${dbName}\` (_id, data) VALUES (?, ?)`) + stmt.run(_id, data) + inserted.push({ ...doc, _id }) + } + return Array.isArray(args[0]) ? inserted : inserted[0] + } else if (op === 'remove') { + const query = args[0] || {} + const sql = `DELETE FROM \`${dbName}\` WHERE _id = ?` + const params = [query._id] + const stmt = db.prepare(sql) + const res = stmt.run(...params) + return res.changes + } else if (op === 'update') { + const query = args[0] + const updateObj = args[1] + const options = args[2] || {} + const { upsert = false } = options + const qid = query._id || query.id + const newData = updateObj.$set || updateObj + const { _id, data } = toRow({ + _id: qid, + ...newData + }, dbName) + let stmt + let res + if (upsert) { + stmt = db.prepare(`REPLACE INTO \`${dbName}\` (_id, data) VALUES (?, ?)`) + res = stmt.run(_id, data) + } else { + stmt = db.prepare(`UPDATE \`${dbName}\` SET data = ? WHERE _id = ?`) + res = stmt.run(data, qid) + } + return res.changes + } + } + + return { + dbAction, + tables + } +} + +module.exports = { + createDb +} diff --git a/src/app/lib/ssh-config.js b/src/app/lib/ssh-config.js new file mode 100644 index 0000000..55f8620 --- /dev/null +++ b/src/app/lib/ssh-config.js @@ -0,0 +1,14 @@ +/** + * 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 new file mode 100644 index 0000000..b4c2d93 --- /dev/null +++ b/src/app/lib/storage-key.js @@ -0,0 +1,46 @@ +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/system-ca.js b/src/app/lib/system-ca.js new file mode 100644 index 0000000..aab8fd9 --- /dev/null +++ b/src/app/lib/system-ca.js @@ -0,0 +1,104 @@ +/** + * Load system-trusted CA certificates into Node.js TLS store. + * Node.js uses its own bundled CA store and does not trust OS-level + * certificates by default. This module exports those certs so they + * can be passed to https.Agent as additional trusted CAs. + */ + +const { execSync } = require('child_process') +const { existsSync, readdirSync, readFileSync } = require('fs') +const { join } = require('path') +const os = require('os') + +let _certs = null + +function loadMacOS () { + try { + return execSync( + 'security find-certificate -a -p ' + + '/System/Library/Keychains/SystemRootCertificates.keychain ' + + '/Library/Keychains/System.keychain ' + + os.homedir() + '/Library/Keychains/login.keychain-db', + { encoding: 'utf8', timeout: 10000 } + ) + } catch { + return '' + } +} + +function loadLinux () { + const dirs = [ + '/etc/ssl/certs', + '/etc/pki/tls/certs', + '/etc/pki/ca-trust/extracted/pem', + '/usr/local/share/certs' + ] + const files = [] + for (const dir of dirs) { + if (existsSync(dir)) { + try { + for (const f of readdirSync(dir)) { + if (f.endsWith('.crt') || f.endsWith('.pem')) { + files.push(join(dir, f)) + } + } + break + } catch { /* skip */ } + } + } + if (!files.length) { + // fallback: try the ca-certificates bundle + const bundlePaths = [ + '/etc/ssl/certs/ca-certificates.crt', + '/etc/pki/tls/certs/ca-bundle.crt', + '/etc/ssl/ca-bundle.pem' + ] + for (const p of bundlePaths) { + if (existsSync(p)) { + return readFileSync(p, 'utf8') + } + } + return '' + } + return files.map(f => { + try { return readFileSync(f, 'utf8') } catch { return '' } + }).join('\n') +} + +function loadWindows () { + try { + return execSync( + 'powershell -Command ' + + '"Get-ChildItem -Path Cert:\\LocalMachine\\Root, Cert:\\LocalMachine\\CA, Cert:\\CurrentUser\\Root, Cert:\\CurrentUser\\CA ' + + '| Where-Object { $_.NotAfter -gt (Get-Date) } ' + + '| ForEach-Object { \'-----BEGIN CERTIFICATE-----\'; ' + + '[System.Convert]::ToBase64String($_.RawData, \'InsertLineBreaks\'); ' + + '\'-----END CERTIFICATE-----\' }"', + { encoding: 'utf8', timeout: 10000, windowsHide: true } + ) + } catch { + return '' + } +} + +function getSystemCAs () { + if (_certs !== null) { + return _certs + } + switch (os.platform()) { + case 'darwin': + _certs = loadMacOS() + break + case 'linux': + _certs = loadLinux() + break + case 'win32': + _certs = loadWindows() + break + default: + _certs = '' + } + return _certs +} + +module.exports = getSystemCAs diff --git a/src/app/lib/user-config-controller.js b/src/app/lib/user-config-controller.js new file mode 100644 index 0000000..bbf5f07 --- /dev/null +++ b/src/app/lib/user-config-controller.js @@ -0,0 +1,53 @@ +/** + * 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 new file mode 100644 index 0000000..4ef48cc --- /dev/null +++ b/src/app/lib/watch-file.js @@ -0,0 +1,40 @@ +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 new file mode 100644 index 0000000..a1c841e --- /dev/null +++ b/src/app/lib/webview-handler.js @@ -0,0 +1,152 @@ +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 new file mode 100644 index 0000000..1c1f14f --- /dev/null +++ b/src/app/lib/window-control.js @@ -0,0 +1,126 @@ +/** + * manage window size save read and set + */ + +const lastStateManager = require('./last-state') +const { + isDev, + minWindowWidth, + minWindowHeight, + isLinux +} = require('../common/runtime-constants') +const globalState = require('./glob-state') + +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 () => { + const rect = await exports.getWindowSizeDep() + if (!isLinux) { + return rect + } + const { + width, + height + } = exports.getScreenSize() + if (rect.width >= width - 200) { + rect.width = width - 200 + rect.x = 100 + } + if (rect.height >= height - 200) { + rect.height = height - 200 + rect.y = 100 + } + return rect +} + +exports.getWindowSizeDep = async () => { + const windowSizeLastState = await lastStateManager.get('windowSize') + const windowPosLastState = await lastStateManager.get('windowPos') + const { + width: maxWidth, + height: maxHeight + } = exports.getScreenSize() + if (!windowSizeLastState || isDev) { + return { + width: maxWidth, + height: maxHeight, + x: 0, + y: 0 + } + } + const { + innerWidth, + height, + screenHeight, + screenWidth + } = windowSizeLastState + const fw = innerWidth / screenWidth + const fh = height / screenHeight + let w = maxWidth * fw + let h = maxHeight * fh + const minW = minWindowWidth + const minH = minWindowHeight + if (w < minW) { + w = minW + } + if (h < minH) { + h = minH + } + let { + x = 0, + y = 0 + } = windowPosLastState || {} + if (x < 0 || x > maxWidth - 100) { + x = 0 + } + if (y < 0 || y > maxHeight - 100) { + y = 0 + } + return { + width: w, + height: h, + x, + y + } +} + +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 new file mode 100644 index 0000000..94c270a --- /dev/null +++ b/src/app/lib/window-drag-move.js @@ -0,0 +1,45 @@ +// 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/zod.js b/src/app/lib/zod.js new file mode 100644 index 0000000..072c6e8 --- /dev/null +++ b/src/app/lib/zod.js @@ -0,0 +1,208 @@ +/** + * 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 new file mode 100644 index 0000000..fedb264 --- /dev/null +++ b/src/app/mcp/server/mcp.js @@ -0,0 +1,21 @@ +class McpServer { + constructor (options) { + this.name = options.name + this.version = options.version + this.tools = new Map() + } + + 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 new file mode 100644 index 0000000..bc59fe7 --- /dev/null +++ b/src/app/mcp/server/streamableHttp.js @@ -0,0 +1,153 @@ +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 + } + + async connect (server) { + this.server = server + this.sessionId = this.sessionIdGenerator() + if (this.onsessioninitialized) { + this.onsessioninitialized(this.sessionId) + } + } + + _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() + } + + async handleRequest (req, res, body) { + if (body) { + const request = body + let result + if (request.method === 'initialize') { + result = { + jsonrpc: '2.0', + id: request.id, + result: { + protocolVersion: '2024-11-05', + capabilities: { + tools: { + listChanged: false + } + }, + serverInfo: { + name: this.server.name, + version: this.server.version + } + } + } + res.setHeader('mcp-session-id', this.sessionId) + } else if (request.method === 'notifications/initialized') { + this.initialized = true + res.status(200).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') { + const { name, arguments: args } = request.params + const tool = this.server.tools.get(name) + if (tool) { + try { + const toolResult = await tool.handler(args) + 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 === '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}` } + } + } + this._sendSSE(res, result) + } else { + if (req.method === 'DELETE') { + this.close() + res.status(200).end() + } else { + res.status(200).end() + } + } + } + + async close () { + if (this.onclose) this.onclose() + } +} + +module.exports = { StreamableHTTPServerTransport } diff --git a/src/app/migrate/index.js b/src/app/migrate/index.js new file mode 100644 index 0000000..7f3452a --- /dev/null +++ b/src/app/migrate/index.js @@ -0,0 +1,85 @@ +/** + * 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('./nedb-instance') +const _ = require('../lib/lodash.js') +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) { + 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/migrate/migrate-1-to-2.js b/src/app/migrate/migrate-1-to-2.js new file mode 100644 index 0000000..b78c278 --- /dev/null +++ b/src/app/migrate/migrate-1-to-2.js @@ -0,0 +1,120 @@ +/** + * migrate from NeDB (v1) to SQLite (v2) + */ + +const { resolve } = require('path') +const { existsSync, renameSync } = require('fs') +const { appPath, defaultUserName } = require('../common/app-props') +const log = require('../common/log') + +const reso = (name) => { + return resolve(appPath, 'electerm', 'users', defaultUserName, `electerm.${name}.nedb`) +} + +const tables = [ + 'bookmarks', + 'bookmarkGroups', + 'addressBookmarks', + 'terminalThemes', + 'lastStates', + 'data', + 'quickCommands', + 'log', + 'dbUpgradeLog', + 'profiles' +] + +/** + * Check if migration from v1 (NeDB) to v2 (SQLite) is needed + * @returns {boolean} true if migration is needed + */ +function checkMigrate () { + if (process.versions.node < '22.0.0') { + return false + } + // Check if any NeDB files exist + for (const table of tables) { + const nedbPath = reso(table) + if (existsSync(nedbPath)) { + return true + } + } + return false +} + +/** + * Migrate all data from NeDB to SQLite and backup NeDB files + */ +async function migrate () { + log.info('Starting migration from NeDB (v1) to SQLite (v2)...') + // nedb-instance: raw nedb without enc/dec (legacy data was never encrypted) + const { dbAction: nedbDbAction } = require('./nedb-instance') + // Use plain sqlite (no enc/dec) for migration writes: safeStorage encryption + // is not reliable across restarts in the IPC/migration context. The app's + // normal read/write path will encrypt data on the next user-triggered write. + const { appPath, defaultUserName } = require('../common/app-props') + const { createDb: createSqlite } = require('../lib/sqlite') + const { dbAction: sqliteDbAction } = createSqlite(appPath, defaultUserName) + const { + checkDbUpgrade, + doUpgrade + } = require('./index') + if (await checkDbUpgrade()) { + await doUpgrade() + } + + // Migrate data from each table + for (const table of tables) { + const nedbPath = reso(table) + + if (existsSync(nedbPath)) { + log.info(`Migrating table: ${table}`) + + // Read all data from NeDB (unencrypted legacy data) + const nedbData = await nedbDbAction(table, 'find', {}) + + if (nedbData && nedbData.length > 0) { + log.info(`Found ${nedbData.length} records in ${table}`) + + // Insert/update data into SQLite via db.js so enc/dec is applied + for (const record of nedbData) { + // Ensure record has an _id field + const recordId = record._id || record.id + if (!recordId) { + log.warn(`Record in ${table} has no _id or id field, skipping:`, record) + continue + } + // Use update with upsert option to handle existing records gracefully + await sqliteDbAction(table, 'update', + { _id: recordId }, + { $set: record }, + { upsert: true } + ) + } + + log.info(`Successfully migrated ${nedbData.length} records from ${table}`) + } else { + log.info(`Table ${table} is empty, nothing to migrate`) + } + + // Rename NeDB file to .bak + const backupPath = nedbPath + '.bak' + try { + renameSync(nedbPath, backupPath) + log.info(`Backed up ${nedbPath} to ${backupPath}`) + } catch (renameError) { + log.error(`Error backing up ${nedbPath}:`, renameError) + } + } else { + log.info(`NeDB file for ${table} does not exist, skipping`) + } + } + + log.info('Migration from NeDB to SQLite completed successfully') + return true +} + +module.exports = { + checkMigrate, + migrate +} diff --git a/src/app/migrate/nedb-instance.js b/src/app/migrate/nedb-instance.js new file mode 100644 index 0000000..500e2ca --- /dev/null +++ b/src/app/migrate/nedb-instance.js @@ -0,0 +1,12 @@ +/** + * Shared NeDB instance for migrate scripts. + * Intentionally created without enc/dec because legacy NeDB data was never + * encrypted and these scripts must be able to read/write the raw nedb files + * during the pre-migration upgrade phase. + */ + +const { appPath, defaultUserName } = require('../common/app-props') +const { createDb } = require('../lib/nedb') + +module.exports = createDb(appPath, defaultUserName) +// exports: { dbAction, tables } diff --git a/src/app/migrate/v1.25.0.js b/src/app/migrate/v1.25.0.js new file mode 100644 index 0000000..0eaba6f --- /dev/null +++ b/src/app/migrate/v1.25.0.js @@ -0,0 +1,48 @@ +/** + * upgrade database to v1.7.0 + */ + +const { userConfigId } = require('../common/constants') +const { dbAction } = require('./nedb-instance') +const { updateDBVersion } = require('./version-upgrade') +const log = require('../common/log') + +async function fixAll () { + const q = { + _id: userConfigId + } + const conf = await dbAction('data', 'findOne', q) + if (!conf) { + return + } + if (!conf.syncSetting) { + conf.syncSetting = {} + } + const { + syncEncrypt, + githubAccessToken, + giteeAccessToken + } = conf.syncSetting + if (!syncEncrypt) { + return + } + if (githubAccessToken) { + conf.syncSetting.githubSyncPassword = githubAccessToken + } + if (giteeAccessToken) { + conf.syncSetting.giteeSyncPassword = giteeAccessToken + } + delete conf.syncSetting.syncEncrypt + await dbAction('data', 'update', q, { + ...q, + ...conf + }) +} + +module.exports = async () => { + const versionTo = '1.25.0' + log.info(`Start: upgrading to v${versionTo}`) + await fixAll() + await updateDBVersion(versionTo) + log.info(`Done: upgrading to v${versionTo}`) +} diff --git a/src/app/migrate/v1.27.17.js b/src/app/migrate/v1.27.17.js new file mode 100644 index 0000000..5058ce1 --- /dev/null +++ b/src/app/migrate/v1.27.17.js @@ -0,0 +1,66 @@ +/** + * upgrade database to v1.27.7 + */ + +const { userConfigId } = require('../common/constants') +const { dbAction } = require('./nedb-instance') +const { updateDBVersion } = require('./version-upgrade') +const log = require('../common/log') +const { buildProxyString } = require('../lib/build-proxy') + +async function fixConf () { + log.info('Start update global proxy config') + const q = { + _id: userConfigId + } + const conf = await dbAction('data', 'findOne', q) + if (!conf) { + return + } + const proxy = buildProxyString(conf) + if (proxy) { + conf.proxy = proxy + } + const props = [ + 'proxyPort', 'proxyType', 'proxyIp', 'proxyUsername', 'proxyPassword' + ] + for (const p of props) { + delete conf[p] + } + await dbAction('data', 'update', q, { + ...q, + ...conf + }) +} + +async function fixBookmarks () { + log.info('Start update bookmark proxy config') + const arr = await dbAction('bookmarks', 'find', {}) + const len = arr.length + let i = 0 + log.info('bookmarks count:', len) + for (const b of arr) { + const proxy = buildProxyString(b.proxy || {}) + console.log(i + 1, b._id, proxy) + await dbAction('bookmarks', 'update', { + _id: b._id + }, { + ...b, + proxy + }) + i = i + 1 + } +} + +async function fixAll () { + await fixConf() + await fixBookmarks() +} + +module.exports = async () => { + const versionTo = '1.27.17' + log.info(`Start: upgrading to v${versionTo}`) + await fixAll() + await updateDBVersion(versionTo) + log.info(`Done: upgrading to v${versionTo}`) +} diff --git a/src/app/migrate/v1.3.0.js b/src/app/migrate/v1.3.0.js new file mode 100644 index 0000000..6a83bf6 --- /dev/null +++ b/src/app/migrate/v1.3.0.js @@ -0,0 +1,107 @@ +/** + * upgrade database to v1.3.0 + * migrate old file based db to nedb + */ + +const { resolve } = require('path') +const { dbAction, tables } = require('./nedb-instance') +const { appPath } = require('../common/app-props') +const userConfigPath = resolve(appPath, 'electerm-user-config.json') +const savePath = resolve(appPath, 'electerm-localstorage.json') +const { existsSync, unlinkSync, writeFileSync } = require('fs') +const log = require('../common/log') +const _ = require('../lib/lodash.js') +const { userConfigId } = require('../common/constants') +const { updateDBVersion } = require('./version-upgrade') + +async function loadArr (arr, name) { + await dbAction(name, 'insert', arr.map(d => { + const { id, ...rest } = d + return { + _id: id, + ...rest + } + })).catch(log.error) +} + +function shouldLoadAsArray (key, value) { + return tables.includes(key) && _.isArray(value) && value.length && value[0].id +} + +async function migrateData () { + const exist = existsSync(savePath) + if (!exist) { + return false + } + log.log('Start migrating data') + let json = {} + try { + json = require(savePath) + } catch (e) { + log.error(e) + log.error('load json data fails') + } + const keys = Object.keys(json) + for (const k of keys) { + const v = json[k] + const _id = k.startsWith('laststate') + ? k.split('.')[1] + : k + const db = k.startsWith('laststate') + ? 'lastStates' + : 'data' + if (k === 'themes') { + const vs = Object.values(v) + for (const vv of vs) { + const { id, ...rest } = vv + await dbAction('terminalThemes', 'insert', { + _id: id, + ...rest + }).catch(log.error) + } + } else if (shouldLoadAsArray(k, v)) { + await loadArr(v, k) + } else { + await dbAction(db, 'insert', { + _id, + value: v + }).catch(log.error) + } + } + await writeFileSync(savePath + '.backup', JSON.stringify(json)) + await unlinkSync(savePath) + log.log('Finish migrating data') +} + +async function migrateUserConfig () { + const exist = existsSync(userConfigPath) + if (!exist) { + return false + } + log.log('Start migrating user config') + let uf = {} + try { + uf = require(userConfigPath) + } catch (e) { + log.error(e) + log.error('load user config fails') + } + await dbAction('data', 'update', { + _id: userConfigId + }, { + _id: userConfigId, + value: uf + }, { + upsert: true + }).catch(log.error) + log.log('End migrating user config') +} + +module.exports = async () => { + const versionTo = '1.3.0' + log.info(`Start: upgrading to v${versionTo}`) + await migrateData() + await migrateUserConfig() + await updateDBVersion(versionTo) + log.info(`Done: upgrading to v${versionTo}`) +} diff --git a/src/app/migrate/v1.3.9.js b/src/app/migrate/v1.3.9.js new file mode 100644 index 0000000..44b3578 --- /dev/null +++ b/src/app/migrate/v1.3.9.js @@ -0,0 +1,44 @@ +/** + * upgrade database to v1.3.9 + * migrate old file based db to nedb + */ + +const { dbAction, tables } = require('./nedb-instance') +const { updateDBVersion } = require('./version-upgrade') +const log = require('../common/log') + +function wait (time) { + return new Promise(resolve => { + setTimeout(resolve, time) + }) +} + +async function fixAll () { + for (const name of tables) { + const all = await dbAction(name, 'find', {}) + for (const inst of all) { + const { id, _id, ...rest } = inst + if (id) { + await dbAction(name, 'remove', { + id + }) + await dbAction(name, 'remove', { + _id: id + }, { multi: true }) + await wait(100) + await dbAction(name, 'insert', { + _id: id, + ...rest + }) + } + } + } +} + +module.exports = async () => { + const versionTo = '1.3.9' + log.info(`Start: upgrading to v${versionTo}`) + await fixAll() + await updateDBVersion(versionTo) + log.info(`Done: upgrading to v${versionTo}`) +} diff --git a/src/app/migrate/v1.32.36.js b/src/app/migrate/v1.32.36.js new file mode 100644 index 0000000..0742247 --- /dev/null +++ b/src/app/migrate/v1.32.36.js @@ -0,0 +1,44 @@ +/** + * upgrade database to v1.32.36 + */ + +const { dbAction } = require('./nedb-instance') +const { updateDBVersion } = require('./version-upgrade') +const log = require('../common/log') +const { buildSshTunnels } = require('../common/build-ssh-tunnel') + +async function fixBookmarks () { + log.info('Start update bookmark Ssh Tunnels config') + const arr = await dbAction('bookmarks', 'find', {}) + const len = arr.length + let i = 0 + log.info('bookmarks count:', len) + for (const b of arr) { + console.log(i + 1, b._id, b.sshTunnel ? 'has sshTunnel' : 'no sshTunnel') + if (b.sshTunnel) { + const sshTunnels = buildSshTunnels(b) + delete b.sshTunnel + delete b.sshTunnelRemotePort + delete b.sshTunnelLocalPort + await dbAction('bookmarks', 'update', { + _id: b._id + }, { + ...b, + sshTunnels + }) + } + i = i + 1 + } +} + +async function fixAll () { + await fixBookmarks() +} + +module.exports = async () => { + const versionTo = '1.32.36' + log.info(`Start: upgrading to v${versionTo}`) + await fixAll() + await updateDBVersion(versionTo) + log.info(`Done: upgrading to v${versionTo}`) +} diff --git a/src/app/migrate/v1.34.20.js b/src/app/migrate/v1.34.20.js new file mode 100644 index 0000000..f425d7e --- /dev/null +++ b/src/app/migrate/v1.34.20.js @@ -0,0 +1,43 @@ +/** + * upgrade database to v1.34.20 + */ + +const { dbAction } = require('./nedb-instance') +const { updateDBVersion } = require('./version-upgrade') +const log = require('../common/log') +const { buildRunScripts } = require('../common/build-run-scripts') + +async function fixBookmarks () { + log.info('Start update bookmark loginScript config') + const arr = await dbAction('bookmarks', 'find', {}) + const len = arr.length + let i = 0 + log.info('bookmarks count:', len) + for (const b of arr) { + console.log(i + 1, b._id, b.loginScript ? 'has loginScript' : 'no loginScript') + if (b.loginScript) { + const runScripts = buildRunScripts(b) + delete b.loginScript + delete b.loginScriptDelay + await dbAction('bookmarks', 'update', { + _id: b._id + }, { + ...b, + runScripts + }) + } + i = i + 1 + } +} + +async function fixAll () { + await fixBookmarks() +} + +module.exports = async () => { + const versionTo = '1.34.20' + log.info(`Start: upgrading to v${versionTo}`) + await fixAll() + await updateDBVersion(versionTo) + log.info(`Done: upgrading to v${versionTo}`) +} diff --git a/src/app/migrate/v1.34.59.js b/src/app/migrate/v1.34.59.js new file mode 100644 index 0000000..56340aa --- /dev/null +++ b/src/app/migrate/v1.34.59.js @@ -0,0 +1,32 @@ +/** + * upgrade database to v1.34.20 + */ + +const { dbAction } = require('./nedb-instance') +const { userConfigId } = require('../common/constants') +const { updateDBVersion } = require('./version-upgrade') +const log = require('../common/log') + +async function fixAll () { + log.info('Start update default terminal word separator config') + const q = { + _id: userConfigId + } + const conf = await dbAction('data', 'findOne', q) + if (conf && conf.terminalWordSeparator && !conf.terminalWordSeparator.includes(' ')) { + conf.terminalWordSeparator = conf.terminalWordSeparator.slice(0, 1) + ' ' + + conf.terminalWordSeparator.slice(1) + await dbAction('data', 'update', q, { + ...q, + ...conf + }) + } +} + +module.exports = async () => { + const versionTo = '1.34.59' + log.info(`Start: upgrading to v${versionTo}`) + await fixAll() + await updateDBVersion(versionTo) + log.info(`Done: upgrading to v${versionTo}`) +} diff --git a/src/app/migrate/v1.5.13.js b/src/app/migrate/v1.5.13.js new file mode 100644 index 0000000..63409a2 --- /dev/null +++ b/src/app/migrate/v1.5.13.js @@ -0,0 +1,57 @@ +/** + * upgrade database to v1.5.13 + */ + +const { userConfigId } = require('../common/constants') +const { dbAction } = require('./nedb-instance') +const { updateDBVersion } = require('./version-upgrade') +const log = require('../common/log') +const { decrypt } = require('../lib/enc') + +async function fixAll () { + const q = { + _id: userConfigId + } + const conf = await dbAction('data', 'findOne', q) + if (!conf.syncSetting) { + conf.syncSetting = {} + } + const { + encrypted, + gistId, + githubAccessToken, + lastSyncTime + } = conf.syncSetting + if (lastSyncTime) { + conf.syncSetting.githubLastSyncTime = lastSyncTime + } + if (gistId) { + conf.syncSetting.githubGistId = gistId + } + if ( + encrypted && + gistId && + githubAccessToken + ) { + const nt = decrypt( + conf.syncSetting.githubAccessToken, + conf.syncSetting.gistId + ) + conf.syncSetting.githubAccessToken = nt + } + delete conf.syncSetting.encrypted + delete conf.syncSetting.lastSyncTime + delete conf.syncSetting.gistId + await dbAction('data', 'update', q, { + ...q, + ...conf + }) +} + +module.exports = async () => { + const versionTo = '1.5.13' + log.info(`Start: upgrading to v${versionTo}`) + await fixAll() + await updateDBVersion(versionTo) + log.info(`Done: upgrading to v${versionTo}`) +} diff --git a/src/app/migrate/v1.7.0.js b/src/app/migrate/v1.7.0.js new file mode 100644 index 0000000..c1d0894 --- /dev/null +++ b/src/app/migrate/v1.7.0.js @@ -0,0 +1,36 @@ +/** + * upgrade database to v1.7.0 + */ + +const { dbAction } = require('./nedb-instance') +const { updateDBVersion } = require('./version-upgrade') +const log = require('../common/log') +const defaults = require('./db-defaults') + +async function fixAll () { + const defaultThemeConfig = defaults[0].data[0] + const defaultLightThemeConfig = defaults[0].data[1] + const all = await dbAction('terminalThemes', 'find', {}).catch(log.error) || [] + for (const item of all) { + const q = { + _id: item.id || item._id + } + const updates = q._id === 'default' + ? defaultThemeConfig + : { + ...item, + uiThemeConfig: defaultThemeConfig.uiThemeConfig + } + await dbAction('terminalThemes', 'update', q, updates).catch(log.error) + } + await dbAction('terminalThemes', 'insert', defaultLightThemeConfig).catch(log.error) + log.info('end: update db') +} + +module.exports = async () => { + const versionTo = '1.7.0' + log.info(`Start: upgrading to v${versionTo}`) + await fixAll() + await updateDBVersion(versionTo) + log.info(`Done: upgrading to v${versionTo}`) +} diff --git a/src/app/migrate/version-upgrade.js b/src/app/migrate/version-upgrade.js new file mode 100644 index 0000000..4a53fd6 --- /dev/null +++ b/src/app/migrate/version-upgrade.js @@ -0,0 +1,39 @@ +/** + * 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('./nedb-instance') + +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/preload/preload.js b/src/app/preload/preload.js new file mode 100644 index 0000000..42b1935 --- /dev/null +++ b/src/app/preload/preload.js @@ -0,0 +1,55 @@ +/** + * 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 new file mode 100644 index 0000000..c2e4fe8 --- /dev/null +++ b/src/app/server/app-wrap.js @@ -0,0 +1,15 @@ +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 new file mode 100644 index 0000000..7208d61 --- /dev/null +++ b/src/app/server/child-process.js @@ -0,0 +1,67 @@ +/** + * run server in child process + * + */ + +const { fork } = require('child_process') +const { resolve } = require('path') +const { writeFileSync, unlinkSync } = require('fs') +const { tmpdir } = require('os') +const { join } = require('path') +const log = require('../common/log') +const getSystemCAs = require('../lib/system-ca') + +// --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) => { + const nodeOpts = [env.NODE_OPTIONS, supportsSystemCa() ? '--use-system-ca' : ''] + .filter(Boolean).join(' ').trim() + + // Load system-trusted CA certificates and pass to child process + // via NODE_EXTRA_CA_CERTS so Node.js extends its trust store natively. + let extraCaFile + const systemCAs = getSystemCAs() + if (systemCAs) { + extraCaFile = join(tmpdir(), `electerm-system-ca-${Date.now()}.pem`) + writeFileSync(extraCaFile, systemCAs) + } + + // Clean Electron-specific env vars from child process environment + const cleanEnv = Object.assign({}, env) + delete cleanEnv.ELECTRON_RUN_AS_NODE + + // start server + const child = fork(resolve(__dirname, './server.js'), { + env: Object.assign( + { + LANG: `${sysLocale.replace(/-/, '_')}.UTF-8`, + electermPort: config.port, + electermHost: config.host, + requireAuth: config.requireAuth || '', + tokenElecterm: config.tokenElecterm, + sshKeysPath: env.sshKeysPath, + NODE_OPTIONS: nodeOpts || undefined, + NODE_EXTRA_CA_CERTS: extraCaFile || undefined + }, + cleanEnv + ), + cwd: process.cwd() + }, (error, stdout, stderr) => { + if (error || stderr) { + throw error || stderr + } + log.info(stdout) + }) + + if (extraCaFile) { + child.on('exit', () => { + try { unlinkSync(extraCaFile) } catch {} + }) + } + + return child +} diff --git a/src/app/server/dispatch-center.js b/src/app/server/dispatch-center.js new file mode 100644 index 0000000..76e71ac --- /dev/null +++ b/src/app/server/dispatch-center.js @@ -0,0 +1,107 @@ +/** + * 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 fetch = require('./fetch') +const sync = require('./sync') +const { + createTerm, + testTerm, + resize, + runCmd, + 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 + globalState.getUpgradeInst(id)[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) + } + } 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 new file mode 100644 index 0000000..142ca4f --- /dev/null +++ b/src/app/server/download-upgrade.js @@ -0,0 +1,192 @@ +/** + * 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.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, + 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 new file mode 100644 index 0000000..a17c301 --- /dev/null +++ b/src/app/server/fetch.js @@ -0,0 +1,42 @@ +/** + * 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.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 new file mode 100644 index 0000000..7b73fd4 --- /dev/null +++ b/src/app/server/fs.js @@ -0,0 +1,27 @@ +/** + * fs in child process + */ + +const { fsExport: fs } = require('../lib/fs') + +function handleFs (ws, msg) { + const { id, args, func } = msg + 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 new file mode 100644 index 0000000..128f589 --- /dev/null +++ b/src/app/server/ftp-client.js @@ -0,0 +1,167 @@ +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 new file mode 100644 index 0000000..74987f9 --- /dev/null +++ b/src/app/server/ftp-file.js @@ -0,0 +1,33 @@ +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 new file mode 100644 index 0000000..19aac3b --- /dev/null +++ b/src/app/server/ftp-transfer.js @@ -0,0 +1,134 @@ +// 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 new file mode 100644 index 0000000..2de7629 --- /dev/null +++ b/src/app/server/global-state.js @@ -0,0 +1,51 @@ +// 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 new file mode 100644 index 0000000..8c2d903 --- /dev/null +++ b/src/app/server/rdp-proxy.js @@ -0,0 +1,678 @@ +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`) + + // 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 new file mode 100644 index 0000000..aa6a948 --- /dev/null +++ b/src/app/server/remote-common.js @@ -0,0 +1,64 @@ +/** + * 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 new file mode 100644 index 0000000..8791620 --- /dev/null +++ b/src/app/server/server.js @@ -0,0 +1,49 @@ +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) + +const runServer = function () { + const { electermPort, electermHost } = process.env + app.listen(electermPort, electermHost, () => { + log.info('server', 'runs on', electermHost, electermPort) + process.send({ serverInited: true }) + }) +} + +// start +runServer() + +process.on('uncaughtException', (err) => { + log.error('uncaughtException', err) +}) +process.on('unhandledRejection', (err) => { + log.error('unhandledRejection', err) +}) + +process.on('SIGTERM', () => { + process.exit(0) +}) diff --git a/src/app/server/session-api.js b/src/app/server/session-api.js new file mode 100644 index 0000000..6f6ffd0 --- /dev/null +++ b/src/app/server/session-api.js @@ -0,0 +1,86 @@ +/** + * 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 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.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 new file mode 100644 index 0000000..12c8087 --- /dev/null +++ b/src/app/server/session-base.js @@ -0,0 +1,147 @@ +/** + * 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 = 220) { + 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 || 220) + 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 + } + this._vtTerm.write(data) + } + + 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 new file mode 100644 index 0000000..e2f9966 --- /dev/null +++ b/src/app/server/session-common.js @@ -0,0 +1,54 @@ +/** + * 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('') + } + }) + }) + } + return Cls +} diff --git a/src/app/server/session-ftp.js b/src/app/server/session-ftp.js new file mode 100644 index 0000000..0cc9790 --- /dev/null +++ b/src/app/server/session-ftp.js @@ -0,0 +1,306 @@ +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 new file mode 100644 index 0000000..5fc4be2 --- /dev/null +++ b/src/app/server/session-hop.js @@ -0,0 +1,75 @@ +/** + * 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 new file mode 100644 index 0000000..9503893 --- /dev/null +++ b/src/app/server/session-local.js @@ -0,0 +1,91 @@ +/** + * 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 () { + 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 + this.term = pty.spawn(exec, argv, { + name: term, + encoding: null, + cols: cols || 80, + rows: rows || 24, + cwd, + env + }) + 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 new file mode 100644 index 0000000..ecb5d05 --- /dev/null +++ b/src/app/server/session-log.js @@ -0,0 +1,36 @@ +/** + * 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 new file mode 100644 index 0000000..98af51f --- /dev/null +++ b/src/app/server/session-process.js @@ -0,0 +1,272 @@ +const { fork } = require('child_process') +const path = require('path') + +// Map to store active terminal processes (pid -> {child, 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) { + // Remove from pending set on error + pendingPorts.delete(startPort) + reject(err) + } else { + // Remember this port for next time + lastPort = freePort + // Remove from pending set when done + pendingPorts.delete(startPort) + resolve(freePort) + } + }) + }) +} + +async function runSessionServer (type, port) { + return new Promise((resolve) => { + const cleanEnv = Object.assign({}, process.env) + delete cleanEnv.ELECTRON_RUN_AS_NODE + const child = fork(path.resolve(__dirname, './session-server.js'), { + env: Object.assign( + { + wsPort: port, + type + }, + cleanEnv + ), + cwd: process.cwd() + }, (error, stdout, stderr) => { + if (error || stderr) { + console.error('Error in session server:', error || stderr) + throw error || stderr + } + }) + child.on('message', (m) => { + if (m && m.serverInited) { + resolve(child) + } + }) + }) +} + +async function sendMsgToChildProcess (pid, msg) { + const child = typeof pid === 'object' ? pid : activeTerminals.get(pid)?.child + if (!child) { + throw new Error(`Terminal with PID ${pid} not found`) + } + + return new Promise((resolve, reject) => { + const responseHandler = (response) => { + if (response.id === msg.id) { + child.removeListener('message', responseHandler) + if (response.error) { + reject(response.error) + } else { + resolve(response.data) + } + } + } + + child.on('message', responseHandler) + child.send({ + type: 'common', + data: msg + }) + }) +} + +exports.terminal = async function (initOptions, ws, uid) { + const type = initOptions.termType || initOptions.type || 'terminal' + const port = await getPort() + const child = await runSessionServer(type, port) + const pid = initOptions.uid + const isSsh = ![ + 'telnet', + 'serial', + 'local', + 'rdp', + 'vnc', + 'spice', + 'ftp' + ].includes(type) + if (isSsh) { + child.on('message', (m) => { + const { type, data } = m + if (type === 'common') { + ws.s(data) + ws.once((data) => { + child.send(data) + }, data.id) + } + }) + } + child.on('exit', () => { + // Remove all pending message listeners to prevent memory leaks + // if the child exits before responding to sendMsgToChildProcess calls + child.removeAllListeners('message') + activeTerminals.delete(pid) + }) + if (type !== 'ftp') { + try { + await sendMsgToChildProcess(child, { + id: uid, + action: 'create-terminal', + body: initOptions + }) + } catch (err) { + child.kill() + throw err + } + } + + // Kill any existing child process for this pid before overwriting. + // This can happen on reconnects where a new process is spawned for the same tab id. + const existingEntry = activeTerminals.get(pid) + if (existingEntry) { + existingEntry.child.kill() + activeTerminals.delete(pid) + } + + // Store the terminal process in the map + activeTerminals.set(pid, { + child, + port, + ws + }) + + return { + pid, + port + } +} + +exports.testConnection = async function (initOptions, ws, uid) { + const type = initOptions.termType || initOptions.type || 'terminal' + const port = await getPort() + const child = await runSessionServer(type, port) + + const isSsh = ![ + 'telnet', + 'serial', + 'local', + 'rdp', + 'vnc', + 'spice', + 'ftp' + ].includes(type) + if (isSsh && ws) { + child.on('message', (m) => { + const { type: msgType, data } = m + if (msgType === 'common') { + ws.s(data) + ws.once((respData) => { + child.send(respData) + }, data.id) + } + }) + } + + const res = await sendMsgToChildProcess(child, { + id: uid, + action: 'test-terminal', + body: initOptions + }) + + child.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 sendMsgToChildProcess(pid, { + id, + action: 'run-cmd', + body: { cmd, pid } + }) + }, + resize: (cols, rows, id) => { + sendMsgToChildProcess(pid, { + id, + action: 'resize-terminal', + body: { cols, rows, pid } + })// Ignore errors for resize + }, + toggleTerminalLog: (id) => { + sendMsgToChildProcess(pid, { + id, + action: 'toggle-terminal-log', + body: { pid } + }) + }, + toggleTerminalLogTimestamp: (id) => { + sendMsgToChildProcess(pid, { + id, + action: 'toggle-terminal-log-timestamp', + body: { pid } + }) + }, + setTerminalLogPath: (id, logPath) => { + sendMsgToChildProcess(pid, { + id, + action: 'set-terminal-log-path', + body: { pid, logPath } + }) + }, + startTerminalLogFile: (id, logFilePath, addTimeStampToTermLog) => { + sendMsgToChildProcess(pid, { + 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.child.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 new file mode 100644 index 0000000..57d292d --- /dev/null +++ b/src/app/server/session-rdp.js @@ -0,0 +1,154 @@ +/** + * 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 new file mode 100644 index 0000000..940f6a6 --- /dev/null +++ b/src/app/server/session-serial.js @@ -0,0 +1,136 @@ +/** + * 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 () { + 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 new file mode 100644 index 0000000..ad8c5ec --- /dev/null +++ b/src/app/server/session-server.js @@ -0,0 +1,548 @@ +const express = require('express') +const { Sftp } = require('./session-sftp') +const { Ftp } = require('./session-ftp') +const { + sftp, + transfer, + onDestroySftp, + onDestroyTransfer, + terminals, + cleanAllSessions +} = require('./remote-common') +const { Transfer } = require('./transfer') +const { Transfer: FtpTransfer } = require('./ftp-transfer') +const app = express() +const log = require('../common/log') +const appDec = require('./app-wrap') +const { + createTerm, + testTerm, + resize, + runCmd, + toggleTerminalLog, + toggleTerminalLogTimestamp, + setTerminalLogPath, + startTerminalLogFile +} = require('./session-api') +const { + isWin +} = require('../common/runtime-constants') +const wsDec = require('./ws-dec') +const { zmodemManager } = require('./zmodem') +const { trzszManager } = require('./trzsz') +const { xmodemManager } = require('./xmodem') + +const { + tokenElecterm, + electermHost, + wsPort, + type +} = process.env + +// Track whether any WebSocket has connected to detect orphaned processes +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) + +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 + + const flushBufferedData = () => { + if (!dataBuffer.length) { + sendTimeout = null + return + } + const combinedData = Buffer.concat(dataBuffer.splice(0).map(d => Buffer.isBuffer(d) ? d : Buffer.from(d))) + + // Write to log (keep this) + term.writeLog(combinedData) + + // Check for zmodem escape sequence before sending to client + const zmodemConsumed = zmodemManager.handleData(pid, combinedData, term, ws) + if (zmodemConsumed) { + sendTimeout = null + return + } + + // Check for trzsz magic key before sending to client + const trzszConsumed = trzszManager.handleData(pid, combinedData, term, ws) + if (trzszConsumed) { + sendTimeout = null + return + } + + // Detect XMODEM auto-trigger markers from serial device + if (term.port) { + detectXmodemMarker(combinedData.toString('utf8')) + } + + // Check for xmodem protocol before sending to client + const xmodemConsumed = xmodemManager.handleData(pid, combinedData, term, ws) + if (xmodemConsumed) { + sendTimeout = null + return + } + + // Not zmodem, trzsz, or xmodem data, send to WebSocket + ws.send(combinedData) + sendTimeout = null + } + + // Create ws.s function for zmodem to send messages to client + ws.s = (data) => { + ws.send(JSON.stringify(data)) + } + + // Auto-trigger XMODEM when the serial device sends a marker message. + // The serial-shell.js sends these markers when the user types tx/rx. + 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' + }) + } + } + + // In the WebSocket setup, replace the data handler: + term.on('data', function (data) { + // Check if zmodem session is active and handle data + if (zmodemManager.isActive(pid)) { + // Let zmodem handle the data, but still log it + term.writeLog(data) + zmodemManager.handleData(pid, data, term, ws) + return + } + + // Check if trzsz session is active and handle data + if (trzszManager.isActive(pid)) { + // Let trzsz handle the data, but still log it + term.writeLog(data) + trzszManager.handleData(pid, data, term, ws) + return + } + + // Detect XMODEM auto-trigger markers from serial device + if (term.port) { + const text = Buffer.isBuffer(data) ? data.toString('utf8') : data + detectXmodemMarker(text) + } + + // Check if xmodem session is active and handle data. + // For serial terminals (term.port exists) a raw port listener (registered below) + // bypasses rxLineEnding transformation and feeds raw bytes to xmodem. + if (xmodemManager.isActive(pid)) { + if (!term.port) { + // Non-serial fallback (should not normally happen) + term.writeLog(data) + xmodemManager.handleData(pid, data, term, ws) + } + return + } + + const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data) + + // Bypass batching for very large chunks to avoid parser desync. + 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 + } + + // Buffer incoming data instead of sending immediately for normal text workload + dataBuffer.push(chunk) + + // If no timeout is pending, schedule a batched send + if (!sendTimeout) { + sendTimeout = setTimeout(flushBufferedData, 10) // Small delay (10ms) to throttle; adjust based on testing + } + }) + + // For serial terminals, register a raw data listener directly on the port to + // feed binary XMODEM data to xmodemManager without rxLineEnding transformation. + 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 + // Cancel any pending batched send + if (sendTimeout) { + clearTimeout(sendTimeout) + sendTimeout = null + } + dataBuffer.length = 0 + // Clean up zmodem session + zmodemManager.destroySession(pid) + // Clean up trzsz session + trzszManager.destroySession(pid) + // Clean up xmodem session + xmodemManager.destroySession(pid) + term.kill() + log.debug('Closed terminal ' + pid) + // Clean things up + ws.close && ws.close() + cleanup() + } + + term.on('close', onClose) + if (term.isLocal && isWin) { + term.on('exit', onClose) + } + + ws.on('message', function (msg) { + try { + // Check if message is a zmodem or trzsz control message (JSON) + 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') { + // Write \n to the PTY. In canonical mode the TTY line discipline + // only delivers data to read() when a newline completes the line, + // so \x00 (NUL) sits in the buffer and never wakes bash up. + // A newline wakes bash's read(), resets the TMOUT alarm, and bash + // simply re-displays the prompt. The client suppresses that echo. + term.write('\n\r\x1b[K') + return + } + } catch (e) { + // Not JSON, treat as regular terminal input + } + } + 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) { + 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) + } + }) + // end + }) + + // 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) + } + transfer(id, sftpId)[func](...args) + } + }) + // end + }) +} + +// Add a process message handler instead +process.on('message', async (message) => { + if (message.type === 'common') { + const msg = message.data + const { action, id, body } = msg + + let promise + + const ws = { + s: (data) => { + process.send({ + type: 'common', + data + }) + }, + once: (callack, id) => { + const func = (arg) => { + if (id === arg.id) { + callack(arg) + process.removeListener('message', func) + } + } + process.on('message', 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) + } + + 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 + } + } + }) + + // Send the result back to the parent process + process.send(result) + } +}) + +const runServer = function () { + return new Promise((resolve) => { + app.listen(wsPort, electermHost, () => { + log.info('session server', 'runs on', electermHost, wsPort) + resolve() + }) + }) +} + +async function main () { + await runServer() + process.send({ serverInited: true }) +} + +main() + +let cleanupCalled = false +function cleanup () { + if (cleanupCalled) return + cleanupCalled = true + cleanAllSessions() + setTimeout(() => { + process.exit(0) + }, 2000) +} + +// Self-terminate if the parent process IPC channel disconnects (e.g. Electron crashes/restarts) +// Without this, child processes become orphans and accumulate in memory +process.on('disconnect', () => { + log.warn('session-server: parent IPC disconnected, terminating') + cleanup() +}) + +// Self-terminate if no WebSocket connects within 2 minutes of server start +// This handles the case where the frontend unmounts before the WebSocket is established +const noConnectionTimer = setTimeout(() => { + if (!firstWsConnected) { + log.warn('session-server: no WS connection within 2min timeout, terminating') + cleanup() + } +}, 120000) +if (noConnectionTimer.unref) noConnectionTimer.unref() + +process.on('uncaughtException', (err) => { + log.error('uncaughtException', err) + cleanup() +}) +process.on('unhandledRejection', (err) => { + log.error('unhandledRejection', err) + cleanup() +}) + +process.on('SIGTERM', cleanup) diff --git a/src/app/server/session-sftp.js b/src/app/server/session-sftp.js new file mode 100644 index 0000000..a6bd5bb --- /dev/null +++ b/src/app/server/session-sftp.js @@ -0,0 +1,634 @@ +/** + * 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 new file mode 100644 index 0000000..6bf2ee6 --- /dev/null +++ b/src/app/server/session-spice.js @@ -0,0 +1,129 @@ +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 new file mode 100644 index 0000000..f032c72 --- /dev/null +++ b/src/app/server/session-ssh.js @@ -0,0 +1,1014 @@ +/** + * 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 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') + +// 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) { + 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 + const { prompts } = options + if (prompts && prompts.length === 1 && this.initOptions.password) { + 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([this.initOptions.password]) + } + } + + 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 () { + const { sshKeysPath } = process.env + try { + return require('fs') + .readdirSync(sshKeysPath) + .filter(file => file.endsWith('.pub')) + .map(file => pathResolve(sshKeysPath, 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) + 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) + .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) + }) + } + + 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() + const info = initOptions.proxy + ? await proxySock({ + readyTimeout: initOptions.readyTimeout, + host: initOptions.host, + port: initOptions.port, + proxy: initOptions.proxy + }) + : undefined + 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.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.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 new file mode 100644 index 0000000..ea3bb58 --- /dev/null +++ b/src/app/server/session-telnet.js @@ -0,0 +1,139 @@ +/** + * 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 new file mode 100644 index 0000000..7798919 --- /dev/null +++ b/src/app/server/session-vnc.js @@ -0,0 +1,143 @@ +/** + * 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 new file mode 100644 index 0000000..5799913 --- /dev/null +++ b/src/app/server/session.js @@ -0,0 +1,34 @@ +/** + * 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 new file mode 100644 index 0000000..52b41d9 --- /dev/null +++ b/src/app/server/sftp-file.js @@ -0,0 +1,68 @@ +/** + * 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 new file mode 100644 index 0000000..fc02305 --- /dev/null +++ b/src/app/server/socks.js @@ -0,0 +1,107 @@ +/** + * 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 new file mode 100644 index 0000000..9c3c4a4 --- /dev/null +++ b/src/app/server/spice-proxy.js @@ -0,0 +1,218 @@ +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 new file mode 100644 index 0000000..3ed223e --- /dev/null +++ b/src/app/server/ssh-known-hosts.js @@ -0,0 +1,452 @@ +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 () { + 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-tunnel.js b/src/app/server/ssh-tunnel.js new file mode 100644 index 0000000..349ace3 --- /dev/null +++ b/src/app/server/ssh-tunnel.js @@ -0,0 +1,207 @@ +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 new file mode 100644 index 0000000..8737571 --- /dev/null +++ b/src/app/server/ssh2-alg.js @@ -0,0 +1,82 @@ +/** + * 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 new file mode 100644 index 0000000..db312aa --- /dev/null +++ b/src/app/server/sync.js @@ -0,0 +1,64 @@ +/** + * 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 + ? { + 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 new file mode 100644 index 0000000..aae84ec --- /dev/null +++ b/src/app/server/telnet.js @@ -0,0 +1,369 @@ +// 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 new file mode 100644 index 0000000..ffcf214 --- /dev/null +++ b/src/app/server/terminal-api.js @@ -0,0 +1,137 @@ +/** + * 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 + }) +} + +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.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 new file mode 100644 index 0000000..7d3ca97 --- /dev/null +++ b/src/app/server/transfer.js @@ -0,0 +1,443 @@ +/** + * 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.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) => { + 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) + } + 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 new file mode 100644 index 0000000..b597e52 --- /dev/null +++ b/src/app/server/trzsz.js @@ -0,0 +1,739 @@ +/** + * 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 new file mode 100644 index 0000000..1f0b0e4 --- /dev/null +++ b/src/app/server/webdav-sync.js @@ -0,0 +1,264 @@ +/** + * 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 + conf = { httpsAgent: new Cls(proxy, { keepAlive: true, rejectUnauthorized: false }) } + } else { + conf = { httpsAgent: proxyAgent } + } + } else if (skipVerify) { + conf = { 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 new file mode 100644 index 0000000..4392641 --- /dev/null +++ b/src/app/server/ws-dec.js @@ -0,0 +1,31 @@ +/** + * 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 new file mode 100644 index 0000000..2efd7f9 --- /dev/null +++ b/src/app/server/xmodem.js @@ -0,0 +1,940 @@ +/** + * 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 new file mode 100644 index 0000000..52b724b --- /dev/null +++ b/src/app/server/zmodem.js @@ -0,0 +1,976 @@ +/** + * Zmodem protocol handler for server-side terminal sessions + * Uses zmodem2 (pure JS) for protocol implementation + */ + +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' +} + +// Zmodem header signature: ** + \x18 + B +const ZMODEM_HEADER = Buffer.from([0x2a, 0x2a, 0x18, 0x42]) + +// ZRQINIT = 00 00 00 00 (receive request) +const ZRQINIT = Buffer.from([0x30, 0x30]) +// ZRINIT = 00 01 (send request) +const ZRINIT = Buffer.from([0x30, 0x31]) + +/** + * 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.uploadStream = null // Read stream for file upload + this.uploadPath = null + this.transferSize = 0 + this.transferredBytes = 0 + this.startTime = 0 + this.savePath = null + this.pendingFiles = [] + this.currentFileIndex = 0 + this.fileBuffer = null // Buffer for file data during upload (used for small chunks) + this.fileReadPosition = 0 // Track read position for streaming + this.pendingData = [] // Buffer for incoming data before save path is set + this.pendingFileInfo = null // Buffer for file info before save path is set + this.pendingSendData = [] // Buffer for incoming data before files are selected + // No WASM initialization needed - pure JS + } + + /** + * Check if data contains zmodem escape sequence + * @param {Buffer} data - Data to check + * @returns {Object|null} - { type: 'receive'|'send', offset: number } or null + */ + detectZmodemStart (data) { + if (!Buffer.isBuffer(data)) { + data = Buffer.from(data) + } + + // Find zmodem header signature + for (let i = 0; i < data.length - 5; 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 typeHex1 = data[i + 4] + const typeHex2 = data[i + 5] + + if (typeHex1 === ZRQINIT[0] && typeHex2 === ZRQINIT[1]) { + // ZRQINIT - Remote wants to send file(s), we receive + return { type: 'receive', offset: i } + } else if (typeHex1 === ZRINIT[0] && typeHex2 === ZRINIT[1]) { + // ZRINIT - Remote is ready to receive, we send + return { type: 'send', offset: i } + } + } + } + return null + } + + /** + * Send message to client via websocket + * @param {Object} msg - Message to send + */ + sendToClient (msg) { + if (this.ws && this.ws.s) { + this.ws.s({ + action: 'zmodem-event', + ...msg + }) + } + } + + /** + * Handle incoming data from terminal + * @param {Buffer} data - Incoming data + * @returns {boolean} - True if data was consumed by zmodem + */ + handleData (data) { + // If waiting for save path, buffer the data + if (this.state === ZMODEM_STATE.WAITING_SAVE_PATH) { + this.pendingData.push(data) + return true + } + + // If sending but waiting for files, buffer the data + if (this.state === ZMODEM_STATE.SENDING && this.pendingFiles.length === 0) { + this.pendingSendData.push(data) + return true + } + + // If already in a session, process data + if (this.state === ZMODEM_STATE.RECEIVING) { + this.handleReceiverData(data) + return true + } else if (this.state === ZMODEM_STATE.SENDING) { + this.handleSenderData(data) + return true + } + + // Check for zmodem start sequence + const detected = this.detectZmodemStart(data) + if (detected) { + if (detected.type === 'receive') { + this.startReceiver(data.subarray(detected.offset)) + } else if (detected.type === 'send') { + this.startSender(data.subarray(detected.offset)) + } + return true + } + + return false + } + + /** + * 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 + // startTime will be set when first data is received + + // Notify client to ask for save path + this.sendToClient({ + event: 'receive-start', + message: 'ZMODEM receive session started' + }) + + // Store initial data and wait for save path + if (initialData) { + this.pendingData.push(initialData) + } + this.state = ZMODEM_STATE.WAITING_SAVE_PATH + } catch (e) { + log.error('Failed to start zmodem receiver', e) + this.endSession() + } + } + + /** + * Process pending data after save path is set + */ + processPendingData () { + if (this.state !== ZMODEM_STATE.WAITING_SAVE_PATH) return + + this.state = ZMODEM_STATE.RECEIVING + + // Process all pending data + for (const data of this.pendingData) { + this.handleReceiverData(data) + } + this.pendingData = [] + + // Process pending file info if any + if (this.pendingFileInfo) { + this.prepareReceiveFile(this.pendingFileInfo.name, this.pendingFileInfo.size) + this.pendingFileInfo = null + } + } + + /** + * Handle data during receive session + * @param {Buffer} data - Incoming data + */ + handleReceiverData (data) { + if (!this.receiver) return + + const u8 = Buffer.isBuffer(data) ? new Uint8Array(data) : new Uint8Array(data) + let offset = 0 + let loopCount = 0 + + while (offset < u8.length && loopCount++ < 1000) { + if (!this.receiver) break + try { + const chunk = u8.subarray(offset) + const consumed = this.receiver.feedIncoming(chunk) + offset += consumed + + const drained = this.pumpReceiver() + + if (consumed === 0 && !drained) { + break + } + } catch (e) { + log.error('Zmodem receiver error:', e) + this.endSession() + break + } + } + } + + /** + * Pump receiver: drain outgoing data and handle events + * @returns {boolean} - True if work was done + */ + pumpReceiver () { + if (!this.receiver) return false + let didWork = false + + try { + // Drain outgoing data and send to terminal + const outgoing = this.receiver.drainOutgoing() + if (outgoing && outgoing.length > 0) { + this.writeToTerminal(Buffer.from(outgoing)) + didWork = true + } + + // Poll for events + let event + while ((event = this.receiver.pollEvent()) !== null) { + didWork = true + + if (event === ReceiverEvent.FileStart) { + const name = this.receiver.getFileName() + const size = this.receiver.getFileSize() + const mtime = this.receiver.getFileMtime() + this.handleFileStart(name, size, mtime) + } else if (event === ReceiverEvent.FileComplete) { + this.handleFileComplete() + } else if (event === ReceiverEvent.SessionComplete) { + this.endSession() + return true + } + } + + // Drain received file data + const chunk = this.receiver.drainFile() + if (chunk && chunk.length > 0) { + this.handleFileData(Buffer.from(chunk)) + this.receiver.advanceFile(chunk.length) + didWork = true + } + } catch (e) { + log.error('Zmodem receiver pump error:', e) + this.endSession() + } + return didWork + } + + /** + * Handle file start event + * @param {string} name - File name + * @param {number} size - File size + */ + handleFileStart (name, size, mtime) { + this.currentTransfer = { + name, + size + } + this.transferSize = size + this.transferredBytes = 0 + this.currentMtime = mtime || 0 + // startTime will be set when first data is received + + // Prepare file for receiving + this.prepareReceiveFile(name, size) + + // Notify client about incoming file + this.sendToClient({ + event: 'file-start', + name, + size + }) + } + + /** + * Handle file data chunk + * @param {Buffer} data - File data chunk + */ + handleFileData (data) { + if (!this.downloadStream || !this.currentTransfer) return + + try { + // Set start time on first data received + if (this.transferredBytes === 0) { + this.startTime = Date.now() + } + + // Write to stream (non-blocking) + if (!this.downloadStream.write(data)) { + // Stream buffer is full, wait for drain + // The stream will handle backpressure automatically + } + this.transferredBytes += data.length + + // Throttled progress update + const now = Date.now() + if (!this.lastProgressUpdate || now - this.lastProgressUpdate > 500) { + this.lastProgressUpdate = now + this.sendProgress() + } + } catch (e) { + log.error('Failed to write file data', e) + this.endSession() + } + } + + /** + * Handle file complete event + */ + handleFileComplete () { + // Send final progress update with 100% + if (this.currentTransfer) { + this.transferredBytes = this.transferSize + this.sendProgress() + } + + const filePath = this.downloadPath + const fileMtime = this.currentMtime + const currentTransfer = this.currentTransfer + + const finalize = () => { + // Set file modification time from the ZMODEM protocol metadata + 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) + } + } + + this.sendToClient({ + event: 'file-complete', + name: currentTransfer?.name, + path: filePath + }) + + this.currentTransfer = null + this.downloadPath = null + this.currentMtime = 0 + } + + if (this.downloadStream) { + const stream = this.downloadStream + this.downloadStream = null + stream.on('finish', finalize) + stream.end() + } else { + finalize() + } + } + + /** + * Send progress update to client + */ + sendProgress () { + const elapsed = (Date.now() - this.startTime) / 1000 // seconds + const speed = elapsed > 0 ? Math.round(this.transferredBytes / elapsed) : 0 // bytes per second + 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 === ZMODEM_STATE.RECEIVING ? 'download' : 'upload', + path: this.state === ZMODEM_STATE.RECEIVING ? this.downloadPath : this.uploadPath + }) + } + + /** + * 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.SENDING + // Create sender as non-initiator (false) since remote sent ZRINIT first + this.sender = new Sender(false) + + // Buffer initial data to process after files are selected + if (initialData && initialData.length > 0) { + this.pendingSendData = [Buffer.isBuffer(initialData) ? initialData : Buffer.from(initialData)] + } else { + this.pendingSendData = [] + } + + // Notify client to select files to send + this.sendToClient({ + event: 'send-start', + message: 'ZMODEM send session started, please select files' + }) + } catch (e) { + log.error('Failed to start zmodem sender', e) + this.endSession() + } + } + + /** + * Handle data during send session + * @param {Buffer} data - Incoming data + */ + handleSenderData (data) { + if (!this.sender) return + + const u8 = Buffer.isBuffer(data) ? new Uint8Array(data) : new Uint8Array(data) + let offset = 0 + let loopCount = 0 + + // Check for ZSKIP frame (file already exists on remote side) + // ZSKIP = frame type 5 = "05" in hex = 0x30 0x35 in ASCII hex encoding + // Format: ** ZDLE B CRC + const dataHex = Buffer.from(u8).toString('hex') + if (dataHex.includes('2a2a18423035') || dataHex.includes('2a2a1842' + '3035')) { + // Handle ZSKIP - skip current file and move to next + this.handleFileSkipped() + return + } + + while (offset < u8.length && loopCount++ < 1000) { + if (!this.sender) break + try { + const chunk = u8.subarray(offset) + const consumed = this.sender.feedIncoming(chunk) + offset += consumed + + const drained = this.pumpSender() + + if (consumed === 0 && !drained) { + break + } + } catch (e) { + log.error('Zmodem sender error:', e) + this.endSession() + break + } + } + } + + /** + * Handle file skipped (ZSKIP received from remote) + */ + handleFileSkipped () { + // Notify client + this.sendToClient({ + event: 'file-skipped', + name: this.currentTransfer?.name, + message: 'File already exists on remote side' + }) + + // Clean up current file + this.currentTransfer = null + this.fileBuffer = null + this.transferredBytes = 0 + + // Send cancel sequence to terminate the session with the remote + // Since the sender is in state 2 (sending file), we can't use finishSession() + // Use cancel sequence instead + const cancelSequence = Buffer.from([0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x42]) + this.writeToTerminal(cancelSequence) + + // End the session + this.endSession() + } + + /** + * Pump sender: drain outgoing, process events, handle file requests + * @returns {boolean} - True if work was done + */ + pumpSender () { + if (!this.sender) return false + let didWork = false + + try { + // Drain and send outgoing data + const outgoing = this.sender.drainOutgoing() + if (outgoing && outgoing.length > 0) { + this.writeToTerminal(Buffer.from(outgoing)) + didWork = true + } + + // Process events + let event + while ((event = this.sender.pollEvent()) !== null) { + didWork = true + + if (event === SenderEvent.FileComplete) { + this.handleSendFileComplete() + } else if (event === SenderEvent.SessionComplete) { + this.endSession() + return true + } + } + + // Handle file data requests + 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.endSession() + } + + return didWork + } + + /** + * Send file data to sender using streaming read + * @param {number} offset - File offset + * @param {number} length - Data length to read + */ + sendFileData (offset, length) { + if (!this.currentTransfer || !this.sender) return + + try { + // Check if we need to read more data from file + // Use a buffer pool for streaming reads + const CHUNK_SIZE = 64 * 1024 // 64KB chunks for streaming + const data = Buffer.allocUnsafe(Math.min(length, CHUNK_SIZE)) + + // Read from current position if using streaming + if (!this.uploadFd) { + // Open file descriptor for streaming read + this.uploadFd = fs.openSync(this.uploadPath, 'r') + this.fileReadPosition = 0 + } + + // Seek to correct position if needed + if (offset !== this.fileReadPosition) { + // For random access, we need to seek + // But zmodem typically reads sequentially, so this should be rare + } + + const bytesRead = fs.readSync(this.uploadFd, data, 0, Math.min(length, CHUNK_SIZE), offset) + const actualData = data.slice(0, bytesRead) + this.fileReadPosition = offset + bytesRead + + if (actualData.length > 0) { + // Set start time on first data transfer + if (this.transferredBytes === 0) { + this.startTime = Date.now() + } + + this.sender.feedFile(new Uint8Array(actualData)) + this.transferredBytes = offset + actualData.length + + // Throttled progress update + const now = Date.now() + if (!this.lastProgressUpdate || now - this.lastProgressUpdate > 500) { + this.lastProgressUpdate = now + this.sendProgress() + } + + // Drain outgoing after feeding file data + const outgoing = this.sender.drainOutgoing() + if (outgoing && outgoing.length > 0) { + this.writeToTerminal(Buffer.from(outgoing)) + } + } + + if (bytesRead === 0 || offset + bytesRead >= this.currentTransfer.size) { + // All data for the current file has been sent. + // Close the file descriptor โ€” the sender state machine will handle + // file completion naturally (ZEOF -> ZRINIT -> FileComplete event). + // Do NOT call finishSession() here: that would set finishRequested=true + // and terminate the entire session after the first file, preventing + // subsequent files from being transferred. finishSession() is only + // called by finishSender() when there are no more files to send. + if (this.uploadFd) { + fs.closeSync(this.uploadFd) + this.uploadFd = null + } + } + } catch (e) { + log.error('Failed to read file data for sending', e) + this.endSession() + } + } + + /** + * Handle send file complete event + */ + handleSendFileComplete () { + // Check if file was skipped (already exists on remote side) + // This happens when transferredBytes is 0 but we got a FileComplete event + const wasSkipped = this.transferredBytes === 0 && this.transferSize > 0 + + if (wasSkipped) { + // File was skipped - already exists on remote side + this.sendToClient({ + event: 'file-skipped', + name: this.currentTransfer?.name, + message: 'File already exists on remote side' + }) + } else { + // Send final progress update with 100% + if (this.currentTransfer) { + this.transferredBytes = this.transferSize + this.sendProgress() + } + + this.sendToClient({ + event: 'file-complete', + name: this.currentTransfer?.name, + path: this.uploadPath + }) + } + + // Move to next file if any + this.currentFileIndex++ + if (this.pendingFiles.length > this.currentFileIndex) { + this.sendFile(this.pendingFiles[this.currentFileIndex]) + } else { + // All files sent, finish session + this.finishSender() + } + } + + /** + * Send a file using streaming (no full file read into memory) + * @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.fileBuffer = null // No longer buffer entire file + + // Close any previous upload file descriptor + if (this.uploadFd) { + fs.closeSync(this.uploadFd) + this.uploadFd = null + } + + // Start file transfer (startTime will be set when first data is sent) + // File will be read in chunks in sendFileData() + // Pass file mtime (in milliseconds) so the remote side preserves the modification time + const mtime = file.modifyTime || 0 + this.sender.startFile(file.name, file.size, mtime) + + // Drain outgoing after starting file + 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.endSession() + } + } + + /** + * Finish sender session + */ + finishSender () { + if (!this.sender) return + + try { + this.sender.finishSession() + const outgoing = this.sender.drainOutgoing() + if (outgoing && outgoing.length > 0) { + this.writeToTerminal(Buffer.from(outgoing)) + } + } catch (e) { + log.error('Failed to finish sender session', e) + this.endSession() + } + } + + /** + * Set save path for receiving files + * @param {string} savePath - Directory path to save files + */ + setSavePath (savePath) { + this.savePath = savePath + // Process pending data now that we have the save path + this.processPendingData() + } + + /** + * Set files to send + * @param {Array} files - Array of file info objects + */ + setSendFiles (files) { + this.pendingFiles = files + this.currentFileIndex = 0 + + // First, process any pending data that was buffered before files were selected + // This includes the initial ZRINIT which the sender needs to transition to state 1 + if (this.pendingSendData.length > 0) { + for (const data of this.pendingSendData) { + this.handleSenderData(data) + } + this.pendingSendData = [] + } + + if (files.length > 0) { + this.sendFile(files[0]) + } else { + this.finishSender() + } + } + + /** + * Prepare to receive a file using write stream + * @param {string} name - File name + * @param {number} size - File size + */ + prepareReceiveFile (name, size) { + if (!this.savePath) { + // Store file info for later + this.pendingFileInfo = { name, size } + return + } + + try { + let filePath = path.join(this.savePath, sanitizeFilename(name)) + + // Check if file exists, add suffix if needed + if (fs.existsSync(filePath)) { + filePath = filePath + '.' + generate() + } + + this.downloadPath = filePath + // Create write stream for streaming file write + this.downloadStream = fs.createWriteStream(filePath, { + highWaterMark: 64 * 1024 // 64KB buffer for better performance + }) + this.transferSize = size + this.transferredBytes = 0 + // startTime will be set when first data is received + + this.sendToClient({ + event: 'file-prepared', + name, + path: filePath, + size + }) + } catch (e) { + log.error('Failed to prepare receive file', e) + this.endSession() + } + } + + /** + * Write data to terminal + * @param {Buffer} data - Data to write + */ + writeToTerminal (data) { + if (this.term && this.term.write) { + this.term.write(data) + } + } + + /** + * End zmodem session + */ + endSession () { + // Close any open file streams + 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 + } + + // Send final progress + if (this.currentTransfer) { + this.transferredBytes = this.transferSize + this.sendProgress() + } + + // Notify client + this.sendToClient({ + event: 'session-end' + }) + + // Reset state + 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.fileBuffer = null + this.fileReadPosition = 0 + this.pendingData = [] + this.pendingFileInfo = null + this.pendingSendData = [] + } + + /** + * Cancel ongoing transfer + * Sends Zmodem cancel sequence (5 CAN characters + ZDLE + 'B') to remote + */ + cancel () { + // Send zmodem cancel sequence: 5 CAN (0x18) + ZDLE (0x18) + 'B' (0x42) + // This tells the remote side to abort the transfer + const cancelSequence = Buffer.from([0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x42]) + this.writeToTerminal(cancelSequence) + this.endSession() + } + + /** + * Check if session is active + * @returns {boolean} + */ + isActive () { + return this.state !== ZMODEM_STATE.IDLE + } + + /** + * Clean up resources + */ + destroy () { + 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': + session.prepareReceiveFile(msg.name, msg.size) + break + } + } + + /** + * 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 +} diff --git a/src/app/upgrade/db-defaults.js b/src/app/upgrade/db-defaults.js new file mode 100644 index 0000000..c41a221 --- /dev/null +++ b/src/app/upgrade/db-defaults.js @@ -0,0 +1,123 @@ +/** + * 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(255, 255, 255, 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' +} + +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: [] + } + ] + } +] diff --git a/src/app/upgrade/index.js b/src/app/upgrade/index.js new file mode 100644 index 0000000..ae11666 --- /dev/null +++ b/src/app/upgrade/index.js @@ -0,0 +1,88 @@ +/** + * 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 new file mode 100644 index 0000000..83a10ee --- /dev/null +++ b/src/app/upgrade/init-db.js @@ -0,0 +1,21 @@ +/** + * 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 new file mode 100644 index 0000000..3ea7bfb --- /dev/null +++ b/src/app/upgrade/version-upgrade.js @@ -0,0 +1,39 @@ +/** + * 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 new file mode 100644 index 0000000..20c40ec --- /dev/null +++ b/src/app/widgets/load-widget.js @@ -0,0 +1,195 @@ +// 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 new file mode 100644 index 0000000..85f28d0 --- /dev/null +++ b/src/app/widgets/widget-batch-op.js @@ -0,0 +1,42 @@ +/** + * 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 new file mode 100644 index 0000000..daf3348 --- /dev/null +++ b/src/app/widgets/widget-local-file-server.js @@ -0,0 +1,194 @@ +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 new file mode 100644 index 0000000..429b91a --- /dev/null +++ b/src/app/widgets/widget-local-ftp-server.js @@ -0,0 +1,143 @@ +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 new file mode 100644 index 0000000..6fa345c --- /dev/null +++ b/src/app/widgets/widget-mcp-server.js @@ -0,0 +1,1130 @@ +/** + * 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 { 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') + +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).' + } + ] +} + +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 = {} + } + + // 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 + }) + }) + } + + // 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', + 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) }] } + } + ) + + // ==================== Background Task APIs ==================== + + server.registerTool( + 'run_electerm_background_command', + { + description: 'Run a command in the background using nohup. The command runs independently of the terminal session โ€” the terminal is freed immediately. Returns a taskId for monitoring. Use get_electerm_background_task_status and get_electerm_background_task_log to check progress. Works best with SSH sessions where monitoring uses a separate exec channel.', + inputSchema: { + command: z.string().describe('The shell command to run in the background'), + tabId: z.string().optional().describe('Tab ID to run on (default: active tab)') + } + }, + async (args) => { + const result = await self.sendToRenderer('tool-call', { + toolName: 'run_background_command', args + }) + return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } + } + ) + + server.registerTool( + 'get_electerm_background_task_status', + { + description: 'Check the status of a background task. Returns whether it is still running, has completed (with exit code), or is unknown. For SSH sessions, this uses a separate exec channel and does not interfere with the terminal.', + inputSchema: { + taskId: z.string().describe('Task ID returned by run_electerm_background_command') + } + }, + async (args) => { + const result = await self.sendToRenderer('tool-call', { + toolName: 'get_background_task_status', args + }) + return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } + } + ) + + server.registerTool( + 'get_electerm_background_task_log', + { + description: 'Read the output log of a background task. Returns the last N lines of output. Useful for monitoring progress of long-running commands like builds, deployments, or installations.', + inputSchema: { + taskId: z.string().describe('Task ID returned by run_electerm_background_command'), + lines: z.number().optional().describe('Number of recent log lines to return (default: 100)') + } + }, + async (args) => { + const result = await self.sendToRenderer('tool-call', { + toolName: 'get_background_task_log', args + }) + return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } + } + ) + + server.registerTool( + 'cancel_electerm_background_task', + { + description: 'Cancel a running background task by killing its process. The task status will be set to cancelled.', + inputSchema: { + taskId: z.string().describe('Task ID returned by run_electerm_background_command') + } + }, + async (args) => { + const result = await self.sendToRenderer('tool-call', { + toolName: 'cancel_background_task', args + }) + 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: { ...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: { ...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: { ...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: { ...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 server + this.mcpServer = new McpServer({ + name: 'electerm-mcp-server', + version: widgetInfo.version + }) + + // 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: '2024-11-05', + apiKey: self.config.apiKey + } + const msg = `MCP Server is running at ${serverInfo.url} (API key required)` + 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 + } + + // 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 new file mode 100644 index 0000000..1509e03 --- /dev/null +++ b/src/app/widgets/widget-rename.js @@ -0,0 +1,181 @@ +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/common/auto-complete-data-mapper.js b/src/client/common/auto-complete-data-mapper.js new file mode 100644 index 0000000..b54c44d --- /dev/null +++ b/src/client/common/auto-complete-data-mapper.js @@ -0,0 +1,6 @@ +export default (d) => { + return { + value: d, + label: d + } +} diff --git a/src/client/common/bookmark-schemas.js b/src/client/common/bookmark-schemas.js new file mode 100644 index 0000000..bd95175 --- /dev/null +++ b/src/client/common/bookmark-schemas.js @@ -0,0 +1,167 @@ +/** + * Bookmark schemas (ES module version for client) + * Mirrors src/app/common/bookmark-zod-schemas.js with additional types + */ +import { z } from './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'), + 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://...)') +} + +export 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'), + color: z.string().optional().describe('Tag color, like #000000'), + sshTunnels: z.array(sshTunnelSchema).optional().describe('SSH tunnel definitions'), + connectionHoppings: z.array(connectionHoppingSchema).optional().describe('Connection hopping definitions') +} + +export 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') +} + +export const serialBookmarkSchema = { + title: z.string().describe('Bookmark title'), + path: z.string().describe('Serial device path, e.g., /dev/ttyUSB0 or COM1'), + 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)'), + description: z.string().optional().describe('Bookmark description') +} + +export const vncBookmarkSchema = { + ...commonNetworkBookmarkProps, + host: z.string().describe('VNC host address'), + port: z.number().optional().describe('VNC port (default 5900)'), + viewOnly: z.boolean().optional().describe('View only mode, default is false'), + clipViewport: z.boolean().optional().describe('Clip viewport to window'), + scaleViewport: z.boolean().optional().describe('Scale viewport to window, default is true'), + qualityLevel: z.number().optional().describe('VNC quality level 0-9, lower is faster, default 3'), + compressionLevel: z.number().optional().describe('VNC compression level 0-9, lower is faster, default 1'), + shared: z.boolean().optional().describe('Shared session, default is true') +} + +export const rdpBookmarkSchema = { + ...commonNetworkBookmarkProps, + host: z.string().describe('RDP host address'), + port: z.number().optional().describe('RDP port (default 3389)'), + domain: z.string().optional().describe('Login domain') +} + +export const ftpBookmarkSchema = { + title: z.string().describe('Bookmark title'), + host: z.string().describe('FTP host address'), + port: z.number().optional().describe('FTP port (default 21)'), + user: z.string().optional().describe('FTP username'), + password: z.string().optional().describe('FTP password'), + secure: z.boolean().optional().describe('Use secure FTP (FTPS), default is false'), + encode: z.string().optional().describe('Charset for file names, default is utf-8'), + profile: z.string().optional().describe('Profile id'), + description: z.string().optional().describe('Bookmark description') +} + +export const webBookmarkSchema = { + url: z.string().describe('Website URL'), + title: z.string().optional().describe('Bookmark title'), + description: z.string().optional().describe('Bookmark description'), + useragent: z.string().optional().describe('Custom user agent') +} + +export const localBookmarkSchema = { + title: z.string().describe('Bookmark title'), + description: z.string().optional().describe('Bookmark description'), + startDirectoryLocal: z.string().optional().describe('Local starting directory') +} + +export const spiceBookmarkSchema = { + ...commonNetworkBookmarkProps, + host: z.string().describe('Spice host address'), + port: z.number().optional().describe('Spice port (default 5900)'), + viewOnly: z.boolean().optional().describe('View only mode'), + scaleViewport: z.boolean().optional().describe('Scale viewport to window, default is true') +} + +export const bookmarkSchemas = { + ssh: sshBookmarkSchema, + telnet: telnetBookmarkSchema, + serial: serialBookmarkSchema, + vnc: vncBookmarkSchema, + rdp: rdpBookmarkSchema, + ftp: ftpBookmarkSchema, + web: webBookmarkSchema, + local: localBookmarkSchema, + spice: spiceBookmarkSchema +} diff --git a/src/client/common/build-proxy.js b/src/client/common/build-proxy.js new file mode 100644 index 0000000..3dc730a --- /dev/null +++ b/src/client/common/build-proxy.js @@ -0,0 +1,18 @@ +export const 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/client/common/build-run-scripts.js b/src/client/common/build-run-scripts.js new file mode 100644 index 0000000..8f0b813 --- /dev/null +++ b/src/client/common/build-run-scripts.js @@ -0,0 +1,6 @@ +export const buildRunScripts = function (inst) { + return [{ + delay: inst.loginScriptDelay || 0, + script: inst.loginScript + }] +} diff --git a/src/client/common/build-ssh-tunnel.js b/src/client/common/build-ssh-tunnel.js new file mode 100644 index 0000000..7514aae --- /dev/null +++ b/src/client/common/build-ssh-tunnel.js @@ -0,0 +1,8 @@ +export const buildSshTunnels = function (inst) { + return [{ + sshTunnel: inst.sshTunnel, + sshTunnelRemotePort: inst.sshTunnelRemotePort, + sshTunnelLocalPort: inst.sshTunnelLocalPort, + sshTunnelRemoteHost: inst.sshTunnelRemoteHost + }] +} diff --git a/src/client/common/cache.js b/src/client/common/cache.js new file mode 100644 index 0000000..f418a59 --- /dev/null +++ b/src/client/common/cache.js @@ -0,0 +1,58 @@ +// cache class that can set(key, value), get(key), init with limit, so we only +// keep limit items in cache +// we persist cache to local storage, so we can keep cache after restart + +import { safeGetItem, safeSetItem } from './safe-local-storage.js' + +class MapCache { + constructor (limit, key) { + this.limit = limit + this.key = key + this.cache = new Map() + this.load() + } + + load () { + const data = safeGetItem(this.key) + if (data) { + const arr = JSON.parse(data) + for (const item of arr) { + this.cache.set(item.key, item.value) + } + } + } + + save () { + const arr = [] + for (const [key, value] of this.cache) { + arr.push({ + key, + value + }) + } + safeSetItem(this.key, JSON.stringify(arr)) + } + + set (key, value) { + this.cache.set(key, value) + if (this.cache.size > this.limit) { + // Delete oldest 20 items when cache exceeds limit + const values = Array.from(this.cache.values()) + for (let i = 0; i < 20 && i < values.length; i++) { + this.cache.delete(values[i]) + } + } + this.save() + } + + get (key) { + return this.cache.get(key) + } + + clear () { + this.cache.clear() + this.save() + } +} + +export const aiSuggestionsCache = new MapCache(100, 'ai-cmd-suggestion-cache') diff --git a/src/client/common/check-skip-src.js b/src/client/common/check-skip-src.js new file mode 100644 index 0000000..d2f31cb --- /dev/null +++ b/src/client/common/check-skip-src.js @@ -0,0 +1,16 @@ +/** + * Check if install source should skip upgrade check + */ + +import { srcsSkipUpgradeCheck } from './constants' + +export const checkSkipSrc = (installSrc) => { + if (!installSrc) return false + return srcsSkipUpgradeCheck.some(skipSrc => { + if (skipSrc === 'skip-upgrade-check') { + return installSrc === skipSrc + } + // For file extensions like '.appx', '.snap', check if installSrc ends with them + return installSrc.endsWith(skipSrc) + }) +} diff --git a/src/client/common/choose-save-folder.js b/src/client/common/choose-save-folder.js new file mode 100644 index 0000000..6ecb51c --- /dev/null +++ b/src/client/common/choose-save-folder.js @@ -0,0 +1,23 @@ +const { + openDialog +} = window.api + +export async function chooseSaveDirectory (opts) { + const savePaths = await openDialog({ + title: 'Choose a folder to save file(s)', + message: 'Choose a folder to save file(s)', + properties: [ + 'openDirectory', + 'showHiddenFiles', + 'createDirectory', + 'noResolveAliases', + 'treatPackageAsDirectory', + 'dontAddToRecent' + ], + ...opts + }) + if (!savePaths || !savePaths.length) { + return undefined + } + return savePaths[0] +} diff --git a/src/client/common/class.js b/src/client/common/class.js new file mode 100644 index 0000000..8b32a3f --- /dev/null +++ b/src/client/common/class.js @@ -0,0 +1,52 @@ +// dom classๆ–นๆณ• +/** + * add css class + * @param elem + * @param _classes + */ +export function addClass (elem, ..._classes) { + const cls = elem.className || '' + const classes = _classes.filter(c => { + return !cls.includes(c) + }) + if (!classes.length) { + return + } + elem.className = cls + (cls ? ' ' : '') + classes.join(' ') +} + +/** + * has css class + * @param elem + * @param clst + * @returns {boolean} + */ +export function hasClass (elem, clst) { + let cls = elem.className || '' + if (!cls || typeof cls !== 'string') { + return false + } + cls = ' ' + cls.split(/\s+/).join(' ') + ' ' + const reg = new RegExp(' ' + clst + ' ') + return reg.test(cls) +} + +/** + * remove css class + * @param elem + * @param classes + */ +export function removeClass (elem, ...classes) { + let cls = elem.className || '' + if (!cls || typeof cls !== 'string') { + return + } + cls = ' ' + cls.split(/\s+/).join(' ') + ' ' + const clst = classes.join(' ').split(/\s+/) + const reg = new RegExp(' ' + clst.join(' | ') + ' ', 'g') + cls = cls.replace(reg, '') + .replace(/^\s+|\s+$/g, '') + .replace(/ {2,}/g, ' ') + + elem.className = cls +} diff --git a/src/client/common/clipboard.js b/src/client/common/clipboard.js new file mode 100644 index 0000000..705f7d0 --- /dev/null +++ b/src/client/common/clipboard.js @@ -0,0 +1,44 @@ +/** + * clipboard related + */ + +import message from '../components/common/message' + +const fileRegWin = /^(remote:)?\w:\\.+/ +const fileReg = /^(remote:)?\/.+/ + +export const readClipboard = () => { + return window.pre.readClipboard() +} + +export const readClipboardAsync = () => { + const { + readClipboardSync, + readClipboard + } = window.pre + return readClipboardSync ? readClipboardSync() : Promise.resolve(readClipboard()) +} + +export const copy = (str) => { + message.success({ + content: window.translate('copied'), + duation: 2, + key: 'copy-message' + }) + window.pre.writeClipboard(str) +} + +export const cut = (str, itemTitle = '') => { + message.success('Cutted ' + itemTitle, 2) + window.pre.writeClipboard(str) +} + +export const hasFileInClipboardText = ( + text = readClipboard() +) => { + const arr = text.split('\n') + return arr.reduce((prev, t) => { + return prev && + (fileReg.test(t) || fileRegWin.test(t)) + }, true) +} diff --git a/src/client/common/constants.js b/src/client/common/constants.js new file mode 100644 index 0000000..73beffc --- /dev/null +++ b/src/client/common/constants.js @@ -0,0 +1,363 @@ +/** + * constants + */ +import logoPath1Ref from '@electerm/electerm-resource/res/imgs/electerm-round-128x128.png' +import logoPath2Ref from '@electerm/electerm-resource/res/imgs/electerm.png' +import logoPath3Ref from '@electerm/electerm-resource/res/imgs/electerm-watermark.png' +export const packInfo = typeof window.et.packInfo === 'undefined' ? window.pre.packInfo : window.et.packInfo +const buildConst = (props) => { + return props.reduce((prev, key) => { + return { + ...prev, + [key]: key + } + }, {}) +} + +export const logoPath1 = logoPath1Ref.replace(/^\//, '') +export const logoPath2 = logoPath2Ref.replace(/^\//, '') +export const logoPath3 = logoPath3Ref.replace(/^\//, '') +export const maxEditFileSize = 1024 * 3000 +export const defaultBookmarkGroupId = 'default' +export const newBookmarkIdPrefix = 'new-bookmark' +export const unexpectedPacketErrorDesc = 'Unexpected packet' +export const noTerminalBgValue = '[๐Ÿšซ]' +export const textTerminalBgValue = '[๐Ÿ“]' +export const sftpRetryInterval = 3000 +export const maxBookmarkGroupTitleLength = 33 +export const termControlHeight = 32 +export const maxDragMove = 30 +export const splitDraggerWidth = 5 +export const minTerminalWidth = 90 +export const filePropMinWidth = 1 +export const contextMenuHeight = 28 +export const contextMenuWidth = 280 +export const contextMenuPaddingTop = 10 +export const sftpControlHeight = 42 + 30 +export const sidebarWidth = 43 +export const maxHistory = 50 +export const maxTransport = 5 +export const maxSftpHistory = 20 +export const maxZoom = 8 +export const minZoom = 0.5 +export const extraTabWidth = 113 + +export const tabWidth = 160 + +export const tabMargin = 1 + +export const fileTypeMap = { + link: 'l', + directory: 'd' +} + +export const statusMap = buildConst([ + 'default', + 'success', + 'error', + 'processing', + 'warning' +]) + +export const connectionMap = buildConst([ + 'ssh', + 'telnet', + 'serial', + 'local', + 'web', + 'rdp', + 'vnc', + 'ftp', + 'spice' +]) + +export const authTypeMap = buildConst([ + 'password', + 'privateKey', + 'profiles' +]) + +export const footerHeight = 36 +export const quickCommandBoxHeight = 180 +export const isWin = typeof window.et.isWin === 'undefined' ? window.pre.isWin : window.et.isWin +export const isMac = typeof window.et.isMac === 'undefined' ? window.pre.isMac : window.et.isMac +export const isMacJs = /Macintosh|Mac|Mac OS|MacIntel|MacPPC|Mac68K/gi.test(window.navigator.userAgent) +export const ctrlOrCmd = isMacJs ? 'cmd' : 'ctrl' +export const typeMap = buildConst([ + 'remote', + 'local' +]) + +export const paneMap = buildConst([ + 'ssh', + 'sftp', + 'terminal', + 'fileManager' +]) + +export const settingMap = buildConst([ + 'setting', + 'bookmarks', + 'terminalThemes', + 'bookmarkGroups', + 'quickCommands', + 'addressBookmarks', + 'profiles', + 'widgets', + 'workspaces' +]) + +export const staticNewItemTabs = new Set([ + 'terminalThemes', + 'quickCommands', + 'profiles' +]) + +export const infoTabs = buildConst([ + 'info', + 'deps', + 'env', + 'os', + 'log', + 'cmd' +]) + +export const transferTypeMap = buildConst([ + 'download', + 'upload', + 'remote', + 'local', + 'compressAndDownload', + 'compressAndUpload' +]) + +export const fileOperationsMap = buildConst([ + 'cp', + 'mv' +]) + +export const terminalSshConfigType = 'ssh-config' +export const terminalWebType = 'web' +export const terminalRdpType = 'rdp' +export const terminalVncType = 'vnc' +export const terminalSerialType = 'serial' +export const terminalTelnetType = 'telnet' +export const terminalLocalType = 'local' +export const terminalFtpType = 'ftp' +export const terminalSpiceType = 'spice' +export const openedSidebarKey = 'opened-sidebar' +export const sidebarPinnedKey = 'sidebar-pinned' +export const pinnedQuickCommandBarKey = 'pinned-quick-command-bar' +export const leftSidebarWidthKey = 'left-sidebar-width' +export const rightSidebarWidthKey = 'right-sidebar-width' +export const addPanelWidthLsKey = 'addPanelWidth' +export const sftpDefaultSortSettingKey = 'sftp-default-sort' +export const qmSortByFrequencyKey = 'qm-sort-by-frequency' + +export const commonBaudRates = [ + 110, + 300, + 1200, + 2400, + 4800, + 9600, + 14400, + 19200, + 38400, + 57600, + 115200 +] + +export const commonDataBits = [ + 8, 7, 6, 5 +] + +export const commonStopBits = [ + 1, 2 +] + +export const commonParities = [ + 'none', 'even', 'mark', 'odd', 'space' +] + +export const commonTxLineEndings = [ + { value: '\r', label: 'CR' }, + { value: '\n', label: 'LF' }, + { value: '\r\n', label: 'CR+LF' } +] + +export const commonRxLineEndings = [ + { value: 'none', label: 'None' }, + { value: 'lf_to_crlf', label: 'LFโ†’CRLF' }, + { value: 'cr_to_crlf', label: 'CRโ†’CRLF' } +] + +// backward compat alias +export const commonLineEndings = commonTxLineEndings + +export const maxBatchInput = 30 +export const windowControlWidth = 94 +export const baseUpdateCheckUrls = [ + packInfo.homepage, + 'https://gitee.com/github-zxdong262/electerm/raw/gh-pages' +] +export const syncTypes = buildConst([ + 'github', + 'gitee', + 'custom', + 'cloud', + 'webdav' +]) +export const syncTokenCreateUrls = { + gitee: 'https://gitee.com/github-zxdong262/electerm/wikis/Create%20personal%20access%20token?sort_id=3028409', + github: 'https://github.com/electerm/electerm/wiki/Create-personal-access-token', + custom: 'https://github.com/electerm/electerm/wiki/Custom-sync-server', + cloud: 'https://sync.electerm.org', + webdav: 'https://github.com/electerm/electerm/wiki/WebDAV-sync' +} +export const settingSyncId = 'setting-sync' +export const settingTerminalId = 'setting-terminal' +export const settingShortcutsId = 'setting-shortcuts' +export const settingAiId = 'setting-ai' +export const settingCommonId = 'setting-common' +export const settingPasswordsId = 'setting-passwords' +export const defaultEnvLang = 'en_US.UTF-8' +export const fileActions = { + cancel: 'cancel', + skip: 'skip', + skipAll: 'skipAll', + mergeOrOverwrite: 'mergeOrOverwrite', + rename: 'rename', + mergeOrOverwriteAll: 'mergeOrOverwriteAll', + renameAll: 'renameAll' +} + +export const srcsSkipUpgradeCheck = [ + '.appx', + '.snap', + 'skip-upgrade-check' +] +export const termLSPrefix = 'term:sess:' +export const batchInputLsKey = 'batch-inputs' +export const rendererTypes = { + dom: 'dom', + webGL: 'webGL' +} +export const downloadUpgradeTimeout = 20000 +export const expandedKeysLsKey = 'expanded-keys' +export const resolutionsLsKey = 'custom-resolution-key' +export const checkedKeysLsKey = 'checked-keys' +export const quickCommandLabelsLsKey = 'quick-command-label' +export const localAddrBookmarkLsKey = 'local-addr-bookmark-keys' +export const dismissDelKeyTipLsKey = 'dismiss-del-key-tip' +export const sshTunnelHelpLink = 'https://github.com/electerm/electerm/wiki/How-to-use-ssh-tunnel' +export const proxyHelpLink = 'https://github.com/electerm/electerm/wiki/proxy-format' +export const regexHelpLink = 'https://github.com/electerm/electerm/wiki/Terminal-keywords-highlight-regular-expression-exmaples' +export const connectionHoppingWikiLink = 'https://github.com/electerm/electerm/wiki/Connection-Hopping-Behavior-Change-in-electerm-since-v1.50.65' +export const aiConfigWikiLink = 'https://github.com/electerm/electerm/wiki/AI-model-config-guide' +export const aiChatModeLsKey = 'ai-chat-mode' +export const modals = { + hide: 0, + setting: 1 +} +export const instSftpKeys = [ + 'connect', + 'list', + 'download', + 'upload', + 'mkdir', + 'getFolderSize', + 'getHomeDir', + 'rmdir', + 'stat', + 'lstat', + 'chmod', + 'rename', + 'rm', + 'touch', + 'readlink', + 'realpath', + 'mv', + 'cp', + 'readFile', + 'writeFile' +] +export const zmodemTransferPackSize = 1024 * 8 +export const splitMap = { + c1: 'c1', + c2: 'c2', + c3: 'c3', + r2: 'r2', + r3: 'r3', + c2x2: 'c2x2', + c1r2: 'c1r2', + r1c2: 'r1c2' +} +export const splitMapDesc = { + c1: 'single', + c2: 'twoColumns', + c3: 'threeColumns', + r2: 'twoRows', + r3: 'threeRows', + c2x2: 'grid2x2', + c1r2: 'twoRowsRight', + r1c2: 'twoColumnsBottom' +} +export const splitConfig = { + c1: { + children: 1, + handle: 0 + }, + c2: { + children: 2, + handle: 1 + }, + c3: { + children: 3, + handle: 2 + }, + r2: { + children: 2, + handle: 1 + }, + r3: { + children: 3, + handle: 2 + }, + c2x2: { + children: 4, + handle: 3 + }, + c1r2: { + children: 3, + handle: 2 + }, + r1c2: { + children: 3, + handle: 2 + } +} +export const syncDataMaps = { + settings: ['config'], + bookmarks: ['bookmarks', 'bookmarkGroups'], + terminalThemes: ['terminalThemes'], + quickCommands: ['quickCommands'], + profiles: ['profiles'], + addressBookmarks: ['addressBookmarks'], + workspaces: ['workspaces'] +} +export const terminalTypes = [ + 'xterm-256color', + 'xterm-new', + 'xterm-color', + 'xterm-vt220', + 'xterm', + 'linux', + 'vt100', + 'ansi', + 'rxvt' +] +export const sshConfigLoadKey = 'ssh-config-loaded' +export const sshConfigKey = 'ignore-ssh-config' +export const connectionHoppingWarnKey = 'connectionHoppingWarnned' +export const syncServerDataKey = 'sync-server-data' diff --git a/src/client/common/create-lang-edit-link.js b/src/client/common/create-lang-edit-link.js new file mode 100644 index 0000000..820f841 --- /dev/null +++ b/src/client/common/create-lang-edit-link.js @@ -0,0 +1,7 @@ +/** + * create edit language file link + */ + +export default (lang) => { + return `https://github.com/electerm/electerm-locales/edit/master/locales/${lang}.js` +} diff --git a/src/client/common/create-title.jsx b/src/client/common/create-title.jsx new file mode 100644 index 0000000..2eefe26 --- /dev/null +++ b/src/client/common/create-title.jsx @@ -0,0 +1,70 @@ +/** + * tab title create rule + */ + +import { + terminalLocalType +} from './constants' + +const e = window.translate + +function maskHost (hostOrIp = '') { + if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostOrIp)) { + const arr = hostOrIp.split('.') + return arr.slice(0, arr.length - 2).join('.') + '.*.*' + } else { + return hostOrIp.replace(/^.{3}/, '***') + } +} + +export default function createTitle (res, hide = true) { + if (!res) { + return '' + } + const { + host, port, username, title, type, url, + path, connectionHoppings, sshTunnels + } = res + const h = hide && window.store.config.hideIP ? maskHost(host) : host + const fixTitle = `${username || ''}@${h}:${port}` + const extra = host || path ? (path || fixTitle) : (url || '') + let f = title + ? `${title}` + (extra ? ` - ${extra}` : '') + : extra + if (connectionHoppings && connectionHoppings.length) { + f = `[โ‹™]${f}` + } + if ( + sshTunnels && + sshTunnels.length && + sshTunnels[0].sshTunnel && + sshTunnels[0].sshTunnelRemoteHost + ) { + f = `[T]${f}` + } + if (type && type !== 'ssh') { + f = `[${type}]${f}` + } + return f || e(terminalLocalType) +} + +export function createTitleTag (obj) { + const { color } = obj + if (!color) { + return null + } + const styleTag = color + ? { color } + : {} + return ( + โ— + ) +} + +export function createTitleWithTag (obj) { + return ( + + {createTitleTag(obj)} {createTitle(obj)} + + ) +} diff --git a/src/client/common/data-compare.js b/src/client/common/data-compare.js new file mode 100644 index 0000000..4193277 --- /dev/null +++ b/src/client/common/data-compare.js @@ -0,0 +1,58 @@ +/** + * Compare two arrays of objects and return differences + * @param {Array} oldArr - Original array of objects + * @param {Array} newArr - New array of objects to compare against + * @returns {Object} Object containing arrays of items to update, add, and remove + */ + +import deepCopy from 'json-deep-copy' + +export default function compare (oldArr, newArr) { + if (!oldArr || !newArr) { + return { + updated: [], + added: [], + removed: [] + } + } + // Create maps for faster lookup + const oldMap = new Map( + oldArr.map(item => [item.id, item]) + ) + const newMap = new Map( + newArr.map(item => [item.id, item]) + ) + + const updated = [] + const added = [] + const removed = [] + + // Find items to update or add + for (const item of newArr) { + const oldItem = oldMap.get(item.id) + if (!oldItem) { + // Item doesn't exist in old array - need to add + added.push(item) + } else { + // Item exists - check if it needs updating + // Convert to JSON strings for deep comparison + const oldStr = JSON.stringify(oldItem) + const newStr = JSON.stringify(item) + if (oldStr !== newStr) { + updated.push(item) + } + } + } + + // Find items to remove + for (const item of oldArr) { + if (!newMap.has(item.id)) { + removed.push(item) + } + } + return { + updated: deepCopy(updated), + added: deepCopy(added), + removed: deepCopy(removed) + } +} diff --git a/src/client/common/db-fix.js b/src/client/common/db-fix.js new file mode 100644 index 0000000..db7d259 --- /dev/null +++ b/src/client/common/db-fix.js @@ -0,0 +1,24 @@ +// make old data imported compatible +import { buildProxyString } from './build-proxy.js' +import { buildSshTunnels } from './build-ssh-tunnel.js' +import { buildRunScripts } from './build-run-scripts.js' + +export function fixBookmarks (arr) { + return arr.map(bookmark => { + if (typeof bookmark.proxy !== 'string') { + bookmark.proxy = buildProxyString(bookmark.proxy || {}) + } + if (bookmark.sshTunnel) { + bookmark.sshTunnels = buildSshTunnels(bookmark.sshTunnel) + delete bookmark.sshTunnel + delete bookmark.sshTunnelRemotePort + delete bookmark.sshTunnelLocalPort + } + if (bookmark.loginScript) { + bookmark.runScripts = buildRunScripts(bookmark) + delete bookmark.loginScript + delete bookmark.loginScriptDelay + } + return bookmark + }) +} diff --git a/src/client/common/db.js b/src/client/common/db.js new file mode 100644 index 0000000..9a96903 --- /dev/null +++ b/src/client/common/db.js @@ -0,0 +1,171 @@ +/** + * db common methods + */ + +import { + settingMap +} from '../common/constants' +import { without, isArray } from 'lodash-es' +import handleError from './error-handler' +import generate from './uid' +import safeParse from './to-simple-obj' +import { decObj } from './pass-enc' + +/** + * db action, never direct use it + * @param {...any} args + */ +const dbAction = (...args) => { + return window.pre.runGlobalAsync('dbAction', ...args) + .catch(handleError) +} + +/** + * standalone db names + */ +export const dbNames = [ + ...without( + Object.keys(settingMap), + settingMap.setting, + settingMap.widgets + ), + 'history', + 'terminalCommandHistory', + 'aiChatHistory', + 'autoRunWidgets' +] +export const dbNamesForSync = [ + ...without( + Object.keys(settingMap), + settingMap.setting, + settingMap.widgets + ) +] + +export const dbNamesForWatch = [ + ...dbNamesForSync, + 'history', + 'terminalCommandHistory', + 'aiChatHistory', + 'autoRunWidgets' +] + +/** + * db insert + * @param {string} dbName + * @param {object or array} inst + */ +export function insert (dbName, inst) { + let arr = isArray(inst) ? inst : [inst] + arr = arr.map(obj => { + const { id, _id, ...rest } = obj + return { + _id: _id || id || generate(), + ...rest + } + }) + return dbAction(dbName, 'insert', safeParse(arr)) +} + +/** + * db delete + * @param {string} dbName + * @param {string} id + */ +export async function remove (dbName, id) { + const q = id + ? { + _id: id + } + : {} + const multi = !id + await dbAction(dbName, 'remove', q, { multi }) +} + +/** + * upsert single data in db + * @param {string} _id + * @param {any} value + * @param {string} db default is 'data' + */ +export function update (_id, value, db = 'data', upsert = true) { + const updates = dbNames.includes(db) + ? { + $set: value + } + : { + $set: { + value + } + } + return dbAction(db, 'update', { + _id + }, safeParse(updates), { + upsert + }) +} + +/** + * get doc from db + * @param {string} dbName + * @param {string} id + * @return any + */ +export async function findOne (dbName, id) { + const res = await dbAction(dbName, 'findOne', { + _id: id + }) + if (!res) { + return res + } + const { _id, ...rest } = res + return { + id: _id, + ...decObj(rest) + } +} + +/** + * get all data as array from databse + * @param {string} dbName + */ +export async function find (dbName) { + const res = await dbAction(dbName, 'find', {}) || [] + return res.map(r => { + const { _id, ...rest } = r + return { + id: _id, + ...decObj(rest) + } + }) +} + +/** + * get value from database: data + * @param {string} name + * @return any + */ +export async function getData (name) { + const res = await dbAction('data', 'findOne', { + _id: name + }) + return res ? res.value : undefined +} + +/** + * get sorted data from db + * @param {string} dbName + */ + +export async function fetchInitData (dbName) { + const res = await find(dbName) + const order = await getData(`${dbName}:order`) + if (order && order.length) { + res.sort((a, b) => { + const ai = order.findIndex(r => r === a.id) + const bi = order.findIndex(r => r === b.id) + return ai - bi + }) + } + return JSON.stringify(res) +} diff --git a/src/client/common/default-log-path.js b/src/client/common/default-log-path.js new file mode 100644 index 0000000..06b88dc --- /dev/null +++ b/src/client/common/default-log-path.js @@ -0,0 +1,5 @@ +import { osResolve } from './resolve' + +export default function () { + return window.et.sessionLogPath || osResolve(window.store.appPath, 'electerm', 'session_logs') +} diff --git a/src/client/common/default-setting.js b/src/client/common/default-setting.js new file mode 100644 index 0000000..cc30b8e --- /dev/null +++ b/src/client/common/default-setting.js @@ -0,0 +1,82 @@ +/** + * default setting + */ + +export 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, + terminalBackgroundText: '', + terminalBackgroundTextSize: 48, + terminalBackgroundTextColor: '#ffffff', + terminalBackgroundTextFontFamily: 'Maple Mono', + rendererType: 'dom', + terminalType: 'xterm-256color', + keepaliveCountMax: 10, + keyword2FA: 'verification code,otp,one-time,two-factor,2fa,totp,authenticator,duo,yubikey,security code,mfa,passcode', + saveTerminalLogToFile: false, + checkUpdateOnStart: true, + cursorBlink: false, + cursorStyle: 'block', + useSystemTitleBar: false, + opacity: 1, + defaultEditor: '', + terminalWordSeparator: './\\()"\'-:,.;<>~!@#$%^&*|+=[]{}`~ ?', + confirmBeforeExit: false, + initDefaultTabOnStart: true, + screenReaderMode: false, + autoRefreshWhenSwitchToSftp: false, + addTimeStampToTermLog: false, + sftpPathFollowSsh: 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', + proxyAI: '', + sessionLogPath: '', + sshSftpSplitView: false, + showCmdSuggestions: false, + autoReconnectTerminal: false, + startDirectoryLocal: '', + allowMultiInstance: false, + disableDeveloperTool: false, + dragDropBehavior: 'ask' +} diff --git a/src/client/common/download-mirrors.js b/src/client/common/download-mirrors.js new file mode 100644 index 0000000..03d1e4a --- /dev/null +++ b/src/client/common/download-mirrors.js @@ -0,0 +1,6 @@ +export default [ + { + name: 'homepage', + url: 'https://electerm.org' + } +] diff --git a/src/client/common/download.jsx b/src/client/common/download.jsx new file mode 100644 index 0000000..6955cd9 --- /dev/null +++ b/src/client/common/download.jsx @@ -0,0 +1,33 @@ +/** + * simulate download + */ +import { notification } from '../components/common/notification' +import ShowItem from '../components/common/show-item' +import { chooseSaveDirectory } from './choose-save-folder' +import { DownloadOutlined } from '@ant-design/icons' + +export default async function download (filename, text) { + const opts = window.et.isWebApp + ? { filename, content: text } + : undefined + const savePath = await chooseSaveDirectory(opts) + if (!savePath) { + return + } + const path = window.require('path') + const filePath = path.join(savePath, filename) + const r = await window.fs.writeFile(filePath, text).catch(window.store.onError) + if (!r) { + return + } + notification.success({ + message: , + description: ( + + {filePath} + + ) + }) +} diff --git a/src/client/common/error-handler.jsx b/src/client/common/error-handler.jsx new file mode 100644 index 0000000..cd49005 --- /dev/null +++ b/src/client/common/error-handler.jsx @@ -0,0 +1,23 @@ +/** + * common error handler + */ + +import { notification } from '../components/common/notification' + +export default (e) => { + const { message = 'error', stack } = e + console.error(e) + const description = ( +
+ {stack} +
+ ) + notification.error({ + message, + description, + duration: 55 + }) +} diff --git a/src/client/common/fetch-from-server.js b/src/client/common/fetch-from-server.js new file mode 100644 index 0000000..5feae92 --- /dev/null +++ b/src/client/common/fetch-from-server.js @@ -0,0 +1,50 @@ +/** + * fetch from server + */ + +import initWs from './ws' +import generate from './uid' +import { NewPromise } from './promise-timeout' + +const id = 's' +window.et.wsOpened = false + +export const initWsCommon = async () => { + if (window.et.wsOpened) { + return + } + const ws = await initWs('common', id, undefined, true) + if (!ws) { + return + } + window.et.wsOpened = true + ws.onclose = () => { + window.et.wsOpened = false + } + window.et.commonWs = ws + window.store.wsInited = true +} + +window.pre.ipcOnEvent('power-resume', initWsCommon) + +const wsFetch = async (data) => { + if (!window.et.wsOpened) { + await initWsCommon() + } + const id = generate() + return new NewPromise((resolve, reject) => { + window.et.commonWs.once((arg) => { + if (arg.error) { + console.error('fetch error', arg.error) + return reject(new Error(arg.error.message)) + } + resolve(arg.data) + }, id) + window.et.commonWs.s({ + id, + ...data + }) + }) +} +window.wsFetch = wsFetch +export default wsFetch diff --git a/src/client/common/fetch.jsx b/src/client/common/fetch.jsx new file mode 100644 index 0000000..4ca0c43 --- /dev/null +++ b/src/client/common/fetch.jsx @@ -0,0 +1,71 @@ +// the final fetch wrapper +import { isString, isFunction } from 'lodash-es' +import { notification } from '../components/common/notification' + +function jsonHeader () { + return { + Accept: 'application/json', + 'Content-Type': 'application/json', + token: window.store?.config.tokenElecterm + } +} + +function parseResponse (response) { + const contentType = response.headers.get('content-type') || '' + const isJsonResult = contentType.toLowerCase().includes('application/json') + return isJsonResult ? response.json() : response.text() +} + +export async function handleErr (res) { + console.debug(res) + let text = res.message || res.statusText + if (!isString(text)) { + try { + text = isFunction(res.text) + ? await res.text() + : isFunction(res.json) ? await res.json() : '' + } catch (e) { + console.error('fetch response parse fails', e) + } + } + console.debug(text, 'fetch err info') + notification.error({ + message: 'error', + description: ( +
+ {text} +
+ ), + duration: 55 + }) +} + +export default class Fetch { + static get (url, data, options) { + return Fetch.connect(url, 'get', null, options) + } + + static post (url, data, options) { + return Fetch.connect(url, 'post', data, options) + } + + static connect (url, method, data, options = {}) { + const body = { + method, + body: data + ? JSON.stringify(data) + : undefined, + headers: jsonHeader(), + timeout: 180000, + ...options + } + return window.fetch(url, body) + .then(res => { + if (res.status > 304) { + throw res + } + return res + }) + .then(options.handleResponse || parseResponse, options.handleErr || handleErr) + } +} diff --git a/src/client/common/file-drop-utils.js b/src/client/common/file-drop-utils.js new file mode 100644 index 0000000..7e8196a --- /dev/null +++ b/src/client/common/file-drop-utils.js @@ -0,0 +1,64 @@ +/** + * Common utilities for handling file drops + */ + +import { getFolderFromFilePath } from '../components/sftp/file-read' +import { typeMap } from './constants' + +/** + * Safely get file path from dropped file + * @param {File} file - File object from drop event + * @returns {string} - File path + */ +export const getFilePath = (file) => { + if (file.path) { + return file.path + } + // Try the official Electron 32+ method first if available + if (window.api && window.api.getPathForFile) { + return window.api.getPathForFile(file) + } + return file.name +} + +/** + * Process dropped files and return file list + * @param {DataTransfer} dataTransfer - DataTransfer object from drop event + * @returns {Array} - Array of file objects + */ +export const getDropFileList = (dataTransfer) => { + const fromFile = dataTransfer.getData('fromFile') + if (fromFile) { + const parsed = JSON.parse(fromFile) + return Array.isArray(parsed) + ? parsed + : [parsed] + } + + const { files } = dataTransfer + const res = [] + for (let i = 0, len = files.length; i < len; i++) { + const item = files[i] + if (!item) { + continue + } + + const filePath = getFilePath(item) + const isRemote = false + const fileObj = getFolderFromFilePath(filePath, isRemote) + res.push({ + ...fileObj, + type: typeMap.local + }) + } + return res +} + +/** + * Check if filename contains unsafe characters + * @param {string} filename - Filename to check + * @returns {boolean} - True if unsafe + */ +export const isUnsafeFilename = (filename) => { + return /["'\n\r]/.test(filename) +} diff --git a/src/client/common/find-bookmark-group-id.js b/src/client/common/find-bookmark-group-id.js new file mode 100644 index 0000000..cf43ca7 --- /dev/null +++ b/src/client/common/find-bookmark-group-id.js @@ -0,0 +1,14 @@ +/** + * find bookmark group id for bookmark id + */ + +import { + defaultBookmarkGroupId +} from './constants' + +export default (bookmarkGroups, id) => { + const obj = bookmarkGroups.find(bg => { + return bg.bookmarkIds.includes(id) + }) + return obj ? obj.id : defaultBookmarkGroupId +} diff --git a/src/client/common/find-parent.js b/src/client/common/find-parent.js new file mode 100644 index 0000000..746a4e7 --- /dev/null +++ b/src/client/common/find-parent.js @@ -0,0 +1,30 @@ +/** + * find the target parentNode + * @param {Node} node + * @param {String} className + * @return {Boolean} + */ +export default function findParentBySel (node, sel) { + if (!node) { + return false + } + let parent = node + if (!parent || !parent.matches) { + return false + } + if (parent.matches(sel)) { + return parent + } + let res = false + while (parent !== document.body) { + parent = parent.parentNode + if (!parent || !parent.matches) { + break + } + if (parent.matches(sel)) { + res = parent + break + } + } + return res +} diff --git a/src/client/common/form-layout.js b/src/client/common/form-layout.js new file mode 100644 index 0000000..0c3ea94 --- /dev/null +++ b/src/client/common/form-layout.js @@ -0,0 +1,27 @@ +/** + * form layout + */ + +export const formItemLayout = { + labelCol: { + xs: { span: 24 }, + sm: { span: 8 } + }, + wrapperCol: { + xs: { span: 24 }, + sm: { span: 16 } + } +} + +export const tailFormItemLayout = { + wrapperCol: { + xs: { + span: 24, + offset: 0 + }, + sm: { + span: 14, + offset: 8 + } + } +} diff --git a/src/client/common/fs.js b/src/client/common/fs.js new file mode 100644 index 0000000..117390c --- /dev/null +++ b/src/client/common/fs.js @@ -0,0 +1,105 @@ +/** + * fs through ws + */ +import fetch from './fetch-from-server' + +const fsFunctions = typeof window.pre.fsFunctions === 'undefined' ? window.et.fsFunctions : window.pre.fsFunctions + +const fs = fsFunctions.reduce((prev, func) => { + prev[func] = (...args) => { + return fetch({ + action: 'fs', + args, + func + }) + } + return prev +}, {}) + +// Encoding function +fs.encodeUint8Array = (uint8Array) => { + let str = '' + const len = uint8Array.byteLength + + for (let i = 0; i < len; i++) { + str += String.fromCharCode(uint8Array[i]) + } + + return btoa(str) +} + +// Decoding function +fs.decodeBase64String = (base64String) => { + const str = atob(base64String) + const len = str.length + + const uint8Array = new Uint8Array(len) + + for (let i = 0; i < len; i++) { + uint8Array[i] = str.charCodeAt(i) + } + + return uint8Array +} + +Object.assign(fs, { + stat: (path, cb) => { + window.fs.statCustom(path) + .catch(err => cb(err)) + .then(obj => { + obj.isDirectory = () => obj.isD + obj.isFile = () => obj.isF + cb(undefined, obj) + }) + }, + access: (...args) => { + const cb = args.pop() + window.fs.access(...args) + .then((data) => cb(undefined, data)) + .catch((err) => cb(err)) + }, + open: (...args) => { + const cb = args.pop() + window.fs.openCustom(...args) + .then((data) => cb(undefined, data)) + .catch((err) => cb(err)) + }, + read: (p1, arr, ...args) => { + const cb = args.pop() + window.fs.readCustom( + p1, + arr.length, + ...args + ) + .then((data) => { + const { n, newArr } = data + const newArr1 = window.fs.decodeBase64String(newArr) + cb(undefined, n, newArr1) + }) + .catch(err => cb(err)) + }, + close: (fd, cb) => { + window.fs.closeCustom(fd) + .then((data) => cb(undefined, data)) + .catch((err) => cb(err)) + }, + readdir: (p, cb) => { + window.fs.readdir(p) + .then((data) => cb(undefined, data)) + .catch((err) => cb(err)) + }, + write: (p1, buf, cb) => { + window.fs.writeCustom(p1, window.fs.encodeUint8Array(buf)) + .then((data) => cb(undefined, data)) + .catch((err) => cb(err)) + }, + realpath: (p, cb) => { + window.fs.realpath(p) + .then((data) => cb(undefined, data)) + .catch((err) => cb(err)) + } +}) + +window.fs = fs + +export default fs diff --git a/src/client/common/get-category-color.js b/src/client/common/get-category-color.js new file mode 100644 index 0000000..ee0957b --- /dev/null +++ b/src/client/common/get-category-color.js @@ -0,0 +1,19 @@ +/** + * Get default color from current category or random color + */ + +import { getRandomDefaultColor } from './rand-hex-color.js' + +export function getColorFromCategory (bookmarkGroups, currentBookmarkGroupId) { + if (!currentBookmarkGroupId || !bookmarkGroups) { + return getRandomDefaultColor() + } + + const currentGroup = bookmarkGroups.find(group => group.id === currentBookmarkGroupId) + + if (currentGroup && currentGroup.color) { + return currentGroup.color + } + + return getRandomDefaultColor() +} diff --git a/src/client/common/get-proxy.js b/src/client/common/get-proxy.js new file mode 100644 index 0000000..dab5065 --- /dev/null +++ b/src/client/common/get-proxy.js @@ -0,0 +1,8 @@ +export default function (tab, conf) { + if (tab.proxy && typeof tab.proxy === 'string') { + return tab.proxy + } else if (conf.enableGlobalProxy && conf.proxy && typeof conf.proxy === 'string') { + return conf.proxy + } + return '' +} diff --git a/src/client/common/has-active-input.js b/src/client/common/has-active-input.js new file mode 100644 index 0000000..dc88614 --- /dev/null +++ b/src/client/common/has-active-input.js @@ -0,0 +1,10 @@ +export default function hasActiveInput (className = 'ant-input-search') { + const activeElement = document.activeElement + const isInput = activeElement && ( + activeElement.tagName === 'INPUT' || + activeElement.tagName === 'TEXTAREA' + ) + const hasClass = className ? activeElement.classList.contains(className) : true + const hasInputDropDown = document.querySelector('.input-context-menu') + return (isInput && hasClass) || hasInputDropDown +} diff --git a/src/client/common/icon-helpers.jsx b/src/client/common/icon-helpers.jsx new file mode 100644 index 0000000..53fbd12 --- /dev/null +++ b/src/client/common/icon-helpers.jsx @@ -0,0 +1,16 @@ +import React from 'react' +import { + InfoCircleFilled, + CheckCircleFilled, + ExclamationCircleFilled, + CloseCircleFilled +} from '@ant-design/icons' + +export const messageIcons = { + info: , + success: , + warning: , + error: +} + +export const getMessageIcon = (type) => messageIcons[type] || messageIcons.info diff --git a/src/client/common/id-with-stamp.js b/src/client/common/id-with-stamp.js new file mode 100644 index 0000000..e6de1c5 --- /dev/null +++ b/src/client/common/id-with-stamp.js @@ -0,0 +1,10 @@ +/** + * create id with time stamp + */ + +import { nanoid } from 'nanoid/non-secure' +import dayjs from 'dayjs' + +export default () => { + return nanoid(7) + '_' + dayjs().format('YYYY-MM-DD-HH-mm-ss') +} diff --git a/src/client/common/index-sorter.js b/src/client/common/index-sorter.js new file mode 100644 index 0000000..54788ae --- /dev/null +++ b/src/client/common/index-sorter.js @@ -0,0 +1,4 @@ +/** + * basic sorter + */ +export default (a, b) => a - b diff --git a/src/client/common/init-setting-item.js b/src/client/common/init-setting-item.js new file mode 100644 index 0000000..8b5a907 --- /dev/null +++ b/src/client/common/init-setting-item.js @@ -0,0 +1,47 @@ +/** + * get init setting item + */ + +import { + settingMap, + newBookmarkIdPrefix, + settingCommonId +} from './constants' +import { buildNewTheme } from '../common/terminal-theme' + +const e = window.translate +const newQuickCommand = 'newQuickCommand' + +export default (arr, tab) => { + if (tab === settingMap.history) { + return arr[0] || {} + } else if (tab === settingMap.bookmarks) { + return { id: newBookmarkIdPrefix + ':' + (Date.now()), title: '' } + } else if (tab === settingMap.setting) { + return { id: settingCommonId, title: e('common') } + } else if (tab === settingMap.terminalThemes) { + return buildNewTheme() + } else if (tab === settingMap.quickCommands) { + return { + id: '', + name: e(newQuickCommand) + } + } else if (tab === settingMap.profiles) { + return { + id: '', + name: e(settingMap.profiles) + } + } else if (tab === settingMap.widgets) { + return { + id: '', + name: e(settingMap.widgets) + } + } else if (tab === settingMap.workspaces) { + return { + id: '', + name: e(settingMap.workspaces), + layout: 'default', + tabsByBatch: {} + } + } +} diff --git a/src/client/common/is-absolute-path.js b/src/client/common/is-absolute-path.js new file mode 100644 index 0000000..b14f226 --- /dev/null +++ b/src/client/common/is-absolute-path.js @@ -0,0 +1,3 @@ +export default (path = '') => { + return path.startsWith('/') || /^[a-zA-Z]:/.test(path) || path.startsWith('\\\\') +} diff --git a/src/client/common/is-color-dark.js b/src/client/common/is-color-dark.js new file mode 100644 index 0000000..7765300 --- /dev/null +++ b/src/client/common/is-color-dark.js @@ -0,0 +1,37 @@ +function expandShorthandColor (color) { + if (color.length === 4) { + return '#' + color[1] + color[1] + color[2] + color[2] + color[3] + color[3] + } + if (color.length === 7) { + return color + } + if (color.length < 7) { + return expandShorthandColor(color + 'f') + } + if (color.length > 7) { + return expandShorthandColor(color.slice(0, 7)) + } + if (!/^#[A-Fa-f0-9]{6}$/.test(color)) { + return '#141314' + } +} + +export default function isColorDark (_color) { + try { + let color = expandShorthandColor(_color) + if (color.charAt(0) === '#') { + color = color.slice(1) // Remove the '#' if present + } + const r = parseInt(color.substr(0, 2), 16) + const g = parseInt(color.substr(2, 2), 16) + const b = parseInt(color.substr(4, 2), 16) + + // Formula to determine brightness + const brightness = (r * 299 + g * 587 + b * 114) / 1000 + + // Decide based on brightness threshold + return brightness < 128 // You can adjust this threshold as needed + } catch (e) { + return true + } +} diff --git a/src/client/common/is-ip.js b/src/client/common/is-ip.js new file mode 100644 index 0000000..afebe3f --- /dev/null +++ b/src/client/common/is-ip.js @@ -0,0 +1,16 @@ +export 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 +} diff --git a/src/client/common/is-valid-path.js b/src/client/common/is-valid-path.js new file mode 100644 index 0000000..6e4a6a4 --- /dev/null +++ b/src/client/common/is-valid-path.js @@ -0,0 +1,7 @@ +/** + * file path validation + */ + +export default (pth) => { + return true +} diff --git a/src/client/common/key-control-pressed.js b/src/client/common/key-control-pressed.js new file mode 100644 index 0000000..f59db05 --- /dev/null +++ b/src/client/common/key-control-pressed.js @@ -0,0 +1,13 @@ +/** + * check event if control/meta key pressed + */ + +import { isMacJs } from './constants' +export default e => { + if (e.metaKey && e.ctrlKey) { + return false + } + return isMacJs + ? e.metaKey + : e.ctrlKey +} diff --git a/src/client/common/key-pressed.js b/src/client/common/key-pressed.js new file mode 100644 index 0000000..760577e --- /dev/null +++ b/src/client/common/key-pressed.js @@ -0,0 +1,13 @@ +/** + * check if given key is pressed + */ + +export default (e, key, type) => { + const keyPressed = 'key' in e + ? e.code.toLowerCase() === key.toLowerCase() + : false + const typeMatch = type + ? e.type === type + : true + return typeMatch && keyPressed +} diff --git a/src/client/common/key-shift-pressed.js b/src/client/common/key-shift-pressed.js new file mode 100644 index 0000000..732ecd2 --- /dev/null +++ b/src/client/common/key-shift-pressed.js @@ -0,0 +1,7 @@ +/** + * check if shift key is pressed + */ + +export default e => { + return 'shiftKey' in e ? e.shiftKey : false +} diff --git a/src/client/common/mode2permission.js b/src/client/common/mode2permission.js new file mode 100644 index 0000000..e9d4e7d --- /dev/null +++ b/src/client/common/mode2permission.js @@ -0,0 +1,81 @@ +/** + * convert mode / permission converter + * (33188).toString(8) => 100644 + */ + +/** + * digit to permission + * @param {string} _d + * @return {object} {read: Boolean, write: Boolean, exec: Boolean} + */ + +import parseInt10 from './parse-int10' + +function digit2permission (_d) { + const d = parseInt10(_d) + const arr = d.toString(2).split('') + const len = arr.length + for (let a = len - 3, i = 0; i < a; i++) { + arr.unshift('0') + } + const [rr, ww, xx] = arr + const read = rr === '1' + const write = ww === '1' + const exec = xx === '1' + return { + read, + write, + exec + } +} + +/** + * permission to digit + * @param {object} {read: Boolean, write: Boolean, exec: Boolean} + * @return {string} + */ +function permission2digit ({ + read, + write, + exec +}) { + const arr = [ + read ? '1' : '0', + write ? '1' : '0', + exec ? '1' : '0' + ] + return Number('0b' + arr.join('')).toString() +} + +/** + * mode to permission + * @param {number} mode + * @return {array} + */ +export function mode2permission (mode) { + const str = mode.toString(8) + const len = str.length + const perms = str.slice(len - 3, len).split('') + const permNames = [ + 'owner', + 'group', + 'other' + ] + return permNames.map((name, i) => { + return { + name, + permission: digit2permission(perms[i]) + } + }) +} + +/** + * permission to mode + * @param {array} arr + * @return {string} eg, '644' + */ +export function permission2mode (arr) { + return arr.reduce((prev, { permission }) => { + return prev + permission2digit(permission) + }, '') +} diff --git a/src/client/common/new-terminal.js b/src/client/common/new-terminal.js new file mode 100644 index 0000000..f113c18 --- /dev/null +++ b/src/client/common/new-terminal.js @@ -0,0 +1,29 @@ +/** + * output default new terminal data obj + */ + +import uid from './id-with-stamp' +import { + paneMap +} from './constants' + +const e = window.translate +window.et.tabCount = 0 + +export function updateCount (tab) { + window.et.tabCount++ + return window.et.tabCount +} + +export default (removeTitle) => { + const res = { + id: uid(), + status: 'processing', + pane: paneMap.terminal, + title: e('newTerminal') + } + if (removeTitle) { + delete res.title + } + return res +} diff --git a/src/client/common/normalize-remote-path.js b/src/client/common/normalize-remote-path.js new file mode 100644 index 0000000..23d0e66 --- /dev/null +++ b/src/client/common/normalize-remote-path.js @@ -0,0 +1,23 @@ +/** + * Ensure remote path always starts with / + * Windows drive letters like c: become /c: + * Also fixes mixed separators like /c:\windows โ†’ /c:/windows + * This is needed because SFTP protocol expects paths with leading / + * @param {String} path + * @return {String} + */ +export default function normalizeRemotePath (path) { + if (!path) return path + // Fix mixed separators: /c:\windows โ†’ /c:/windows + if (/^\/[a-zA-Z]:\\/.test(path)) { + path = path.replace(/\\/g, '/') + } else if (/^[a-zA-Z]:/.test(path)) { + // Add leading / to bare drive letters: c: โ†’ /c:, c:\windows โ†’ /c:/windows + path = '/' + path.replace(/\\/g, '/') + } + // Strip trailing slashes except for root path "/" + if (path.length > 1 && path.endsWith('/')) { + path = path.replace(/\/+$/, '') + } + return path +} diff --git a/src/client/common/parse-int10.js b/src/client/common/parse-int10.js new file mode 100644 index 0000000..6cf494c --- /dev/null +++ b/src/client/common/parse-int10.js @@ -0,0 +1,3 @@ +export default (str) => { + return parseInt(str, 10) +} diff --git a/src/client/common/parse-json-safe.js b/src/client/common/parse-json-safe.js new file mode 100644 index 0000000..9e1cc16 --- /dev/null +++ b/src/client/common/parse-json-safe.js @@ -0,0 +1,11 @@ +/** + * safe parse json + */ +export default str => { + try { + return JSON.parse(str) + } catch (e) { + console.error('JSON.parse fails', e.stack) + return str + } +} diff --git a/src/client/common/parse-quick-connect.js b/src/client/common/parse-quick-connect.js new file mode 100644 index 0000000..1dda143 --- /dev/null +++ b/src/client/common/parse-quick-connect.js @@ -0,0 +1,454 @@ +/** + * 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, + 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 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] +} + +export { + parseQuickConnect, + getDefaultPort, + getSupportedProtocols, + SUPPORTED_PROTOCOLS, + DEFAULT_PORTS, + OPTS_DENY_LIST +} diff --git a/src/client/common/pass-enc.js b/src/client/common/pass-enc.js new file mode 100644 index 0000000..aab4414 --- /dev/null +++ b/src/client/common/pass-enc.js @@ -0,0 +1,20 @@ +const 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('') +} + +/** + * dec password + * @param {object} obj + */ +export function decObj (obj) { + if (obj.passwordEncrypted && obj.password) { + obj.password = dec(obj.password) + delete obj.passwordEncrypted + } + return obj +} diff --git a/src/client/common/pre.js b/src/client/common/pre.js new file mode 100644 index 0000000..ffd04ff --- /dev/null +++ b/src/client/common/pre.js @@ -0,0 +1,57 @@ +const { + runSync, + ipcOnEvent, + ipcOffEvent, + runGlobalAsync, + getZoomFactor, + setZoomFactor +} = window.api + +const props = runSync('getConstants') + +props.versions = JSON.parse(props.versions) + +window.pre = { + requireAuth: runSync('shouldAuth'), + readClipboard: () => { + return runSync('readClipboard') + }, + + writeClipboard: str => { + return runSync('writeClipboard', str) + }, + + resolve: (...args) => runSync('resolve', ...args), + join: (...args) => runSync('join', ...args), + basename: (...args) => runSync('basename', ...args), + showItemInFolder: (href) => runSync('showItemInFolder', href), + ...props, + ipcOnEvent, + ipcOffEvent, + + getZoomFactor, + setZoomFactor, + openExternal: (url) => runSync('openExternal', url), + + osInfo: () => runSync('osInfo'), + runGlobalAsync, + runSync +} + +const path = { + resolve: window.pre.resolve, + join: window.pre.join, + basename: window.pre.basename +} + +window.reqs = { + path +} + +function require (name) { + return window.reqs[name] +} + +require.resolve = name => name + +window.require = require diff --git a/src/client/common/promise-timeout.js b/src/client/common/promise-timeout.js new file mode 100644 index 0000000..dad3d87 --- /dev/null +++ b/src/client/common/promise-timeout.js @@ -0,0 +1,27 @@ +/** + * Promise support timeout + */ + +export class NewPromise extends Promise { + constructor (executor, timeout = 120000) { + let timeoutId + + super((resolve, reject) => { + const wrappedResolve = (value) => { + clearTimeout(timeoutId) + resolve(value) + } + + const wrappedReject = (reason) => { + clearTimeout(timeoutId) + reject(reason) + } + + timeoutId = setTimeout(() => { + wrappedReject(new Error('Promise timed out')) + }, timeout) + + executor(wrappedResolve, wrappedReject) + }) + } +} diff --git a/src/client/common/rand-hex-color.js b/src/client/common/rand-hex-color.js new file mode 100644 index 0000000..3e6e7b7 --- /dev/null +++ b/src/client/common/rand-hex-color.js @@ -0,0 +1,28 @@ +export function getRandomHexColor () { + let color = '#' + const hexValues = '0123456789ABCDEF' + + for (let i = 0; i < 6; i++) { + const index = Math.floor(Math.random() * 16) + color += hexValues[index] + } + + return color +} + +export const defaultColors = [ + '#0366d6', + '#28a745', + '#d73a49', + '#ffab4a', + '#ffd33d', + '#6f42c1', + '#e99695', + '#24292e', + '#6a737d' +] + +export const getRandomDefaultColor = () => { + const index = Math.floor(Math.random() * defaultColors.length) + return defaultColors[index] +} diff --git a/src/client/common/resolve.js b/src/client/common/resolve.js new file mode 100644 index 0000000..637a5cd --- /dev/null +++ b/src/client/common/resolve.js @@ -0,0 +1,52 @@ +/** + * smart resolve function + * @param {String} basePath + * @param {String} nameOrDot + * @return {String} + */ + +export const isWslPath = (path) => /^\\\\(?:wsl\$|wsl\.localhost)\\/.test(path) + +export const isWslDistroRoot = (path) => { + const trimmed = path.replace(/\\$/, '') + return /^\\\\(?:wsl\$|wsl\.localhost)\\[^\\]+$/.test(trimmed) +} + +export default function resolve (basePath, nameOrDot) { + const hasWinDrive = (path) => /^[a-zA-Z]:/.test(path) + const isWin = basePath.includes('\\') || nameOrDot.includes('\\') || hasWinDrive(basePath) || hasWinDrive(nameOrDot) + const sep = isWin ? '\\' : '/' + if (/^[a-zA-Z]:/.test(nameOrDot)) { + return nameOrDot.replace(/^\//, '').replace(/\//g, sep) + } + if (nameOrDot.startsWith('/')) { + return nameOrDot.replace(/\\/g, sep) + } + if (nameOrDot.startsWith('\\\\')) { + return nameOrDot + } + if (nameOrDot === '..') { + if (isWslDistroRoot(basePath)) { + return '/' + } + const baseEndsWithSep = basePath.endsWith(sep) + const parts = basePath.split(sep) + if (parts.length > 1) { + parts.pop() + if (isWin && parts.length === 1) { + return baseEndsWithSep ? '/' : parts.join(sep) + } + return parts.join(sep) || '/' + } + return '/' + } + if (isWslDistroRoot(basePath) && !basePath.endsWith(sep)) { + return basePath + sep + nameOrDot + } + const result = basePath.endsWith(sep) ? basePath + nameOrDot : basePath + sep + nameOrDot + return isWin && result.length === 3 && result.endsWith(':\\') ? '/' : result +} + +export const osResolve = (...args) => { + return window.pre.resolve(...args) +} diff --git a/src/client/common/reverse-color.js b/src/client/common/reverse-color.js new file mode 100644 index 0000000..ff3caca --- /dev/null +++ b/src/client/common/reverse-color.js @@ -0,0 +1,12 @@ +export function getReverseColor (hex) { + // Check if the input is a valid hex color code + if (!/^#[0-9a-fA-F]{6}$/.test(hex)) { + return '#0088cc' + } + // Convert the hex color code to an integer + const num = parseInt(hex.slice(1), 16) + // Bitwise XOR the integer with 0xFFFFFF to get the reverse color + const reverse = num ^ 0xFFFFFF + // Convert the reverse color to a hex string and pad with zeros if needed + return '#' + reverse.toString(16).padStart(6, '0') +} diff --git a/src/client/common/run-idle.js b/src/client/common/run-idle.js new file mode 100644 index 0000000..3191762 --- /dev/null +++ b/src/client/common/run-idle.js @@ -0,0 +1 @@ +export default (...args) => window.requestIdleCallback(...args) diff --git a/src/client/common/safe-local-storage.js b/src/client/common/safe-local-storage.js new file mode 100644 index 0000000..5a57da3 --- /dev/null +++ b/src/client/common/safe-local-storage.js @@ -0,0 +1,140 @@ +import { termLSPrefix } from './constants' +import parseJsonSafe from './parse-json-safe' + +// โ”€โ”€โ”€ Encryption helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +// Prefix that marks a value encrypted by this module. +// Values without this prefix are treated as legacy plaintext. +const ENC_PREFIX = 'enc1:' + +let _encKey = null + +function getKey () { + if (_encKey !== null) return _encKey + try { + // window.pre is set by pre.js before the store is used; runSync is synchronous IPC + _encKey = (window.pre && window.pre.runSync && window.pre.runSync('getStorageKey')) || '' + } catch (e) { + _encKey = '' + } + return _encKey +} + +function encrypt (str) { + if (!str) return str + const key = getKey() + if (!key) return str + const strBytes = new TextEncoder().encode(str) + const keyBytes = new TextEncoder().encode(key) + const out = new Uint8Array(strBytes.length) + for (let i = 0; i < strBytes.length; i++) { + out[i] = strBytes[i] ^ keyBytes[i % keyBytes.length] + } + let binary = '' + for (let i = 0; i < out.length; i++) { + binary += String.fromCharCode(out[i]) + } + return ENC_PREFIX + btoa(binary) +} + +function decrypt (str) { + if (!str || !str.startsWith(ENC_PREFIX)) return str + const key = getKey() + if (!key) return str + try { + const binary = atob(str.slice(ENC_PREFIX.length)) + const keyBytes = new TextEncoder().encode(key) + const out = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) { + out[i] = binary.charCodeAt(i) ^ keyBytes[i % keyBytes.length] + } + return new TextDecoder().decode(out) + } catch (e) { + return str + } +} + +// โ”€โ”€โ”€ Internal helper โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function clear () { + const keys = Object.keys(window.localStorage) + for (const key of keys) { + if (key.startsWith(termLSPrefix)) { + window.localStorage.removeItem(key) + } + } +} + +// โ”€โ”€โ”€ Original (plain) functions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export function setItem (id, str) { + try { + window.localStorage.setItem(id, str) + } catch (e) { + console.log(e) + console.log('maybe local storage full, lets reset') + clear() + window.localStorage.setItem(id, str) + } +} + +export function getItem (id) { + return window.localStorage.getItem(id) || '' +} + +export function getItemJSON (id, defaultValue) { + const str = window.localStorage.getItem(id) || '' + const r = parseJsonSafe(str) + if (typeof r === 'string') { + return defaultValue + } + return r || defaultValue +} + +export function setItemJSON (id, obj) { + const str = JSON.stringify(obj) + return setItem(id, str) +} + +// โ”€โ”€โ”€ Safe (encrypted) functions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export function safeSetItem (id, str) { + if (window.et.isWebApp) { + return setItem(id, str) + } + try { + window.localStorage.setItem(id, encrypt(str)) + } catch (e) { + console.log(e) + console.log('maybe local storage full, lets reset') + clear() + window.localStorage.setItem(id, encrypt(str)) + } +} + +export function safeGetItem (id) { + if (window.et.isWebApp) { + return getItem(id) + } + return decrypt(window.localStorage.getItem(id) || '') +} + +export function safeGetItemJSON (id, defaultValue) { + if (window.et.isWebApp) { + return getItemJSON(id, defaultValue) + } + const str = decrypt(window.localStorage.getItem(id) || '') + const r = parseJsonSafe(str) + if (typeof r === 'string') { + return defaultValue + } + return r || defaultValue +} + +export function safeSetItemJSON (id, obj) { + if (window.et.isWebApp) { + return setItemJSON(id, obj) + } + const str = JSON.stringify(obj) + return safeSetItem(id, str) +} diff --git a/src/client/common/sanitize-filename.js b/src/client/common/sanitize-filename.js new file mode 100644 index 0000000..bcdebf9 --- /dev/null +++ b/src/client/common/sanitize-filename.js @@ -0,0 +1,73 @@ +/** + * 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 = '_' + +export default 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/client/common/setting-list.js b/src/client/common/setting-list.js new file mode 100644 index 0000000..a7abb3a --- /dev/null +++ b/src/client/common/setting-list.js @@ -0,0 +1,32 @@ +import { + settingSyncId, + settingShortcutsId, + settingTerminalId, + settingAiId, + settingPasswordsId +} from '../common/constants' + +const e = window.translate + +export default () => ([ + { + id: settingTerminalId, + title: e('terminal') + }, + { + id: settingShortcutsId, + title: e('settingShortcuts') + }, + { + id: settingSyncId, + title: e('settingSync') + }, + { + id: settingAiId, + title: 'AI' + }, + { + id: settingPasswordsId, + title: e('password') + } +]) diff --git a/src/client/common/sftp.js b/src/client/common/sftp.js new file mode 100644 index 0000000..c862927 --- /dev/null +++ b/src/client/common/sftp.js @@ -0,0 +1,81 @@ +/** + * sftp through ws + */ + +import generate from './uid' +import Transfer from './transfer' +import { transferTypeMap, instSftpKeys as keys } from './constants' +import initWs from './ws' + +const transferKeys = Object.keys(transferTypeMap) + +class Sftp { + async init (terminalId, port) { + const id = generate() + const ws = await initWs('sftp', id, terminalId, undefined, port) + this.ws = ws + this.id = id + this.terminalId = terminalId + this.port = port + ws.s({ + action: 'sftp-new', + id, + type: this.type, + terminalId + }) + const th = this + this.ws = ws + keys.forEach(func => { + th[func] = async (...args) => { + if (transferKeys.includes(func)) { + return Transfer({ + sftpId: id, + isFtp: this.type === 'ftp', + ...args[0], + terminalId, + type: func, + port + }) + } + const fid = generate() + const uid = func + ':' + fid + // let ws = await initWs() + return new Promise((resolve, reject) => { + ws.s({ + action: 'sftp-func', + id, + uid, + func, + args, + terminalId, + type: this.type + }) + ws.once((arg) => { + if (arg.error) { + console.debug('sftp error', arg.error.message) + return reject(new Error(arg.error.message)) + } + resolve(arg.data) + }, uid) + }) + } + }) + } + + async destroy () { + const { ws } = this + ws.s({ + action: 'sftp-destroy', + id: this.id, + terminalId: this.terminalId + }) + ws.close() + } +} + +export default async (terminalId, type = 'sftp', port) => { + const sftp = new Sftp() + sftp.type = type + await sftp.init(terminalId, port) + return sftp +} diff --git a/src/client/common/terminal-theme.js b/src/client/common/terminal-theme.js new file mode 100644 index 0000000..284079c --- /dev/null +++ b/src/client/common/terminal-theme.js @@ -0,0 +1,152 @@ +/** + * theme control + */ + +import { settingMap } from '../common/constants' +import download from '../common/download' +import { + defaultTheme +} from './theme-defaults' + +const e = window.translate +const terminalPrefix = 'terminal:' +export const requiredThemeProps = [ + 'main', + 'main-dark', + 'main-light', + 'text', + 'text-light', + 'text-dark', + 'text-disabled', + 'primary', + 'info', + 'success', + 'error', + 'warn', + 'terminal:foreground', + 'terminal:background', + 'terminal:cursor', + 'terminal:cursorAccent', + 'terminal:selectionBackground', + 'terminal:black', + 'terminal:red', + 'terminal:green', + 'terminal:yellow', + 'terminal:blue', + 'terminal:magenta', + 'terminal:cyan', + 'terminal:white', + 'terminal:brightBlack', + 'terminal:brightRed', + 'terminal:brightGreen', + 'terminal:brightYellow', + 'terminal:brightBlue', + 'terminal:brightMagenta', + 'terminal:brightCyan', + 'terminal:brightWhite' +] +export const validThemeProps = [ + ...requiredThemeProps, + 'name' +] + +/** + * build new theme + */ +export const buildNewTheme = (theme = defaultTheme()) => { + return Object.assign( + theme, + { + id: '', + name: e('newTheme') + } + ) +} + +/** + * convert theme object to themeText + * @param {object} themeObj + * @param {boolean} withName + * @return {string} + */ +export const convertThemeToText = (themeObj = {}, withName = false) => { + const theme = themeObj || {} + const { themeConfig = {}, name, uiThemeConfig = {} } = theme + const begin = withName + ? `themeName=${name}\n` + : '' + const res = Object.keys(uiThemeConfig).reduce((prev, key) => { + return prev + + (prev ? '\n' : '') + + key + '=' + uiThemeConfig[key] + }, begin) + return Object.keys(themeConfig).reduce((prev, key) => { + return prev + + (prev ? '\n' : '') + terminalPrefix + + key + '=' + themeConfig[key] + }, res) +} + +/** + * convert themeText to themeConfig object + * @param {string} themeTxt + * @return {object} + */ +export const convertTheme = (themeTxt) => { + return themeTxt.split('\n').reduce((prev, line) => { + let [key = '', value = ''] = line.split('=') + key = key.trim() + value = value.trim() + if (!key || !value) { + return prev + } + if (key === 'themeName') { + prev.name = value.slice(0, 50) + } else { + const isTerminal = key.startsWith(terminalPrefix) + key = key.replace(terminalPrefix, '') + if (key.includes('selection')) { + key = 'selectionBackground' + } + if (isTerminal) { + prev.themeConfig[key] = value + } else { + prev.uiThemeConfig[key] = value + } + } + return prev + }, { + themeConfig: {}, + uiThemeConfig: {} + }) +} + +/** + * verify theme config + * @param {object} themeConfig + * @return {array} extra keys + */ +export const verifyTheme = (themeConfig) => { + const keysRight = Object.keys(defaultTheme().themeConfig) + const keys = Object.keys(themeConfig) + const extraKeys = keys.filter(k => !keysRight.includes(k)) + return extraKeys +} + +/** + * export theme as txt + * @param {string} themeId + */ +export const exportTheme = (themeId) => { + const themes = window.store.getSidebarList(settingMap.terminalThemes) + const theme = themes.find(d => d.id === themeId) + if (!theme) { + console.error('export error', themeId) + return + } + const text = convertThemeToText(theme, true) + download( + `${theme.name}.txt`, + text + ) +} diff --git a/src/client/common/test-connection.js b/src/client/common/test-connection.js new file mode 100644 index 0000000..aef06a3 --- /dev/null +++ b/src/client/common/test-connection.js @@ -0,0 +1,12 @@ +/** + * test connection + */ + +import fetch from './fetch-from-server' + +export default (body) => { + return fetch({ + body, + action: 'test-terminal' + }) +} diff --git a/src/client/common/theme-defaults.js b/src/client/common/theme-defaults.js new file mode 100644 index 0000000..16432a8 --- /dev/null +++ b/src/client/common/theme-defaults.js @@ -0,0 +1,120 @@ +/** + * 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 defaultThemeDark = () => { + return parsor(` +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 +main=#121214 + `) +} +const defaultThemeLightFunc = () => { + return 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 = () => { + return parsor(` +background=#121214 +foreground=#af9a91 +cursor=#af9a91 +selectionBackground=#575256 +cursorAccent=#121214 +black=#572100 +red=#ba3934 +green=#91773f +yellow=#b55600 +blue=#5f63b4 +magenta=#a17c7b +cyan=#8faea9 +white=#af9a91 +brightBlack=#4e4b61 +brightRed=#d9443f +brightGreen=#d6b04e +brightYellow=#f66813 +brightBlue=#8086ef +brightMagenta=#e2c2bb +brightCyan=#a4dce7 +brightWhite=#d2c7a9 + ` + ) +} + +const defaultThemeDarkTerminal = () => { + return { + foreground: '#bbbbbb', + background: '#20111b', + cursor: '#b5bd68', + cursorAccent: '#1d1f21', + selectionBackground: 'rgba(255, 255, 255, 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' + } +} + +export function defaultTheme () { + return { + id: 'default', + name: 'default', + themeConfig: defaultThemeDarkTerminal(), + uiThemeConfig: defaultThemeDark() + } +} + +export function defaultThemeLight () { + return { + id: 'defaultLight', + name: 'default light', + themeConfig: defaultThemeLightTerminal(), + uiThemeConfig: defaultThemeLightFunc() + } +} diff --git a/src/client/common/time.js b/src/client/common/time.js new file mode 100644 index 0000000..e3102cd --- /dev/null +++ b/src/client/common/time.js @@ -0,0 +1,30 @@ +/** + * time formatter + */ + +function prefix (n) { + const str = '' + n + if (str.length < 2) { + return '0' + str + } + return str +} + +export default ( + time = new Date(), + format = 'YYYY-MM-DD HH:mm:ss' +) => { + const t = new Date(time) + const d = t.getDate() + const y = t.getFullYear() + const m = t.getMonth() + const h = t.getHours() + const mm = t.getMinutes() + const s = t.getSeconds() + return format.replace('YYYY', prefix(y)) + .replace('MM', prefix(m + 1)) + .replace('DD', prefix(d)) + .replace('HH', prefix(h)) + .replace('mm', prefix(mm)) + .replace('ss', prefix(s)) +} diff --git a/src/client/common/to-simple-obj.js b/src/client/common/to-simple-obj.js new file mode 100644 index 0000000..953ac52 --- /dev/null +++ b/src/client/common/to-simple-obj.js @@ -0,0 +1,5 @@ +import { cloneDeep } from 'lodash-es' + +export default (obj) => { + return cloneDeep(obj) +} diff --git a/src/client/common/transfer.js b/src/client/common/transfer.js new file mode 100644 index 0000000..f4eb3d3 --- /dev/null +++ b/src/client/common/transfer.js @@ -0,0 +1,77 @@ +/** + * transfer through ws + */ + +import generate from './uid' +import initWs from './ws' + +const keys = window.pre.transferKeys + +class Transfer { + async init ({ + onData, + onEnd, + onError, + ...rest + }) { + const id = generate() + this.id = id + const th = this + const { + sftpId, + isFtp, + port + } = rest + const ws = await initWs('transfer', id, sftpId, undefined, port) + ws.s({ + action: 'transfer-new', + ...rest, + id + }) + keys.forEach(func => { + th[func] = (...args) => { + ws.s({ + action: 'transfer-func', + id: th.id, + isFtp, + func, + sftpId, + args + }) + if (func === 'destroy') { + th.onDestroy(ws) + } + } + }) + + const did = 'transfer:data:' + id + this.onData = (evt) => { + const arg = JSON.parse(evt.data) + if (did === arg.id) { + onData(arg.data) + } + } + ws.addEventListener('message', this.onData) + ws.once((arg) => { + onEnd(arg) + th.onDestroy(ws) + }, 'transfer:end:' + id) + ws.once((arg) => { + console.debug('sftp transfer error') + console.debug(arg.error.stack) + onError(new Error(arg.error.message)) + th.onDestroy(ws) + }, 'transfer:err:' + id) + } + + onDestroy (ws) { + ws.removeEventListener('message', this.onData) + ws.close() + } +} + +export default async (props) => { + const transfer = new Transfer() + await transfer.init(props) + return transfer +} diff --git a/src/client/common/ui-theme.js b/src/client/common/ui-theme.js new file mode 100644 index 0000000..54be4b1 --- /dev/null +++ b/src/client/common/ui-theme.js @@ -0,0 +1,57 @@ +/** + * ui theme related + */ + +const defaultUiThemeStylus = ` + --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 +` + +export function getUiThemeConfig (conf = defaultUiThemeStylus) { + const lines = conf.split('\n').filter(line => line.trim()) + return lines.reduce((p, line) => { + const [k, v] = line.trim().replace('--', '').split(' ') + if (k && v) { + return { + ...p, + [k]: v + } + } + return p + }, {}) +} + +/** + * convert themeText to themeConfig object + * @param {string} themeTxt + * @return {object} + */ +export const convertTheme = (themeTxt) => { + return themeTxt.split('\n').reduce((prev, line) => { + let [key = '', value = ''] = line.split('=') + key = key.trim() + value = value.trim() + if (!key || !value) { + return prev + } + if (key === 'themeName') { + prev.name = value.slice(0, 50) + } else { + prev.themeConfig[key] = value + } + return prev + }, { + name: 'unnamed theme', + themeConfig: {} + }) +} diff --git a/src/client/common/uid.js b/src/client/common/uid.js new file mode 100644 index 0000000..1a99b49 --- /dev/null +++ b/src/client/common/uid.js @@ -0,0 +1,5 @@ +import { nanoid } from 'nanoid' + +export default () => { + return nanoid(7) +} diff --git a/src/client/common/update-check.js b/src/client/common/update-check.js new file mode 100644 index 0000000..d747ccd --- /dev/null +++ b/src/client/common/update-check.js @@ -0,0 +1,85 @@ +/** + * check latest release for update warn + */ + +import fetch from './fetch-from-server' +import { + baseUpdateCheckUrls, packInfo +} from './constants' +import dayjs from 'dayjs' + +async function fetchData (url, options) { + const data = { + action: 'fetch', + options: { + ...options, + url, + timeout: 15000 + }, + proxy: window.store.getProxySetting() + } + return fetch(data) +} + +function getInfo (url) { + const n = Date.now() + const tail = url.includes('?') ? '' : '?_=' + n + return fetchData(url + tail, { + action: 'get-update-info', + headers: { + 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.3071.115 Safari/537.36' + }, + timeout: 1000 * 60 * 5 + }) + .catch(() => { + return null + }) +} + +export async function getLatestReleaseVersion (n) { + let q = '' + if (n) { + const { store } = window + const info = { + load_time: store.loadTime, + bookmark_count: store.bookmarks.length, + lang: store.config.language, + sync_with_github: !!store.config.syncSetting?.githubGistId, + sync_with_gitee: !!store.config.syncSetting?.giteeGistId, + version: packInfo.version, + installSrc: store.installSrc, + n: Date.now() + } + q = Object.keys(info).reduce((p, k, i) => { + const pre = i ? '&' : '?' + return p + pre + k + '=' + encodeURIComponent(info[k]) + }, '') + } + + let url = `${baseUpdateCheckUrls[0]}/version.html${q}` + let tagName = await getInfo(url) + if (!tagName) { + url = `${baseUpdateCheckUrls[1]}/version.html${q}` + tagName = await getInfo(url) + } + if (tagName) { + return { + tag_name: tagName + } + } +} + +export async function getLatestReleaseInfo () { + let url = `${baseUpdateCheckUrls[0]}/data/electerm-github-release.json` + let res = await getInfo(url) + if (!res?.release?.body) { + url = `${baseUpdateCheckUrls[1]}/data/electerm-github-release.json` + res = await getInfo(url) + } + return res && res.release + ? { + body: res.release.body, + date: dayjs(res.release.published_at).format('YYYY-MM-DD') + } + : undefined +} diff --git a/src/client/common/upgrade.js b/src/client/common/upgrade.js new file mode 100644 index 0000000..81e73e3 --- /dev/null +++ b/src/client/common/upgrade.js @@ -0,0 +1,68 @@ +/** + * upgrade through ws + */ + +import generate from './uid' +import initWs from './ws' + +const keys = window.pre.transferKeys + +class Upgrade { + async init ({ + onData, + onEnd, + onError, + ...rest + }) { + const id = generate() + this.id = id + const th = this + const ws = await initWs('upgrade', id) + ws.s({ + action: 'upgrade-new', + ...rest, + id + }) + keys.forEach(func => { + th[func] = (...args) => { + ws.s({ + action: 'upgrade-func', + id: th.id, + func, + args + }) + if (func === 'destroy') { + th.onDestroy(ws) + } + } + }) + + const did = 'upgrade:data:' + id + this.onData = (evt) => { + const arg = JSON.parse(evt.data) + if (did === arg.id) { + onData(arg.data) + } + } + ws.addEventListener('message', this.onData) + ws.once((arg) => { + onEnd(arg) + }, 'upgrade:end:' + id) + ws.once((arg) => { + console.debug('upgrade error') + console.debug(arg.error.stack) + onError(new Error(arg.error.message)) + }, 'upgrade:err:' + id) + } + + onDestroy (ws) { + ws.removeEventListener('message', this.onData) + ws.close() + } +} + +export default async (props) => { + const upgrade = new Upgrade() + await upgrade.init(props) + return upgrade +} diff --git a/src/client/common/version-compare.js b/src/client/common/version-compare.js new file mode 100644 index 0000000..1c88b88 --- /dev/null +++ b/src/client/common/version-compare.js @@ -0,0 +1,25 @@ +/** + * 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 +export 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/client/common/wait.js b/src/client/common/wait.js new file mode 100644 index 0000000..8fdbf5c --- /dev/null +++ b/src/client/common/wait.js @@ -0,0 +1,8 @@ +/** + * wait async + */ +export default function wait (time) { + return new Promise(resolve => { + setTimeout(resolve, time) + }) +} diff --git a/src/client/common/ws.js b/src/client/common/ws.js new file mode 100644 index 0000000..b6e84e4 --- /dev/null +++ b/src/client/common/ws.js @@ -0,0 +1,201 @@ +/** + * ws function for sftp/file transfer communication + */ + +import generate from './uid' +import wait from './wait' +import { pick } from 'lodash-es' + +const onces = {} +const wss = {} +const persists = {} + +function send (data) { + window.worker.postMessage(data) +} + +class Ws { + constructor (id, persist) { + this.id = id + this.persist = !!persist + } + + onceIds = [] + + s (data) { + send({ + action: 's', + args: [data], + wsId: this.id + }) + } + + async once (func, id) { + const maxWait = 300 + let waited = 0 + while (this.closed) { + if (++waited >= maxWait) { + console.warn('ws once timeout waiting for reconnection', id) + return + } + await wait(100) + } + this.onceIds.push(id) + onces[id] = { + resolve: (...args) => { + func(...args) + delete onces[id] + this.onceIds = this.onceIds.filter(d => d !== id) + } + } + send({ + id, + wsId: this.id, + action: 'once' + }) + } + + addEventListener (type = 'message', cb = this.cb) { + this.cb = cb + const id = generate() + if (!this.eids) { + this.eids = new Set() + } + if (!this.cbToId) { + this.cbToId = new Map() + } + this.eids.add(id) + this.cbToId.set(cb, id) + persists[id] = { + resolve: cb + } + send({ + id, + wsId: this.id, + type, + action: 'addEventListener' + }) + } + + removeEventListener (type, cb) { + if (!this.cbToId || !this.cbToId.has(cb)) { + return + } + const id = this.cbToId.get(cb) + this.cbToId.delete(cb) + this.eids.delete(id) + delete persists[id] + send({ + id, + wsId: this.id, + type, + action: 'removeEventListener' + }) + } + + onclose () { + + } + + clearOnces () { + if (this.eids) { + for (const eid of this.eids) { + delete persists[eid] + } + this.eids.clear() + } + if (this.cbToId) { + this.cbToId.clear() + } + const ids = [...this.onceIds] + ids.forEach(k => { + delete onces[k] + }) + this.onceIds = [] + } + + close () { + this.onclose() + if (this.persist) { + this.closed = true + return + } + this.clearOnces() + send({ + action: 'close', + wsId: this.id + }) + delete wss[this.id] + } +} + +function onEvent (e) { + if (!e || !e.data) { + return false + } + const { + id, + data, + action, + persist + } = e.data + if (wss[id] && action === 'close') { + const ws = wss[id] + ws.onclose() + if (ws.persist) { + ws.closed = true + } else { + ws.clearOnces() + delete wss[id] + } + return + } + if (persists[id]) { + persists[id].resolve(data) + } else if (onces[id]) { + if (action === 'create') { + if (!wss[id]) { + wss[id] = new Ws(id, persist) + } else { + wss[id].addEventListener() + wss[id].closed = false + } + onces[id].resolve(wss[id]) + } else { + onces[id].resolve(data) + } + delete onces[id] + } +} + +window.worker.addEventListener('message', onEvent) + +export default (type, id, sftpId = '', persist, port) => { + const opts = pick(window.store.config, [ + 'host', + 'port', + 'tokenElecterm', + 'server', + 'urlPath' + ]) + if (port) { + opts.port = port + } + return new Promise((resolve) => { + send({ + id, + action: 'create', + persist, + type, + args: [ + type, + id, + sftpId, + opts + ] + }) + onces[id] = { + resolve + } + }) +} diff --git a/src/client/common/zod.js b/src/client/common/zod.js new file mode 100644 index 0000000..fe8a733 --- /dev/null +++ b/src/client/common/zod.js @@ -0,0 +1,180 @@ +/** + * Lightweight zod replacement (ES module version for client) + * 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 + 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) + 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 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 + } +} + +function schemaToJsonSchema (schema) { + if (!schema) { + return {} + } + if (schema instanceof ZodType) { + const base = schema._toJsonSchema() + if (schema._description) { + base.description = schema._description + } + return base + } + 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._description) { + properties[key].description = value._description + } + if (!value._optional) { + required.push(key) + } + } + } + const result = { type: 'object', properties } + if (required.length > 0) { + result.required = required + } + return result +} + +export const z = { + string: () => new ZodString(), + number: () => new ZodNumber(), + boolean: () => new ZodBoolean(), + enum: (values) => new ZodEnum(values), + object: (shape) => new ZodObject(shape || {}), + array: (itemSchema) => new ZodArray(itemSchema), + toJSONSchema: (schema) => { + if (schema instanceof ZodType) { + return schema._toJsonSchema() + } + if (typeof schema === 'object' && schema !== null) { + const hasZodValues = Object.values(schema).some( + v => v instanceof ZodType + ) + if (hasZodValues) { + return objectShapeToJsonSchema(schema) + } + } + return { type: 'object', properties: {} } + } +} + +export { ZodType, schemaToJsonSchema } diff --git a/src/client/components/ai/agent-tool-call-card.jsx b/src/client/components/ai/agent-tool-call-card.jsx new file mode 100644 index 0000000..9bd4d7f --- /dev/null +++ b/src/client/components/ai/agent-tool-call-card.jsx @@ -0,0 +1,90 @@ +import { useState } from 'react' +import { Tag } from 'antd' +import { + CaretDownOutlined, + CaretRightOutlined, + LoadingOutlined, + CheckCircleOutlined, + CloseCircleOutlined, + CodeOutlined, + DatabaseOutlined +} from '@ant-design/icons' + +const toolIcons = { + send_terminal_command: CodeOutlined, + get_terminal_output: CodeOutlined, + open_local_terminal: CodeOutlined, + list_tabs: CodeOutlined, + get_active_tab: CodeOutlined, + switch_tab: CodeOutlined, + list_bookmarks: DatabaseOutlined, + open_bookmark: DatabaseOutlined, + add_bookmark: DatabaseOutlined +} + +function formatResult (result) { + if (!result) return '' + try { + const parsed = JSON.parse(result) + if (parsed.output) return parsed.output + return JSON.stringify(parsed, null, 2) + } catch { + return result + } +} + +export default function AgentToolCallCard ({ toolCall }) { + const [expanded, setExpanded] = useState(toolCall.status === 'running') + const { name, args, status, result } = toolCall + const Icon = toolIcons[name] || CodeOutlined + + function renderStatus () { + if (status === 'running') { + return + } + if (status === 'completed') { + return + } + return + } + + function renderTag () { + const color = status === 'running' ? 'processing' : status === 'completed' ? 'success' : 'error' + return ( + + {status} + + ) + } + + return ( +
+
setExpanded(!expanded)} + > + {expanded ? : } + + {name} + {renderTag()} + {renderStatus()} +
+ {expanded && ( +
+ {args && Object.keys(args).length > 0 && ( +
+
Arguments:
+
{JSON.stringify(args, null, 2)}
+
+ )} + {result && ( +
+
Result:
+
{formatResult(result)}
+
+ )} +
+ )} +
+ ) +} diff --git a/src/client/components/ai/agent-tools.js b/src/client/components/ai/agent-tools.js new file mode 100644 index 0000000..1587d17 --- /dev/null +++ b/src/client/components/ai/agent-tools.js @@ -0,0 +1,517 @@ +import { z } from '../../common/zod' +import { bookmarkSchemas } from '../../common/bookmark-schemas' + +function buildAddBookmarkParameters () { + const typeProperties = {} + for (const [type, schema] of Object.entries(bookmarkSchemas)) { + typeProperties[type] = z.toJSONSchema(z.object(schema)) + } + + return { + type: 'object', + properties: { + type: { + type: 'string', + enum: Object.keys(bookmarkSchemas), + description: 'Bookmark type' + }, + ...Object.fromEntries( + Object.entries(typeProperties).map(([type, schema]) => [ + type, + { type: 'object', description: `Fields for ${type} bookmark`, ...schema } + ]) + ) + }, + required: ['type'] + } +} + +export const agentTools = [ + { + type: 'function', + function: { + name: 'send_terminal_command', + description: 'Send a command to a terminal tab and wait for it to finish. Returns the command output. For long-running commands (builds, deployments, installations), use run_background_command instead to avoid timeouts.', + parameters: { + type: 'object', + properties: { + command: { + type: 'string', + description: 'The shell command to execute' + }, + tabId: { + type: 'string', + description: 'Terminal tab ID. Omit to use the active terminal.' + } + }, + required: ['command'] + } + } + }, + { + type: 'function', + function: { + name: 'get_terminal_output', + description: 'Read the current visible output from a terminal.', + parameters: { + type: 'object', + properties: { + tabId: { + type: 'string', + description: 'Terminal tab ID. Omit for active terminal.' + }, + lines: { + type: 'number', + description: 'Number of recent lines to read (default 50).' + } + } + } + } + }, + { + type: 'function', + function: { + name: 'open_local_terminal', + description: 'Open a new local terminal tab. Returns the new tab ID.', + parameters: { + type: 'object', + properties: {} + } + } + }, + { + type: 'function', + function: { + name: 'list_tabs', + description: 'List all open terminal tabs with their IDs, titles, hosts, and types.', + parameters: { + type: 'object', + properties: {} + } + } + }, + { + type: 'function', + function: { + name: 'get_active_tab', + description: 'Get the currently active terminal tab.', + parameters: { + type: 'object', + properties: {} + } + } + }, + { + type: 'function', + function: { + name: 'switch_tab', + description: 'Switch to a different terminal tab.', + parameters: { + type: 'object', + properties: { + tabId: { + type: 'string', + description: 'The tab ID to switch to.' + } + }, + required: ['tabId'] + } + } + }, + { + type: 'function', + function: { + name: 'close_tab', + description: 'Close a terminal tab by its ID. Use this to clean up tabs after a task is finished.', + parameters: { + type: 'object', + properties: { + tabId: { + type: 'string', + description: 'The tab ID to close.' + } + }, + required: ['tabId'] + } + } + }, + { + type: 'function', + function: { + name: 'list_bookmarks', + description: 'List all saved bookmarks (SSH, Telnet, VNC, etc.).', + parameters: { + type: 'object', + properties: {} + } + } + }, + { + type: 'function', + function: { + name: 'open_bookmark', + description: 'Open a saved bookmark as a new terminal tab.', + parameters: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'The bookmark ID to open.' + } + }, + required: ['id'] + } + } + }, + { + type: 'function', + function: { + name: 'add_bookmark', + description: 'Create a new bookmark. Specify the type and provide type-specific fields. Supported types: ' + Object.keys(bookmarkSchemas).join(', ') + '.', + parameters: buildAddBookmarkParameters() + } + }, + { + type: 'function', + function: { + name: 'open_tab', + description: 'Open a terminal tab directly with connection parameters without creating a bookmark. Supported types: ' + Object.keys(bookmarkSchemas).join(', ') + '.', + parameters: buildAddBookmarkParameters() + } + }, + { + type: 'function', + function: { + name: 'sftp_list', + description: 'List files and directories at a remote path via SFTP. Requires an SSH/FTP tab.', + parameters: { + type: 'object', + properties: { + remotePath: { + type: 'string', + description: 'Remote directory path to list.' + }, + tabId: { + type: 'string', + description: 'SSH/FTP tab ID. Omit to use the active tab.' + } + }, + required: ['remotePath'] + } + } + }, + { + type: 'function', + function: { + name: 'sftp_stat', + description: 'Get file/directory stats (size, permissions, etc.) at a remote path via SFTP.', + parameters: { + type: 'object', + properties: { + remotePath: { + type: 'string', + description: 'Remote path to stat.' + }, + tabId: { + type: 'string', + description: 'SSH/FTP tab ID. Omit to use the active tab.' + } + }, + required: ['remotePath'] + } + } + }, + { + type: 'function', + function: { + name: 'sftp_read_file', + description: 'Read the contents of a remote file via SFTP.', + parameters: { + type: 'object', + properties: { + remotePath: { + type: 'string', + description: 'Remote file path to read.' + }, + tabId: { + type: 'string', + description: 'SSH/FTP tab ID. Omit to use the active tab.' + } + }, + required: ['remotePath'] + } + } + }, + { + type: 'function', + function: { + name: 'sftp_del', + description: 'Delete a remote file or directory via SFTP.', + parameters: { + type: 'object', + properties: { + remotePath: { + type: 'string', + description: 'Remote file or directory path to delete.' + }, + tabId: { + type: 'string', + description: 'SSH/FTP tab ID. Omit to use the active tab.' + } + }, + required: ['remotePath'] + } + } + }, + { + type: 'function', + function: { + name: 'sftp_upload', + description: 'Upload a local file to a remote server via SFTP.', + parameters: { + type: 'object', + properties: { + localPath: { + type: 'string', + description: 'Local file path to upload.' + }, + remotePath: { + type: 'string', + description: 'Remote destination path.' + }, + tabId: { + type: 'string', + description: 'SSH/FTP tab ID. Omit to use the active tab.' + } + }, + required: ['localPath', 'remotePath'] + } + } + }, + { + type: 'function', + function: { + name: 'sftp_download', + description: 'Download a remote file to a local path via SFTP.', + parameters: { + type: 'object', + properties: { + remotePath: { + type: 'string', + description: 'Remote file path to download.' + }, + localPath: { + type: 'string', + description: 'Local destination path.' + }, + tabId: { + type: 'string', + description: 'SSH/FTP tab ID. Omit to use the active tab.' + } + }, + required: ['remotePath', 'localPath'] + } + } + }, + { + type: 'function', + function: { + name: 'sftp_transfer_list', + description: 'List current active SFTP file transfers.', + parameters: { + type: 'object', + properties: {} + } + } + }, + { + type: 'function', + function: { + name: 'sftp_transfer_history', + description: 'List past SFTP file transfer history.', + parameters: { + type: 'object', + properties: {} + } + } + }, + { + type: 'function', + function: { + name: 'get_terminal_status', + description: 'Check terminal status: running (actively receiving data), idle, or password prompt. Returns last 20 lines of output. Lightweight, non-blocking.', + parameters: { + type: 'object', + properties: { + tabId: { + type: 'string', + description: 'Tab ID. Omit for active terminal.' + } + } + } + } + }, + { + type: 'function', + function: { + name: 'cancel_terminal_command', + description: 'Cancel the running command in a terminal by sending Ctrl+C.', + parameters: { + type: 'object', + properties: { + tabId: { + type: 'string', + description: 'Tab ID. Omit for active terminal.' + } + } + } + } + }, + { + type: 'function', + function: { + name: 'run_background_command', + description: 'Run a command in the background using nohup. The terminal is freed immediately. Returns a taskId for monitoring. Use get_background_task_status and get_background_task_log to check progress.', + parameters: { + type: 'object', + properties: { + command: { + type: 'string', + description: 'The shell command to run in the background.' + }, + tabId: { + type: 'string', + description: 'Tab ID. Omit for active terminal.' + } + }, + required: ['command'] + } + } + }, + { + type: 'function', + function: { + name: 'get_background_task_status', + description: 'Check if a background task is running, completed (with exit code), or unknown.', + parameters: { + type: 'object', + properties: { + taskId: { + type: 'string', + description: 'Task ID from run_background_command.' + } + }, + required: ['taskId'] + } + } + }, + { + type: 'function', + function: { + name: 'get_background_task_log', + description: 'Read the output log of a background task. Returns the last N lines.', + parameters: { + type: 'object', + properties: { + taskId: { + type: 'string', + description: 'Task ID from run_background_command.' + }, + lines: { + type: 'number', + description: 'Number of recent lines to read (default 100).' + } + }, + required: ['taskId'] + } + } + }, + { + type: 'function', + function: { + name: 'cancel_background_task', + description: 'Cancel a running background task by killing its process.', + parameters: { + type: 'object', + properties: { + taskId: { + type: 'string', + description: 'Task ID from run_background_command.' + } + }, + required: ['taskId'] + } + } + } +] + +export async function executeToolCall (toolName, args) { + const store = window.store + switch (toolName) { + case 'send_terminal_command': { + store.mcpSendTerminalCommand(args) + const idleResult = await store.mcpWaitForTerminalIdle({ + tabId: args.tabId || store.activeTabId, + timeout: 30000, + lines: 100 + }) + return JSON.stringify(idleResult) + } + case 'get_terminal_output': + return JSON.stringify(store.mcpGetTerminalOutput(args)) + case 'open_local_terminal': + return JSON.stringify(store.mcpOpenLocalTerminal()) + case 'list_tabs': + return JSON.stringify(store.mcpListTabs()) + case 'get_active_tab': + return JSON.stringify(store.mcpGetActiveTab()) + case 'switch_tab': + return JSON.stringify(store.mcpSwitchTab(args)) + case 'close_tab': + return JSON.stringify(store.mcpCloseTab(args)) + case 'list_bookmarks': + return JSON.stringify(store.mcpListBookmarks()) + case 'open_bookmark': + return JSON.stringify(store.mcpOpenBookmark(args)) + case 'add_bookmark': { + const { type } = args + const typeFields = args[type] || {} + return JSON.stringify(await store.mcpAddBookmark({ type, ...typeFields })) + } + case 'open_tab': { + const { type } = args + const typeFields = args[type] || {} + return JSON.stringify(store.mcpOpenTab({ type, ...typeFields })) + } + case 'sftp_list': + return JSON.stringify(await store.mcpSftpList(args)) + case 'sftp_stat': + return JSON.stringify(await store.mcpSftpStat(args)) + case 'sftp_read_file': + return JSON.stringify(await store.mcpSftpReadFile(args)) + case 'sftp_del': + return JSON.stringify(await store.mcpSftpDel(args)) + case 'sftp_upload': + return JSON.stringify(await store.mcpSftpUpload(args)) + case 'sftp_download': + return JSON.stringify(await store.mcpSftpDownload(args)) + case 'sftp_transfer_list': + return JSON.stringify(store.mcpSftpTransferList()) + case 'sftp_transfer_history': + return JSON.stringify(store.mcpSftpTransferHistory()) + case 'get_terminal_status': + return JSON.stringify(store.mcpGetTerminalStatus(args)) + case 'cancel_terminal_command': + return JSON.stringify(store.mcpCancelTerminalCommand(args)) + case 'run_background_command': + return JSON.stringify(store.mcpRunBackgroundCommand(args)) + case 'get_background_task_status': + return JSON.stringify(await store.mcpGetBackgroundTaskStatus(args)) + case 'get_background_task_log': + return JSON.stringify(await store.mcpGetBackgroundTaskLog(args)) + case 'cancel_background_task': + return JSON.stringify(await store.mcpCancelBackgroundTask(args)) + default: + throw new Error(`Unknown agent tool: ${toolName}`) + } +} diff --git a/src/client/components/ai/agent.js b/src/client/components/ai/agent.js new file mode 100644 index 0000000..66dafdc --- /dev/null +++ b/src/client/components/ai/agent.js @@ -0,0 +1,167 @@ +import { agentTools, executeToolCall } from './agent-tools' + +const MAX_ITERATIONS = 150 + +function buildAgentSystemPrompt (config) { + const lang = config.languageAI || window.store.getLangName() + const baseRole = config.roleAI || 'You are a helpful assistant.' + return `${baseRole} + +You are operating inside electerm, a terminal/SSH client. You have access to tools that let you: +- Run commands in terminal tabs and read their output +- Open new terminal tabs (local or SSH) +- Manage bookmarks (create, list, open connections) +- Switch between tabs +- Transfer files via SFTP (upload, download, list, read, delete remote files) + +When the user asks you to perform terminal operations, use the available tools. +Always explain what you are doing before executing commands. +If a command produces errors, analyze the output and try to fix the issue. +Prefer using the active terminal unless the user specifies otherwise. +For SSH connections, prefer using open_tab to connect directly, or create a bookmark with add_bookmark and open it with open_bookmark if the user wants to save the connection. +For file transfers, use the sftp_upload and sftp_download tools. The tab must be an SSH/FTP connection with SFTP initialized. + +Reply in ${lang} language.` +} + +function updateChatEntry (chatEntry, updates) { + const index = window.store.aiChatHistory.findIndex(i => i.id === chatEntry.id) + if (index !== -1) { + Object.assign(window.store.aiChatHistory[index], updates) + window.store.aiChatHistory = [...window.store.aiChatHistory] + } +} + +async function callBackendAIchatWithTools (messages, config) { + return window.pre.runGlobalAsync( + 'AIchatWithTools', + messages, + config.modelAI, + config.baseURLAI, + config.apiPathAI, + config.apiKeyAI, + config.proxyAI, + agentTools, + config.authHeaderNameAI + ) +} + +export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming) { + window.store.agentRunning = true + try { + const messages = [ + { role: 'system', content: buildAgentSystemPrompt(config) }, + { role: 'user', content: chatEntry.prompt } + ] + const toolCallsLog = [] + let accumulatedContent = '' + + setIsStreaming(true) + updateChatEntry(chatEntry, { + toolCalls: [], + response: '' + }) + + for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) { + if (abortRef && abortRef.current) { + setIsStreaming(false) + updateChatEntry(chatEntry, { + response: accumulatedContent + '\n\n*(Agent stopped by user)*' + }) + return + } + + const result = await callBackendAIchatWithTools(messages, config) + + if (result.error) { + setIsStreaming(false) + updateChatEntry(chatEntry, { + response: accumulatedContent + `\n\n**Error:** ${result.error}` + }) + return + } + + const assistantMessage = result.message + if (!assistantMessage) { + setIsStreaming(false) + updateChatEntry(chatEntry, { + response: accumulatedContent || 'No response from AI.' + }) + return + } + + messages.push(assistantMessage) + + if (assistantMessage.content) { + accumulatedContent += (accumulatedContent ? '\n\n' : '') + assistantMessage.content + updateChatEntry(chatEntry, { + response: accumulatedContent + }) + } + + if (!assistantMessage.tool_calls || assistantMessage.tool_calls.length === 0) { + setIsStreaming(false) + updateChatEntry(chatEntry, { + response: accumulatedContent + }) + return + } + + for (const toolCall of assistantMessage.tool_calls) { + if (abortRef && abortRef.current) { + setIsStreaming(false) + updateChatEntry(chatEntry, { + response: accumulatedContent + '\n\n*(Agent stopped by user)*' + }) + return + } + + let args + try { + args = JSON.parse(toolCall.function.arguments) + } catch { + args = {} + } + + const toolEntry = { + id: toolCall.id, + name: toolCall.function.name, + args, + status: 'running', + result: null + } + toolCallsLog.push(toolEntry) + updateChatEntry(chatEntry, { + toolCalls: [...toolCallsLog] + }) + + let toolResult + try { + toolResult = await executeToolCall(toolCall.function.name, args) + toolEntry.status = 'completed' + toolEntry.result = toolResult + } catch (err) { + toolEntry.status = 'error' + toolEntry.result = err.message + } + + updateChatEntry(chatEntry, { + toolCalls: [...toolCallsLog] + }) + + messages.push({ + role: 'tool', + tool_call_id: toolCall.id, + content: toolEntry.result + }) + } + } + + setIsStreaming(false) + updateChatEntry(chatEntry, { + response: accumulatedContent + '\n\n*(Agent reached maximum iterations)*' + }) + } finally { + window.store.agentRunning = false + } +} diff --git a/src/client/components/ai/ai-cache.jsx b/src/client/components/ai/ai-cache.jsx new file mode 100644 index 0000000..ea2d1d0 --- /dev/null +++ b/src/client/components/ai/ai-cache.jsx @@ -0,0 +1,36 @@ +import { aiSuggestionsCache } from '../../common/cache' +import { useEffect, useState } from 'react' +import { + Button, + Alert +} from 'antd' + +const e = window.translate + +export default function AiCache () { + const [count, setCount] = useState(0) + + function handleClick () { + aiSuggestionsCache.clear() + setCount(0) + } + + useEffect(() => { + setCount(aiSuggestionsCache.cache.size) + }, []) + + const msg = ( + <> + {e('aiSuggestionsCache')}: {count} + + + ) + return ( + + ) +} diff --git a/src/client/components/ai/ai-chat-entry.jsx b/src/client/components/ai/ai-chat-entry.jsx new file mode 100644 index 0000000..ac4eddd --- /dev/null +++ b/src/client/components/ai/ai-chat-entry.jsx @@ -0,0 +1,11 @@ +import { lazy, Suspense } from 'react' + +const AIChat = lazy(() => import('./ai-chat')) + +export default function AIChatEntry (props) { + return ( + + + + ) +} diff --git a/src/client/components/ai/ai-chat-history-item.jsx b/src/client/components/ai/ai-chat-history-item.jsx new file mode 100644 index 0000000..48983d5 --- /dev/null +++ b/src/client/components/ai/ai-chat-history-item.jsx @@ -0,0 +1,249 @@ +import { useState, useEffect, useRef, useCallback } from 'react' +import AIOutput from './ai-output' +import AIStopIcon from './ai-stop-icon' +import AgentToolCallCard from './agent-tool-call-card' +import { runAgentLoop } from './agent' +import { + Alert, + Tooltip +} from 'antd' +import { + CopyOutlined, + CloseOutlined, + CaretDownOutlined, + CaretRightOutlined +} from '@ant-design/icons' +import { copy } from '../../common/clipboard' + +export default function AIChatHistoryItem ({ item }) { + const [showOutput, setShowOutput] = useState(true) + const [isStreaming, setIsStreaming] = useState(false) + const abortRef = useRef(false) + const { + prompt, + sessionId, + nameAI, + modelAI, + roleAI, + baseURLAI, + apiPathAI, + apiKeyAI, + proxyAI, + languageAI, + mode, + toolCalls + } = item + + function toggleOutput () { + setShowOutput(!showOutput) + } + + function buildRole () { + const lang = languageAI || window.store.getLangName() + return roleAI + `;็”จ[${lang}]ๅ›žๅค` + } + + const pollStreamContent = useCallback(async (sid) => { + try { + const streamResponse = await window.pre.runGlobalAsync('getStreamContent', sid) + + if (streamResponse && streamResponse.error) { + if (streamResponse.error === 'Session not found') { + return + } + window.store.removeAiHistory(item.id) + return window.store.onError(new Error(streamResponse.error)) + } + + const index = window.store.aiChatHistory.findIndex(i => i.id === item.id) + if (index !== -1) { + window.store.aiChatHistory[index].response = streamResponse.content || '' + window.store.aiChatHistory = [...window.store.aiChatHistory] + } + setIsStreaming(streamResponse.hasMore) + if (streamResponse.hasMore) { + setTimeout(() => pollStreamContent(sid), 200) + } + } catch (error) { + window.store.removeAiHistory(item.id) + window.store.onError(error) + } + }, [item.id]) + + const startRequest = useCallback(async () => { + try { + const aiResponse = await window.pre.runGlobalAsync( + 'AIchat', + prompt, + modelAI, + buildRole(), + baseURLAI, + apiPathAI, + apiKeyAI, + proxyAI, + true + ) + + if (aiResponse && aiResponse.error) { + window.store.removeAiHistory(item.id) + return window.store.onError(new Error(aiResponse.error)) + } + + if (aiResponse && aiResponse.isStream && aiResponse.sessionId) { + setIsStreaming(true) + const index = window.store.aiChatHistory.findIndex(i => i.id === item.id) + if (index !== -1) { + window.store.aiChatHistory[index].sessionId = aiResponse.sessionId + window.store.aiChatHistory[index].response = aiResponse.content || '' + } + pollStreamContent(aiResponse.sessionId) + } else if (aiResponse && aiResponse.response) { + const index = window.store.aiChatHistory.findIndex(i => i.id === item.id) + if (index !== -1) { + window.store.aiChatHistory[index].response = aiResponse.response + } + } + } catch (error) { + window.store.removeAiHistory(item.id) + window.store.onError(error) + } + }, [prompt, modelAI, baseURLAI, apiPathAI, apiKeyAI, proxyAI, item.id, pollStreamContent]) + + const startAgentRequest = useCallback(async () => { + abortRef.current = false + const config = { + modelAI, + roleAI, + baseURLAI, + apiPathAI, + apiKeyAI, + proxyAI, + languageAI + } + await runAgentLoop(item, config, abortRef, setIsStreaming) + }, [modelAI, roleAI, baseURLAI, apiPathAI, apiKeyAI, proxyAI, languageAI, item.id]) + + useEffect(() => { + if (item.pending) { + const index = window.store.aiChatHistory.findIndex(i => i.id === item.id) + if (index !== -1) { + window.store.aiChatHistory[index].pending = false + } + if (mode === 'agent') { + startAgentRequest() + } else { + startRequest() + } + } + }, []) + + async function handleStop (e) { + e.stopPropagation() + if (mode === 'agent') { + abortRef.current = true + setIsStreaming(false) + return + } + if (!sessionId) return + + try { + await window.pre.runGlobalAsync('stopStream', sessionId) + setIsStreaming(false) + } catch (error) { + console.error('Error stopping stream:', error) + } + } + + function renderStopButton () { + if (!isStreaming) { + return null + } + return ( + + ) + } + + const alertProps = { + title: ( +
+ + {showOutput ? : } + + {prompt} +
+ ), + type: 'info' + } + + function handleDel (e) { + e.stopPropagation() + window.store.removeAiHistory(item.id) + } + + function handleCopy () { + copy(prompt) + } + + function renderTitle () { + return ( +
+ {nameAI && ( +

+ Name: {nameAI} +

+ )} +

+ Model: {modelAI} +

+

+ Role: {roleAI} +

+

+ Base URL: {baseURLAI} +

+

+ Time: {new Date(item.timestamp).toLocaleString()} +

+

+ + +

+
+ ) + } + + function renderToolCalls () { + if (mode !== 'agent' || !toolCalls || !toolCalls.length) { + return null + } + return ( +
+ {toolCalls.map((tc) => ( + + ))} +
+ ) + } + + return ( +
+
+ + + +
+ {renderToolCalls()} + {showOutput && } + {renderStopButton()} +
+ ) +} diff --git a/src/client/components/ai/ai-chat-history.jsx b/src/client/components/ai/ai-chat-history.jsx new file mode 100644 index 0000000..dd8a06f --- /dev/null +++ b/src/client/components/ai/ai-chat-history.jsx @@ -0,0 +1,31 @@ +// ai-chat-history.jsx +import { useLayoutEffect, useRef } from 'react' +import { auto } from 'manate/react' +import AIChatHistoryItem from './ai-chat-history-item' + +export default auto(function AIChatHistory ({ history }) { + const historyRef = useRef(null) + + useLayoutEffect(() => { + if (historyRef.current) { + historyRef.current.scrollTop = historyRef.current.scrollHeight + } + }, [history]) + if (!history.length) { + return
+ } + return ( +
+ { + history.map((item) => { + return ( + + ) + }) + } +
+ ) +}) diff --git a/src/client/components/ai/ai-chat.jsx b/src/client/components/ai/ai-chat.jsx new file mode 100644 index 0000000..c776b28 --- /dev/null +++ b/src/client/components/ai/ai-chat.jsx @@ -0,0 +1,198 @@ +import { useState, useCallback, useEffect } from 'react' +import { Flex, Input, Popconfirm, Segmented } from 'antd' +import TabSelect from '../footer/tab-select' +import AiChatHistory from './ai-chat-history' +import uid from '../../common/uid' +import { pick } from 'lodash-es' +import { + SettingOutlined, + SendOutlined, + UnorderedListOutlined +} from '@ant-design/icons' +import { + aiConfigWikiLink, + aiChatModeLsKey +} from '../../common/constants' +import { getItem, setItem } from '../../common/safe-local-storage.js' +import HelpIcon from '../common/help-icon' +import { refsStatic } from '../common/ref' +import './ai.styl' + +const { TextArea } = Input +const MAX_HISTORY = 100 + +export default function AIChat (props) { + const [prompt, setPrompt] = useState('') + const [mode, setMode] = useState(() => getItem(aiChatModeLsKey) || 'ask') + const isAgent = mode === 'agent' + const submitDisabled = isAgent && props.agentRunning + + function handlePromptChange (e) { + setPrompt(e.target.value) + } + + function handleModeChange (val) { + const m = val === 'Ask' ? 'ask' : 'agent' + setItem(aiChatModeLsKey, m) + setMode(m) + } + + const handleSubmit = useCallback(function () { + if (window.store.aiConfigMissing()) { + window.store.toggleAIConfig() + } + if (!prompt.trim()) return + + const chatId = uid() + const chatEntry = { + prompt, + response: '', + isStreaming: false, + pending: true, + sessionId: null, + mode, + toolCalls: [], + ...pick(props.config, [ + 'nameAI', + 'modelAI', + 'roleAI', + 'baseURLAI', + 'apiPathAI', + 'apiKeyAI', + 'proxyAI', + 'languageAI' + ]), + timestamp: Date.now(), + id: chatId + } + + window.store.aiChatHistory.push(chatEntry) + setPrompt('') + + if (window.store.aiChatHistory.length > MAX_HISTORY) { + window.store.aiChatHistory.splice(MAX_HISTORY) + } + }, [prompt, mode]) + + function renderHistory () { + return ( + + ) + } + + function toggleConfig () { + window.store.toggleAIConfig() + } + + function clearHistory () { + window.store.aiChatHistory = [] + } + + function renderTabSelect () { + if (isAgent) { + return null + } + return ( + + ) + } + + function renderSendIcon () { + if (submitDisabled) { + return ( + + ) + } + return ( + + ) + } + + useEffect(() => { + refsStatic.add('AIChat', { + setPrompt, + handleSubmit + }) + if (props.rightPanelTab === 'ai' && window.store.aiConfigMissing()) { + window.store.toggleAIConfig() + } + return () => { + refsStatic.remove('AIChat') + } + }, [handleSubmit]) + + if (props.rightPanelTab !== 'ai') { + return null + } + + const handleKeyPress = (e) => { + if (!e.shiftKey) { + e.preventDefault() + if (!submitDisabled) { + handleSubmit() + } + } + } + + return ( + + + {renderHistory()} + + + +