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:
陈大猫
2026-04-30 08:25:57 +08:00
committed by GitHub
parent e4e1b54374
commit 7dd25a55bb
22 changed files with 2800 additions and 21 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
*.sh text eol=lf

View 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

View File

@@ -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
View 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
View File

@@ -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

View File

@@ -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

View 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,
};

View 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);
});

View 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);
});

View 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);
});

View File

@@ -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,

View 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");
});

View File

@@ -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
View 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
View 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
View 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"

View 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"

View 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
View 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,
};

View 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/,
);
});

View 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 };

View 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"), []);
});