diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..dfdb8b77 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/.github/workflows/build-mosh-binaries.yml b/.github/workflows/build-mosh-binaries.yml new file mode 100644 index 00000000..dc65e964 --- /dev/null +++ b/.github/workflows/build-mosh-binaries.yml @@ -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--` (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 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5f36affd..8ae9481c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..7537d616 --- /dev/null +++ b/.github/workflows/test.yml @@ -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 diff --git a/.gitignore b/.gitignore index 22ee8ce2..86b752e3 100755 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/electron-builder.config.cjs b/electron-builder.config.cjs index 58e5a50d..22c46646 100644 --- a/electron-builder.config.cjs +++ b/electron-builder.config.cjs @@ -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 diff --git a/electron/bridges/moshHandshake.cjs b/electron/bridges/moshHandshake.cjs new file mode 100644 index 00000000..05945c06 --- /dev/null +++ b/electron/bridges/moshHandshake.cjs @@ -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 " 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 ` is detected, kill the ssh PTY, + * spawn `mosh-client ` in a fresh node-pty with + * MOSH_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 (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, +}; diff --git a/electron/bridges/moshHandshake.test.cjs b/electron/bridges/moshHandshake.test.cjs new file mode 100644 index 00000000..51974efe --- /dev/null +++ b/electron/bridges/moshHandshake.test.cjs @@ -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); +}); diff --git a/electron/bridges/terminalBridge.bareMoshClient.test.cjs b/electron/bridges/terminalBridge.bareMoshClient.test.cjs new file mode 100644 index 00000000..b03bd8d6 --- /dev/null +++ b/electron/bridges/terminalBridge.bareMoshClient.test.cjs @@ -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//), + // 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); +}); diff --git a/electron/bridges/terminalBridge.bundledMosh.test.cjs b/electron/bridges/terminalBridge.bundledMosh.test.cjs new file mode 100644 index 00000000..a4250e6c --- /dev/null +++ b/electron/bridges/terminalBridge.bundledMosh.test.cjs @@ -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); +}); diff --git a/electron/bridges/terminalBridge.cjs b/electron/bridges/terminalBridge.cjs index 79168ee1..9788617b 100644 --- a/electron/bridges/terminalBridge.cjs +++ b/electron/bridges/terminalBridge.cjs @@ -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=` 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: /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//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, diff --git a/electron/bridges/terminalBridge.moshHandshakeSession.test.cjs b/electron/bridges/terminalBridge.moshHandshakeSession.test.cjs new file mode 100644 index 00000000..12a43bcf --- /dev/null +++ b/electron/bridges/terminalBridge.moshHandshakeSession.test.cjs @@ -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"); +}); diff --git a/package.json b/package.json index b599ef72..5e20002f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/resources/mosh/README.md b/resources/mosh/README.md new file mode 100644 index 00000000..4b921e87 --- /dev/null +++ b/resources/mosh/README.md @@ -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//`. 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. diff --git a/scripts/build-mosh/build-linux.sh b/scripts/build-mosh/build-linux.sh new file mode 100755 index 00000000..ac077c7d --- /dev/null +++ b/scripts/build-mosh/build-linux.sh @@ -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- + sha256 +# +# Output: +# $OUT_DIR/mosh-client-linux- +# $OUT_DIR/mosh-client-linux-.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" diff --git a/scripts/build-mosh/build-macos.sh b/scripts/build-mosh/build-macos.sh new file mode 100755 index 00000000..8d680b4f --- /dev/null +++ b/scripts/build-mosh/build-macos.sh @@ -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" diff --git a/scripts/build-mosh/build-windows.sh b/scripts/build-mosh/build-windows.sh new file mode 100755 index 00000000..9f7d154e --- /dev/null +++ b/scripts/build-mosh/build-windows.sh @@ -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-.exe + DLL bundle +# +# Output: +# $OUT_DIR/mosh-client-win32-.exe +# $OUT_DIR/mosh-client-win32--dlls/*.dll +# $OUT_DIR/mosh-client-win32-.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" diff --git a/scripts/build-mosh/fetch-windows.sh b/scripts/build-mosh/fetch-windows.sh new file mode 100755 index 00000000..5bf7504d --- /dev/null +++ b/scripts/build-mosh/fetch-windows.sh @@ -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" diff --git a/scripts/fetch-mosh-binaries.cjs b/scripts/fetch-mosh-binaries.cjs new file mode 100755 index 00000000..43b7ea43 --- /dev/null +++ b/scripts/fetch-mosh-binaries.cjs @@ -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, +}; diff --git a/scripts/fetch-mosh-binaries.test.cjs b/scripts/fetch-mosh-binaries.test.cjs new file mode 100644 index 00000000..ce589e45 --- /dev/null +++ b/scripts/fetch-mosh-binaries.test.cjs @@ -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/, + ); +}); diff --git a/scripts/mosh-extra-resources.cjs b/scripts/mosh-extra-resources.cjs new file mode 100644 index 00000000..bfe75fe4 --- /dev/null +++ b/scripts/mosh-extra-resources.cjs @@ -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// 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 }; diff --git a/scripts/mosh-extra-resources.test.cjs b/scripts/mosh-extra-resources.test.cjs new file mode 100644 index 00000000..24be2021 --- /dev/null +++ b/scripts/mosh-extra-resources.test.cjs @@ -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"), []); +});