Bundle mosh-client + Node-side PTY handshake
* Bundle mosh-client via CI build pipeline Add a GitHub Actions workflow that builds a static, distro-portable mosh-client for linux-x64, linux-arm64, darwin-universal (arm64+x86_64) from upstream mobile-shell/mosh source, plus a pinned win32-x64 binary sourced from FluentTerminal (GPL-3.0). Releases attach SHA256SUMS so scripts/fetch-mosh-binaries.cjs can verify and pull the right binary into resources/mosh/<platform-arch>/ during npm run pack. electron-builder.config.cjs gains a moshExtraResources() helper that adds the binary to extraResources only when present on disk, keeping local dev packages working without bundled mosh. terminalBridge.cjs now exports bundledMoshClient() and prefers the bundled static client over whatever the system mosh wrapper would resolve via PATH (via the MOSH_CLIENT env var). The Windows branch throws a clear error pointing at Settings instead of silently falling back to a literal "mosh.exe" string when no wrapper is installed. This is Phase 1 — Phase 2 (follow-up) replaces the FluentTerminal Windows binary with an in-CI Cygwin static build and adds a Node-side mosh-server bootstrap so Mosh works out-of-the-box on Windows. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Phase 2: Node-side Mosh handshake (no Perl wrapper required) Reimplement what the upstream Mosh Perl wrapper does in pure Node: spawn `ssh [user@]host -- mosh-server new`, sniff the byte stream for `MOSH CONNECT <port> <key>`, then spawn `mosh-client` locally with MOSH_KEY in the environment. The new electron/bridges/moshHandshake.cjs module exposes the parser, sniffer, and command builders as pure functions so they can be unit tested without spawning real ssh. terminalBridge.startMoshSession now prefers this path whenever a bare mosh-client (bundled, explicit, or system) and ssh (in-box OpenSSH on Win10 1809+, system everywhere else) are both detectable. The legacy path through the system mosh Perl wrapper is preserved as a fallback so users with custom mosh setups don't regress. Auth is delegated to system ssh, so keys, agent, ssh_config, and known_hosts all keep working. Password / 2FA need a controlling TTY which the bootstrap doesn't provide; affected users keep the legacy wrapper path until interactive UI lands. Tests: - moshHandshake.test.cjs (20 tests) — parser corner cases, command builders, sniffer split-chunk handling, ring-buffer trim, exec resolver - terminalBridge.bareMoshClient.test.cjs (4 tests) — explicit-path basename gating 317 → 341 passing tests; lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Phase 3: in-CI Cygwin Windows build + visible PTY handshake Phase 3a — in-CI Cygwin Windows build - scripts/build-mosh/build-windows.sh builds mosh-client.exe from upstream mobile-shell/mosh source inside Cygwin, then walks the cygcheck import graph to bundle every required Cygwin DLL (cygwin1.dll, cygcrypto, cygprotobuf, cygncursesw, etc) into a tar.gz alongside the exe. - The `build-mosh-binaries` workflow swaps the FluentTerminal-pinned fetch job for a real Cygwin build (windows-latest + cygwin-install- action). fetch-windows.sh is preserved as an emergency fallback but no longer wired into the matrix. - fetch-mosh-binaries.cjs unpacks the tar.gz into resources/mosh/ win32-x64/ so mosh-client.exe sits next to its DLLs. - mosh-extra-resources.cjs ships the entire win32-x64/ dir (exe + DLL bundle) into Resources/mosh/, so the packaged installer runs on a stock Windows host with no Cygwin install. Phase 3b — visible PTY handshake (password / 2FA prompts) - terminalBridge.startMoshSession now spawns ssh inside node-pty so the user sees and can answer password / 2FA / known-hosts prompts in their terminal. When `MOSH CONNECT` is sniffed from the byte stream, session.proc is atomically swapped from the ssh PTY to a freshly-spawned mosh-client PTY. The MOSH CONNECT line itself is redacted from the visible output. - writeToSession / resizeSession read session.proc lazily, so input arriving after the swap goes to mosh-client without extra wiring. - The ZMODEM sentry is recreated for the new proc since its writeToRemote closure captured the previous handle. - Removes the earlier non-PTY child_process.spawn handshake — the PTY-based one supersedes it. Phase 3c — win32-arm64 deferred - Cygwin's arm64 port has no stable cygwin1.dll release yet, so we do not attempt an arm64 Windows build. arm64 Windows installs fall through to the legacy `mosh` wrapper path that the bridge already handles. Documented in the workflow. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Allow branch/PR pushes to test the mosh-binaries workflow Mirrors the build-packages workflow change in #868: any push or PR that touches the mosh build pipeline triggers the matrix (artifacts only, no release), while only `mosh-bin-*` tag pushes (or an explicit workflow_dispatch with release_tag) publish a release. `paths` filter keeps unrelated commits from running this expensive workflow (~30min for the Cygwin leg). Concurrency group cancels superseded branch/PR builds; tag builds use a unique group so a follow-up commit can't kill an in-progress release. Release job's `if:` enforces the same rule independently — even if the trigger gets re-broadened, branches/PRs can't leak a release. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Fix mosh binary workflow runners * Fix Windows mosh workflow invocation * Keep shell scripts LF in workflow checkouts * Trigger mosh workflow on attributes changes * Fix mosh build tool dependencies * Fix Linux mosh static build * Fix macOS mosh build tool lookup * Skip macOS ncurses terminfo install * Fix mosh PR review findings * Allow Linux system mosh dependencies * Fix Windows mosh DLL bundling * Limit bundled Windows mosh DLLs * Honor configured PATH for mosh handshake --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*.sh text eol=lf
|
||||
253
.github/workflows/build-mosh-binaries.yml
vendored
Normal file
253
.github/workflows/build-mosh-binaries.yml
vendored
Normal file
@@ -0,0 +1,253 @@
|
||||
name: build-mosh-binaries
|
||||
|
||||
# Trigger philosophy (mirrors build.yml):
|
||||
# - Pushes that touch the mosh build pipeline + PRs run the matrix
|
||||
# so we can validate workflow / script changes without tagging.
|
||||
# Artifacts upload as workflow artifacts only; *no* release.
|
||||
# - Tag push matching `mosh-bin-<version>-<rev>` (e.g.
|
||||
# `mosh-bin-1.4.0-1`) runs the matrix and publishes a Release with
|
||||
# the binaries + SHA256SUMS.
|
||||
# - Manual `workflow_dispatch` → opt-in publish via `release_tag`.
|
||||
#
|
||||
# `paths` keeps unrelated commits (UI, bridges, etc) from rebuilding
|
||||
# mosh on every push — this workflow is expensive (~30min Cygwin leg).
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
mosh_ref:
|
||||
description: "mosh upstream git ref (tag/branch/commit) — see https://github.com/mobile-shell/mosh"
|
||||
type: string
|
||||
default: "mosh-1.4.0"
|
||||
release_tag:
|
||||
description: "Optional release tag to attach binaries to (e.g. mosh-bin-1.4.0-1). Empty = artifacts only."
|
||||
type: string
|
||||
default: ""
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
tags:
|
||||
- "mosh-bin-*"
|
||||
paths:
|
||||
- ".gitattributes"
|
||||
- ".github/workflows/build-mosh-binaries.yml"
|
||||
- "electron-builder.config.cjs"
|
||||
- "package.json"
|
||||
- "scripts/build-mosh/**"
|
||||
- "scripts/fetch-mosh-binaries.cjs"
|
||||
- "scripts/mosh-extra-resources.cjs"
|
||||
pull_request:
|
||||
paths:
|
||||
- ".gitattributes"
|
||||
- ".github/workflows/build-mosh-binaries.yml"
|
||||
- "electron-builder.config.cjs"
|
||||
- "package.json"
|
||||
- "scripts/build-mosh/**"
|
||||
- "scripts/fetch-mosh-binaries.cjs"
|
||||
- "scripts/mosh-extra-resources.cjs"
|
||||
|
||||
# Cancel superseded branch / PR builds; tag builds use a unique group
|
||||
# so a follow-up commit doesn't kill the in-progress release.
|
||||
concurrency:
|
||||
group: build-mosh-binaries-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
|
||||
env:
|
||||
MOSH_REF: ${{ inputs.mosh_ref || 'mosh-1.4.0' }}
|
||||
|
||||
jobs:
|
||||
# ------------------------------------------------------------------
|
||||
# Linux x64 (manylinux2014 / glibc 2.17, broad distro compatibility).
|
||||
# Static-links the heavy third-party deps where possible; the resulting
|
||||
# mosh-client still depends on baseline Linux system libraries.
|
||||
# ------------------------------------------------------------------
|
||||
build-linux-x64:
|
||||
name: build-linux-x64
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build mosh-client (linux-x64)
|
||||
run: |
|
||||
# Run only the compiler inside manylinux2014. JavaScript actions
|
||||
# need the host runner's newer glibc.
|
||||
docker run --rm \
|
||||
-e MOSH_REF="${MOSH_REF}" \
|
||||
-e OUT_DIR=/work/out \
|
||||
-e ARCH=x64 \
|
||||
-v "${GITHUB_WORKSPACE}:/work" \
|
||||
-w /work \
|
||||
quay.io/pypa/manylinux2014_x86_64 \
|
||||
bash scripts/build-mosh/build-linux.sh
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mosh-client-linux-x64
|
||||
path: out/
|
||||
|
||||
build-linux-arm64:
|
||||
name: build-linux-arm64
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build mosh-client (linux-arm64)
|
||||
run: |
|
||||
# Run only the compiler inside manylinux2014. JavaScript actions
|
||||
# need the host runner's newer glibc.
|
||||
docker run --rm \
|
||||
-e MOSH_REF="${MOSH_REF}" \
|
||||
-e OUT_DIR=/work/out \
|
||||
-e ARCH=arm64 \
|
||||
-v "${GITHUB_WORKSPACE}:/work" \
|
||||
-w /work \
|
||||
quay.io/pypa/manylinux2014_aarch64 \
|
||||
bash scripts/build-mosh/build-linux.sh
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mosh-client-linux-arm64
|
||||
path: out/
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# macOS universal2 (arm64 + x86_64 lipo).
|
||||
# Min deployment target: macOS 11 (Big Sur) — covers arm64 hardware.
|
||||
# Static-links OpenSSL, protobuf, ncurses for both arches.
|
||||
# ------------------------------------------------------------------
|
||||
build-macos-universal:
|
||||
name: build-macos-universal
|
||||
runs-on: macos-15-intel
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build mosh-client (darwin-universal)
|
||||
env:
|
||||
MOSH_REF: ${{ env.MOSH_REF }}
|
||||
OUT_DIR: ${{ github.workspace }}/out
|
||||
MACOSX_DEPLOYMENT_TARGET: "11.0"
|
||||
run: bash scripts/build-mosh/build-macos.sh
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mosh-client-darwin-universal
|
||||
path: out/
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Windows x64 — in-CI Cygwin build from upstream mobile-shell/mosh
|
||||
# source. Cygwin's POSIX runtime can't be fully statically linked, so
|
||||
# we accept the dynamic Cygwin DLL deps and bundle them alongside the
|
||||
# exe (cygcheck-discovered, ~10 MB total). The pinned-FluentTerminal
|
||||
# path is preserved as `fetch-windows.sh` for emergency fallback.
|
||||
# ------------------------------------------------------------------
|
||||
build-windows-x64:
|
||||
name: build-windows-x64
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Cygwin
|
||||
uses: cygwin/cygwin-install-action@v5
|
||||
with:
|
||||
add-to-path: false
|
||||
# Keep package signature checks, but avoid the setup.exe hash
|
||||
# fetch path that currently fails on windows-latest runners.
|
||||
check-hash: false
|
||||
packages: >
|
||||
gcc-g++ make autoconf automake libtool perl perl_pods pkg-config git
|
||||
openssl-devel libssl-devel libprotobuf-devel libncurses-devel
|
||||
libncursesw-devel zlib-devel protobuf-compiler
|
||||
- name: Build mosh-client.exe (win32-x64)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$cygwinBin = "C:\cygwin\bin"
|
||||
$workspace = (& "$cygwinBin\cygpath.exe" -u "$env:GITHUB_WORKSPACE").Trim()
|
||||
$scriptPath = Join-Path $env:RUNNER_TEMP "build-mosh-windows.sh"
|
||||
$script = @'
|
||||
set -euo pipefail
|
||||
cd "__WORKSPACE__"
|
||||
export MOSH_REF="${MOSH_REF:?missing MOSH_REF}"
|
||||
export ARCH=x64
|
||||
export OUT_DIR="__WORKSPACE__/out"
|
||||
mkdir -p "$OUT_DIR"
|
||||
bash scripts/build-mosh/build-windows.sh
|
||||
'@
|
||||
$script = $script.Replace("__WORKSPACE__", $workspace).Replace("`r`n", "`n")
|
||||
Set-Content -Path $scriptPath -Value $script -NoNewline -Encoding utf8
|
||||
$scriptPathCygwin = (& "$cygwinBin\cygpath.exe" -u "$scriptPath").Trim()
|
||||
& "$cygwinBin\bash.exe" --login "$scriptPathCygwin"
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mosh-client-win32-x64
|
||||
path: out/
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Windows arm64 — intentionally not built.
|
||||
# Cygwin's arm64 port is still experimental (no stable cygwin1.dll
|
||||
# release for aarch64 as of this commit), so we don't attempt an
|
||||
# arm64 mosh build. arm64 Windows installs fall through to the
|
||||
# legacy `mosh` wrapper path in terminalBridge.startMoshSession.
|
||||
# When upstream Cygwin ships a stable arm64 build, drop the same
|
||||
# cygwin-install-action job below with `platform: arm64`.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Aggregate + (optional) GitHub Release.
|
||||
# ------------------------------------------------------------------
|
||||
release:
|
||||
name: release
|
||||
needs:
|
||||
- build-linux-x64
|
||||
- build-linux-arm64
|
||||
- build-macos-universal
|
||||
- build-windows-x64
|
||||
runs-on: ubuntu-latest
|
||||
# Release only on a `mosh-bin-*` tag push or an explicit
|
||||
# workflow_dispatch with `release_tag` provided. Branch pushes /
|
||||
# PRs (even on a branch literally called `mosh-bin-foo`) can't
|
||||
# match `refs/tags/mosh-bin-*`, so they never publish.
|
||||
if: |
|
||||
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/mosh-bin-'))
|
||||
|| (github.event_name == 'workflow_dispatch' && inputs.release_tag != '')
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
- name: Stage release files
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p release
|
||||
for d in artifacts/*/; do
|
||||
find "$d" -maxdepth 1 -type f -exec cp {} release/ \;
|
||||
done
|
||||
(cd release && find . -maxdepth 1 -type f ! -name SHA256SUMS -printf '%P\n' | sort | xargs sha256sum > SHA256SUMS)
|
||||
ls -la release
|
||||
cat release/SHA256SUMS
|
||||
- name: Determine tag
|
||||
id: tag
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.release_tag }}
|
||||
run: |
|
||||
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
else
|
||||
tag="${RELEASE_TAG}"
|
||||
fi
|
||||
if [[ ! "$tag" =~ ^mosh-bin-[A-Za-z0-9._-]+$ ]]; then
|
||||
echo "Invalid mosh binary release tag: $tag" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf 'name=%s\n' "$tag" >> "$GITHUB_OUTPUT"
|
||||
- name: Create / update release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ steps.tag.outputs.name }}
|
||||
name: ${{ steps.tag.outputs.name }}
|
||||
files: release/*
|
||||
body: |
|
||||
Pre-built `mosh-client` binaries consumed by `scripts/fetch-mosh-binaries.cjs`
|
||||
during `npm run pack`. Built from `mobile-shell/mosh` upstream ref `${{ env.MOSH_REF }}`.
|
||||
|
||||
All artifacts are GPL-3.0; see `resources/mosh/README.md` for source provenance.
|
||||
draft: false
|
||||
prerelease: false
|
||||
44
.github/workflows/build.yml
vendored
44
.github/workflows/build.yml
vendored
@@ -7,10 +7,17 @@ on:
|
||||
description: "Publish GitHub Release after build"
|
||||
type: boolean
|
||||
default: false
|
||||
mosh_bin_release:
|
||||
description: "Release tag containing bundled mosh-client binaries"
|
||||
type: string
|
||||
default: ""
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
env:
|
||||
MOSH_BIN_RELEASE: ${{ github.event.inputs.mosh_bin_release || vars.MOSH_BIN_RELEASE || '' }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: build-${{ matrix.name }}
|
||||
@@ -58,6 +65,23 @@ jobs:
|
||||
npm install "@zed-industries/codex-acp-win32-x64@${CODEX_VER}" "@zed-industries/codex-acp-win32-arm64@${CODEX_VER}" --no-save --force
|
||||
fi
|
||||
|
||||
- name: Require bundled mosh-client release
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ -z "${MOSH_BIN_RELEASE:-}" ]]; then
|
||||
echo "::error::Set workflow input mosh_bin_release or repository variable MOSH_BIN_RELEASE before packaging."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Fetch bundled mosh-client
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "${{ matrix.name }}" == "macos" ]]; then
|
||||
npm run fetch:mosh -- --platform=darwin --arch=universal
|
||||
elif [[ "${{ matrix.name }}" == "windows" ]]; then
|
||||
npm run fetch:mosh -- --platform=win32 --arch=x64
|
||||
fi
|
||||
|
||||
- name: Set version
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -143,6 +167,16 @@ jobs:
|
||||
npm_config_arch: x64
|
||||
run: bash scripts/ensure-node-pty-linux.sh prepare x64
|
||||
|
||||
- name: Require bundled mosh-client release
|
||||
run: |
|
||||
if [ -z "${MOSH_BIN_RELEASE:-}" ]; then
|
||||
echo "::error::Set workflow input mosh_bin_release or repository variable MOSH_BIN_RELEASE before packaging."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Fetch bundled mosh-client
|
||||
run: npm run fetch:mosh -- --platform=linux --arch=x64
|
||||
|
||||
- name: Build package
|
||||
env:
|
||||
npm_config_arch: x64
|
||||
@@ -214,6 +248,16 @@ jobs:
|
||||
npm_config_arch: arm64
|
||||
run: bash scripts/ensure-node-pty-linux.sh prepare arm64
|
||||
|
||||
- name: Require bundled mosh-client release
|
||||
run: |
|
||||
if [ -z "${MOSH_BIN_RELEASE:-}" ]; then
|
||||
echo "::error::Set workflow input mosh_bin_release or repository variable MOSH_BIN_RELEASE before packaging."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Fetch bundled mosh-client
|
||||
run: npm run fetch:mosh -- --platform=linux --arch=arm64
|
||||
|
||||
- name: Build package
|
||||
env:
|
||||
npm_config_arch: arm64
|
||||
|
||||
37
.github/workflows/test.yml
vendored
Normal file
37
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
name: test
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
|
||||
concurrency:
|
||||
group: test-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: lint-and-test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Test
|
||||
run: npm test
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -63,3 +63,13 @@ Directory.Build.props
|
||||
Directory.Build.targets
|
||||
build_with_vs.bat
|
||||
build_with_vs2022.bat
|
||||
|
||||
# Bundled mosh-client binaries fetched at pack time by
|
||||
# scripts/fetch-mosh-binaries.cjs. resources/mosh/README.md is
|
||||
# committed; the actual binaries (and on Windows the Cygwin DLL
|
||||
# bundle that ships alongside mosh-client.exe) are pulled from a
|
||||
# release tag, never committed.
|
||||
/resources/mosh/*/mosh-client
|
||||
/resources/mosh/*/mosh-client.exe
|
||||
/resources/mosh/*/mosh-client-*-dlls/
|
||||
/resources/mosh/*/*.dll
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
const { moshExtraResources } = require('./scripts/mosh-extra-resources.cjs');
|
||||
|
||||
/**
|
||||
* @type {import('electron-builder').Configuration}
|
||||
*/
|
||||
@@ -79,7 +81,8 @@ module.exports = {
|
||||
NSCameraUsageDescription: 'Netcatty may use the camera for video calls',
|
||||
NSMicrophoneUsageDescription: 'Netcatty may use the microphone for audio',
|
||||
NSLocalNetworkUsageDescription: 'Netcatty needs local network access for SSH connections'
|
||||
}
|
||||
},
|
||||
extraResources: moshExtraResources('darwin')
|
||||
},
|
||||
dmg: {
|
||||
title: '${productName}',
|
||||
@@ -105,7 +108,8 @@ module.exports = {
|
||||
target: 'portable',
|
||||
arch: ['x64', 'arm64']
|
||||
}
|
||||
]
|
||||
],
|
||||
extraResources: moshExtraResources('win32')
|
||||
},
|
||||
portable: {
|
||||
artifactName: '${productName}-${version}-portable-${os}-${arch}.${ext}',
|
||||
@@ -125,7 +129,8 @@ module.exports = {
|
||||
// GNOME launchers or AppImage integrations.
|
||||
icon: 'public/icon-win.png',
|
||||
target: ['AppImage', 'deb', 'rpm'],
|
||||
category: 'Development'
|
||||
category: 'Development',
|
||||
extraResources: moshExtraResources('linux')
|
||||
},
|
||||
deb: {
|
||||
// Use gzip instead of default xz(lzma) for better compatibility with
|
||||
|
||||
344
electron/bridges/moshHandshake.cjs
Normal file
344
electron/bridges/moshHandshake.cjs
Normal file
@@ -0,0 +1,344 @@
|
||||
/**
|
||||
* Node-side replacement for the upstream Mosh Perl wrapper.
|
||||
*
|
||||
* The upstream `mosh` script is a tiny orchestrator: it execs `ssh` to
|
||||
* run `mosh-server new` on the remote host, scrapes the
|
||||
* "MOSH CONNECT <port> <key>" line from the SSH stream, then execs
|
||||
* `mosh-client` locally with that port/key. This module does the same
|
||||
* thing in JS so we no longer need a Perl interpreter on the user's
|
||||
* machine — and so we can drive a bundled `mosh-client` even on
|
||||
* Windows (which has no Perl wrapper).
|
||||
*
|
||||
* Flow (driven by terminalBridge.startMoshSession):
|
||||
* 1. spawn `ssh -t [-p port] [user@]host -- mosh-server new -s ...`
|
||||
* inside a node-pty, sized to the renderer's cols/rows so password
|
||||
* / 2FA prompts render natively.
|
||||
* 2. forward every byte from the ssh PTY to the renderer (parsing
|
||||
* simultaneously via parseMoshConnect).
|
||||
* 3. when `MOSH CONNECT <port> <key>` is detected, kill the ssh PTY,
|
||||
* spawn `mosh-client <ip> <port>` in a fresh node-pty with
|
||||
* MOSH_KEY=<key> in the environment, and let the bridge swap that
|
||||
* new PTY into the existing session.
|
||||
*
|
||||
* On every supported platform the module relies on the system `ssh`
|
||||
* binary for the SSH bootstrap (Windows 10 1809+ ships OpenSSH by
|
||||
* default, macOS / Linux have it everywhere). That keeps key / agent /
|
||||
* config handling identical to what the user already has working with
|
||||
* `ssh` — no need to reimplement OpenSSH features in this codebase.
|
||||
*/
|
||||
|
||||
const path = require("node:path");
|
||||
const net = require("node:net");
|
||||
|
||||
const MOSH_CONNECT_RE = /MOSH CONNECT[ \t]+(\d{1,5})[ \t]+([A-Za-z0-9+/]+={0,2})[ \t]*$/;
|
||||
const MOSH_IP_RE = /MOSH IP[ \t]+(\S+)[ \t]*/;
|
||||
const PROTOCOL_MARKERS = ["MOSH CONNECT", "MOSH IP"];
|
||||
|
||||
function shellQuote(value) {
|
||||
const text = String(value);
|
||||
return `'${text.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
function validMoshKey(key) {
|
||||
return key.length === 22 || (key.length === 24 && key.endsWith("=="));
|
||||
}
|
||||
|
||||
function parseConnectLine(line) {
|
||||
const m = MOSH_CONNECT_RE.exec(line);
|
||||
if (!m) return null;
|
||||
const port = Number(m[1]);
|
||||
const key = m[2];
|
||||
if (!Number.isFinite(port) || port <= 0 || port > 65535) return null;
|
||||
if (!validMoshKey(key)) return null;
|
||||
return {
|
||||
port,
|
||||
key,
|
||||
matchStartOffset: m.index,
|
||||
matchEndOffset: m.index + m[0].length,
|
||||
};
|
||||
}
|
||||
|
||||
function parseMoshIpLine(line) {
|
||||
const m = MOSH_IP_RE.exec(line);
|
||||
if (!m) return null;
|
||||
const host = m[1];
|
||||
return net.isIP(host) ? host : null;
|
||||
}
|
||||
|
||||
function forEachCompleteLine(text, visit) {
|
||||
const lineRe = /([^\r\n]*)(\r\n|\r|\n)/g;
|
||||
let m;
|
||||
while ((m = lineRe.exec(text)) !== null) {
|
||||
if (visit({
|
||||
line: m[1],
|
||||
newline: m[2],
|
||||
startIndex: m.index,
|
||||
endIndex: lineRe.lastIndex,
|
||||
}) === false) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findMoshConnect(text) {
|
||||
let found = null;
|
||||
forEachCompleteLine(text, ({ line, newline, startIndex, endIndex }) => {
|
||||
const parsed = parseConnectLine(line);
|
||||
if (!parsed) return;
|
||||
found = {
|
||||
port: parsed.port,
|
||||
key: parsed.key,
|
||||
matchStartIndex: startIndex + parsed.matchStartOffset,
|
||||
matchEndIndex: endIndex,
|
||||
visiblePrefix: line.slice(0, parsed.matchStartOffset),
|
||||
visibleSuffix: line.slice(parsed.matchEndOffset) + newline,
|
||||
};
|
||||
return false;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function potentialProtocolStart(text) {
|
||||
if (!text) return -1;
|
||||
let best = -1;
|
||||
for (const marker of PROTOCOL_MARKERS) {
|
||||
const full = text.indexOf(marker);
|
||||
if (full !== -1) {
|
||||
best = best === -1 ? full : Math.min(best, full);
|
||||
}
|
||||
for (let len = Math.min(marker.length - 1, text.length); len > 0; len -= 1) {
|
||||
if (marker.startsWith(text.slice(text.length - len))) {
|
||||
const pos = text.length - len;
|
||||
best = best === -1 ? pos : Math.min(best, pos);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function buildMoshServerCommand(moshServerPath) {
|
||||
const trimmed = typeof moshServerPath === "string" ? moshServerPath.trim() : "";
|
||||
if (!trimmed) return "mosh-server new -s";
|
||||
return `${shellQuote(trimmed)} new -s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a buffer of bytes from the SSH PTY for a MOSH CONNECT line.
|
||||
*
|
||||
* Returns { port: number, key: string, matchEndIndex: number } when the
|
||||
* marker is found, otherwise null. matchEndIndex is the byte offset
|
||||
* immediately after the matched line in the *current* chunk so callers
|
||||
* can tell what to strip from the renderer-visible stream (since the
|
||||
* line is internal protocol, not a user-visible prompt).
|
||||
*
|
||||
* The parser is deliberately stateless: callers should keep a small
|
||||
* trailing window (≤ 4096 bytes) of unmatched data so the marker isn't
|
||||
* lost when it spans chunk boundaries.
|
||||
*/
|
||||
function parseMoshConnect(buffer) {
|
||||
const text = Buffer.isBuffer(buffer) ? buffer.toString("utf8") : String(buffer);
|
||||
const found = findMoshConnect(text);
|
||||
if (!found) return null;
|
||||
return { port: found.port, key: found.key, matchEndIndex: found.matchEndIndex };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the argv for the ssh bootstrap command.
|
||||
*
|
||||
* ssh -t [-p port] [user@]host -- LC_ALL=... mosh-server new -s [...]
|
||||
*
|
||||
* `-t` allocates a remote TTY so password / 2FA prompts work; `--`
|
||||
* separates ssh's options from the remote command we want it to run.
|
||||
* The remote command runs `mosh-server new` and exits, with the magic
|
||||
* line emitted to stdout.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} opts.host — hostname or IP
|
||||
* @param {number} [opts.port] — ssh port (omit for default 22)
|
||||
* @param {string} [opts.username] — ssh user (defaults to ssh's choice)
|
||||
* @param {string} [opts.lang] — LC_ALL override for mosh-server
|
||||
* @param {string} [opts.moshServer]— remote command (default "mosh-server new")
|
||||
* @param {string[]} [opts.sshArgs] — extra args passed to ssh (e.g. -i path)
|
||||
* @returns {{ command: string, args: string[] }}
|
||||
*/
|
||||
function buildSshHandshakeCommand(opts) {
|
||||
if (!opts || !opts.host) throw new Error("buildSshHandshakeCommand: host is required");
|
||||
// No -t / -tt by default: this command only runs `mosh-server new`
|
||||
// and immediately exits; mosh-server itself doesn't need a TTY for
|
||||
// the `new` subcommand (it prints MOSH CONNECT to stdout and forks
|
||||
// into the background). Forcing a TTY would require -tt and break
|
||||
// BatchMode-friendly stdout capture.
|
||||
const args = [];
|
||||
if (opts.port && Number(opts.port) !== 22) {
|
||||
args.push("-p", String(opts.port));
|
||||
}
|
||||
if (Array.isArray(opts.sshArgs)) {
|
||||
args.push(...opts.sshArgs);
|
||||
}
|
||||
const target = opts.username ? `${opts.username}@${opts.host}` : opts.host;
|
||||
args.push(target);
|
||||
args.push("--");
|
||||
// Quote the remote command minimally — ssh runs it through the
|
||||
// remote shell so simple "command arg arg" works without shell
|
||||
// metacharacters from us. mosh-server prints the magic CONNECT line
|
||||
// and otherwise stays silent.
|
||||
const lang = opts.lang || "en_US.UTF-8";
|
||||
const moshServer = opts.moshServer || "mosh-server new -s";
|
||||
args.push(`LC_ALL=${shellQuote(lang)} ${moshServer}`);
|
||||
return { command: "ssh", args };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the argv for the local mosh-client invocation once the
|
||||
* handshake produced an ip + port + key.
|
||||
*
|
||||
* mosh-client <ip> <port> (with MOSH_KEY in env)
|
||||
*
|
||||
* `mosh-server` listens on UDP at the IP/port pair it announced. By
|
||||
* convention, the IP is derived from the "MOSH IP" line emitted before
|
||||
* MOSH CONNECT, but most servers omit it and the client just uses the
|
||||
* SSH-resolved hostname / IP. We default to the original hostname when
|
||||
* no MOSH IP override is available.
|
||||
*/
|
||||
function buildMoshClientCommand({ moshClientPath, host, port }) {
|
||||
if (!moshClientPath) throw new Error("buildMoshClientCommand: moshClientPath is required");
|
||||
if (!host) throw new Error("buildMoshClientCommand: host is required");
|
||||
if (!port || port <= 0) throw new Error("buildMoshClientCommand: port must be > 0");
|
||||
return { command: moshClientPath, args: [host, String(port)] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight stream sniffer: hands chunks in, emits MOSH CONNECT
|
||||
* details + the byte ranges that should be hidden from the user-
|
||||
* visible stream.
|
||||
*
|
||||
* Usage:
|
||||
* const sniffer = createMoshConnectSniffer();
|
||||
* for each chunk: const { visible, parsed } = sniffer.feed(chunk);
|
||||
* send `visible` to renderer; if `parsed`, switch to mosh-client.
|
||||
*
|
||||
* Once a parse hits, every subsequent chunk passes through unchanged
|
||||
* (defensive: the bridge will tear down the SSH PTY immediately after
|
||||
* the parse so further chunks are unlikely, but we don't want to leak
|
||||
* partial copies of MOSH CONNECT lines if we somehow get more bytes).
|
||||
*
|
||||
* The sniffer keeps a trailing window of unmatched bytes (RING_SIZE) so
|
||||
* it can detect MOSH CONNECT spanning chunk boundaries.
|
||||
*/
|
||||
function createMoshConnectSniffer() {
|
||||
const RING_SIZE = 4096;
|
||||
const MAX_PROTOCOL_LINE = 512;
|
||||
let pending = "";
|
||||
let parsed = null;
|
||||
let moshHost = null;
|
||||
|
||||
return {
|
||||
feed(chunk) {
|
||||
if (parsed) return { visible: chunk, parsed: null };
|
||||
|
||||
const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
|
||||
pending += text;
|
||||
let visibleText = "";
|
||||
let consumed = 0;
|
||||
|
||||
forEachCompleteLine(pending, ({ line, newline, startIndex, endIndex }) => {
|
||||
if (startIndex > consumed) {
|
||||
visibleText += pending.slice(consumed, startIndex);
|
||||
}
|
||||
|
||||
const ip = parseMoshIpLine(line);
|
||||
if (ip) {
|
||||
moshHost = ip;
|
||||
consumed = endIndex;
|
||||
return;
|
||||
}
|
||||
|
||||
const connect = parseConnectLine(line);
|
||||
if (connect) {
|
||||
parsed = { port: connect.port, key: connect.key };
|
||||
if (moshHost) parsed.host = moshHost;
|
||||
visibleText += line.slice(0, connect.matchStartOffset);
|
||||
const suffix = line.slice(connect.matchEndOffset);
|
||||
if (suffix) visibleText += suffix + newline;
|
||||
consumed = endIndex;
|
||||
return false;
|
||||
}
|
||||
|
||||
visibleText += line + newline;
|
||||
consumed = endIndex;
|
||||
});
|
||||
|
||||
if (parsed) {
|
||||
visibleText += pending.slice(consumed);
|
||||
pending = "";
|
||||
const visible = Buffer.isBuffer(chunk) ? Buffer.from(visibleText, "utf8") : visibleText;
|
||||
return { visible, parsed };
|
||||
}
|
||||
|
||||
pending = pending.slice(consumed);
|
||||
const holdIndex = potentialProtocolStart(pending);
|
||||
if (holdIndex === -1) {
|
||||
visibleText += pending;
|
||||
pending = "";
|
||||
} else {
|
||||
visibleText += pending.slice(0, holdIndex);
|
||||
pending = pending.slice(holdIndex);
|
||||
if (pending.length > MAX_PROTOCOL_LINE) {
|
||||
visibleText += pending;
|
||||
pending = "";
|
||||
}
|
||||
}
|
||||
|
||||
if (pending.length > RING_SIZE) {
|
||||
const overflow = pending.length - RING_SIZE;
|
||||
visibleText += pending.slice(0, overflow);
|
||||
pending = pending.slice(overflow);
|
||||
}
|
||||
const visible = Buffer.isBuffer(chunk) ? Buffer.from(visibleText, "utf8") : visibleText;
|
||||
return { visible, parsed };
|
||||
},
|
||||
isParsed() { return parsed !== null; },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the env that `mosh-client` will see. MOSH_KEY is the secret
|
||||
* shared with mosh-server, and we preserve TERM + LANG so the local
|
||||
* terminfo lookups pick the right entry.
|
||||
*/
|
||||
function buildMoshClientEnv({ baseEnv, key, lang }) {
|
||||
const env = { ...(baseEnv || {}), MOSH_KEY: key };
|
||||
if (lang && !env.LANG) env.LANG = lang;
|
||||
if (!env.TERM) env.TERM = "xterm-256color";
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the absolute path of the system `ssh` binary. On Windows we
|
||||
* try the in-box OpenSSH location first because PATH may not list
|
||||
* it inside the Electron child env.
|
||||
*/
|
||||
function resolveSshExecutable({ findExecutable, fileExists, platform = process.platform }) {
|
||||
const fromPath = findExecutable("ssh");
|
||||
if (fromPath && fromPath !== "ssh" && fileExists(fromPath)) return fromPath;
|
||||
if (platform === "win32") {
|
||||
const sysRoot = process.env.SystemRoot || process.env.SYSTEMROOT || "C:\\Windows";
|
||||
// Build with the win32-flavored path module so the result is
|
||||
// back-slash-joined regardless of the host platform we're running
|
||||
// the lookup from (relevant for cross-platform unit tests).
|
||||
const inbox = path.win32.join(sysRoot, "System32", "OpenSSH", "ssh.exe");
|
||||
if (fileExists(inbox)) return inbox;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseMoshConnect,
|
||||
buildSshHandshakeCommand,
|
||||
buildMoshServerCommand,
|
||||
buildMoshClientCommand,
|
||||
createMoshConnectSniffer,
|
||||
buildMoshClientEnv,
|
||||
resolveSshExecutable,
|
||||
};
|
||||
229
electron/bridges/moshHandshake.test.cjs
Normal file
229
electron/bridges/moshHandshake.test.cjs
Normal file
@@ -0,0 +1,229 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const {
|
||||
parseMoshConnect,
|
||||
buildSshHandshakeCommand,
|
||||
buildMoshServerCommand,
|
||||
buildMoshClientCommand,
|
||||
createMoshConnectSniffer,
|
||||
buildMoshClientEnv,
|
||||
resolveSshExecutable,
|
||||
} = require("./moshHandshake.cjs");
|
||||
|
||||
test("parseMoshConnect captures port and key from a typical mosh-server line", () => {
|
||||
const line = "Welcome\r\nMOSH CONNECT 60001 ABCDEFGHIJKLMNOPQRSTUV==\r\n";
|
||||
const got = parseMoshConnect(line);
|
||||
assert.deepEqual(got && { port: got.port, key: got.key }, {
|
||||
port: 60001,
|
||||
key: "ABCDEFGHIJKLMNOPQRSTUV==",
|
||||
});
|
||||
});
|
||||
|
||||
test("parseMoshConnect accepts unpadded base64 keys (length 22)", () => {
|
||||
const line = "MOSH CONNECT 60005 abcdefghijklmnopqrstuv\n";
|
||||
const got = parseMoshConnect(line);
|
||||
assert.equal(got && got.port, 60005);
|
||||
assert.equal(got && got.key.length, 22);
|
||||
});
|
||||
|
||||
test("parseMoshConnect rejects out-of-range ports", () => {
|
||||
assert.equal(parseMoshConnect("MOSH CONNECT 99999 ABCDEFGHIJKLMNOPQRSTUV==\n"), null);
|
||||
assert.equal(parseMoshConnect("MOSH CONNECT 0 ABCDEFGHIJKLMNOPQRSTUV==\n"), null);
|
||||
});
|
||||
|
||||
test("parseMoshConnect rejects implausibly short keys (substring noise)", () => {
|
||||
assert.equal(parseMoshConnect("MOSH CONNECT 60000 abc\n"), null);
|
||||
});
|
||||
|
||||
test("parseMoshConnect handles a Buffer chunk", () => {
|
||||
const buf = Buffer.from("garbage MOSH CONNECT 60010 ABCDEFGHIJKLMNOPQRSTUV==\n");
|
||||
const got = parseMoshConnect(buf);
|
||||
assert.equal(got && got.port, 60010);
|
||||
});
|
||||
|
||||
test("buildSshHandshakeCommand omits -t and uses default port", () => {
|
||||
const got = buildSshHandshakeCommand({ host: "example.com", username: "alice" });
|
||||
assert.equal(got.command, "ssh");
|
||||
assert.deepEqual(got.args, [
|
||||
"alice@example.com",
|
||||
"--",
|
||||
"LC_ALL='en_US.UTF-8' mosh-server new -s",
|
||||
]);
|
||||
});
|
||||
|
||||
test("buildSshHandshakeCommand passes a non-default port via -p", () => {
|
||||
const got = buildSshHandshakeCommand({ host: "example.com", port: 2222 });
|
||||
assert.deepEqual(got.args.slice(0, 2), ["-p", "2222"]);
|
||||
});
|
||||
|
||||
test("buildSshHandshakeCommand interpolates lang and moshServer overrides", () => {
|
||||
const got = buildSshHandshakeCommand({
|
||||
host: "h",
|
||||
lang: "zh_CN.UTF-8",
|
||||
moshServer: "/opt/mosh/bin/mosh-server new -s -c 256",
|
||||
});
|
||||
assert.equal(got.args.at(-1), "LC_ALL='zh_CN.UTF-8' /opt/mosh/bin/mosh-server new -s -c 256");
|
||||
});
|
||||
|
||||
test("buildSshHandshakeCommand shell-quotes lang values", () => {
|
||||
const got = buildSshHandshakeCommand({
|
||||
host: "h",
|
||||
lang: "C; touch /tmp/netcatty-owned",
|
||||
});
|
||||
assert.equal(got.args.at(-1), "LC_ALL='C; touch /tmp/netcatty-owned' mosh-server new -s");
|
||||
});
|
||||
|
||||
test("buildMoshServerCommand treats custom server input as a path", () => {
|
||||
assert.equal(
|
||||
buildMoshServerCommand("/opt/Mosh Tools/mosh-server; touch /tmp/nope"),
|
||||
"'/opt/Mosh Tools/mosh-server; touch /tmp/nope' new -s",
|
||||
);
|
||||
});
|
||||
|
||||
test("buildSshHandshakeCommand throws when host is missing", () => {
|
||||
assert.throws(() => buildSshHandshakeCommand({}), /host is required/);
|
||||
});
|
||||
|
||||
test("buildMoshClientCommand wires moshClientPath, host, port", () => {
|
||||
const got = buildMoshClientCommand({
|
||||
moshClientPath: "/usr/local/bin/mosh-client",
|
||||
host: "10.0.0.1",
|
||||
port: 60001,
|
||||
});
|
||||
assert.equal(got.command, "/usr/local/bin/mosh-client");
|
||||
assert.deepEqual(got.args, ["10.0.0.1", "60001"]);
|
||||
});
|
||||
|
||||
test("buildMoshClientCommand validates inputs", () => {
|
||||
assert.throws(() => buildMoshClientCommand({ host: "h", port: 1 }), /moshClientPath/);
|
||||
assert.throws(() => buildMoshClientCommand({ moshClientPath: "x", port: 1 }), /host/);
|
||||
assert.throws(() => buildMoshClientCommand({ moshClientPath: "x", host: "h", port: 0 }), /port/);
|
||||
});
|
||||
|
||||
test("createMoshConnectSniffer detects MOSH CONNECT split across chunks", () => {
|
||||
const sniffer = createMoshConnectSniffer();
|
||||
const r1 = sniffer.feed("login as: alice\r\nlast login: yesterday\r\nMOSH CONNE");
|
||||
assert.equal(r1.parsed, null);
|
||||
assert.ok(!String(r1.visible).includes("MOSH CONNE"));
|
||||
const r2 = sniffer.feed("CT 60002 ABCDEFGHIJKLMNOPQRSTUV==\r\n");
|
||||
assert.deepEqual(r2.parsed, { port: 60002, key: "ABCDEFGHIJKLMNOPQRSTUV==" });
|
||||
assert.ok(!String(r2.visible).includes("MOSH CONNECT"));
|
||||
assert.ok(!String(r2.visible).includes("ABCDEFGHIJKLMNOPQRSTUV=="));
|
||||
});
|
||||
|
||||
test("createMoshConnectSniffer does not leak a split MOSH key", () => {
|
||||
const sniffer = createMoshConnectSniffer();
|
||||
const r1 = sniffer.feed("intro\r\nMOSH CONNECT 60002 ABCDEFGHIJ");
|
||||
assert.equal(r1.parsed, null);
|
||||
assert.equal(String(r1.visible), "intro\r\n");
|
||||
const r2 = sniffer.feed("KLMNOPQRSTUV==\r\n");
|
||||
assert.deepEqual(r2.parsed, { port: 60002, key: "ABCDEFGHIJKLMNOPQRSTUV==" });
|
||||
assert.equal(String(r2.visible), "");
|
||||
});
|
||||
|
||||
test("createMoshConnectSniffer passes through prompts without waiting for a newline", () => {
|
||||
const sniffer = createMoshConnectSniffer();
|
||||
const r = sniffer.feed("password:");
|
||||
assert.equal(r.parsed, null);
|
||||
assert.equal(String(r.visible), "password:");
|
||||
});
|
||||
|
||||
test("createMoshConnectSniffer ignores invalid MOSH CONNECT lines", () => {
|
||||
for (const line of [
|
||||
"MOSH CONNECT 99999 ABCDEFGHIJKLMNOPQRSTUV==\r\n",
|
||||
"MOSH CONNECT 0 ABCDEFGHIJKLMNOPQRSTUV==\r\n",
|
||||
"MOSH CONNECT 60000 short\r\n",
|
||||
"MOSH CONNECT 60000 ABCDEFGHIJKLMNOPQRSTUVWXYZ\r\n",
|
||||
"MOSH CONNECT 60000 ABCDEFGHIJKLMNOPQRSTUV==oops\r\n",
|
||||
]) {
|
||||
const sniffer = createMoshConnectSniffer();
|
||||
const r = sniffer.feed(line);
|
||||
assert.equal(r.parsed, null, line);
|
||||
}
|
||||
});
|
||||
|
||||
test("createMoshConnectSniffer captures MOSH IP without showing protocol lines", () => {
|
||||
const sniffer = createMoshConnectSniffer();
|
||||
const r = sniffer.feed("welcome\r\nMOSH IP 203.0.113.8\r\nMOSH CONNECT 60002 ABCDEFGHIJKLMNOPQRSTUV==\r\n");
|
||||
assert.deepEqual(r.parsed, { port: 60002, key: "ABCDEFGHIJKLMNOPQRSTUV==", host: "203.0.113.8" });
|
||||
assert.equal(String(r.visible), "welcome\r\n");
|
||||
});
|
||||
|
||||
test("createMoshConnectSniffer ignores unsafe MOSH IP values", () => {
|
||||
const sniffer = createMoshConnectSniffer();
|
||||
const r = sniffer.feed("MOSH IP --help\r\nMOSH CONNECT 60002 ABCDEFGHIJKLMNOPQRSTUV==\r\n");
|
||||
assert.deepEqual(r.parsed, { port: 60002, key: "ABCDEFGHIJKLMNOPQRSTUV==" });
|
||||
});
|
||||
|
||||
test("createMoshConnectSniffer strips the magic line from visible output", () => {
|
||||
const sniffer = createMoshConnectSniffer();
|
||||
const chunk = "shell prompt $ \r\nMOSH CONNECT 60003 ABCDEFGHIJKLMNOPQRSTUV==\r\nbye\r\n";
|
||||
const { visible, parsed } = sniffer.feed(chunk);
|
||||
assert.deepEqual(parsed, { port: 60003, key: "ABCDEFGHIJKLMNOPQRSTUV==" });
|
||||
assert.ok(!String(visible).includes("MOSH CONNECT"), "visible output should not leak the marker");
|
||||
});
|
||||
|
||||
test("createMoshConnectSniffer is idempotent after a parse", () => {
|
||||
const sniffer = createMoshConnectSniffer();
|
||||
const r1 = sniffer.feed("MOSH CONNECT 60010 ABCDEFGHIJKLMNOPQRSTUV==\r\n");
|
||||
assert.ok(r1.parsed);
|
||||
// Second feed should not re-parse / re-strip — it just passes through.
|
||||
const r2 = sniffer.feed("trailing bytes after handshake\r\n");
|
||||
assert.equal(r2.parsed, null);
|
||||
assert.equal(String(r2.visible), "trailing bytes after handshake\r\n");
|
||||
});
|
||||
|
||||
test("createMoshConnectSniffer trims its ring buffer so old data doesn't accumulate", () => {
|
||||
const sniffer = createMoshConnectSniffer();
|
||||
// Feed >> RING_SIZE (4096) bytes of harmless output.
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
const r = sniffer.feed("x".repeat(1024));
|
||||
assert.equal(r.parsed, null);
|
||||
}
|
||||
// Now feed a CONNECT line — ring trimming must not have lost the
|
||||
// ability to match a fresh marker.
|
||||
const r = sniffer.feed("MOSH CONNECT 60020 ABCDEFGHIJKLMNOPQRSTUV==\r\n");
|
||||
assert.equal(r.parsed && r.parsed.port, 60020);
|
||||
});
|
||||
|
||||
test("buildMoshClientEnv injects MOSH_KEY without mutating the input env", () => {
|
||||
const base = { LANG: "C", PATH: "/x" };
|
||||
const env = buildMoshClientEnv({ baseEnv: base, key: "deadbeef", lang: "C" });
|
||||
assert.equal(env.MOSH_KEY, "deadbeef");
|
||||
assert.equal(env.PATH, "/x");
|
||||
assert.equal(base.MOSH_KEY, undefined, "input env should not be mutated");
|
||||
});
|
||||
|
||||
test("buildMoshClientEnv defaults TERM when missing", () => {
|
||||
const env = buildMoshClientEnv({ baseEnv: {}, key: "k", lang: "C" });
|
||||
assert.equal(env.TERM, "xterm-256color");
|
||||
});
|
||||
|
||||
test("resolveSshExecutable prefers PATH lookups", () => {
|
||||
const resolved = resolveSshExecutable({
|
||||
findExecutable: () => "/opt/ssh/bin/ssh",
|
||||
fileExists: () => true,
|
||||
platform: "linux",
|
||||
});
|
||||
assert.equal(resolved, "/opt/ssh/bin/ssh");
|
||||
});
|
||||
|
||||
test("resolveSshExecutable falls back to in-box OpenSSH on win32", () => {
|
||||
process.env.SystemRoot = "C:\\Windows";
|
||||
const resolved = resolveSshExecutable({
|
||||
findExecutable: () => "ssh", // fakes "not found, returns the bare name"
|
||||
fileExists: (p) => p.endsWith("OpenSSH\\ssh.exe"),
|
||||
platform: "win32",
|
||||
});
|
||||
assert.equal(resolved, "C:\\Windows\\System32\\OpenSSH\\ssh.exe");
|
||||
});
|
||||
|
||||
test("resolveSshExecutable returns null when nothing is found", () => {
|
||||
const resolved = resolveSshExecutable({
|
||||
findExecutable: () => "ssh",
|
||||
fileExists: () => false,
|
||||
platform: "linux",
|
||||
});
|
||||
assert.equal(resolved, null);
|
||||
});
|
||||
56
electron/bridges/terminalBridge.bareMoshClient.test.cjs
Normal file
56
electron/bridges/terminalBridge.bareMoshClient.test.cjs
Normal file
@@ -0,0 +1,56 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const { resolveBareMoshClient } = require("./terminalBridge.cjs");
|
||||
|
||||
function makeTmp() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-mosh-resolve-"));
|
||||
}
|
||||
|
||||
function writeExecutable(filePath) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, "#!/bin/sh\nexit 0\n");
|
||||
fs.chmodSync(filePath, 0o755);
|
||||
}
|
||||
|
||||
test("resolveBareMoshClient honors an explicit path with a mosh-client basename", () => {
|
||||
const tmp = makeTmp();
|
||||
const p = path.join(tmp, "mosh-client");
|
||||
writeExecutable(p);
|
||||
assert.equal(resolveBareMoshClient({ moshClientPath: p }), p);
|
||||
});
|
||||
|
||||
test("resolveBareMoshClient ignores an explicit path whose basename is `mosh` (the wrapper)", () => {
|
||||
const tmp = makeTmp();
|
||||
const p = path.join(tmp, "mosh");
|
||||
writeExecutable(p);
|
||||
// No bundled mosh available in this test (no resources/mosh/<x>/),
|
||||
// so the fallback is undefined → null/undefined return.
|
||||
const got = resolveBareMoshClient({ moshClientPath: p });
|
||||
assert.notEqual(got, p, "explicit `mosh` wrapper path should not be treated as a bare client");
|
||||
});
|
||||
|
||||
test("resolveBareMoshClient rejects relative explicit paths", () => {
|
||||
const got = resolveBareMoshClient({ moshClientPath: "./mosh-client" });
|
||||
assert.notEqual(got, "./mosh-client");
|
||||
});
|
||||
|
||||
test("resolveBareMoshClient ignores a non-executable explicit path", () => {
|
||||
const tmp = makeTmp();
|
||||
const p = path.join(tmp, "mosh-client");
|
||||
fs.writeFileSync(p, "");
|
||||
fs.chmodSync(p, 0o644);
|
||||
const got = resolveBareMoshClient({ moshClientPath: p });
|
||||
assert.notEqual(got, p);
|
||||
});
|
||||
|
||||
test("resolveBareMoshClient honors caller PATH overrides", () => {
|
||||
const tmp = makeTmp();
|
||||
const p = path.join(tmp, "mosh-client");
|
||||
writeExecutable(p);
|
||||
|
||||
assert.equal(resolveBareMoshClient({}, { pathOverride: tmp }), p);
|
||||
});
|
||||
101
electron/bridges/terminalBridge.bundledMosh.test.cjs
Normal file
101
electron/bridges/terminalBridge.bundledMosh.test.cjs
Normal file
@@ -0,0 +1,101 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const { bundledMoshClient } = require("./terminalBridge.cjs");
|
||||
|
||||
function makeTmp() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-mosh-"));
|
||||
}
|
||||
|
||||
function writeExecutable(filePath, contents = "#!/bin/sh\nexit 0\n") {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, contents);
|
||||
fs.chmodSync(filePath, 0o755);
|
||||
}
|
||||
|
||||
test("bundledMoshClient returns null when no binary is present", () => {
|
||||
const projectRoot = makeTmp();
|
||||
const result = bundledMoshClient({
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
projectRoot,
|
||||
resourcesPath: path.join(projectRoot, "missing-resources"),
|
||||
});
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test("bundledMoshClient prefers the packaged Resources path", () => {
|
||||
const projectRoot = makeTmp();
|
||||
const resourcesPath = makeTmp();
|
||||
const packagedBin = path.join(resourcesPath, "mosh", "mosh-client");
|
||||
writeExecutable(packagedBin);
|
||||
|
||||
const devBin = path.join(projectRoot, "resources", "mosh", "linux-x64", "mosh-client");
|
||||
writeExecutable(devBin);
|
||||
|
||||
const result = bundledMoshClient({ platform: "linux", arch: "x64", projectRoot, resourcesPath });
|
||||
assert.equal(result, packagedBin);
|
||||
});
|
||||
|
||||
test("bundledMoshClient falls back to the project-root dev path", () => {
|
||||
const projectRoot = makeTmp();
|
||||
const devBin = path.join(projectRoot, "resources", "mosh", "linux-x64", "mosh-client");
|
||||
writeExecutable(devBin);
|
||||
|
||||
const result = bundledMoshClient({
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
projectRoot,
|
||||
resourcesPath: path.join(projectRoot, "missing"),
|
||||
});
|
||||
assert.equal(result, devBin);
|
||||
});
|
||||
|
||||
test("bundledMoshClient looks under darwin-universal regardless of arch on macOS", () => {
|
||||
const projectRoot = makeTmp();
|
||||
const universalBin = path.join(projectRoot, "resources", "mosh", "darwin-universal", "mosh-client");
|
||||
writeExecutable(universalBin);
|
||||
|
||||
for (const arch of ["arm64", "x64"]) {
|
||||
const result = bundledMoshClient({
|
||||
platform: "darwin",
|
||||
arch,
|
||||
projectRoot,
|
||||
resourcesPath: path.join(projectRoot, "missing"),
|
||||
});
|
||||
assert.equal(result, universalBin, `arch=${arch}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("bundledMoshClient uses .exe basename on win32 (when running on a POSIX host)", { skip: process.platform === "win32" }, () => {
|
||||
const projectRoot = makeTmp();
|
||||
const winBin = path.join(projectRoot, "resources", "mosh", "win32-x64", "mosh-client.exe");
|
||||
writeExecutable(winBin);
|
||||
|
||||
const result = bundledMoshClient({
|
||||
platform: "win32",
|
||||
arch: "x64",
|
||||
projectRoot,
|
||||
resourcesPath: path.join(projectRoot, "missing"),
|
||||
});
|
||||
assert.equal(result, winBin);
|
||||
});
|
||||
|
||||
test("bundledMoshClient ignores non-executable matches", () => {
|
||||
const projectRoot = makeTmp();
|
||||
const candidate = path.join(projectRoot, "resources", "mosh", "linux-x64", "mosh-client");
|
||||
fs.mkdirSync(path.dirname(candidate), { recursive: true });
|
||||
fs.writeFileSync(candidate, "");
|
||||
fs.chmodSync(candidate, 0o644);
|
||||
|
||||
const result = bundledMoshClient({
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
projectRoot,
|
||||
resourcesPath: path.join(projectRoot, "missing"),
|
||||
});
|
||||
assert.equal(result, null);
|
||||
});
|
||||
@@ -19,6 +19,7 @@ const { detectShellKind } = require("./ai/ptyExec.cjs");
|
||||
const { trackSessionIdlePrompt } = require("./ai/shellUtils.cjs");
|
||||
const { createZmodemSentry } = require("./zmodemHelper.cjs");
|
||||
const { discoverShells } = require("./shellDiscovery.cjs");
|
||||
const moshHandshake = require("./moshHandshake.cjs");
|
||||
|
||||
// Shared references
|
||||
let sessions = null;
|
||||
@@ -229,12 +230,17 @@ function isWindowsAppExecutionAlias(filePath) {
|
||||
return !!windowsAppsDir && normalizedPath.startsWith(`${windowsAppsDir}${path.sep}`);
|
||||
}
|
||||
|
||||
function findExecutable(name) {
|
||||
function findExecutable(name, opts = {}) {
|
||||
if (process.platform !== "win32") return name;
|
||||
|
||||
const { execFileSync } = require("child_process");
|
||||
try {
|
||||
const result = execFileSync("where.exe", [name], { encoding: "utf8" });
|
||||
const pathOverride = Object.prototype.hasOwnProperty.call(opts, "pathOverride")
|
||||
? opts.pathOverride
|
||||
: process.env.PATH;
|
||||
const env = { ...process.env, PATH: pathOverride || "" };
|
||||
const whereExe = path.join(process.env.SystemRoot || "C:\\Windows", "System32", "where.exe");
|
||||
const result = execFileSync(fs.existsSync(whereExe) ? whereExe : "where.exe", [name], { encoding: "utf8", env });
|
||||
const candidates = result
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
@@ -249,7 +255,6 @@ function findExecutable(name) {
|
||||
console.warn(`Could not find ${name} via where.exe:`, err.message);
|
||||
}
|
||||
|
||||
const path = require("node:path");
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(name)) return name;
|
||||
|
||||
const commonPaths = [];
|
||||
@@ -779,9 +784,325 @@ async function startTelnetSession(event, options) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a Mosh session using system mosh-client
|
||||
* Resolve a usable bare `mosh-client` binary (i.e. not the Perl
|
||||
* wrapper) from, in order:
|
||||
* 1. options.moshClientPath when its basename matches `mosh-client[.exe]`
|
||||
* 2. resources/mosh/<…>/ via bundledMoshClient()
|
||||
* 3. PATH lookup for `mosh-client`
|
||||
*
|
||||
* Returns the absolute path or null.
|
||||
*/
|
||||
function resolveBareMoshClient(options, opts = {}) {
|
||||
const explicit = typeof options.moshClientPath === "string" ? options.moshClientPath.trim() : "";
|
||||
if (explicit) {
|
||||
const expanded = expandHomePath(explicit);
|
||||
if (path.isAbsolute(expanded) && isExecutableFile(expanded)) {
|
||||
const base = path.basename(expanded).toLowerCase();
|
||||
if (base === "mosh-client" || base === "mosh-client.exe") {
|
||||
return path.resolve(expanded);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const bundled = bundledMoshClient();
|
||||
if (bundled) return bundled;
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const onPath = findExecutable("mosh-client", { pathOverride: opts.pathOverride });
|
||||
if (onPath && onPath !== "mosh-client" && fs.existsSync(onPath)) return onPath;
|
||||
} else {
|
||||
const onPath = resolvePosixExecutable("mosh-client", { pathOverride: opts.pathOverride });
|
||||
if (onPath) return onPath;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase-2 / Phase-3b path: run the SSH bootstrap ourselves *inside the
|
||||
* user's terminal PTY* so password / 2FA / known-hosts prompts render
|
||||
* naturally, then swap to a bare `mosh-client` once `MOSH CONNECT` is
|
||||
* detected. Replaces both the upstream Mosh Perl wrapper and the
|
||||
* earlier non-PTY (BatchMode-style) implementation that couldn't show
|
||||
* prompts.
|
||||
*
|
||||
* State machine:
|
||||
* ssh-spawn ──onData──▶ sniffer.feed ──visible──▶ renderer
|
||||
* └──parsed──▶ remember port/key
|
||||
* ssh-pty exits ─────▶ if parsed: spawn mosh-client + swap
|
||||
* else: surface error
|
||||
*
|
||||
* The session keeps a stable sessionId across the swap. session.proc
|
||||
* is updated atomically before any user input arrives at the new
|
||||
* mosh-client (writeToSession / resizeSession route through
|
||||
* session.proc, so they automatically address the right process). The
|
||||
* ZMODEM sentry is recreated for the new proc because its
|
||||
* writeToRemote closure captures the previous handle.
|
||||
*
|
||||
* Caller has already validated that `bareClient` and `sshExe` exist.
|
||||
*/
|
||||
async function startMoshSessionViaHandshake(event, options, { bareClient, sshExe }) {
|
||||
const sessionId = options.sessionId || randomUUID();
|
||||
const cols = options.cols || 80;
|
||||
const rows = options.rows || 24;
|
||||
const optionsEnv = options.env || {};
|
||||
const lang = optionsEnv.LANG || resolveLangFromCharsetForMosh(options.charset);
|
||||
|
||||
const { args: sshArgs } = moshHandshake.buildSshHandshakeCommand({
|
||||
host: options.hostname,
|
||||
port: options.port,
|
||||
username: options.username,
|
||||
lang,
|
||||
moshServer: moshHandshake.buildMoshServerCommand(options.moshServerPath),
|
||||
});
|
||||
|
||||
const sshEnv = { ...process.env, ...optionsEnv, TERM: "xterm-256color" };
|
||||
if (options.agentForwarding && process.env.SSH_AUTH_SOCK) {
|
||||
sshEnv.SSH_AUTH_SOCK = process.env.SSH_AUTH_SOCK;
|
||||
}
|
||||
|
||||
const sshPty = pty.spawn(sshExe, sshArgs, {
|
||||
cols,
|
||||
rows,
|
||||
env: sshEnv,
|
||||
cwd: os.homedir(),
|
||||
encoding: null,
|
||||
});
|
||||
|
||||
const session = {
|
||||
proc: sshPty,
|
||||
pty: sshPty,
|
||||
type: "mosh",
|
||||
protocol: "mosh",
|
||||
webContentsId: event.sender.id,
|
||||
hostname: options.hostname || "",
|
||||
username: options.username || "",
|
||||
label: options.label || options.hostname || "Mosh Session",
|
||||
shellKind: "posix",
|
||||
shellExecutable: "remote-shell",
|
||||
flushPendingData: null,
|
||||
lastIdlePrompt: "",
|
||||
lastIdlePromptAt: 0,
|
||||
_promptTrackTail: "",
|
||||
cols,
|
||||
rows,
|
||||
moshHandshakePhase: "ssh",
|
||||
moshHandshakeResult: null,
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
if (options.sessionLog?.enabled && options.sessionLog?.directory) {
|
||||
sessionLogStreamManager.startStream(sessionId, {
|
||||
hostLabel: options.label || options.hostname,
|
||||
hostname: options.hostname,
|
||||
directory: options.sessionLog.directory,
|
||||
format: options.sessionLog.format || "txt",
|
||||
startTime: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
const { bufferData, flush } = createPtyBuffer((data) => {
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:data", { sessionId, data });
|
||||
});
|
||||
session.flushPendingData = flush;
|
||||
|
||||
const sniffer = moshHandshake.createMoshConnectSniffer();
|
||||
|
||||
// Forward bytes from the ssh PTY to the renderer, redacting the
|
||||
// MOSH CONNECT magic line. ZMODEM is intentionally not enabled
|
||||
// during handshake — it can't appear during ssh login output and
|
||||
// would only complicate the swap.
|
||||
sshPty.onData((chunk) => {
|
||||
const { visible, parsed } = sniffer.feed(chunk);
|
||||
if (visible && (visible.length || (typeof visible === "string" && visible))) {
|
||||
const str = Buffer.isBuffer(visible) ? visible.toString("utf8") : visible;
|
||||
if (str.length > 0) {
|
||||
bufferData(str);
|
||||
sessionLogStreamManager.appendData(sessionId, str);
|
||||
}
|
||||
}
|
||||
if (parsed && session.moshHandshakePhase === "ssh") {
|
||||
session.moshHandshakePhase = "parsed";
|
||||
session.moshHandshakeResult = parsed;
|
||||
}
|
||||
});
|
||||
|
||||
sshPty.onExit(({ exitCode, signal }) => {
|
||||
if (sessions.get(sessionId) !== session || session.closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.moshHandshakePhase === "parsed" && session.moshHandshakeResult) {
|
||||
try {
|
||||
swapToMoshClient(session, options, {
|
||||
bareClient,
|
||||
optionsEnv,
|
||||
lang,
|
||||
parsed: session.moshHandshakeResult,
|
||||
bufferData,
|
||||
flush,
|
||||
sessionId,
|
||||
});
|
||||
} catch (err) {
|
||||
flush();
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:exit", {
|
||||
sessionId,
|
||||
reason: "error",
|
||||
error: `Failed to spawn mosh-client: ${err.message}`,
|
||||
});
|
||||
sessions.delete(sessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handshake failed before MOSH CONNECT — ssh exited without parse.
|
||||
// The user has already seen the failure output (auth error, host
|
||||
// key warning, etc). Just surface a session-exit with the code so
|
||||
// the renderer can label the session "disconnected".
|
||||
flush();
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:exit", {
|
||||
sessionId,
|
||||
exitCode,
|
||||
signal,
|
||||
reason: "error",
|
||||
});
|
||||
sessions.delete(sessionId);
|
||||
});
|
||||
|
||||
return { sessionId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mid-session PTY swap: replaces session.proc (currently the ssh
|
||||
* handshake PTY) with a freshly-spawned mosh-client PTY, re-wiring
|
||||
* the data / exit listeners and (on POSIX) recreating the ZMODEM
|
||||
* sentry whose writeToRemote closure captured the previous handle.
|
||||
*/
|
||||
function swapToMoshClient(session, options, ctx) {
|
||||
const { bareClient, optionsEnv, lang, parsed, bufferData, flush, sessionId } = ctx;
|
||||
|
||||
const env = moshHandshake.buildMoshClientEnv({
|
||||
baseEnv: { ...process.env, ...optionsEnv, TERM: "xterm-256color" },
|
||||
key: parsed.key,
|
||||
lang,
|
||||
});
|
||||
if (options.agentForwarding && process.env.SSH_AUTH_SOCK) {
|
||||
env.SSH_AUTH_SOCK = process.env.SSH_AUTH_SOCK;
|
||||
}
|
||||
|
||||
const { command, args: clientArgs } = moshHandshake.buildMoshClientCommand({
|
||||
moshClientPath: bareClient,
|
||||
host: parsed.host || options.hostname,
|
||||
port: parsed.port,
|
||||
});
|
||||
|
||||
const mcPty = pty.spawn(command, clientArgs, {
|
||||
cols: session.cols,
|
||||
rows: session.rows,
|
||||
env,
|
||||
cwd: os.homedir(),
|
||||
encoding: null,
|
||||
});
|
||||
|
||||
// Atomic swap — writeToSession / resizeSession both read
|
||||
// session.proc lazily, so any keystroke that arrives after this
|
||||
// assignment goes to mosh-client, not the dead ssh PTY.
|
||||
session.proc = mcPty;
|
||||
session.pty = mcPty;
|
||||
session.moshHandshakePhase = "mosh-client";
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
const decoder = new StringDecoder("utf8");
|
||||
const sentry = createZmodemSentry({
|
||||
sessionId,
|
||||
onData(buf) {
|
||||
const str = decoder.write(buf);
|
||||
if (!str) return;
|
||||
trackSessionIdlePrompt(session, str);
|
||||
bufferData(str);
|
||||
sessionLogStreamManager.appendData(sessionId, str);
|
||||
},
|
||||
writeToRemote(buf) {
|
||||
try { return mcPty.write(buf); } catch { return true; }
|
||||
},
|
||||
getWebContents() { return electronModule.webContents.fromId(session.webContentsId); },
|
||||
protocolLabel: "Mosh",
|
||||
});
|
||||
session.zmodemSentry = sentry;
|
||||
mcPty.onData((data) => sentry.consume(data));
|
||||
} else {
|
||||
mcPty.onData((data) => {
|
||||
const str = data.toString("utf8");
|
||||
trackSessionIdlePrompt(session, str);
|
||||
bufferData(str);
|
||||
sessionLogStreamManager.appendData(sessionId, str);
|
||||
});
|
||||
}
|
||||
|
||||
mcPty.onExit(({ exitCode, signal }) => {
|
||||
if (sessions.get(sessionId) !== session || session.closed) {
|
||||
return;
|
||||
}
|
||||
flush();
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:exit", {
|
||||
sessionId,
|
||||
exitCode,
|
||||
signal,
|
||||
reason: exitCode !== 0 ? "error" : "exited",
|
||||
});
|
||||
sessions.delete(sessionId);
|
||||
});
|
||||
}
|
||||
|
||||
function resolveLangFromCharsetForMosh(charset) {
|
||||
if (!charset) return "en_US.UTF-8";
|
||||
const trimmed = String(charset).trim();
|
||||
if (/^utf-?8$/i.test(trimmed) || /^utf8$/i.test(trimmed)) return "en_US.UTF-8";
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a Mosh session.
|
||||
*
|
||||
* Phase 2 (preferred): when a bare `mosh-client` binary is available
|
||||
* and `ssh` is on the system, drive the handshake in Node and spawn
|
||||
* mosh-client directly — no Perl wrapper, works on Windows.
|
||||
*
|
||||
* Phase 1 fallback (legacy): delegate to the system `mosh` wrapper,
|
||||
* preserved so users with custom mosh setups don't regress.
|
||||
*/
|
||||
async function startMoshSession(event, options) {
|
||||
const optionsEnv = options.env || {};
|
||||
// Program discovery must consider the same PATH the spawned PTY will
|
||||
// receive, including host-level terminal environment overrides.
|
||||
const mergedPathForResolution = Object.prototype.hasOwnProperty.call(optionsEnv, "PATH")
|
||||
? optionsEnv.PATH
|
||||
: process.env.PATH;
|
||||
|
||||
// Phase 2 path — try first.
|
||||
const bareClient = resolveBareMoshClient(options, { pathOverride: mergedPathForResolution });
|
||||
if (bareClient) {
|
||||
const sshExe = moshHandshake.resolveSshExecutable({
|
||||
findExecutable: (name) => (
|
||||
process.platform === "win32"
|
||||
? findExecutable(name, { pathOverride: mergedPathForResolution })
|
||||
: resolvePosixExecutable(name, { pathOverride: mergedPathForResolution })
|
||||
),
|
||||
fileExists: (p) => isExecutableFile(p) || fs.existsSync(p),
|
||||
});
|
||||
if (sshExe) {
|
||||
return startMoshSessionViaHandshake(event, options, { bareClient, sshExe });
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 1 legacy: system `mosh` Perl wrapper.
|
||||
const sessionId = options.sessionId || randomUUID();
|
||||
|
||||
const cols = options.cols || 80;
|
||||
@@ -792,15 +1113,6 @@ async function startMoshSession(event, options) {
|
||||
// `/usr/bin:/bin:/usr/sbin:/sbin` and silently fails for Homebrew installs
|
||||
// (see issue #842). On Windows keep the existing behaviour.
|
||||
//
|
||||
// Resolution must consider the same PATH the spawned process will see —
|
||||
// host-level `environmentVariables.PATH` is merged into the child env
|
||||
// below, so the resolver checks that merged value first to avoid
|
||||
// rejecting a binary the child would actually have found.
|
||||
const optionsEnv = options.env || {};
|
||||
const mergedPathForResolution = Object.prototype.hasOwnProperty.call(optionsEnv, "PATH")
|
||||
? optionsEnv.PATH
|
||||
: process.env.PATH;
|
||||
|
||||
let moshCmd;
|
||||
let resolvedMoshDir = null;
|
||||
// 1. Honor user-supplied moshClientPath (Settings → Terminal → Mosh).
|
||||
@@ -831,7 +1143,25 @@ async function startMoshSession(event, options) {
|
||||
// though the wrapper itself runs.
|
||||
resolvedMoshDir = path.dirname(moshCmd);
|
||||
} else if (process.platform === "win32") {
|
||||
moshCmd = findExecutable("mosh") || "mosh.exe";
|
||||
// Windows fallback when the Phase-2 handshake can't fire (no
|
||||
// bundled mosh-client on disk and no `ssh` resolvable). Most
|
||||
// packaged Windows installs *do* hit the Phase-2 path because
|
||||
// OpenSSH ships in-box and mosh-client is bundled, so this branch
|
||||
// is hit primarily by dev builds without bundled binaries.
|
||||
const winResolved = findExecutable("mosh");
|
||||
if (winResolved && winResolved !== "mosh" && fs.existsSync(winResolved)) {
|
||||
moshCmd = winResolved;
|
||||
resolvedMoshDir = path.dirname(winResolved);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Mosh prerequisites not detected on Windows. The packaged build " +
|
||||
"ships a bundled mosh-client and uses the in-box OpenSSH client; " +
|
||||
"this dev build is missing one of them. Either install a `mosh` " +
|
||||
"wrapper (Cygwin / WSL) on PATH, point Settings → Terminal → Mosh " +
|
||||
"at an absolute mosh-client.exe / mosh wrapper path, or run a " +
|
||||
"packaged build that includes resources/mosh/win32-x64/mosh-client.exe.",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const resolved = resolvePosixExecutable("mosh", { pathOverride: mergedPathForResolution });
|
||||
if (!resolved) {
|
||||
@@ -907,6 +1237,16 @@ async function startMoshSession(event, options) {
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer the bundled mosh-client over whatever the system mosh wrapper
|
||||
// would otherwise resolve via PATH. We've vendored a known-good build
|
||||
// (see scripts/build-mosh/, .github/workflows/build-mosh-binaries.yml)
|
||||
// so distro skew on libssl / libprotobuf / libncurses can't break a
|
||||
// connection.
|
||||
if (!explicitClient && !env.MOSH_CLIENT) {
|
||||
const bundled = bundledMoshClient();
|
||||
if (bundled) env.MOSH_CLIENT = bundled;
|
||||
}
|
||||
|
||||
if (options.agentForwarding && process.env.SSH_AUTH_SOCK) {
|
||||
env.SSH_AUTH_SOCK = process.env.SSH_AUTH_SOCK;
|
||||
}
|
||||
@@ -1183,6 +1523,8 @@ function writeToSession(event, payload) {
|
||||
function resizeSession(event, payload) {
|
||||
const session = sessions.get(payload.sessionId);
|
||||
if (!session) return;
|
||||
if (Number.isFinite(payload.cols)) session.cols = payload.cols;
|
||||
if (Number.isFinite(payload.rows)) session.rows = payload.rows;
|
||||
|
||||
try {
|
||||
if (session.stream) {
|
||||
@@ -1214,6 +1556,7 @@ function resizeSession(event, payload) {
|
||||
function closeSession(event, payload) {
|
||||
const session = sessions.get(payload.sessionId);
|
||||
if (!session) return;
|
||||
session.closed = true;
|
||||
|
||||
try {
|
||||
session.zmodemSentry?.cancel();
|
||||
@@ -1368,6 +1711,50 @@ function validatePath(event, payload) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the mosh-client binary bundled by electron-builder via
|
||||
* `extraResources` (see electron-builder.config.cjs and
|
||||
* .github/workflows/build-mosh-binaries.yml).
|
||||
*
|
||||
* Returns an absolute path when the binary is on disk, otherwise null.
|
||||
* In dev / non-packaged runs the path is computed against the project
|
||||
* root so the helper is testable without packaging the app.
|
||||
*
|
||||
* Note this returns the network-protocol `mosh-client`, not the `mosh`
|
||||
* wrapper script. POSIX platforms still rely on a system `mosh` Perl
|
||||
* wrapper to orchestrate the SSH bootstrap; the bundled binary is wired
|
||||
* into that wrapper via `MOSH_CLIENT=<bundled>` so users get a known-good
|
||||
* client instead of whatever the distro happens to ship.
|
||||
*/
|
||||
function bundledMoshClient(opts = {}) {
|
||||
const isWin = (opts.platform || process.platform) === "win32";
|
||||
const basename = isWin ? "mosh-client.exe" : "mosh-client";
|
||||
|
||||
// Packaged: <Resources>/mosh/mosh-client[.exe]
|
||||
const resourcesPath = opts.resourcesPath || process.resourcesPath;
|
||||
if (resourcesPath) {
|
||||
const packaged = path.join(resourcesPath, "mosh", basename);
|
||||
if (fs.existsSync(packaged) && isExecutableFile(packaged)) return packaged;
|
||||
}
|
||||
|
||||
// Dev fallback: resources/mosh/<platform-arch>/mosh-client[.exe] under
|
||||
// the project root. Useful for `npm run start` after running
|
||||
// `npm run fetch:mosh` locally.
|
||||
const projectRoot = opts.projectRoot || path.resolve(__dirname, "..", "..");
|
||||
const platform = opts.platform || process.platform;
|
||||
const arch = opts.arch || process.arch;
|
||||
const candidates = [];
|
||||
if (platform === "darwin") {
|
||||
candidates.push(path.join(projectRoot, "resources", "mosh", "darwin-universal", basename));
|
||||
} else {
|
||||
candidates.push(path.join(projectRoot, "resources", "mosh", `${platform}-${arch}`, basename));
|
||||
}
|
||||
for (const c of candidates) {
|
||||
if (fs.existsSync(c) && isExecutableFile(c)) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the same auto-discovery startMoshSession uses, surfacing the result
|
||||
* (and the search list when nothing was found) to the Settings UI.
|
||||
@@ -1484,6 +1871,8 @@ module.exports = {
|
||||
startTelnetSession,
|
||||
startMoshSession,
|
||||
detectMoshClient,
|
||||
bundledMoshClient,
|
||||
resolveBareMoshClient,
|
||||
pickMoshClient,
|
||||
startSerialSession,
|
||||
listSerialPorts,
|
||||
|
||||
208
electron/bridges/terminalBridge.moshHandshakeSession.test.cjs
Normal file
208
electron/bridges/terminalBridge.moshHandshakeSession.test.cjs
Normal file
@@ -0,0 +1,208 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const Module = require("node:module");
|
||||
|
||||
class FakePty {
|
||||
constructor(command, args, opts) {
|
||||
this.command = command;
|
||||
this.args = args;
|
||||
this.opts = opts;
|
||||
this.pid = FakePty.nextPid += 1;
|
||||
this.dataHandlers = [];
|
||||
this.exitHandlers = [];
|
||||
this.writes = [];
|
||||
this.resizes = [];
|
||||
this.killed = false;
|
||||
}
|
||||
|
||||
onData(handler) {
|
||||
this.dataHandlers.push(handler);
|
||||
}
|
||||
|
||||
onExit(handler) {
|
||||
this.exitHandlers.push(handler);
|
||||
}
|
||||
|
||||
write(data) {
|
||||
this.writes.push(data);
|
||||
}
|
||||
|
||||
resize(cols, rows) {
|
||||
this.resizes.push({ cols, rows });
|
||||
}
|
||||
|
||||
kill() {
|
||||
this.killed = true;
|
||||
}
|
||||
|
||||
emitData(data) {
|
||||
for (const handler of this.dataHandlers) handler(data);
|
||||
}
|
||||
|
||||
emitExit(evt) {
|
||||
for (const handler of this.exitHandlers) handler(evt);
|
||||
}
|
||||
}
|
||||
FakePty.nextPid = 1000;
|
||||
|
||||
function writeExecutable(filePath) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, "#!/bin/sh\nexit 0\n");
|
||||
fs.chmodSync(filePath, 0o755);
|
||||
}
|
||||
|
||||
function loadBridgeWithFakePty(spawns) {
|
||||
const bridgePath = require.resolve("./terminalBridge.cjs");
|
||||
delete require.cache[bridgePath];
|
||||
const originalLoad = Module._load;
|
||||
Module._load = function patchedLoad(request, parent, isMain) {
|
||||
if (request === "node-pty") {
|
||||
return {
|
||||
spawn(command, args, opts) {
|
||||
const pty = new FakePty(command, args, opts);
|
||||
spawns.push(pty);
|
||||
return pty;
|
||||
},
|
||||
};
|
||||
}
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
try {
|
||||
return require("./terminalBridge.cjs");
|
||||
} finally {
|
||||
Module._load = originalLoad;
|
||||
}
|
||||
}
|
||||
|
||||
function makeHarness(t) {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-mosh-session-"));
|
||||
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
const binDir = path.join(tmp, "bin");
|
||||
const sshPath = path.join(binDir, "ssh");
|
||||
const moshClientPath = path.join(binDir, "mosh-client");
|
||||
writeExecutable(sshPath);
|
||||
writeExecutable(moshClientPath);
|
||||
|
||||
const oldPath = process.env.PATH;
|
||||
process.env.PATH = `${binDir}${path.delimiter}${oldPath || ""}`;
|
||||
t.after(() => { process.env.PATH = oldPath; });
|
||||
|
||||
const spawns = [];
|
||||
const bridge = loadBridgeWithFakePty(spawns);
|
||||
const sessions = new Map();
|
||||
const sent = [];
|
||||
bridge.init({
|
||||
sessions,
|
||||
electronModule: {
|
||||
webContents: {
|
||||
fromId() {
|
||||
return { send: (channel, payload) => sent.push({ channel, payload }) };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
bridge,
|
||||
sessions,
|
||||
sent,
|
||||
spawns,
|
||||
options: {
|
||||
sessionId: "mosh-test-session",
|
||||
hostname: "example.com",
|
||||
username: "alice",
|
||||
moshClientPath,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
},
|
||||
event: { sender: { id: 42 } },
|
||||
};
|
||||
}
|
||||
|
||||
test("startMoshSession handshake path returns the same shape as the legacy path", async (t) => {
|
||||
const h = makeHarness(t);
|
||||
const result = await h.bridge.startMoshSession(h.event, h.options);
|
||||
assert.deepEqual(result, { sessionId: "mosh-test-session" });
|
||||
});
|
||||
|
||||
test("startMoshSession handshake path honors configured PATH during discovery", async (t) => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-mosh-session-path-"));
|
||||
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
const binDir = path.join(tmp, "bin");
|
||||
const sshPath = path.join(binDir, "ssh");
|
||||
const moshClientPath = path.join(binDir, "mosh-client");
|
||||
writeExecutable(sshPath);
|
||||
writeExecutable(moshClientPath);
|
||||
|
||||
const oldPath = process.env.PATH;
|
||||
process.env.PATH = "";
|
||||
t.after(() => { process.env.PATH = oldPath; });
|
||||
|
||||
const spawns = [];
|
||||
const bridge = loadBridgeWithFakePty(spawns);
|
||||
const sessions = new Map();
|
||||
const sent = [];
|
||||
bridge.init({
|
||||
sessions,
|
||||
electronModule: {
|
||||
webContents: {
|
||||
fromId() {
|
||||
return { send: (channel, payload) => sent.push({ channel, payload }) };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await bridge.startMoshSession(
|
||||
{ sender: { id: 42 } },
|
||||
{
|
||||
sessionId: "mosh-path-session",
|
||||
hostname: "example.com",
|
||||
username: "alice",
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
env: { PATH: binDir },
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(result, { sessionId: "mosh-path-session" });
|
||||
assert.equal(spawns[0].command, sshPath);
|
||||
|
||||
spawns[0].emitData("MOSH CONNECT 60002 ABCDEFGHIJKLMNOPQRSTUV==\r\n");
|
||||
spawns[0].emitExit({ exitCode: 0, signal: 0 });
|
||||
|
||||
assert.equal(spawns[1].command, moshClientPath);
|
||||
});
|
||||
|
||||
test("startMoshSession handshake path sends the existing exit event on failure", async (t) => {
|
||||
const h = makeHarness(t);
|
||||
await h.bridge.startMoshSession(h.event, h.options);
|
||||
|
||||
h.spawns[0].emitExit({ exitCode: 255, signal: 0 });
|
||||
|
||||
const exit = h.sent.find((evt) => evt.channel === "netcatty:exit");
|
||||
assert.ok(exit);
|
||||
assert.equal(exit.payload.sessionId, "mosh-test-session");
|
||||
assert.equal(exit.payload.reason, "error");
|
||||
});
|
||||
|
||||
test("startMoshSession handshake path sends the existing exit event after mosh-client exits", async (t) => {
|
||||
const h = makeHarness(t);
|
||||
await h.bridge.startMoshSession(h.event, h.options);
|
||||
|
||||
h.spawns[0].emitData("MOSH CONNECT 60002 ABCDEFGHIJKLMNOPQRSTUV==\r\n");
|
||||
h.spawns[0].emitExit({ exitCode: 0, signal: 0 });
|
||||
|
||||
assert.equal(h.spawns.length, 2);
|
||||
h.spawns[1].emitExit({ exitCode: 0, signal: 0 });
|
||||
|
||||
const exit = h.sent.find((evt) => evt.channel === "netcatty:exit");
|
||||
assert.ok(exit);
|
||||
assert.equal(exit.payload.sessionId, "mosh-test-session");
|
||||
assert.equal(exit.payload.reason, "exited");
|
||||
});
|
||||
@@ -15,22 +15,23 @@
|
||||
"dev": "npm run lint && concurrently -k \"vite\" \"npm:dev:electron\"",
|
||||
"dev:electron": "wait-on http-get://localhost:5173 && cross-env VITE_DEV_SERVER_URL=http://localhost:5173 node electron/launch.cjs",
|
||||
"prebuild": "node scripts/copy-monaco.cjs",
|
||||
"fetch:mosh": "node scripts/fetch-mosh-binaries.cjs",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"start": "node electron/launch.cjs",
|
||||
"pack": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --publish=never",
|
||||
"pack:dir": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --dir --publish=never",
|
||||
"pack:win": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --win --publish=never",
|
||||
"pack:win": "npm run build && cross-env npm_config_arch=x64 NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --win --x64 --publish=never && cross-env npm_config_arch=arm64 NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --win --arm64 --publish=never",
|
||||
"pack:mac": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --mac --publish=never",
|
||||
"pack:linux": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --publish=never",
|
||||
"pack:linux-x64": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --x64 --publish=never",
|
||||
"pack:linux-arm64": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --arm64 --publish=never",
|
||||
"pack:linux-x64": "npm run build && cross-env npm_config_arch=x64 NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --x64 --publish=never",
|
||||
"pack:linux-arm64": "npm run build && cross-env npm_config_arch=arm64 NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --arm64 --publish=never",
|
||||
"postinstall": "electron-builder install-app-deps && patch-package",
|
||||
"rebuild": "electron-builder install-app-deps",
|
||||
"tool:cli": "node electron/cli/netcatty-tool-cli.cjs",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "node --test --import tsx electron/bridges/*.test.cjs electron/bridges/*/*.test.cjs application/state/*.test.ts components/*.test.tsx components/ai/*.test.ts components/terminal/*.test.ts components/terminal/runtime/*.test.ts domain/*.test.ts infrastructure/ai/*.test.ts"
|
||||
"test": "node --test --import tsx electron/bridges/*.test.cjs electron/bridges/*/*.test.cjs scripts/*.test.cjs application/state/*.test.ts components/*.test.tsx components/ai/*.test.ts components/terminal/*.test.ts components/terminal/runtime/*.test.ts domain/*.test.ts infrastructure/ai/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.58",
|
||||
|
||||
94
resources/mosh/README.md
Normal file
94
resources/mosh/README.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# Bundled `mosh-client`
|
||||
|
||||
This directory holds the network-protocol-only `mosh-client` binary
|
||||
bundled with the Netcatty installer. Netcatty drives the `ssh` +
|
||||
`mosh-server` bootstrap itself and then launches this bundled client
|
||||
directly (see `electron/bridges/moshHandshake.cjs` and
|
||||
`electron/bridges/terminalBridge.cjs`).
|
||||
|
||||
## How binaries land here
|
||||
|
||||
1. `.github/workflows/build-mosh-binaries.yml` builds `mosh-client` on a
|
||||
`workflow_dispatch` or `mosh-bin-*` tag push. It uses
|
||||
`scripts/build-mosh/{build-linux,build-macos,build-windows}.sh` to
|
||||
produce one binary per target from upstream `mobile-shell/mosh`
|
||||
source:
|
||||
|
||||
| target | provenance |
|
||||
|-------------------|-----------------------------------------------------------------|
|
||||
| `linux-x64` | upstream source, manylinux2014, static third-party deps + glibc |
|
||||
| `linux-arm64` | upstream source, manylinux2014, static third-party deps + glibc |
|
||||
| `darwin-universal`| upstream source, lipo arm64 + x86_64, macOS system dylibs only |
|
||||
| `win32-x64` | upstream source, Cygwin GCC, ships with bundled Cygwin DLLs |
|
||||
| `win32-arm64` | (not built — Cygwin arm64 port not yet stable) |
|
||||
|
||||
`fetch-windows.sh` is preserved as an emergency fallback that pulls
|
||||
the FluentTerminal-pinned binary; it's no longer wired into the
|
||||
default workflow.
|
||||
|
||||
2. The release built by that workflow gets a tag like
|
||||
`mosh-bin-1.4.0-1`, with `SHA256SUMS` attached.
|
||||
|
||||
3. Release packaging sets `MOSH_BIN_RELEASE=mosh-bin-1.4.0-1` and runs
|
||||
`npm run fetch:mosh` to pull the binaries into
|
||||
`resources/mosh/<platform-arch>/`. For local packaging, set
|
||||
`MOSH_BIN_RELEASE` yourself before running the same fetch command.
|
||||
`electron-builder.config.cjs` then copies the matching binary into
|
||||
`Resources/mosh/mosh-client[.exe]`.
|
||||
|
||||
The directory is otherwise empty (binaries are gitignored).
|
||||
|
||||
## Licenses
|
||||
|
||||
- Mosh itself is licensed under **GPL-3.0**
|
||||
(https://github.com/mobile-shell/mosh).
|
||||
- Netcatty is **GPL-3.0**, so redistribution as part of the installer
|
||||
is permitted.
|
||||
- The Windows binary is built in CI from upstream
|
||||
https://github.com/mobile-shell/mosh @ tag `MOSH_REF` (default
|
||||
`mosh-1.4.0`) using the Cygwin GCC toolchain. The bundled DLLs are
|
||||
redistributable Cygwin runtime libraries — see
|
||||
`mosh-client-win32-x64-dlls/README.txt` (generated by the build) for
|
||||
the per-DLL license listing.
|
||||
- Bundled/static deps (OpenSSL Apache-2.0, protobuf BSD-3-Clause,
|
||||
ncurses MIT) are compatible with GPL-3.0.
|
||||
|
||||
## Reproducible build
|
||||
|
||||
To reproduce the binaries locally:
|
||||
|
||||
```sh
|
||||
docker run --rm -v $PWD:/workspace -w /workspace \
|
||||
-e MOSH_REF=mosh-1.4.0 -e ARCH=x64 -e OUT_DIR=/workspace/out \
|
||||
quay.io/pypa/manylinux2014_x86_64 \
|
||||
bash scripts/build-mosh/build-linux.sh
|
||||
```
|
||||
|
||||
For macOS the build needs an Xcode toolchain; see
|
||||
`scripts/build-mosh/build-macos.sh`.
|
||||
|
||||
## Phase 2/3 — done in this PR
|
||||
|
||||
- `electron/bridges/moshHandshake.cjs` reimplements the upstream Mosh
|
||||
Perl wrapper in Node: parser + sniffer + command builders as pure
|
||||
functions.
|
||||
- `terminalBridge.startMoshSession` runs the SSH bootstrap in a
|
||||
node-pty so password / 2FA / known-hosts prompts render naturally
|
||||
in the user's terminal, then swaps `session.proc` from the ssh PTY
|
||||
to a freshly-spawned `mosh-client` PTY when `MOSH CONNECT` is
|
||||
detected. Keystrokes that arrive after the swap go to mosh-client
|
||||
because `writeToSession` reads `session.proc` lazily.
|
||||
- Preferred whenever a bare `mosh-client` (bundled / explicit /
|
||||
system) and `ssh` (in-box OpenSSH on Win10 1809+, system everywhere
|
||||
else) are both detectable. The legacy path through the system
|
||||
`mosh` Perl wrapper is preserved as a fallback so existing setups
|
||||
don't regress.
|
||||
- Windows binary built in-CI from upstream source via Cygwin GCC; ships
|
||||
alongside `cygwin1.dll` + transitive deps so it runs on a stock
|
||||
Windows machine without a Cygwin install.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- Cygwin arm64 port stabilizes → add a `build-windows-arm64` matrix
|
||||
leg using the same `build-windows.sh` script.
|
||||
- Make `MOSH_REF` track upstream release tags automatically.
|
||||
114
scripts/build-mosh/build-linux.sh
Executable file
114
scripts/build-mosh/build-linux.sh
Executable file
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build a portable mosh-client binary inside manylinux2014.
|
||||
#
|
||||
# Inputs (env):
|
||||
# MOSH_REF — git ref of mobile-shell/mosh to build (e.g. mosh-1.4.0)
|
||||
# ARCH — x64 | arm64 (for output naming only; container is already that arch)
|
||||
# OUT_DIR — directory to write mosh-client-linux-<arch> + sha256
|
||||
#
|
||||
# Output:
|
||||
# $OUT_DIR/mosh-client-linux-<arch>
|
||||
# $OUT_DIR/mosh-client-linux-<arch>.sha256
|
||||
#
|
||||
# Strategy: build OpenSSL, protobuf, ncurses as static archives in a
|
||||
# scratch prefix, then build mosh against those and link libstdc++/libgcc
|
||||
# statically. The resulting binary still depends on standard Linux system
|
||||
# libraries such as glibc/libz/libutil from the manylinux2014 baseline
|
||||
# (compatible with virtually every distro released since 2014, including
|
||||
# Debian 9+, Ubuntu 18.04+, CentOS 7+).
|
||||
set -euo pipefail
|
||||
|
||||
: "${MOSH_REF:?missing MOSH_REF}"
|
||||
: "${ARCH:?missing ARCH}"
|
||||
: "${OUT_DIR:?missing OUT_DIR}"
|
||||
|
||||
validate_mosh_ref() {
|
||||
if [[ ! "$MOSH_REF" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ ]] \
|
||||
|| [[ "$MOSH_REF" == *..* ]] \
|
||||
|| [[ "$MOSH_REF" == *@\{* ]] \
|
||||
|| [[ "$MOSH_REF" == */ ]] \
|
||||
|| [[ "$MOSH_REF" == *.lock ]]; then
|
||||
echo "ERROR: invalid MOSH_REF: $MOSH_REF" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
validate_mosh_ref
|
||||
|
||||
OPENSSL_VER=3.0.13
|
||||
PROTOBUF_VER=21.12
|
||||
NCURSES_VER=6.4
|
||||
|
||||
WORK=$(mktemp -d)
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
PREFIX="$WORK/prefix"
|
||||
mkdir -p "$PREFIX/lib" "$PREFIX/include" "$OUT_DIR"
|
||||
|
||||
yum install -y -q autoconf automake libtool perl perl-IPC-Cmd make gcc gcc-c++ pkgconfig zlib-devel
|
||||
|
||||
cd "$WORK"
|
||||
|
||||
# OpenSSL static
|
||||
curl -fsSL "https://www.openssl.org/source/openssl-$OPENSSL_VER.tar.gz" | tar xz
|
||||
( cd "openssl-$OPENSSL_VER"
|
||||
./config no-shared no-tests --prefix="$PREFIX" --openssldir="$PREFIX/ssl"
|
||||
make -j"$(nproc)"
|
||||
make install_sw )
|
||||
|
||||
# protobuf static (3.x stays compatible with mosh's generated proto code)
|
||||
curl -fsSL "https://github.com/protocolbuffers/protobuf/releases/download/v$PROTOBUF_VER/protobuf-cpp-3.$PROTOBUF_VER.tar.gz" | tar xz
|
||||
( cd "protobuf-3.$PROTOBUF_VER"
|
||||
./configure --prefix="$PREFIX" --enable-static --disable-shared --with-pic
|
||||
make -j"$(nproc)"
|
||||
make install )
|
||||
|
||||
# ncurses static
|
||||
curl -fsSL "https://invisible-island.net/archives/ncurses/ncurses-$NCURSES_VER.tar.gz" | tar xz
|
||||
( cd "ncurses-$NCURSES_VER"
|
||||
CFLAGS="-fPIC -O2" CXXFLAGS="-fPIC -O2" \
|
||||
./configure --prefix="$PREFIX" --without-shared --without-debug --without-cxx-shared --without-tests --disable-pc-files --enable-widec
|
||||
make -j"$(nproc)"
|
||||
make install )
|
||||
|
||||
# Mosh. Fetch the requested ref explicitly so branch names, tags, and commit
|
||||
# SHAs all work from workflow_dispatch.
|
||||
git init mosh
|
||||
git -C mosh remote add origin https://github.com/mobile-shell/mosh.git
|
||||
git -C mosh fetch --depth 1 origin "$MOSH_REF"
|
||||
git -C mosh checkout --detach FETCH_HEAD
|
||||
( cd mosh
|
||||
export PATH="$PREFIX/bin:$PATH"
|
||||
./autogen.sh
|
||||
PKG_CONFIG_PATH="$PREFIX/lib/pkgconfig:$PREFIX/lib64/pkgconfig" \
|
||||
./configure --enable-completion=no --disable-server \
|
||||
CPPFLAGS="-I$PREFIX/include -I$PREFIX/include/ncursesw" \
|
||||
CXXFLAGS="-I$PREFIX/include -I$PREFIX/include/ncursesw -O2" \
|
||||
CFLAGS="-I$PREFIX/include -I$PREFIX/include/ncursesw -O2" \
|
||||
LDFLAGS="-L$PREFIX/lib -L$PREFIX/lib64 -static-libstdc++ -static-libgcc" \
|
||||
LIBS="-ldl -lpthread"
|
||||
make -j"$(nproc)" )
|
||||
|
||||
OUT_BIN="$OUT_DIR/mosh-client-linux-$ARCH"
|
||||
cp mosh/src/frontend/mosh-client "$OUT_BIN"
|
||||
strip "$OUT_BIN"
|
||||
|
||||
echo "--- file ---"
|
||||
file "$OUT_BIN"
|
||||
echo "--- ldd ---"
|
||||
ldd "$OUT_BIN" || true
|
||||
echo "--- size ---"
|
||||
ls -lh "$OUT_BIN"
|
||||
|
||||
# Sanity check: must not link any non-system shared libraries. Allow only
|
||||
# the glibc runtime family and the ELF loader.
|
||||
ldd "$OUT_BIN" > "$WORK/ldd.txt" || true
|
||||
awk '
|
||||
/=>/ { print $1; next }
|
||||
/^[[:space:]]*\/.*ld-linux/ { print $1; next }
|
||||
' "$WORK/ldd.txt" > "$WORK/deps.txt"
|
||||
if grep -Ev '^(linux-vdso\.so\.1|lib(c|m|pthread|rt|dl|resolv|util|z)\.so\.[0-9]+|/lib.*/ld-linux.*\.so\.[0-9]+|ld-linux.*\.so\.[0-9]+)$' "$WORK/deps.txt"; then
|
||||
echo "ERROR: mosh-client links a non-system shared library; static linking failed." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
( cd "$OUT_DIR" && sha256sum "mosh-client-linux-$ARCH" > "mosh-client-linux-$ARCH.sha256" )
|
||||
cat "$OUT_DIR/mosh-client-linux-$ARCH.sha256"
|
||||
161
scripts/build-mosh/build-macos.sh
Executable file
161
scripts/build-mosh/build-macos.sh
Executable file
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build a universal2 (arm64 + x86_64) mosh-client for macOS.
|
||||
#
|
||||
# Inputs (env):
|
||||
# MOSH_REF — git ref of mobile-shell/mosh
|
||||
# OUT_DIR — destination directory
|
||||
# MACOSX_DEPLOYMENT_TARGET — minimum macOS version (default 11.0)
|
||||
#
|
||||
# Output:
|
||||
# $OUT_DIR/mosh-client-darwin-universal
|
||||
# $OUT_DIR/mosh-client-darwin-universal.sha256
|
||||
#
|
||||
# Strategy: build OpenSSL/protobuf/ncurses for arm64 and x86_64
|
||||
# (cross-compile via Apple clang's -arch flag), link mosh-client per arch,
|
||||
# then lipo the two single-arch binaries into one universal binary. The
|
||||
# final binary is allowed to depend only on macOS system dylibs.
|
||||
set -euo pipefail
|
||||
|
||||
: "${MOSH_REF:?missing MOSH_REF}"
|
||||
: "${OUT_DIR:?missing OUT_DIR}"
|
||||
|
||||
validate_mosh_ref() {
|
||||
if [[ ! "$MOSH_REF" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ ]] \
|
||||
|| [[ "$MOSH_REF" == *..* ]] \
|
||||
|| [[ "$MOSH_REF" == *@\{* ]] \
|
||||
|| [[ "$MOSH_REF" == */ ]] \
|
||||
|| [[ "$MOSH_REF" == *.lock ]]; then
|
||||
echo "ERROR: invalid MOSH_REF: $MOSH_REF" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
validate_mosh_ref
|
||||
|
||||
export MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-11.0}"
|
||||
|
||||
OPENSSL_VER=3.0.13
|
||||
PROTOBUF_VER=21.12
|
||||
NCURSES_VER=6.4
|
||||
|
||||
# Install build tools when they are not already present on the runner.
|
||||
brew list autoconf >/dev/null 2>&1 || brew install autoconf
|
||||
brew list automake >/dev/null 2>&1 || brew install automake
|
||||
brew list pkg-config >/dev/null 2>&1 || brew install pkg-config
|
||||
brew list libtool >/dev/null 2>&1 || brew install libtool
|
||||
|
||||
WORK=$(mktemp -d)
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
mkdir -p "$OUT_DIR"
|
||||
NATIVE_PROTOC_DIR=""
|
||||
|
||||
# Pre-fetch sources once.
|
||||
cd "$WORK"
|
||||
curl -fsSL "https://www.openssl.org/source/openssl-$OPENSSL_VER.tar.gz" -o openssl.tgz
|
||||
curl -fsSL "https://github.com/protocolbuffers/protobuf/releases/download/v$PROTOBUF_VER/protobuf-cpp-3.$PROTOBUF_VER.tar.gz" -o protobuf.tgz
|
||||
curl -fsSL "https://invisible-island.net/archives/ncurses/ncurses-$NCURSES_VER.tar.gz" -o ncurses.tgz
|
||||
git init mosh-src
|
||||
git -C mosh-src remote add origin https://github.com/mobile-shell/mosh.git
|
||||
git -C mosh-src fetch --depth 1 origin "$MOSH_REF"
|
||||
git -C mosh-src checkout --detach FETCH_HEAD
|
||||
|
||||
build_arch() {
|
||||
local ARCH="$1"
|
||||
local TRIPLE
|
||||
case "$ARCH" in
|
||||
arm64) TRIPLE=aarch64-apple-darwin ;;
|
||||
x86_64) TRIPLE=x86_64-apple-darwin ;;
|
||||
*) echo "unknown arch: $ARCH" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
local PREFIX="$WORK/prefix-$ARCH"
|
||||
mkdir -p "$PREFIX"
|
||||
|
||||
local CFLAGS_COMMON="-arch $ARCH -mmacosx-version-min=$MACOSX_DEPLOYMENT_TARGET -O2"
|
||||
local LDFLAGS_COMMON="-arch $ARCH -mmacosx-version-min=$MACOSX_DEPLOYMENT_TARGET"
|
||||
|
||||
# OpenSSL
|
||||
rm -rf "openssl-$OPENSSL_VER"
|
||||
tar xf openssl.tgz
|
||||
( cd "openssl-$OPENSSL_VER"
|
||||
if [ "$ARCH" = "arm64" ]; then
|
||||
./Configure darwin64-arm64-cc no-shared no-tests --prefix="$PREFIX" --openssldir="$PREFIX/ssl" -mmacosx-version-min=$MACOSX_DEPLOYMENT_TARGET
|
||||
else
|
||||
./Configure darwin64-x86_64-cc no-shared no-tests --prefix="$PREFIX" --openssldir="$PREFIX/ssl" -mmacosx-version-min=$MACOSX_DEPLOYMENT_TARGET
|
||||
fi
|
||||
make -j"$(sysctl -n hw.ncpu)"
|
||||
make install_sw )
|
||||
|
||||
# protobuf
|
||||
rm -rf "protobuf-3.$PROTOBUF_VER"
|
||||
tar xf protobuf.tgz
|
||||
( cd "protobuf-3.$PROTOBUF_VER"
|
||||
./configure --prefix="$PREFIX" --enable-static --disable-shared --with-pic --host="$TRIPLE" \
|
||||
CXX="clang++" CC="clang" \
|
||||
CFLAGS="$CFLAGS_COMMON" CXXFLAGS="$CFLAGS_COMMON" LDFLAGS="$LDFLAGS_COMMON"
|
||||
# protoc must run on the host (not the cross-target) — but here host arch is one of the two,
|
||||
# so this works directly when ARCH matches the runner. For the *other* arch we reuse the
|
||||
# protoc compiled in the first pass via PATH.
|
||||
make -j"$(sysctl -n hw.ncpu)" || make -j1
|
||||
make install )
|
||||
if [ "$ARCH" = "$NATIVE_ARCH" ]; then
|
||||
NATIVE_PROTOC_DIR="$PREFIX/bin"
|
||||
fi
|
||||
|
||||
# ncurses
|
||||
rm -rf "ncurses-$NCURSES_VER"
|
||||
tar xf ncurses.tgz
|
||||
( cd "ncurses-$NCURSES_VER"
|
||||
./configure --prefix="$PREFIX" --without-shared --without-debug --without-cxx-shared --without-tests --disable-pc-files --enable-widec --host="$TRIPLE" \
|
||||
CC="clang" CXX="clang++" \
|
||||
CFLAGS="$CFLAGS_COMMON" CXXFLAGS="$CFLAGS_COMMON" LDFLAGS="$LDFLAGS_COMMON"
|
||||
make -j"$(sysctl -n hw.ncpu)"
|
||||
make -C include install
|
||||
make -C ncurses install )
|
||||
|
||||
# mosh per-arch build
|
||||
( cd mosh-src
|
||||
make distclean >/dev/null 2>&1 || true
|
||||
export PATH="${NATIVE_PROTOC_DIR:-$PREFIX/bin}:$PATH"
|
||||
./autogen.sh
|
||||
PKG_CONFIG_PATH="$PREFIX/lib/pkgconfig" \
|
||||
./configure --enable-completion=no --disable-server --host="$TRIPLE" \
|
||||
CXX="clang++" CC="clang" \
|
||||
CPPFLAGS="-I$PREFIX/include -I$PREFIX/include/ncursesw" \
|
||||
CXXFLAGS="-I$PREFIX/include -I$PREFIX/include/ncursesw $CFLAGS_COMMON" \
|
||||
CFLAGS="-I$PREFIX/include -I$PREFIX/include/ncursesw $CFLAGS_COMMON" \
|
||||
LDFLAGS="-L$PREFIX/lib $LDFLAGS_COMMON"
|
||||
make -j"$(sysctl -n hw.ncpu)"
|
||||
cp src/frontend/mosh-client "$WORK/mosh-client-$ARCH" )
|
||||
}
|
||||
|
||||
# Build host arch first so the first protobuf pass can use a native protoc.
|
||||
NATIVE_ARCH=$(uname -m)
|
||||
if [ "$NATIVE_ARCH" = "arm64" ]; then
|
||||
build_arch arm64
|
||||
build_arch x86_64
|
||||
else
|
||||
build_arch x86_64
|
||||
build_arch arm64
|
||||
fi
|
||||
|
||||
OUT_BIN="$OUT_DIR/mosh-client-darwin-universal"
|
||||
lipo -create "$WORK/mosh-client-arm64" "$WORK/mosh-client-x86_64" -output "$OUT_BIN"
|
||||
strip -x "$OUT_BIN" || true
|
||||
|
||||
echo "--- file ---"
|
||||
file "$OUT_BIN"
|
||||
echo "--- otool -L ---"
|
||||
otool -L "$OUT_BIN"
|
||||
echo "--- lipo -info ---"
|
||||
lipo -info "$OUT_BIN"
|
||||
echo "--- size ---"
|
||||
ls -lh "$OUT_BIN"
|
||||
|
||||
# Sanity check: must not depend on non-system dylibs.
|
||||
if otool -L "$OUT_BIN" | tail -n +2 | awk '{print $1}' | grep -Ev "^(/usr/lib/|/System/)"; then
|
||||
echo "ERROR: mosh-client links a non-system dylib; static linking failed." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
( cd "$OUT_DIR" && shasum -a 256 "mosh-client-darwin-universal" > "mosh-client-darwin-universal.sha256" )
|
||||
cat "$OUT_DIR/mosh-client-darwin-universal.sha256"
|
||||
157
scripts/build-mosh/build-windows.sh
Executable file
157
scripts/build-mosh/build-windows.sh
Executable file
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build mosh-client.exe from upstream mobile-shell/mosh source inside a
|
||||
# Cygwin environment. Phase 1 pinned a third-party prebuilt
|
||||
# (FluentTerminal); this rebuilds it in CI so we own the provenance
|
||||
# end-to-end and ship the same upstream version everywhere.
|
||||
#
|
||||
# Cygwin doesn't make full static linking practical (cygwin1.dll
|
||||
# implements the POSIX runtime; it must be present at runtime), so we
|
||||
# bundle every required Cygwin DLL alongside `mosh-client.exe`. This
|
||||
# keeps the binary reproducible and self-contained — the only
|
||||
# environmental requirement is the Cygwin Project's GPL-3.0 DLLs, all
|
||||
# of which we redistribute under their respective licenses.
|
||||
#
|
||||
# Inputs (env):
|
||||
# MOSH_REF — git ref of mobile-shell/mosh (e.g. mosh-1.4.0)
|
||||
# ARCH — x64 (only — Cygwin's arm64 port isn't release-ready)
|
||||
# OUT_DIR — directory to write mosh-client-win32-<arch>.exe + DLL bundle
|
||||
#
|
||||
# Output:
|
||||
# $OUT_DIR/mosh-client-win32-<arch>.exe
|
||||
# $OUT_DIR/mosh-client-win32-<arch>-dlls/*.dll
|
||||
# $OUT_DIR/mosh-client-win32-<arch>.sha256
|
||||
#
|
||||
# Expected to run inside a Cygwin bash login shell (set up by the CI's
|
||||
# cygwin-install-action with development packages already installed).
|
||||
set -euo pipefail
|
||||
|
||||
: "${MOSH_REF:?missing MOSH_REF}"
|
||||
: "${ARCH:?missing ARCH}"
|
||||
: "${OUT_DIR:?missing OUT_DIR}"
|
||||
|
||||
validate_mosh_ref() {
|
||||
if [[ ! "$MOSH_REF" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ ]] \
|
||||
|| [[ "$MOSH_REF" == *..* ]] \
|
||||
|| [[ "$MOSH_REF" == *@\{* ]] \
|
||||
|| [[ "$MOSH_REF" == */ ]] \
|
||||
|| [[ "$MOSH_REF" == *.lock ]]; then
|
||||
echo "ERROR: invalid MOSH_REF: $MOSH_REF" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
validate_mosh_ref
|
||||
|
||||
if [ "$ARCH" != "x64" ]; then
|
||||
echo "ERROR: only ARCH=x64 supported by the Cygwin Windows build (got: $ARCH)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Sanity: must run under Cygwin so we have access to cygcheck and the
|
||||
# Cygwin gcc toolchain.
|
||||
if ! uname -a | grep -qi CYGWIN; then
|
||||
echo "ERROR: build-windows.sh must run inside a Cygwin shell." >&2
|
||||
uname -a >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
WORK=$(mktemp -d)
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
mkdir -p "$OUT_DIR"
|
||||
|
||||
cd "$WORK"
|
||||
|
||||
# Build mosh against the Cygwin-supplied OpenSSL, protobuf, ncurses.
|
||||
# Static linking against those is not supported by the upstream
|
||||
# build for Cygwin, so we accept the dynamic deps and bundle the DLLs.
|
||||
git init mosh
|
||||
git -C mosh remote add origin https://github.com/mobile-shell/mosh.git
|
||||
git -C mosh fetch --depth 1 origin "$MOSH_REF"
|
||||
git -C mosh checkout --detach FETCH_HEAD
|
||||
cd mosh
|
||||
./autogen.sh
|
||||
./configure --enable-completion=no --disable-server \
|
||||
CXXFLAGS="-O2 -static-libgcc -static-libstdc++" \
|
||||
LDFLAGS="-static-libgcc -static-libstdc++"
|
||||
make -j"$(nproc)"
|
||||
|
||||
OUT_EXE="$OUT_DIR/mosh-client-win32-x64.exe"
|
||||
DLL_DIR="$OUT_DIR/mosh-client-win32-x64-dlls"
|
||||
mkdir -p "$DLL_DIR"
|
||||
cp src/frontend/mosh-client.exe "$OUT_EXE"
|
||||
strip "$OUT_EXE"
|
||||
|
||||
echo "--- file ---"
|
||||
file "$OUT_EXE"
|
||||
echo "--- size ---"
|
||||
ls -lh "$OUT_EXE"
|
||||
|
||||
# Walk the import graph via cygcheck and copy every Cygwin-shipped DLL
|
||||
# (paths that normalize to /usr/bin/) so the binary runs anywhere without
|
||||
# an external Cygwin install.
|
||||
echo "--- cygcheck ---"
|
||||
CYGCHECK_OUT="$WORK/cygcheck.txt"
|
||||
cygcheck "$OUT_EXE" | tee "$CYGCHECK_OUT"
|
||||
bundled_count=0
|
||||
while IFS= read -r line; do
|
||||
candidate=$(printf '%s' "$line" | tr -d '\r' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
|
||||
case "$candidate" in
|
||||
*.dll|*.DLL)
|
||||
# Convert Windows-style paths to Cygwin paths if present.
|
||||
cyg_candidate=$(cygpath -u "$candidate" 2>/dev/null || echo "$candidate")
|
||||
case "$cyg_candidate" in
|
||||
/usr/bin/*.dll|/usr/bin/*.DLL)
|
||||
if [ -f "$cyg_candidate" ]; then
|
||||
base=$(basename "$cyg_candidate")
|
||||
if [ ! -f "$DLL_DIR/$base" ]; then
|
||||
cp "$cyg_candidate" "$DLL_DIR/$base"
|
||||
echo "bundled DLL: $base"
|
||||
bundled_count=$((bundled_count + 1))
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
done < "$CYGCHECK_OUT"
|
||||
|
||||
if [ "$bundled_count" -eq 0 ] || [ ! -f "$DLL_DIR/cygwin1.dll" ]; then
|
||||
echo "ERROR: failed to bundle required Cygwin DLLs for mosh-client.exe." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "--- bundled DLLs ---"
|
||||
ls -lh "$DLL_DIR"
|
||||
|
||||
# License: the Cygwin DLLs ship under various GPL-compatible licenses.
|
||||
# Ship a top-level NOTICE so end users can see what we redistributed.
|
||||
cat > "$DLL_DIR/README.txt" <<'EOF'
|
||||
This directory bundles the Cygwin runtime DLLs required by
|
||||
mosh-client.exe (built from https://github.com/mobile-shell/mosh ).
|
||||
|
||||
cygwin1.dll : LGPL-3.0 (Cygwin Project, https://cygwin.com/)
|
||||
cygcrypto-*.dll : Apache-2.0 (OpenSSL Project, https://www.openssl.org/)
|
||||
cygprotobuf-*.dll : BSD-3-Clause (Google, https://github.com/protocolbuffers/protobuf)
|
||||
cygncursesw-*.dll : MIT-style (Free Software Foundation)
|
||||
cygintl-*.dll : LGPL-2.1 (GNU gettext)
|
||||
cyggcc_s-*.dll, cygstdc++ : GPL-3.0 with GCC Runtime Library Exception
|
||||
|
||||
The full text of each license is reproduced in the upstream source
|
||||
tree of the respective project.
|
||||
EOF
|
||||
|
||||
# Bundle exe + DLLs into a single tar.gz artifact for distribution.
|
||||
# fetch-mosh-binaries.cjs unpacks the tarball into the local
|
||||
# resources/mosh/win32-x64/ directory.
|
||||
BUNDLE_TGZ="$OUT_DIR/mosh-client-win32-x64.tar.gz"
|
||||
BUNDLE_DIR="$WORK/win32-x64-bundle"
|
||||
mkdir -p "$BUNDLE_DIR"
|
||||
cp "$OUT_EXE" "$BUNDLE_DIR/mosh-client.exe"
|
||||
cp -R "$DLL_DIR" "$BUNDLE_DIR/mosh-client-win32-x64-dlls"
|
||||
( cd "$BUNDLE_DIR" && tar -czf "$BUNDLE_TGZ" \
|
||||
"mosh-client.exe" \
|
||||
"mosh-client-win32-x64-dlls" )
|
||||
|
||||
( cd "$OUT_DIR" && sha256sum "mosh-client-win32-x64.exe" > "mosh-client-win32-x64.sha256" )
|
||||
( cd "$OUT_DIR" && sha256sum "mosh-client-win32-x64.tar.gz" > "mosh-client-win32-x64.tar.gz.sha256" )
|
||||
cat "$OUT_DIR/mosh-client-win32-x64.sha256"
|
||||
cat "$OUT_DIR/mosh-client-win32-x64.tar.gz.sha256"
|
||||
41
scripts/build-mosh/fetch-windows.sh
Executable file
41
scripts/build-mosh/fetch-windows.sh
Executable file
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
# Phase-1 source: pin to the FluentTerminal-shipped mosh-cygwin standalone
|
||||
# build (PE32+ x86-64, statically linked Cygwin runtime, no cygwin1.dll
|
||||
# dependency). FluentTerminal is GPL-3.0 — same license as netcatty —
|
||||
# and the binary itself is GPL-3.0 from upstream mobile-shell/mosh.
|
||||
#
|
||||
# Phase-2 replaced this fetch with an in-CI Cygwin build from upstream
|
||||
# source so we own the provenance end-to-end.
|
||||
#
|
||||
# The pinned commit is FluentTerminal master @ bad0f85 (2019-09-12), which
|
||||
# is the commit where the prebuilt mosh-client.exe was added to the repo.
|
||||
# Verifying SHA256 against a frozen value protects against silent updates.
|
||||
#
|
||||
# Inputs (env): OUT_DIR
|
||||
# Output: $OUT_DIR/mosh-client-win32-x64.exe (+ .sha256)
|
||||
set -euo pipefail
|
||||
|
||||
: "${OUT_DIR:?missing OUT_DIR}"
|
||||
|
||||
# Pin: github.com/felixse/FluentTerminal commit bad0f85,
|
||||
# Dependencies/MoshExecutables/x64/mosh-client.exe.
|
||||
SOURCE_URL="https://raw.githubusercontent.com/felixse/FluentTerminal/bad0f85/Dependencies/MoshExecutables/x64/mosh-client.exe"
|
||||
EXPECTED_SHA256="5a8d84ff205c6a0711e53b961f909484a892f42648807e52d46d4fa93c05e286"
|
||||
|
||||
mkdir -p "$OUT_DIR"
|
||||
OUT="$OUT_DIR/mosh-client-win32-x64.exe"
|
||||
|
||||
curl -fsSL "$SOURCE_URL" -o "$OUT"
|
||||
ACTUAL=$(sha256sum "$OUT" | awk '{print $1}')
|
||||
|
||||
if [ "$ACTUAL" != "$EXPECTED_SHA256" ]; then
|
||||
echo "ERROR: SHA256 mismatch for mosh-client.exe" >&2
|
||||
echo " expected: $EXPECTED_SHA256" >&2
|
||||
echo " actual: $ACTUAL" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Fetched mosh-client.exe (sha256=$ACTUAL)."
|
||||
ls -lh "$OUT"
|
||||
echo "$ACTUAL mosh-client-win32-x64.exe" > "$OUT.sha256"
|
||||
cat "$OUT.sha256"
|
||||
283
scripts/fetch-mosh-binaries.cjs
Executable file
283
scripts/fetch-mosh-binaries.cjs
Executable file
@@ -0,0 +1,283 @@
|
||||
#!/usr/bin/env node
|
||||
/* eslint-disable no-console */
|
||||
//
|
||||
// Download platform-specific mosh-client binaries built by the
|
||||
// `build-mosh-binaries` GitHub Actions workflow into resources/mosh/, so
|
||||
// electron-builder can bundle them via `extraResources`. Designed to be
|
||||
// idempotent and safe to skip in dev / CI matrix legs that don't ship
|
||||
// mosh (e.g. when MOSH_BIN_RELEASE is unset).
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/fetch-mosh-binaries.cjs # all platforms
|
||||
// node scripts/fetch-mosh-binaries.cjs --platform=darwin --arch=universal
|
||||
//
|
||||
// Env knobs:
|
||||
// MOSH_BIN_RELEASE — release tag in ${MOSH_BIN_OWNER}/${MOSH_BIN_REPO}.
|
||||
// Skip the whole step if unset (printed as a notice
|
||||
// so the build doesn't silently miss the bundling).
|
||||
// MOSH_BIN_OWNER — default 'binaricat'
|
||||
// MOSH_BIN_REPO — default 'Netcatty' (binaries attached to a
|
||||
// dedicated tag in the netcatty repo to keep
|
||||
// provenance auditable).
|
||||
// MOSH_BIN_BASE_URL — full override (e.g. for staging / local mirror).
|
||||
// MOSH_BIN_RES_DIR — override output dir for tests.
|
||||
// MOSH_BIN_ALLOW_UNVERIFIED=true — explicit local escape hatch for mirrors
|
||||
// without SHA256SUMS. Never use for release builds.
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const http = require("node:http");
|
||||
const https = require("node:https");
|
||||
const os = require("node:os");
|
||||
const crypto = require("node:crypto");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const ROOT = path.resolve(__dirname, "..");
|
||||
const DEFAULT_RES_DIR = path.join(ROOT, "resources", "mosh");
|
||||
|
||||
// (file basename in the release -> relative subpath under resources/mosh/)
|
||||
// Using flat names in the release for SHA256SUMS readability, then
|
||||
// fanning out into platform-arch subdirs locally.
|
||||
//
|
||||
// `extract` indicates a tar.gz archive containing the binary + helper
|
||||
// DLLs (Windows). The tarball is unpacked into the platform-arch
|
||||
// directory so resources/mosh/win32-x64/ ends up with mosh-client.exe
|
||||
// alongside cygwin1.dll, cygcrypto-*.dll, etc.
|
||||
const TARGETS = [
|
||||
{ platform: "linux", arch: "x64", file: "mosh-client-linux-x64", local: "linux-x64/mosh-client" },
|
||||
{ platform: "linux", arch: "arm64", file: "mosh-client-linux-arm64", local: "linux-arm64/mosh-client" },
|
||||
{ platform: "darwin", arch: "universal", file: "mosh-client-darwin-universal", local: "darwin-universal/mosh-client" },
|
||||
{ platform: "win32", arch: "x64", file: "mosh-client-win32-x64.tar.gz", localDir: "win32-x64", extract: "tar.gz" },
|
||||
];
|
||||
|
||||
function log(msg) { console.log(`[fetch-mosh-binaries] ${msg}`); }
|
||||
function warn(msg) { console.warn(`[fetch-mosh-binaries] WARN ${msg}`); }
|
||||
|
||||
function transferFor(url) {
|
||||
const protocol = new URL(url).protocol;
|
||||
if (protocol === "https:") return https;
|
||||
if (protocol === "http:") return http;
|
||||
throw new Error(`unsupported protocol for ${url}`);
|
||||
}
|
||||
|
||||
function follow(url, depth = 0) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (depth > 5) return reject(new Error("too many redirects"));
|
||||
transferFor(url).get(url, (res) => {
|
||||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
res.resume();
|
||||
resolve(follow(new URL(res.headers.location, url).toString(), depth + 1));
|
||||
return;
|
||||
}
|
||||
if (res.statusCode !== 200) {
|
||||
res.resume();
|
||||
reject(new Error(`HTTP ${res.statusCode} for ${url}`));
|
||||
return;
|
||||
}
|
||||
const chunks = [];
|
||||
res.on("data", (c) => chunks.push(c));
|
||||
res.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
res.on("error", reject);
|
||||
}).on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function parseSums(text) {
|
||||
const map = new Map();
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const m = line.match(/^([0-9a-f]{64})\s+\*?\s*(\S+)\s*$/i);
|
||||
if (m) map.set(m[2], m[1].toLowerCase());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async function fetchSums(baseUrl, { allowUnverified = false } = {}) {
|
||||
try {
|
||||
const buf = await follow(`${baseUrl}/SHA256SUMS`);
|
||||
return parseSums(buf.toString("utf8"));
|
||||
} catch (err) {
|
||||
if (allowUnverified) {
|
||||
warn(`could not fetch SHA256SUMS from ${baseUrl} (${err.message})`);
|
||||
return new Map();
|
||||
}
|
||||
throw new Error(`could not fetch SHA256SUMS from ${baseUrl} (${err.message})`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertSafeTarEntry(entry) {
|
||||
const name = entry.trim();
|
||||
if (!name) throw new Error("tarball contains an empty entry name");
|
||||
if (name.startsWith("/") || name.startsWith("\\") || /^[A-Za-z]:/.test(name)) {
|
||||
throw new Error(`tarball contains an absolute path: ${name}`);
|
||||
}
|
||||
if (name.includes("\\")) {
|
||||
throw new Error(`tarball contains a Windows-style path: ${name}`);
|
||||
}
|
||||
const parts = name.split("/");
|
||||
if (parts.includes("..")) {
|
||||
throw new Error(`tarball contains a parent-directory path: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
function listTarEntries(archivePath) {
|
||||
const out = execFileSync("tar", ["-tzf", archivePath], { encoding: "utf8" });
|
||||
return out.split(/\r?\n/).filter(Boolean);
|
||||
}
|
||||
|
||||
function validateTarEntries(entries) {
|
||||
if (entries.length === 0) throw new Error("tarball is empty");
|
||||
for (const entry of entries) assertSafeTarEntry(entry);
|
||||
}
|
||||
|
||||
function chmodExecutable(filePath) {
|
||||
if (process.platform !== "win32" && fs.existsSync(filePath) && !fs.lstatSync(filePath).isSymbolicLink()) {
|
||||
try { fs.chmodSync(filePath, 0o755); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
function assertExtractedTreeSafe(root) {
|
||||
const stack = [root];
|
||||
while (stack.length > 0) {
|
||||
const dir = stack.pop();
|
||||
for (const name of fs.readdirSync(dir)) {
|
||||
const file = path.join(dir, name);
|
||||
const stat = fs.lstatSync(file);
|
||||
if (stat.isSymbolicLink()) {
|
||||
throw new Error(`tarball contains a symbolic link: ${path.relative(root, file)}`);
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
stack.push(file);
|
||||
continue;
|
||||
}
|
||||
if (!stat.isFile()) {
|
||||
throw new Error(`tarball contains an unsupported file type: ${path.relative(root, file)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeWindowsBundle(extractDir, target) {
|
||||
const genericExe = path.join(extractDir, "mosh-client.exe");
|
||||
const legacyExe = path.join(extractDir, `mosh-client-${target.platform}-${target.arch}.exe`);
|
||||
if (!fs.existsSync(genericExe) && fs.existsSync(legacyExe)) {
|
||||
fs.renameSync(legacyExe, genericExe);
|
||||
}
|
||||
if (!fs.existsSync(genericExe) || !fs.lstatSync(genericExe).isFile()) {
|
||||
throw new Error(`${target.file} did not contain mosh-client.exe`);
|
||||
}
|
||||
const dllDir = path.join(extractDir, `mosh-client-${target.platform}-${target.arch}-dlls`);
|
||||
if (!fs.existsSync(dllDir) || !fs.statSync(dllDir).isDirectory()) {
|
||||
throw new Error(`${target.file} did not contain ${path.basename(dllDir)}/`);
|
||||
}
|
||||
chmodExecutable(genericExe);
|
||||
}
|
||||
|
||||
function replaceDir(srcDir, destDir) {
|
||||
fs.rmSync(destDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(path.dirname(destDir), { recursive: true });
|
||||
fs.renameSync(srcDir, destDir);
|
||||
}
|
||||
|
||||
function unpackTarGz(buf, target, { resDir }) {
|
||||
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-mosh-"));
|
||||
const archive = path.join(tmpRoot, "bundle.tar.gz");
|
||||
const extractDir = path.join(tmpRoot, "extract");
|
||||
const destDir = path.join(resDir, target.localDir);
|
||||
fs.mkdirSync(extractDir, { recursive: true });
|
||||
try {
|
||||
fs.writeFileSync(archive, buf);
|
||||
validateTarEntries(listTarEntries(archive));
|
||||
execFileSync("tar", ["-xzf", archive, "-C", extractDir], { stdio: "inherit" });
|
||||
assertExtractedTreeSafe(extractDir);
|
||||
if (target.platform === "win32") {
|
||||
normalizeWindowsBundle(extractDir, target);
|
||||
}
|
||||
replaceDir(extractDir, destDir);
|
||||
} finally {
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
}
|
||||
return destDir;
|
||||
}
|
||||
|
||||
async function fetchOne(target, sums, opts) {
|
||||
const { baseUrl, resDir, allowUnverified = false } = opts;
|
||||
const url = `${baseUrl}/${target.file}`;
|
||||
let buf;
|
||||
try {
|
||||
buf = await follow(url);
|
||||
} catch (err) {
|
||||
throw new Error(`download failed for ${target.file}: ${err.message}`);
|
||||
}
|
||||
|
||||
const expected = sums.get(target.file);
|
||||
const actual = crypto.createHash("sha256").update(buf).digest("hex");
|
||||
if (expected && expected !== actual) {
|
||||
throw new Error(`SHA256 mismatch for ${target.file}: expected ${expected}, got ${actual}`);
|
||||
}
|
||||
if (!expected) {
|
||||
if (!allowUnverified) {
|
||||
throw new Error(`no SHA256 entry for ${target.file}`);
|
||||
}
|
||||
warn(`no SHA256 entry for ${target.file} - accepting actual ${actual}`);
|
||||
}
|
||||
|
||||
if (target.extract === "tar.gz") {
|
||||
const destDir = unpackTarGz(buf, target, { resDir });
|
||||
log(`unpacked ${target.file} into ${path.relative(ROOT, destDir)}/ (sha256=${actual})`);
|
||||
return true;
|
||||
}
|
||||
|
||||
const dest = path.join(resDir, target.local);
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
fs.writeFileSync(dest, buf);
|
||||
if (target.platform !== "win32") fs.chmodSync(dest, 0o755);
|
||||
log(`wrote ${path.relative(ROOT, dest)} (${buf.length} bytes, sha256=${actual})`);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function main(argv = process.argv.slice(2), env = process.env) {
|
||||
const release = env.MOSH_BIN_RELEASE;
|
||||
if (!release) {
|
||||
log("MOSH_BIN_RELEASE is unset - skipping. Set it (e.g. mosh-bin-1.4.0-1) to bundle mosh-client into the package.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
const owner = env.MOSH_BIN_OWNER || "binaricat";
|
||||
const repo = env.MOSH_BIN_REPO || "Netcatty";
|
||||
const baseUrl = env.MOSH_BIN_BASE_URL ||
|
||||
`https://github.com/${owner}/${repo}/releases/download/${encodeURIComponent(release)}`;
|
||||
const resDir = path.resolve(env.MOSH_BIN_RES_DIR || DEFAULT_RES_DIR);
|
||||
const allowUnverified = env.MOSH_BIN_ALLOW_UNVERIFIED === "true";
|
||||
const platformFilter = (argv.find((a) => a.startsWith("--platform=")) || "").split("=")[1];
|
||||
const archFilter = (argv.find((a) => a.startsWith("--arch=")) || "").split("=")[1];
|
||||
|
||||
log(`release=${release} owner=${owner} repo=${repo}`);
|
||||
const sums = await fetchSums(baseUrl, { allowUnverified });
|
||||
let ok = 0;
|
||||
let total = 0;
|
||||
for (const target of TARGETS) {
|
||||
if (platformFilter && target.platform !== platformFilter) continue;
|
||||
if (archFilter && target.arch !== archFilter) continue;
|
||||
total += 1;
|
||||
if (await fetchOne(target, sums, { baseUrl, resDir, allowUnverified })) ok += 1;
|
||||
}
|
||||
log(`done - ${ok}/${total} binaries written`);
|
||||
if (ok < total) throw new Error(`only wrote ${ok}/${total} requested binaries`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((err) => {
|
||||
console.error(`[fetch-mosh-binaries] FATAL ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
TARGETS,
|
||||
parseSums,
|
||||
validateTarEntries,
|
||||
assertExtractedTreeSafe,
|
||||
unpackTarGz,
|
||||
main,
|
||||
};
|
||||
130
scripts/fetch-mosh-binaries.test.cjs
Normal file
130
scripts/fetch-mosh-binaries.test.cjs
Normal file
@@ -0,0 +1,130 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const http = require("node:http");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { execFile, execFileSync } = require("node:child_process");
|
||||
const { promisify } = require("node:util");
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
const script = path.resolve(__dirname, "fetch-mosh-binaries.cjs");
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
function makeTmp(t) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-fetch-mosh-"));
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
return dir;
|
||||
}
|
||||
|
||||
function sha256(buf) {
|
||||
return crypto.createHash("sha256").update(buf).digest("hex");
|
||||
}
|
||||
|
||||
function makeTarGz(t, entries) {
|
||||
const dir = makeTmp(t);
|
||||
for (const [name, contents] of Object.entries(entries)) {
|
||||
const file = path.join(dir, name);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, contents);
|
||||
}
|
||||
const tarPath = path.join(makeTmp(t), "bundle.tar.gz");
|
||||
execFileSync("tar", ["-czf", tarPath, "-C", dir, "."], { stdio: "pipe" });
|
||||
return fs.readFileSync(tarPath);
|
||||
}
|
||||
|
||||
async function serveAssets(t, assets) {
|
||||
const server = http.createServer((req, res) => {
|
||||
const name = decodeURIComponent(req.url.split("/").pop());
|
||||
if (!Object.prototype.hasOwnProperty.call(assets, name)) {
|
||||
res.writeHead(404);
|
||||
res.end("missing");
|
||||
return;
|
||||
}
|
||||
res.writeHead(200);
|
||||
res.end(assets[name]);
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
t.after(() => server.close());
|
||||
return `http://127.0.0.1:${server.address().port}`;
|
||||
}
|
||||
|
||||
test("fetch-mosh-binaries normalizes the Windows tarball to mosh-client.exe", async (t) => {
|
||||
const resDir = path.join(makeTmp(t), "resources", "mosh");
|
||||
const tar = makeTarGz(t, {
|
||||
"mosh-client-win32-x64.exe": "exe",
|
||||
"mosh-client-win32-x64-dlls/cygwin1.dll": "dll",
|
||||
});
|
||||
const baseUrl = await serveAssets(t, {
|
||||
"mosh-client-win32-x64.tar.gz": tar,
|
||||
SHA256SUMS: `${sha256(tar)} mosh-client-win32-x64.tar.gz\n`,
|
||||
});
|
||||
|
||||
await execFileAsync(process.execPath, [script, "--platform=win32", "--arch=x64"], {
|
||||
env: {
|
||||
...process.env,
|
||||
MOSH_BIN_RELEASE: "test",
|
||||
MOSH_BIN_BASE_URL: baseUrl,
|
||||
MOSH_BIN_RES_DIR: resDir,
|
||||
CI: "true",
|
||||
},
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
assert.equal(fs.existsSync(path.join(resDir, "win32-x64", "mosh-client.exe")), true);
|
||||
assert.equal(fs.existsSync(path.join(resDir, "win32-x64", "mosh-client-win32-x64-dlls", "cygwin1.dll")), true);
|
||||
});
|
||||
|
||||
test("fetch-mosh-binaries fails when SHA256SUMS lacks the requested asset", async (t) => {
|
||||
const resDir = path.join(makeTmp(t), "resources", "mosh");
|
||||
const tar = makeTarGz(t, {
|
||||
"mosh-client.exe": "exe",
|
||||
"mosh-client-win32-x64-dlls/cygwin1.dll": "dll",
|
||||
});
|
||||
const baseUrl = await serveAssets(t, {
|
||||
"mosh-client-win32-x64.tar.gz": tar,
|
||||
SHA256SUMS: `${sha256(Buffer.from("other"))} other-file\n`,
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
execFileAsync(process.execPath, [script, "--platform=win32", "--arch=x64"], {
|
||||
env: {
|
||||
...process.env,
|
||||
MOSH_BIN_RELEASE: "test",
|
||||
MOSH_BIN_BASE_URL: baseUrl,
|
||||
MOSH_BIN_RES_DIR: resDir,
|
||||
CI: "true",
|
||||
},
|
||||
stdio: "pipe",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("fetch-mosh-binaries rejects symlinks inside Windows tarballs", { skip: process.platform === "win32" }, async (t) => {
|
||||
const srcDir = makeTmp(t);
|
||||
fs.writeFileSync(path.join(srcDir, "outside.exe"), "outside");
|
||||
fs.symlinkSync(path.join(srcDir, "outside.exe"), path.join(srcDir, "mosh-client.exe"));
|
||||
fs.mkdirSync(path.join(srcDir, "mosh-client-win32-x64-dlls"));
|
||||
fs.writeFileSync(path.join(srcDir, "mosh-client-win32-x64-dlls", "cygwin1.dll"), "dll");
|
||||
const tarPath = path.join(makeTmp(t), "symlink.tar.gz");
|
||||
execFileSync("tar", ["-czf", tarPath, "-C", srcDir, "mosh-client.exe", "mosh-client-win32-x64-dlls"], { stdio: "pipe" });
|
||||
const tar = fs.readFileSync(tarPath);
|
||||
const baseUrl = await serveAssets(t, {
|
||||
"mosh-client-win32-x64.tar.gz": tar,
|
||||
SHA256SUMS: `${sha256(tar)} mosh-client-win32-x64.tar.gz\n`,
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
execFileAsync(process.execPath, [script, "--platform=win32", "--arch=x64"], {
|
||||
env: {
|
||||
...process.env,
|
||||
MOSH_BIN_RELEASE: "test",
|
||||
MOSH_BIN_BASE_URL: baseUrl,
|
||||
MOSH_BIN_RES_DIR: path.join(makeTmp(t), "resources", "mosh"),
|
||||
CI: "true",
|
||||
},
|
||||
stdio: "pipe",
|
||||
}),
|
||||
/symbolic link|did not contain mosh-client\.exe/,
|
||||
);
|
||||
});
|
||||
64
scripts/mosh-extra-resources.cjs
Normal file
64
scripts/mosh-extra-resources.cjs
Normal file
@@ -0,0 +1,64 @@
|
||||
// Compute the platform-specific `extraResources` entry for bundling
|
||||
// mosh-client. Lives under scripts/ (eslint-ignored) so it can use
|
||||
// Node CommonJS globals freely; consumed from electron-builder.config.cjs.
|
||||
//
|
||||
// Binaries are produced by .github/workflows/build-mosh-binaries.yml and
|
||||
// downloaded into resources/mosh/<platform-arch>/ by
|
||||
// scripts/fetch-mosh-binaries.cjs (gated on MOSH_BIN_RELEASE).
|
||||
//
|
||||
// We only emit the directive when the binary is actually on disk so that
|
||||
// `npm run pack` keeps working without bundled mosh — for example, when
|
||||
// the developer skipped the fetch step or the relevant arch hasn't been
|
||||
// built yet.
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
function requestedArch() {
|
||||
return process.env.npm_config_arch || process.env.npm_config_target_arch || process.arch;
|
||||
}
|
||||
|
||||
function hasFile(file) {
|
||||
return fs.existsSync(file) && fs.statSync(file).isFile();
|
||||
}
|
||||
|
||||
function hasDir(dir) {
|
||||
return fs.existsSync(dir) && fs.statSync(dir).isDirectory();
|
||||
}
|
||||
|
||||
function moshExtraResources(platform) {
|
||||
const moshRoot = path.resolve(process.cwd(), "resources", "mosh");
|
||||
if (!fs.existsSync(moshRoot)) return [];
|
||||
|
||||
if (platform === "darwin") {
|
||||
const file = path.join(moshRoot, "darwin-universal", "mosh-client");
|
||||
if (!hasFile(file)) return [];
|
||||
return [
|
||||
{ from: "resources/mosh/darwin-universal/", to: "mosh/", filter: ["mosh-client"] },
|
||||
];
|
||||
}
|
||||
|
||||
if (platform === "linux") {
|
||||
const arch = requestedArch();
|
||||
const file = path.join(moshRoot, `linux-${arch}`, "mosh-client");
|
||||
if (!hasFile(file)) return [];
|
||||
return [{ from: `resources/mosh/linux-${arch}/`, to: "mosh/", filter: ["mosh-client"] }];
|
||||
}
|
||||
|
||||
if (platform === "win32") {
|
||||
// Windows ships mosh-client.exe + Cygwin DLL bundle (cygwin1.dll,
|
||||
// cygcrypto-*.dll, etc.) — copy the entire arch directory so the
|
||||
// exe finds its DLLs at runtime via Windows' default search order.
|
||||
const arch = requestedArch();
|
||||
const exe = path.join(moshRoot, `win32-${arch}`, "mosh-client.exe");
|
||||
const dllDir = path.join(moshRoot, `win32-${arch}`, `mosh-client-win32-${arch}-dlls`);
|
||||
if (!hasFile(exe) || !hasDir(dllDir)) return [];
|
||||
return [
|
||||
{ from: `resources/mosh/win32-${arch}/`, to: "mosh/", filter: ["mosh-client.exe"] },
|
||||
{ from: `resources/mosh/win32-${arch}/mosh-client-win32-${arch}-dlls/`, to: "mosh/", filter: ["**/*"] },
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
module.exports = { moshExtraResources };
|
||||
57
scripts/mosh-extra-resources.test.cjs
Normal file
57
scripts/mosh-extra-resources.test.cjs
Normal file
@@ -0,0 +1,57 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const { moshExtraResources } = require("./mosh-extra-resources.cjs");
|
||||
|
||||
function makeTmp(t) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-mosh-resources-"));
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
return dir;
|
||||
}
|
||||
|
||||
function withCwdAndArch(t, cwd, arch) {
|
||||
const oldCwd = process.cwd();
|
||||
const oldArch = process.env.npm_config_arch;
|
||||
process.chdir(cwd);
|
||||
process.env.npm_config_arch = arch;
|
||||
t.after(() => {
|
||||
process.chdir(oldCwd);
|
||||
if (oldArch === undefined) delete process.env.npm_config_arch;
|
||||
else process.env.npm_config_arch = oldArch;
|
||||
});
|
||||
}
|
||||
|
||||
function writeFile(filePath) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, "x");
|
||||
}
|
||||
|
||||
test("moshExtraResources returns concrete Linux arch paths", (t) => {
|
||||
const root = makeTmp(t);
|
||||
withCwdAndArch(t, root, "x64");
|
||||
writeFile(path.join(root, "resources", "mosh", "linux-x64", "mosh-client"));
|
||||
|
||||
const got = moshExtraResources("linux");
|
||||
assert.deepEqual(got, [
|
||||
{ from: "resources/mosh/linux-x64/", to: "mosh/", filter: ["mosh-client"] },
|
||||
]);
|
||||
});
|
||||
|
||||
test("moshExtraResources returns concrete Windows arch paths only when that arch exists", (t) => {
|
||||
const root = makeTmp(t);
|
||||
withCwdAndArch(t, root, "x64");
|
||||
writeFile(path.join(root, "resources", "mosh", "win32-x64", "mosh-client.exe"));
|
||||
writeFile(path.join(root, "resources", "mosh", "win32-x64", "mosh-client-win32-x64-dlls", "cygwin1.dll"));
|
||||
|
||||
const got = moshExtraResources("win32");
|
||||
assert.deepEqual(got, [
|
||||
{ from: "resources/mosh/win32-x64/", to: "mosh/", filter: ["mosh-client.exe"] },
|
||||
{ from: "resources/mosh/win32-x64/mosh-client-win32-x64-dlls/", to: "mosh/", filter: ["**/*"] },
|
||||
]);
|
||||
|
||||
process.env.npm_config_arch = "arm64";
|
||||
assert.deepEqual(moshExtraResources("win32"), []);
|
||||
});
|
||||
Reference in New Issue
Block a user