Fix Windows mosh startup
Fix Windows mosh startup
This commit is contained in:
61
.github/workflows/build-mosh-binaries.yml
vendored
61
.github/workflows/build-mosh-binaries.yml
vendored
@@ -9,7 +9,7 @@ name: build-mosh-binaries
|
||||
# (`binaricat/Netcatty-mosh-bin` by default).
|
||||
#
|
||||
# `paths` keeps unrelated commits (UI, bridges, etc) from rebuilding
|
||||
# mosh on every push — this workflow is expensive (~30min Cygwin leg).
|
||||
# or refreshing mosh binaries on every push.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -129,48 +129,22 @@ jobs:
|
||||
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.
|
||||
# Windows x64 pinned standalone client.
|
||||
# Do not compile this in CI: the upstream Cygwin build can clear the
|
||||
# terminal and never render output on Windows. Ship the SHA256-pinned
|
||||
# FluentTerminal standalone binary verified by fetch-windows.sh.
|
||||
# ------------------------------------------------------------------
|
||||
build-windows-x64:
|
||||
name: build-windows-x64
|
||||
runs-on: windows-latest
|
||||
fetch-windows-x64:
|
||||
name: fetch-windows-x64
|
||||
runs-on: ubuntu-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
|
||||
- name: Fetch pinned mosh-client.exe (win32-x64)
|
||||
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"
|
||||
export OUT_DIR="${GITHUB_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"
|
||||
bash scripts/build-mosh/fetch-windows.sh
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -179,12 +153,8 @@ jobs:
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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`.
|
||||
# The pinned upstream source only provides x64. arm64 Windows builds
|
||||
# should be added only after we have a tested standalone arm64 client.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -196,7 +166,7 @@ jobs:
|
||||
- build-linux-x64
|
||||
- build-linux-arm64
|
||||
- build-macos-universal
|
||||
- build-windows-x64
|
||||
- fetch-windows-x64
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.release_tag != ''
|
||||
permissions:
|
||||
@@ -241,7 +211,8 @@ jobs:
|
||||
fi
|
||||
{
|
||||
printf '%s\n' 'Pre-built `mosh-client` binaries consumed by `scripts/fetch-mosh-binaries.cjs` during `npm run pack`.'
|
||||
printf 'Built from `mobile-shell/mosh` upstream ref `%s`.\n\n' "${MOSH_REF}"
|
||||
printf 'Linux/macOS artifacts are built from `mobile-shell/mosh` upstream ref `%s`.\n' "${MOSH_REF}"
|
||||
printf '%s\n\n' 'Windows x64 is the SHA256-pinned FluentTerminal standalone `mosh-client.exe` fallback.'
|
||||
printf 'Source workflow: %s/%s/actions/runs/%s\n' "${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}" "${GITHUB_RUN_ID}"
|
||||
printf 'Source commit: `%s`\n\n' "${GITHUB_SHA}"
|
||||
printf '%s\n' 'All artifacts are GPL-3.0; see `resources/mosh/README.md` for source provenance.'
|
||||
|
||||
@@ -624,8 +624,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
pendingAuthRef,
|
||||
termRef,
|
||||
onUpdateHost,
|
||||
onStartSsh: (term) => {
|
||||
sessionStartersRef.current?.startSSH(term);
|
||||
onStartSession: (term) => {
|
||||
const starters = sessionStartersRef.current;
|
||||
if (!starters) return;
|
||||
if (host.moshEnabled) {
|
||||
starters.startMosh(term);
|
||||
return;
|
||||
}
|
||||
starters.startSSH(term);
|
||||
},
|
||||
setStatus: (next) => setStatus(next),
|
||||
setProgressLogs,
|
||||
|
||||
@@ -11,7 +11,7 @@ export const useTerminalAuthState = ({
|
||||
pendingAuthRef,
|
||||
termRef,
|
||||
onUpdateHost,
|
||||
onStartSsh,
|
||||
onStartSession,
|
||||
setStatus,
|
||||
setProgressLogs,
|
||||
}: {
|
||||
@@ -19,7 +19,7 @@ export const useTerminalAuthState = ({
|
||||
pendingAuthRef: RefObject<PendingAuth>;
|
||||
termRef: RefObject<XTerm | null>;
|
||||
onUpdateHost?: (host: Host) => void;
|
||||
onStartSsh: (term: XTerm) => void;
|
||||
onStartSession: (term: XTerm) => void;
|
||||
setStatus: (status: TerminalSession["status"]) => void;
|
||||
setProgressLogs: (next: string[] | ((prev: string[]) => string[])) => void;
|
||||
}) => {
|
||||
@@ -106,7 +106,7 @@ export const useTerminalAuthState = ({
|
||||
logger.warn("Failed to clear terminal", err);
|
||||
}
|
||||
|
||||
onStartSsh(term);
|
||||
onStartSession(term);
|
||||
},
|
||||
[
|
||||
authKeyId,
|
||||
@@ -116,7 +116,7 @@ export const useTerminalAuthState = ({
|
||||
authUsername,
|
||||
host,
|
||||
isValid,
|
||||
onStartSsh,
|
||||
onStartSession,
|
||||
onUpdateHost,
|
||||
pendingAuthRef,
|
||||
saveCredentials,
|
||||
|
||||
@@ -172,6 +172,248 @@ test("startMosh passes the saved password to the mosh backend", async () => {
|
||||
assert.equal(capturedOptions.password, "saved-secret");
|
||||
});
|
||||
|
||||
test("startMosh passes configured key material to the mosh backend", async () => {
|
||||
let capturedOptions: Record<string, unknown> | null = null;
|
||||
|
||||
const terminalBackend = {
|
||||
backendAvailable: () => true,
|
||||
telnetAvailable: () => true,
|
||||
moshAvailable: () => true,
|
||||
localAvailable: () => true,
|
||||
serialAvailable: () => true,
|
||||
execAvailable: () => true,
|
||||
startSSHSession: async () => "ssh-session",
|
||||
startTelnetSession: async () => "telnet-session",
|
||||
startMoshSession: async (options: Record<string, unknown>) => {
|
||||
capturedOptions = options;
|
||||
return "mosh-session";
|
||||
},
|
||||
startLocalSession: async () => "local-session",
|
||||
startSerialSession: async () => "serial-session",
|
||||
execCommand: async () => ({}),
|
||||
onSessionData: () => noop,
|
||||
onSessionExit: () => noop,
|
||||
onChainProgress: () => noop,
|
||||
writeToSession: noop,
|
||||
resizeSession: noop,
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Example",
|
||||
hostname: "example.test",
|
||||
username: "alice",
|
||||
password: "wrong-password",
|
||||
authMethod: "key",
|
||||
identityFileId: "key-1",
|
||||
identityFilePaths: ["/should/not/be/used"],
|
||||
port: 2200,
|
||||
},
|
||||
keys: [{
|
||||
id: "key-1",
|
||||
label: "Deploy key",
|
||||
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nkey\n-----END OPENSSH PRIVATE KEY-----",
|
||||
passphrase: "key-passphrase",
|
||||
}],
|
||||
resolvedChainHosts: [],
|
||||
sessionId: "session-1",
|
||||
terminalSettings: {},
|
||||
terminalBackend,
|
||||
sessionRef: { current: null },
|
||||
hasConnectedRef: { current: false },
|
||||
hasRunStartupCommandRef: { current: false },
|
||||
disposeDataRef: { current: null },
|
||||
disposeExitRef: { current: null },
|
||||
fitAddonRef: { current: null },
|
||||
serializeAddonRef: { current: null },
|
||||
pendingAuthRef: { current: null },
|
||||
updateStatus: noop,
|
||||
setStatus: noop,
|
||||
setError: noop,
|
||||
setNeedsAuth: noop,
|
||||
setAuthRetryMessage: noop,
|
||||
setAuthPassword: noop,
|
||||
setProgressLogs: noop,
|
||||
setProgressValue: noop,
|
||||
setChainProgress: noop,
|
||||
};
|
||||
|
||||
const term = {
|
||||
cols: 120,
|
||||
rows: 32,
|
||||
write: noop,
|
||||
writeln: noop,
|
||||
scrollToBottom: noop,
|
||||
};
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startMosh(term as never);
|
||||
|
||||
assert.ok(capturedOptions);
|
||||
assert.equal(capturedOptions.password, "wrong-password");
|
||||
assert.equal(capturedOptions.privateKey, "-----BEGIN OPENSSH PRIVATE KEY-----\nkey\n-----END OPENSSH PRIVATE KEY-----");
|
||||
assert.equal(capturedOptions.keyId, "key-1");
|
||||
assert.equal(capturedOptions.passphrase, "key-passphrase");
|
||||
assert.equal(capturedOptions.identityFilePaths, undefined);
|
||||
});
|
||||
|
||||
test("startMosh asks for credential re-entry when saved key material cannot be decrypted", async () => {
|
||||
let started = false;
|
||||
let needsAuth = false;
|
||||
let retryMessage: string | null = null;
|
||||
|
||||
const terminalBackend = {
|
||||
backendAvailable: () => true,
|
||||
telnetAvailable: () => true,
|
||||
moshAvailable: () => true,
|
||||
localAvailable: () => true,
|
||||
serialAvailable: () => true,
|
||||
execAvailable: () => true,
|
||||
startSSHSession: async () => "ssh-session",
|
||||
startTelnetSession: async () => "telnet-session",
|
||||
startMoshSession: async () => {
|
||||
started = true;
|
||||
return "mosh-session";
|
||||
},
|
||||
startLocalSession: async () => "local-session",
|
||||
startSerialSession: async () => "serial-session",
|
||||
execCommand: async () => ({}),
|
||||
onSessionData: () => noop,
|
||||
onSessionExit: () => noop,
|
||||
onChainProgress: () => noop,
|
||||
writeToSession: noop,
|
||||
resizeSession: noop,
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Example",
|
||||
hostname: "example.test",
|
||||
username: "alice",
|
||||
authMethod: "key",
|
||||
identityFileId: "key-1",
|
||||
port: 2200,
|
||||
},
|
||||
keys: [{
|
||||
id: "key-1",
|
||||
label: "Deploy key",
|
||||
privateKey: "enc:v1:djEwAAAA",
|
||||
}],
|
||||
resolvedChainHosts: [],
|
||||
sessionId: "session-1",
|
||||
terminalSettings: {},
|
||||
terminalBackend,
|
||||
sessionRef: { current: null },
|
||||
hasConnectedRef: { current: false },
|
||||
hasRunStartupCommandRef: { current: false },
|
||||
disposeDataRef: { current: null },
|
||||
disposeExitRef: { current: null },
|
||||
fitAddonRef: { current: null },
|
||||
serializeAddonRef: { current: null },
|
||||
pendingAuthRef: { current: null },
|
||||
updateStatus: noop,
|
||||
setStatus: noop,
|
||||
setError: noop,
|
||||
setNeedsAuth: (value: boolean) => { needsAuth = value; },
|
||||
setAuthRetryMessage: (message: string | null) => { retryMessage = message; },
|
||||
setAuthPassword: noop,
|
||||
setProgressLogs: noop,
|
||||
setProgressValue: noop,
|
||||
setChainProgress: noop,
|
||||
};
|
||||
|
||||
const term = {
|
||||
cols: 120,
|
||||
rows: 32,
|
||||
write: noop,
|
||||
writeln: noop,
|
||||
scrollToBottom: noop,
|
||||
};
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startMosh(term as never);
|
||||
|
||||
assert.equal(started, false);
|
||||
assert.equal(needsAuth, true);
|
||||
assert.match(retryMessage || "", /Saved credentials cannot be decrypted/);
|
||||
});
|
||||
|
||||
test("startMosh omits identity file paths when password auth is explicit", async () => {
|
||||
let capturedOptions: Record<string, unknown> | null = null;
|
||||
|
||||
const terminalBackend = {
|
||||
backendAvailable: () => true,
|
||||
telnetAvailable: () => true,
|
||||
moshAvailable: () => true,
|
||||
localAvailable: () => true,
|
||||
serialAvailable: () => true,
|
||||
execAvailable: () => true,
|
||||
startSSHSession: async () => "ssh-session",
|
||||
startTelnetSession: async () => "telnet-session",
|
||||
startMoshSession: async (options: Record<string, unknown>) => {
|
||||
capturedOptions = options;
|
||||
return "mosh-session";
|
||||
},
|
||||
startLocalSession: async () => "local-session",
|
||||
startSerialSession: async () => "serial-session",
|
||||
execCommand: async () => ({}),
|
||||
onSessionData: () => noop,
|
||||
onSessionExit: () => noop,
|
||||
onChainProgress: () => noop,
|
||||
writeToSession: noop,
|
||||
resizeSession: noop,
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Example",
|
||||
hostname: "example.test",
|
||||
username: "alice",
|
||||
authMethod: "password",
|
||||
password: "saved-secret",
|
||||
identityFilePaths: ["/should/not/be/used"],
|
||||
port: 2200,
|
||||
},
|
||||
keys: [],
|
||||
resolvedChainHosts: [],
|
||||
sessionId: "session-1",
|
||||
terminalSettings: {},
|
||||
terminalBackend,
|
||||
sessionRef: { current: null },
|
||||
hasConnectedRef: { current: false },
|
||||
hasRunStartupCommandRef: { current: false },
|
||||
disposeDataRef: { current: null },
|
||||
disposeExitRef: { current: null },
|
||||
fitAddonRef: { current: null },
|
||||
serializeAddonRef: { current: null },
|
||||
pendingAuthRef: { current: null },
|
||||
updateStatus: noop,
|
||||
setStatus: noop,
|
||||
setError: noop,
|
||||
setNeedsAuth: noop,
|
||||
setAuthRetryMessage: noop,
|
||||
setAuthPassword: noop,
|
||||
setProgressLogs: noop,
|
||||
setProgressValue: noop,
|
||||
setChainProgress: noop,
|
||||
};
|
||||
|
||||
const term = {
|
||||
cols: 120,
|
||||
rows: 32,
|
||||
write: noop,
|
||||
writeln: noop,
|
||||
scrollToBottom: noop,
|
||||
};
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startMosh(term as never);
|
||||
|
||||
assert.ok(capturedOptions);
|
||||
assert.equal(capturedOptions.password, "saved-secret");
|
||||
assert.equal(capturedOptions.identityFilePaths, undefined);
|
||||
});
|
||||
|
||||
test("startMosh rejects missing saved proxy profiles", async () => {
|
||||
let started = false;
|
||||
let error = "";
|
||||
@@ -321,6 +563,80 @@ test("startMosh rejects configured proxies instead of connecting directly", asyn
|
||||
assert.match(error, /Mosh does not support proxy/);
|
||||
});
|
||||
|
||||
test("startMosh rejects jump host chains instead of connecting directly", async () => {
|
||||
let started = false;
|
||||
let error = "";
|
||||
|
||||
const terminalBackend = {
|
||||
backendAvailable: () => true,
|
||||
telnetAvailable: () => true,
|
||||
moshAvailable: () => true,
|
||||
localAvailable: () => true,
|
||||
serialAvailable: () => true,
|
||||
execAvailable: () => true,
|
||||
startSSHSession: async () => "ssh-session",
|
||||
startTelnetSession: async () => "telnet-session",
|
||||
startMoshSession: async () => {
|
||||
started = true;
|
||||
return "mosh-session";
|
||||
},
|
||||
startLocalSession: async () => "local-session",
|
||||
startSerialSession: async () => "serial-session",
|
||||
execCommand: async () => ({}),
|
||||
onSessionData: () => noop,
|
||||
onSessionExit: () => noop,
|
||||
onChainProgress: () => noop,
|
||||
writeToSession: noop,
|
||||
resizeSession: noop,
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Example",
|
||||
hostname: "example.test",
|
||||
username: "alice",
|
||||
hostChain: { hostIds: ["jump-1"] },
|
||||
port: 2200,
|
||||
},
|
||||
keys: [],
|
||||
resolvedChainHosts: [{ id: "jump-1", hostname: "jump.example.test" }],
|
||||
sessionId: "session-1",
|
||||
terminalSettings: {},
|
||||
terminalBackend,
|
||||
sessionRef: { current: null },
|
||||
hasConnectedRef: { current: false },
|
||||
hasRunStartupCommandRef: { current: false },
|
||||
disposeDataRef: { current: null },
|
||||
disposeExitRef: { current: null },
|
||||
fitAddonRef: { current: null },
|
||||
serializeAddonRef: { current: null },
|
||||
pendingAuthRef: { current: null },
|
||||
updateStatus: noop,
|
||||
setStatus: noop,
|
||||
setError: (message: string) => { error = message; },
|
||||
setNeedsAuth: noop,
|
||||
setAuthRetryMessage: noop,
|
||||
setAuthPassword: noop,
|
||||
setProgressLogs: noop,
|
||||
setProgressValue: noop,
|
||||
setChainProgress: noop,
|
||||
};
|
||||
|
||||
const term = {
|
||||
cols: 120,
|
||||
rows: 32,
|
||||
write: noop,
|
||||
writeln: noop,
|
||||
scrollToBottom: noop,
|
||||
};
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startMosh(term as never);
|
||||
|
||||
assert.equal(started, false);
|
||||
assert.match(error, /Mosh does not support jump host chains/);
|
||||
});
|
||||
|
||||
test("startTelnet rejects missing saved proxy profiles", async () => {
|
||||
let started = false;
|
||||
let error = "";
|
||||
|
||||
@@ -824,6 +824,14 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
return;
|
||||
}
|
||||
|
||||
const hasConfiguredJumpHostChain =
|
||||
(ctx.host.hostChain?.hostIds?.length || 0) > 0 ||
|
||||
ctx.resolvedChainHosts.length > 0;
|
||||
if (hasConfiguredJumpHostChain) {
|
||||
stopMosh("Mosh does not support jump host chains. Use SSH for this host or remove the jump hosts from this connection.");
|
||||
return;
|
||||
}
|
||||
|
||||
const unresolvedJumpProxyHost = ctx.resolvedChainHosts.find((jumpHost) => jumpHost.proxyProfileId && !jumpHost.proxyConfig);
|
||||
if (unresolvedJumpProxyHost) {
|
||||
stopMosh(`Saved proxy for jump host "${unresolvedJumpProxyHost.label || unresolvedJumpProxyHost.hostname}" is missing. Open host settings and select a valid proxy.`);
|
||||
@@ -854,12 +862,44 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
: null,
|
||||
});
|
||||
const effectivePassword = sanitizeCredentialValue(resolvedAuth.password);
|
||||
const effectivePassphrase = sanitizeCredentialValue(resolvedAuth.passphrase);
|
||||
const authMethod = resolvedAuth.authMethod;
|
||||
const key = authMethod === "password" ? undefined : resolvedAuth.key;
|
||||
const hasEncryptedPrimaryPassword = isEncryptedCredentialPlaceholder(resolvedAuth.password);
|
||||
const hasEncryptedPrimaryKey = isEncryptedCredentialPlaceholder(resolvedAuth.key?.privateKey);
|
||||
const hasKeyMaterial = !!sanitizeCredentialValue(key?.privateKey) && authMethod !== "password";
|
||||
const hasPassword = !!effectivePassword;
|
||||
const needsCredentialReentry =
|
||||
(authMethod === "password" && hasEncryptedPrimaryPassword && !hasPassword) ||
|
||||
(authMethod !== "password" && hasEncryptedPrimaryKey && !hasKeyMaterial && !hasPassword);
|
||||
|
||||
if (needsCredentialReentry) {
|
||||
ctx.setError(null);
|
||||
ctx.setNeedsAuth(true);
|
||||
ctx.setAuthRetryMessage(
|
||||
tr(
|
||||
"terminal.auth.credentialsUnavailable",
|
||||
"Saved credentials cannot be decrypted on this device. Please re-enter and save them again.",
|
||||
),
|
||||
);
|
||||
ctx.setAuthPassword("");
|
||||
ctx.setStatus("connecting");
|
||||
return;
|
||||
}
|
||||
|
||||
const moshEnv = buildTermEnv(ctx.host, ctx.terminalSettings);
|
||||
const id = await ctx.terminalBackend.startMoshSession({
|
||||
sessionId: ctx.sessionId,
|
||||
hostname: ctx.host.hostname,
|
||||
username: resolvedAuth.username || "root",
|
||||
password: effectivePassword,
|
||||
privateKey: sanitizeCredentialValue(key?.privateKey),
|
||||
certificate: key?.certificate,
|
||||
keyId: key?.id,
|
||||
passphrase: key
|
||||
? (effectivePassphrase || sanitizeCredentialValue(key.passphrase))
|
||||
: undefined,
|
||||
identityFilePaths: authMethod !== "password" && !key ? ctx.host.identityFilePaths : undefined,
|
||||
port: ctx.host.port || 22,
|
||||
moshServerPath: ctx.host.moshServerPath,
|
||||
agentForwarding: ctx.host.agentForwarding,
|
||||
|
||||
@@ -7,7 +7,9 @@ const os = require("node:os");
|
||||
const fs = require("node:fs");
|
||||
const net = require("node:net");
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const { execFile } = require("node:child_process");
|
||||
const path = require("node:path");
|
||||
const { promisify } = require("node:util");
|
||||
const { StringDecoder } = require("node:string_decoder");
|
||||
const pty = require("node-pty");
|
||||
const { SerialPort } = require("serialport");
|
||||
@@ -20,6 +22,9 @@ const { trackSessionIdlePrompt } = require("./ai/shellUtils.cjs");
|
||||
const { createZmodemSentry } = require("./zmodemHelper.cjs");
|
||||
const { discoverShells } = require("./shellDiscovery.cjs");
|
||||
const moshHandshake = require("./moshHandshake.cjs");
|
||||
const tempDirBridge = require("./tempDirBridge.cjs");
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// Shared references
|
||||
let sessions = null;
|
||||
@@ -924,27 +929,166 @@ function addBundledMoshRuntimeEnv(env, bareClient, opts = {}) {
|
||||
return env;
|
||||
}
|
||||
|
||||
function createMoshSshPasswordResponder(sshPty, password) {
|
||||
if (typeof password !== "string" || password.length === 0) {
|
||||
function createMoshSshPasswordResponder(sshPty, password, passphrase) {
|
||||
if (
|
||||
(typeof password !== "string" || password.length === 0) &&
|
||||
(typeof passphrase !== "string" || passphrase.length === 0)
|
||||
) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
let answered = false;
|
||||
let answeredPassword = false;
|
||||
let answeredPassphrase = false;
|
||||
let tail = "";
|
||||
|
||||
return (chunk) => {
|
||||
if (answered) return;
|
||||
if (answeredPassword && answeredPassphrase) return;
|
||||
const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk || "");
|
||||
if (!text) return;
|
||||
|
||||
tail = (tail + text).slice(-512);
|
||||
if (typeof passphrase === "string" && passphrase.length > 0 && !answeredPassphrase && /(^|[\r\n]).*passphrase.*:\s*$/i.test(tail)) {
|
||||
answeredPassphrase = true;
|
||||
sshPty.write(`${passphrase}\r`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof password !== "string" || password.length === 0 || answeredPassword) return;
|
||||
if (!/(^|[\r\n]).*password:\s*$/i.test(tail)) return;
|
||||
|
||||
answered = true;
|
||||
answeredPassword = true;
|
||||
sshPty.write(`${password}\r`);
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMoshIdentityPath(keyPath) {
|
||||
if (typeof keyPath !== "string") return null;
|
||||
const trimmed = keyPath.trim();
|
||||
if (!trimmed) return null;
|
||||
if (trimmed === "~") return os.homedir();
|
||||
if (trimmed.startsWith("~/")) return path.join(os.homedir(), trimmed.slice(2));
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function safeMoshAuthFileName(sessionId, keyId, suffix) {
|
||||
const safeId = String(keyId || sessionId || randomUUID())
|
||||
.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
.slice(0, 80);
|
||||
return `mosh-auth-${safeId}-${randomUUID()}${suffix}`;
|
||||
}
|
||||
|
||||
async function writeMoshAuthTempFile(fileName, content) {
|
||||
const target = tempDirBridge.getTempFilePath(fileName);
|
||||
const normalized = content.endsWith("\n") ? content : `${content}\n`;
|
||||
let created = false;
|
||||
try {
|
||||
const handle = await fs.promises.open(target, "wx", 0o600);
|
||||
created = true;
|
||||
await handle.close();
|
||||
await restrictMoshAuthFilePermissions(target, { failClosed: true });
|
||||
await fs.promises.writeFile(target, normalized, { flag: "w", mode: 0o600 });
|
||||
try {
|
||||
await fs.promises.chmod(target, 0o600);
|
||||
} catch {
|
||||
// Best effort on Windows; ACL hardening above is the security boundary.
|
||||
}
|
||||
} catch (err) {
|
||||
if (created) cleanupMoshAuthTempFiles([target]);
|
||||
throw err;
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
async function restrictMoshAuthFilePermissions(target, opts = {}) {
|
||||
if (process.platform !== "win32") return true;
|
||||
|
||||
let username = process.env.USERNAME;
|
||||
if (!username) {
|
||||
try {
|
||||
username = os.userInfo().username;
|
||||
} catch {
|
||||
username = "";
|
||||
}
|
||||
}
|
||||
if (!username) {
|
||||
if (opts.failClosed) {
|
||||
throw new Error("Failed to restrict private key ACLs: unable to resolve current Windows user");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const identities = [];
|
||||
if (process.env.USERDOMAIN) identities.push(`${process.env.USERDOMAIN}\\${username}`);
|
||||
identities.push(username);
|
||||
|
||||
let lastError = null;
|
||||
for (const identity of identities) {
|
||||
try {
|
||||
await execFileAsync("icacls.exe", [target, "/grant:r", `${identity}:F`], { windowsHide: true });
|
||||
await execFileAsync("icacls.exe", [target, "/inheritance:r"], { windowsHide: true });
|
||||
await execFileAsync("icacls.exe", [target, "/grant:r", `${identity}:F`], { windowsHide: true });
|
||||
return true;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
}
|
||||
}
|
||||
|
||||
const message = lastError?.message || String(lastError || "unknown error");
|
||||
if (opts.failClosed) {
|
||||
throw new Error(`Failed to restrict private key ACLs: ${message}`);
|
||||
}
|
||||
console.warn("[Mosh] Failed to restrict private key ACLs:", message);
|
||||
return false;
|
||||
}
|
||||
|
||||
function cleanupMoshAuthTempFiles(files) {
|
||||
for (const file of files || []) {
|
||||
try {
|
||||
fs.unlinkSync(file);
|
||||
} catch {
|
||||
// Best effort cleanup; Settings > System can clear Netcatty temp files.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function buildMoshSshAuthArgs(options, sessionId) {
|
||||
const sshArgs = [];
|
||||
const tempFiles = [];
|
||||
|
||||
try {
|
||||
if (typeof options.privateKey === "string" && options.privateKey.trim().length > 0) {
|
||||
const keyPath = await writeMoshAuthTempFile(
|
||||
safeMoshAuthFileName(sessionId, options.keyId, ".pem"),
|
||||
options.privateKey,
|
||||
);
|
||||
tempFiles.push(keyPath);
|
||||
sshArgs.push("-i", keyPath, "-o", "IdentitiesOnly=yes");
|
||||
|
||||
if (typeof options.certificate === "string" && options.certificate.trim().length > 0) {
|
||||
const certPath = await writeMoshAuthTempFile(
|
||||
safeMoshAuthFileName(sessionId, options.keyId, "-cert.pub"),
|
||||
options.certificate,
|
||||
);
|
||||
tempFiles.push(certPath);
|
||||
sshArgs.push("-o", `CertificateFile=${certPath}`);
|
||||
}
|
||||
} else if (Array.isArray(options.identityFilePaths) && options.identityFilePaths.length > 0) {
|
||||
for (const keyPath of options.identityFilePaths) {
|
||||
const normalized = normalizeMoshIdentityPath(keyPath);
|
||||
if (normalized) sshArgs.push("-i", normalized);
|
||||
}
|
||||
if (sshArgs.length > 0) {
|
||||
sshArgs.push("-o", "IdentitiesOnly=yes");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
cleanupMoshAuthTempFiles(tempFiles);
|
||||
throw err;
|
||||
}
|
||||
|
||||
return { sshArgs, tempFiles };
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase-2 / Phase-3b path: run the SSH bootstrap ourselves *inside the
|
||||
* user's terminal PTY* so password / 2FA / known-hosts prompts render
|
||||
@@ -974,6 +1118,7 @@ async function startMoshSessionViaHandshake(event, options, { bareClient, sshExe
|
||||
const rows = options.rows || 24;
|
||||
const optionsEnv = options.env || {};
|
||||
const lang = optionsEnv.LANG || resolveLangFromCharsetForMosh(options.charset);
|
||||
const moshAuth = await buildMoshSshAuthArgs(options, sessionId);
|
||||
|
||||
const { args: sshArgs } = moshHandshake.buildSshHandshakeCommand({
|
||||
host: options.hostname,
|
||||
@@ -981,6 +1126,7 @@ async function startMoshSessionViaHandshake(event, options, { bareClient, sshExe
|
||||
username: options.username,
|
||||
lang,
|
||||
moshServer: moshHandshake.buildMoshServerCommand(options.moshServerPath),
|
||||
sshArgs: moshAuth.sshArgs,
|
||||
});
|
||||
|
||||
const sshEnv = { ...process.env, ...optionsEnv, TERM: "xterm-256color" };
|
||||
@@ -988,13 +1134,19 @@ async function startMoshSessionViaHandshake(event, options, { bareClient, sshExe
|
||||
sshEnv.SSH_AUTH_SOCK = process.env.SSH_AUTH_SOCK;
|
||||
}
|
||||
|
||||
const sshPty = pty.spawn(sshExe, sshArgs, {
|
||||
cols,
|
||||
rows,
|
||||
env: sshEnv,
|
||||
cwd: os.homedir(),
|
||||
encoding: null,
|
||||
});
|
||||
let sshPty;
|
||||
try {
|
||||
sshPty = pty.spawn(sshExe, sshArgs, {
|
||||
cols,
|
||||
rows,
|
||||
env: sshEnv,
|
||||
cwd: os.homedir(),
|
||||
encoding: null,
|
||||
});
|
||||
} catch (err) {
|
||||
cleanupMoshAuthTempFiles(moshAuth.tempFiles);
|
||||
throw err;
|
||||
}
|
||||
|
||||
const session = {
|
||||
proc: sshPty,
|
||||
@@ -1015,6 +1167,7 @@ async function startMoshSessionViaHandshake(event, options, { bareClient, sshExe
|
||||
rows,
|
||||
moshHandshakePhase: "ssh",
|
||||
moshHandshakeResult: null,
|
||||
moshAuthTempFiles: moshAuth.tempFiles,
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
@@ -1035,7 +1188,7 @@ async function startMoshSessionViaHandshake(event, options, { bareClient, sshExe
|
||||
session.flushPendingData = flush;
|
||||
|
||||
const sniffer = moshHandshake.createMoshConnectSniffer();
|
||||
const respondToPasswordPrompt = createMoshSshPasswordResponder(sshPty, options.password);
|
||||
const respondToPasswordPrompt = createMoshSshPasswordResponder(sshPty, options.password, options.passphrase);
|
||||
|
||||
// Forward bytes from the ssh PTY to the renderer, redacting the
|
||||
// MOSH CONNECT magic line. ZMODEM is intentionally not enabled
|
||||
@@ -1059,8 +1212,10 @@ async function startMoshSessionViaHandshake(event, options, { bareClient, sshExe
|
||||
|
||||
sshPty.onExit(({ exitCode, signal }) => {
|
||||
if (sessions.get(sessionId) !== session || session.closed) {
|
||||
cleanupMoshAuthTempFiles(moshAuth.tempFiles);
|
||||
return;
|
||||
}
|
||||
cleanupMoshAuthTempFiles(moshAuth.tempFiles);
|
||||
|
||||
if (session.moshHandshakePhase === "parsed" && session.moshHandshakeResult) {
|
||||
try {
|
||||
@@ -1470,6 +1625,8 @@ function closeSession(event, payload) {
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Close failed", err);
|
||||
} finally {
|
||||
cleanupMoshAuthTempFiles(session.moshAuthTempFiles);
|
||||
}
|
||||
ptyProcessTree.unregisterPid(payload.sessionId);
|
||||
sessions.delete(payload.sessionId);
|
||||
|
||||
@@ -219,6 +219,98 @@ test("startMoshSession writes the saved password when ssh prompts for one", asyn
|
||||
assert.deepEqual(h.spawns[0].writes, ["saved-secret\r"]);
|
||||
});
|
||||
|
||||
test("startMoshSession passes vault private keys to ssh via a temp identity file", async (t) => {
|
||||
const h = makeHarness(t);
|
||||
await h.bridge.startMoshSession(
|
||||
h.event,
|
||||
{
|
||||
...h.options,
|
||||
keyId: "key-1",
|
||||
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nkey\n-----END OPENSSH PRIVATE KEY-----",
|
||||
password: "wrong-password",
|
||||
},
|
||||
{ moshClientLookup: h.lookupOpts },
|
||||
);
|
||||
|
||||
const keyFlagIndex = h.spawns[0].args.indexOf("-i");
|
||||
assert.notEqual(keyFlagIndex, -1);
|
||||
const keyPath = h.spawns[0].args[keyFlagIndex + 1];
|
||||
assert.equal(fs.existsSync(keyPath), true);
|
||||
assert.equal(h.spawns[0].args.includes("IdentitiesOnly=yes"), true);
|
||||
assert.equal(h.spawns[0].args.includes("alice@example.com"), true);
|
||||
|
||||
h.spawns[0].emitExit({ exitCode: 255, signal: 0 });
|
||||
assert.equal(fs.existsSync(keyPath), false);
|
||||
});
|
||||
|
||||
test("startMoshSession uses unique temp identity files for concurrent sessions with the same key", async (t) => {
|
||||
const h = makeHarness(t);
|
||||
const authOptions = {
|
||||
keyId: "key-1",
|
||||
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nkey\n-----END OPENSSH PRIVATE KEY-----",
|
||||
};
|
||||
|
||||
await h.bridge.startMoshSession(
|
||||
h.event,
|
||||
{ ...h.options, ...authOptions },
|
||||
{ moshClientLookup: h.lookupOpts },
|
||||
);
|
||||
await h.bridge.startMoshSession(
|
||||
h.event,
|
||||
{ ...h.options, ...authOptions, sessionId: "mosh-test-session-2" },
|
||||
{ moshClientLookup: h.lookupOpts },
|
||||
);
|
||||
|
||||
const firstKeyPath = h.spawns[0].args[h.spawns[0].args.indexOf("-i") + 1];
|
||||
const secondKeyPath = h.spawns[1].args[h.spawns[1].args.indexOf("-i") + 1];
|
||||
assert.notEqual(firstKeyPath, secondKeyPath);
|
||||
assert.equal(fs.existsSync(firstKeyPath), true);
|
||||
assert.equal(fs.existsSync(secondKeyPath), true);
|
||||
|
||||
h.spawns[0].emitExit({ exitCode: 255, signal: 0 });
|
||||
assert.equal(fs.existsSync(firstKeyPath), false);
|
||||
assert.equal(fs.existsSync(secondKeyPath), true);
|
||||
|
||||
h.spawns[1].emitExit({ exitCode: 255, signal: 0 });
|
||||
assert.equal(fs.existsSync(secondKeyPath), false);
|
||||
});
|
||||
|
||||
test("closeSession removes Mosh temp identity files even before ssh exits", async (t) => {
|
||||
const h = makeHarness(t);
|
||||
await h.bridge.startMoshSession(
|
||||
h.event,
|
||||
{
|
||||
...h.options,
|
||||
keyId: "key-1",
|
||||
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nkey\n-----END OPENSSH PRIVATE KEY-----",
|
||||
},
|
||||
{ moshClientLookup: h.lookupOpts },
|
||||
);
|
||||
|
||||
const keyPath = h.spawns[0].args[h.spawns[0].args.indexOf("-i") + 1];
|
||||
assert.equal(fs.existsSync(keyPath), true);
|
||||
|
||||
h.bridge.closeSession(h.event, { sessionId: "mosh-test-session" });
|
||||
assert.equal(fs.existsSync(keyPath), false);
|
||||
});
|
||||
|
||||
test("startMoshSession writes the saved passphrase when ssh prompts for the temp key", async (t) => {
|
||||
const h = makeHarness(t);
|
||||
await h.bridge.startMoshSession(
|
||||
h.event,
|
||||
{
|
||||
...h.options,
|
||||
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nkey\n-----END OPENSSH PRIVATE KEY-----",
|
||||
passphrase: "key-passphrase",
|
||||
},
|
||||
{ moshClientLookup: h.lookupOpts },
|
||||
);
|
||||
|
||||
h.spawns[0].emitData("Enter passphrase for key 'mosh-auth-key-1.pem':");
|
||||
|
||||
assert.deepEqual(h.spawns[0].writes, ["key-passphrase\r"]);
|
||||
});
|
||||
|
||||
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, { moshClientLookup: h.lookupOpts });
|
||||
|
||||
5
global.d.ts
vendored
5
global.d.ts
vendored
@@ -178,6 +178,11 @@ declare global {
|
||||
hostname: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
certificate?: string;
|
||||
keyId?: string;
|
||||
passphrase?: string;
|
||||
identityFilePaths?: string[];
|
||||
port?: number;
|
||||
moshServerPath?: string;
|
||||
moshClientPath?: string;
|
||||
|
||||
@@ -8,23 +8,23 @@ directly (see `electron/bridges/moshHandshake.cjs` and
|
||||
|
||||
## How binaries land here
|
||||
|
||||
1. `.github/workflows/build-mosh-binaries.yml` builds `mosh-client` on
|
||||
relevant pushes/PRs, or on a manual `workflow_dispatch`. It uses
|
||||
`scripts/build-mosh/{build-linux,build-macos,build-windows}.sh` to
|
||||
produce one binary per target from upstream `mobile-shell/mosh`
|
||||
source:
|
||||
1. `.github/workflows/build-mosh-binaries.yml` builds or fetches
|
||||
`mosh-client` on relevant pushes/PRs, or on a manual
|
||||
`workflow_dispatch`. It uses `scripts/build-mosh/build-linux.sh` and
|
||||
`scripts/build-mosh/build-macos.sh` for Linux/macOS, and
|
||||
`scripts/build-mosh/fetch-windows.sh` for the pinned Windows binary:
|
||||
|
||||
| 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-x64` | FluentTerminal-pinned standalone fallback, SHA256 pinned |
|
||||
| `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.
|
||||
The upstream Cygwin Windows build path was removed from the default
|
||||
workflow because the tested build clears the terminal but never
|
||||
renders remote output on Windows.
|
||||
|
||||
2. When manually dispatched with `release_tag`, that workflow publishes
|
||||
the binaries to the dedicated `binaricat/Netcatty-mosh-bin`
|
||||
@@ -50,8 +50,8 @@ directly (see `electron/bridges/moshHandshake.cjs` and
|
||||
whatever happens to be installed on the developer machine.
|
||||
|
||||
Official Windows package builds currently ship x64 only for bundled
|
||||
Mosh coverage. Windows arm64 packaging should be re-enabled there
|
||||
after the `build-mosh-binaries` workflow can produce `win32-arm64`.
|
||||
Mosh coverage. Windows arm64 packaging should be added only after we
|
||||
have a tested standalone arm64 client.
|
||||
|
||||
The directory is otherwise empty (binaries are gitignored).
|
||||
|
||||
@@ -61,12 +61,12 @@ The directory is otherwise empty (binaries are gitignored).
|
||||
(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.
|
||||
- The default Windows x64 binary is the FluentTerminal-pinned
|
||||
standalone `mosh-client.exe` from
|
||||
https://github.com/felixse/FluentTerminal @ commit `bad0f85`, pinned
|
||||
by SHA256 in `scripts/fetch-mosh-binaries.cjs`. The old Cygwin build
|
||||
path is intentionally not used for Windows releases while it
|
||||
reproduces the blank-screen runtime issue.
|
||||
- Bundled/static deps (OpenSSL Apache-2.0, protobuf BSD-3-Clause,
|
||||
ncurses MIT) are compatible with GPL-3.0.
|
||||
|
||||
@@ -98,12 +98,12 @@ For macOS the build needs an Xcode toolchain; see
|
||||
- Mosh startup requires Netcatty's bundled `mosh-client` and a usable
|
||||
`ssh` client for the remote bootstrap. System-installed `mosh` /
|
||||
`mosh-client` binaries are intentionally ignored.
|
||||
- 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.
|
||||
- Windows x64 currently ships the FluentTerminal-pinned standalone
|
||||
client because the upstream Cygwin bundle can blank after terminal
|
||||
initialization on Windows.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- Cygwin arm64 port stabilizes → add a `build-windows-arm64` matrix
|
||||
leg using the same `build-windows.sh` script.
|
||||
- Add Windows arm64 only after a tested standalone arm64 client is
|
||||
available.
|
||||
- Make `MOSH_REF` track upstream release tags automatically.
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build mosh-client.exe from upstream mobile-shell/mosh source inside a
|
||||
# Cygwin environment. Phase 1 pinned a third-party prebuilt
|
||||
# (FluentTerminal); this rebuilds it in CI so we own the provenance
|
||||
# end-to-end and ship the same upstream version everywhere.
|
||||
#
|
||||
# Cygwin doesn't make full static linking practical (cygwin1.dll
|
||||
# implements the POSIX runtime; it must be present at runtime), so we
|
||||
# bundle every required Cygwin DLL alongside `mosh-client.exe`. This
|
||||
# keeps the binary reproducible and self-contained — the only
|
||||
# environmental requirement is the Cygwin Project's GPL-3.0 DLLs, all
|
||||
# of which we redistribute under their respective licenses.
|
||||
#
|
||||
# Inputs (env):
|
||||
# MOSH_REF — git ref of mobile-shell/mosh (e.g. mosh-1.4.0)
|
||||
# ARCH — x64 (only — Cygwin's arm64 port isn't release-ready)
|
||||
# OUT_DIR — directory to write mosh-client-win32-<arch>.exe + DLL bundle
|
||||
#
|
||||
# Output:
|
||||
# $OUT_DIR/mosh-client-win32-<arch>.exe
|
||||
# $OUT_DIR/mosh-client-win32-<arch>-dlls/*.dll
|
||||
# $OUT_DIR/terminfo/<first-letter-or-hash>/xterm-256color
|
||||
# $OUT_DIR/mosh-client-win32-<arch>.sha256
|
||||
#
|
||||
# Expected to run inside a Cygwin bash login shell (set up by the CI's
|
||||
# cygwin-install-action with development packages already installed).
|
||||
set -euo pipefail
|
||||
|
||||
: "${MOSH_REF:?missing MOSH_REF}"
|
||||
: "${ARCH:?missing ARCH}"
|
||||
: "${OUT_DIR:?missing OUT_DIR}"
|
||||
|
||||
validate_mosh_ref() {
|
||||
if [[ ! "$MOSH_REF" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ ]] \
|
||||
|| [[ "$MOSH_REF" == *..* ]] \
|
||||
|| [[ "$MOSH_REF" == *@\{* ]] \
|
||||
|| [[ "$MOSH_REF" == */ ]] \
|
||||
|| [[ "$MOSH_REF" == *.lock ]]; then
|
||||
echo "ERROR: invalid MOSH_REF: $MOSH_REF" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
validate_mosh_ref
|
||||
|
||||
if [ "$ARCH" != "x64" ]; then
|
||||
echo "ERROR: only ARCH=x64 supported by the Cygwin Windows build (got: $ARCH)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Sanity: must run under Cygwin so we have access to cygcheck and the
|
||||
# Cygwin gcc toolchain.
|
||||
if ! uname -a | grep -qi CYGWIN; then
|
||||
echo "ERROR: build-windows.sh must run inside a Cygwin shell." >&2
|
||||
uname -a >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
WORK=$(mktemp -d)
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
mkdir -p "$OUT_DIR"
|
||||
|
||||
cd "$WORK"
|
||||
|
||||
# Build mosh against the Cygwin-supplied OpenSSL, protobuf, ncurses.
|
||||
# Static linking against those is not supported by the upstream
|
||||
# build for Cygwin, so we accept the dynamic deps and bundle the DLLs.
|
||||
git init mosh
|
||||
git -C mosh remote add origin https://github.com/mobile-shell/mosh.git
|
||||
git -C mosh fetch --depth 1 origin "$MOSH_REF"
|
||||
git -C mosh checkout --detach FETCH_HEAD
|
||||
cd mosh
|
||||
./autogen.sh
|
||||
./configure --enable-completion=no --disable-server \
|
||||
CXXFLAGS="-O2 -static-libgcc -static-libstdc++" \
|
||||
LDFLAGS="-static-libgcc -static-libstdc++"
|
||||
make -j"$(nproc)"
|
||||
|
||||
OUT_EXE="$OUT_DIR/mosh-client-win32-x64.exe"
|
||||
DLL_DIR="$OUT_DIR/mosh-client-win32-x64-dlls"
|
||||
TERMINFO_DIR="$OUT_DIR/terminfo"
|
||||
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"
|
||||
|
||||
# mosh-client is linked to ncurses and looks up TERM=xterm-256color at
|
||||
# startup. A bare Cygwin DLL bundle does not include the compiled terminfo
|
||||
# database, so ship the exact entry that Netcatty uses.
|
||||
TERMINFO_ENTRY=$(find /usr/share/terminfo -type f -name xterm-256color | head -n 1 || true)
|
||||
if [ -z "$TERMINFO_ENTRY" ]; then
|
||||
echo "ERROR: failed to find /usr/share/terminfo/**/xterm-256color for mosh-client.exe." >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$TERMINFO_DIR/$(basename "$(dirname "$TERMINFO_ENTRY")")"
|
||||
cp "$TERMINFO_ENTRY" "$TERMINFO_DIR/$(basename "$(dirname "$TERMINFO_ENTRY")")/xterm-256color"
|
||||
|
||||
echo "--- bundled terminfo ---"
|
||||
find "$TERMINFO_DIR" -type f -print
|
||||
|
||||
# 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"
|
||||
cp -R "$TERMINFO_DIR" "$BUNDLE_DIR/terminfo"
|
||||
( cd "$BUNDLE_DIR" && tar -czf "$BUNDLE_TGZ" \
|
||||
"mosh-client.exe" \
|
||||
"mosh-client-win32-x64-dlls" \
|
||||
"terminfo" )
|
||||
|
||||
( 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"
|
||||
@@ -1,12 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# Phase-1 source: pin to the FluentTerminal-shipped mosh-cygwin standalone
|
||||
# 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.
|
||||
#
|
||||
# dependency). FluentTerminal is GPL-3.0, same license as Netcatty, and
|
||||
# the binary itself is GPL-3.0 from upstream mobile-shell/mosh.
|
||||
# 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.
|
||||
|
||||
@@ -23,6 +23,13 @@
|
||||
// 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.
|
||||
// MOSH_BIN_FORCE_WINDOWS_CYGWIN=true — debug escape hatch for the upstream
|
||||
// Cygwin Windows bundle. The default Windows x64 asset
|
||||
// is the FluentTerminal-pinned standalone client because
|
||||
// the current Cygwin build clears the terminal and never
|
||||
// renders remote output on Windows.
|
||||
// MOSH_BIN_WINDOWS_LEGACY_URL / MOSH_BIN_WINDOWS_LEGACY_SHA256 — test/mirror
|
||||
// overrides for that pinned Windows fallback.
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
@@ -35,15 +42,24 @@ const { main: resolveMoshBinRelease } = require("./resolve-mosh-bin-release.cjs"
|
||||
|
||||
const ROOT = path.resolve(__dirname, "..");
|
||||
const DEFAULT_RES_DIR = path.join(ROOT, "resources", "mosh");
|
||||
const WINDOWS_LEGACY_FLUENT_MOSH_CLIENT = {
|
||||
id: "windows-fluentterminal-standalone",
|
||||
file: "mosh-client-win32-x64.exe",
|
||||
local: "win32-x64/mosh-client.exe",
|
||||
url: "https://raw.githubusercontent.com/felixse/FluentTerminal/bad0f85/Dependencies/MoshExecutables/x64/mosh-client.exe",
|
||||
sha256: "5a8d84ff205c6a0711e53b961f909484a892f42648807e52d46d4fa93c05e286",
|
||||
};
|
||||
|
||||
// (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.
|
||||
//
|
||||
// All targets are tar.gz bundles containing the binary plus the runtime
|
||||
// helpers each platform needs: ncurses terminfo on every platform, plus
|
||||
// the Cygwin DLL set on Windows. Bundling terminfo lets the bundled
|
||||
// statically-linked mosh-client work on minimal hosts that don't have a
|
||||
// Linux/macOS targets are tar.gz bundles containing the binary plus the
|
||||
// runtime helpers each platform needs. Windows x64 defaults to the
|
||||
// SHA256-pinned FluentTerminal standalone exe because the tested Cygwin
|
||||
// bundle clears the terminal and never renders remote output on Windows.
|
||||
// Bundling terminfo lets bundled Posix mosh-client builds work on
|
||||
// minimal hosts that don't have a
|
||||
// system ncurses-base — see issue #890.
|
||||
//
|
||||
// `legacy` describes the pre-bundle artifact name some published mosh
|
||||
@@ -67,18 +83,38 @@ const TARGETS = [
|
||||
file: "mosh-client-darwin-universal.tar.gz", localDir: "darwin-universal", extract: "tar.gz",
|
||||
legacy: { 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" },
|
||||
{
|
||||
platform: "win32", arch: "x64",
|
||||
file: "mosh-client-win32-x64.tar.gz", localDir: "win32-x64", extract: "tar.gz",
|
||||
legacy: WINDOWS_LEGACY_FLUENT_MOSH_CLIENT,
|
||||
preferLegacy: true,
|
||||
},
|
||||
];
|
||||
|
||||
function selectReleaseAsset(target, sums) {
|
||||
function applyReleaseAssetOverrides(asset, opts = {}) {
|
||||
if (asset.id !== WINDOWS_LEGACY_FLUENT_MOSH_CLIENT.id) return asset;
|
||||
return {
|
||||
...asset,
|
||||
url: opts.windowsLegacyUrl || asset.url,
|
||||
sha256: opts.windowsLegacySha256 || asset.sha256,
|
||||
};
|
||||
}
|
||||
|
||||
function selectReleaseAsset(target, sums, opts = {}) {
|
||||
const primary = { file: target.file, extract: target.extract, local: target.local, localDir: target.localDir };
|
||||
if (!target.legacy) return primary;
|
||||
if (target.preferLegacy && !opts.forceWindowsCygwin) {
|
||||
if (sums.has(target.legacy.file)) {
|
||||
return { file: target.legacy.file, local: target.legacy.local };
|
||||
}
|
||||
return applyReleaseAssetOverrides(target.legacy, opts);
|
||||
}
|
||||
// SHA256SUMS unavailable (allowUnverified mirror) — keep the primary
|
||||
// and let download / extraction errors surface naturally.
|
||||
if (sums.size === 0) return primary;
|
||||
if (sums.has(target.file)) return primary;
|
||||
if (sums.has(target.legacy.file)) {
|
||||
return { file: target.legacy.file, local: target.legacy.local };
|
||||
return applyReleaseAssetOverrides({ file: target.legacy.file, local: target.legacy.local }, opts);
|
||||
}
|
||||
return primary;
|
||||
}
|
||||
@@ -299,13 +335,28 @@ function unpackTarGz(buf, target, { resDir }) {
|
||||
return destDir;
|
||||
}
|
||||
|
||||
function writeFlatAsset(buf, target, asset, { resDir }) {
|
||||
const dest = path.join(resDir, asset.local);
|
||||
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-mosh-flat-"));
|
||||
const tmpDest = path.join(tmpRoot, path.basename(dest));
|
||||
try {
|
||||
fs.writeFileSync(tmpDest, buf);
|
||||
if (target.platform !== "win32") fs.chmodSync(tmpDest, 0o755);
|
||||
replaceDir(tmpRoot, path.dirname(dest));
|
||||
} catch (err) {
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
throw err;
|
||||
}
|
||||
return dest;
|
||||
}
|
||||
|
||||
async function fetchOne(target, sums, opts) {
|
||||
const { baseUrl, resDir, allowUnverified = false } = opts;
|
||||
const asset = selectReleaseAsset(target, sums);
|
||||
const asset = selectReleaseAsset(target, sums, opts);
|
||||
if (asset.file !== target.file) {
|
||||
log(`using legacy asset ${asset.file} (release predates the bundled tar.gz layout for ${target.platform}-${target.arch})`);
|
||||
log(`using legacy asset ${asset.file} for ${target.platform}-${target.arch}`);
|
||||
}
|
||||
const url = `${baseUrl}/${asset.file}`;
|
||||
const url = asset.url || `${baseUrl}/${asset.file}`;
|
||||
let buf;
|
||||
try {
|
||||
buf = await follow(url);
|
||||
@@ -313,7 +364,7 @@ async function fetchOne(target, sums, opts) {
|
||||
throw new Error(`download failed for ${asset.file}: ${err.message}`);
|
||||
}
|
||||
|
||||
const expected = sums.get(asset.file);
|
||||
const expected = asset.sha256 || sums.get(asset.file);
|
||||
const actual = crypto.createHash("sha256").update(buf).digest("hex");
|
||||
if (expected && expected !== actual) {
|
||||
throw new Error(`SHA256 mismatch for ${asset.file}: expected ${expected}, got ${actual}`);
|
||||
@@ -331,10 +382,7 @@ async function fetchOne(target, sums, opts) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const dest = path.join(resDir, asset.local);
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
fs.writeFileSync(dest, buf);
|
||||
if (target.platform !== "win32") fs.chmodSync(dest, 0o755);
|
||||
const dest = writeFlatAsset(buf, target, asset, { resDir });
|
||||
log(`wrote ${path.relative(ROOT, dest)} (${buf.length} bytes, sha256=${actual})`);
|
||||
return true;
|
||||
}
|
||||
@@ -366,6 +414,7 @@ async function main(argv = process.argv.slice(2), env = process.env) {
|
||||
`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 forceWindowsCygwin = env.MOSH_BIN_FORCE_WINDOWS_CYGWIN === "true";
|
||||
const platformFilter = hostTarget?.platform || platformArg;
|
||||
const archFilter = hostTarget?.arch || archArg;
|
||||
|
||||
@@ -377,7 +426,14 @@ async function main(argv = process.argv.slice(2), env = process.env) {
|
||||
if (platformFilter && target.platform !== platformFilter) continue;
|
||||
if (archFilter && target.arch !== archFilter) continue;
|
||||
total += 1;
|
||||
if (await fetchOne(target, sums, { baseUrl, resDir, allowUnverified })) ok += 1;
|
||||
if (await fetchOne(target, sums, {
|
||||
baseUrl,
|
||||
resDir,
|
||||
allowUnverified,
|
||||
forceWindowsCygwin,
|
||||
windowsLegacyUrl: env.MOSH_BIN_WINDOWS_LEGACY_URL,
|
||||
windowsLegacySha256: env.MOSH_BIN_WINDOWS_LEGACY_SHA256,
|
||||
})) ok += 1;
|
||||
}
|
||||
log(`done - ${ok}/${total} binaries written`);
|
||||
if (ok < total) throw new Error(`only wrote ${ok}/${total} requested binaries`);
|
||||
@@ -402,5 +458,6 @@ module.exports = {
|
||||
validateTarEntries,
|
||||
assertExtractedTreeSafe,
|
||||
unpackTarGz,
|
||||
writeFlatAsset,
|
||||
main,
|
||||
};
|
||||
|
||||
@@ -197,6 +197,7 @@ test("fetch-mosh-binaries normalizes the Windows tarball to mosh-client.exe", as
|
||||
MOSH_BIN_RELEASE: "test",
|
||||
MOSH_BIN_BASE_URL: baseUrl,
|
||||
MOSH_BIN_RES_DIR: resDir,
|
||||
MOSH_BIN_FORCE_WINDOWS_CYGWIN: "true",
|
||||
CI: "true",
|
||||
},
|
||||
stdio: "pipe",
|
||||
@@ -224,6 +225,7 @@ test("fetch-mosh-binaries accepts legacy Windows bundles without terminfo", asyn
|
||||
MOSH_BIN_RELEASE: "test",
|
||||
MOSH_BIN_BASE_URL: baseUrl,
|
||||
MOSH_BIN_RES_DIR: resDir,
|
||||
MOSH_BIN_FORCE_WINDOWS_CYGWIN: "true",
|
||||
CI: "true",
|
||||
},
|
||||
stdio: "pipe",
|
||||
@@ -255,6 +257,7 @@ test("fetch-mosh-binaries rejects invalid Windows terminfo entries", async (t) =
|
||||
MOSH_BIN_RELEASE: "test",
|
||||
MOSH_BIN_BASE_URL: baseUrl,
|
||||
MOSH_BIN_RES_DIR: resDir,
|
||||
MOSH_BIN_FORCE_WINDOWS_CYGWIN: "true",
|
||||
CI: "true",
|
||||
},
|
||||
stdio: "pipe",
|
||||
@@ -282,6 +285,7 @@ test("fetch-mosh-binaries fails when SHA256SUMS lacks the requested asset", asyn
|
||||
MOSH_BIN_RELEASE: "test",
|
||||
MOSH_BIN_BASE_URL: baseUrl,
|
||||
MOSH_BIN_RES_DIR: resDir,
|
||||
MOSH_BIN_FORCE_WINDOWS_CYGWIN: "true",
|
||||
CI: "true",
|
||||
},
|
||||
stdio: "pipe",
|
||||
@@ -324,9 +328,85 @@ test("selectReleaseAsset stays on the primary when SHA256SUMS is empty (unverifi
|
||||
assert.equal(selectReleaseAsset(target, new Map()).file, "mosh-client-linux-x64.tar.gz");
|
||||
});
|
||||
|
||||
test("selectReleaseAsset prefers the pinned Windows standalone client by default", () => {
|
||||
const target = {
|
||||
platform: "win32", arch: "x64",
|
||||
file: "mosh-client-win32-x64.tar.gz", localDir: "win32-x64", extract: "tar.gz",
|
||||
legacy: {
|
||||
id: "windows-fluentterminal-standalone",
|
||||
file: "mosh-client-win32-x64.exe",
|
||||
local: "win32-x64/mosh-client.exe",
|
||||
url: "https://example.test/mosh-client.exe",
|
||||
sha256: "abc",
|
||||
},
|
||||
preferLegacy: true,
|
||||
};
|
||||
const sums = new Map([["mosh-client-win32-x64.tar.gz", "def"]]);
|
||||
|
||||
assert.equal(selectReleaseAsset(target, sums).file, "mosh-client-win32-x64.exe");
|
||||
assert.equal(selectReleaseAsset(target, sums, { forceWindowsCygwin: true }).file, "mosh-client-win32-x64.tar.gz");
|
||||
});
|
||||
|
||||
test("selectReleaseAsset uses the released Windows standalone asset when published", () => {
|
||||
const target = {
|
||||
platform: "win32", arch: "x64",
|
||||
file: "mosh-client-win32-x64.tar.gz", localDir: "win32-x64", extract: "tar.gz",
|
||||
legacy: {
|
||||
id: "windows-fluentterminal-standalone",
|
||||
file: "mosh-client-win32-x64.exe",
|
||||
local: "win32-x64/mosh-client.exe",
|
||||
url: "https://example.test/mosh-client.exe",
|
||||
sha256: "abc",
|
||||
},
|
||||
preferLegacy: true,
|
||||
};
|
||||
const asset = selectReleaseAsset(target, new Map([["mosh-client-win32-x64.exe", "def"]]));
|
||||
|
||||
assert.equal(asset.file, "mosh-client-win32-x64.exe");
|
||||
assert.equal(asset.local, "win32-x64/mosh-client.exe");
|
||||
assert.equal(asset.url, undefined);
|
||||
assert.equal(asset.sha256, undefined);
|
||||
});
|
||||
|
||||
test("fetch-mosh-binaries downloads the pinned Windows standalone client", async (t) => {
|
||||
const resDir = path.join(makeTmp(t), "resources", "mosh");
|
||||
const flat = Buffer.from("working-windows-standalone");
|
||||
fs.mkdirSync(path.join(resDir, "win32-x64", "mosh-client-win32-x64-dlls"), { recursive: true });
|
||||
fs.mkdirSync(path.join(resDir, "win32-x64", "terminfo", "78"), { recursive: true });
|
||||
fs.writeFileSync(path.join(resDir, "win32-x64", "mosh-client-win32-x64-dlls", "cygwin1.dll"), "stale");
|
||||
fs.writeFileSync(path.join(resDir, "win32-x64", "terminfo", "78", "xterm-256color"), "stale");
|
||||
const baseUrl = await serveAssets(t, {
|
||||
SHA256SUMS: "",
|
||||
});
|
||||
const legacyBaseUrl = await serveAssets(t, {
|
||||
"mosh-client-win32-x64.exe": flat,
|
||||
});
|
||||
|
||||
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,
|
||||
MOSH_BIN_WINDOWS_LEGACY_URL: `${legacyBaseUrl}/mosh-client-win32-x64.exe`,
|
||||
MOSH_BIN_WINDOWS_LEGACY_SHA256: sha256(flat),
|
||||
CI: "true",
|
||||
},
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const dest = path.join(resDir, "win32-x64", "mosh-client.exe");
|
||||
assert.equal(fs.existsSync(dest), true);
|
||||
assert.equal(fs.readFileSync(dest, "utf8"), "working-windows-standalone");
|
||||
assert.equal(fs.existsSync(path.join(resDir, "win32-x64", "mosh-client-win32-x64-dlls")), false);
|
||||
assert.equal(fs.existsSync(path.join(resDir, "win32-x64", "terminfo")), false);
|
||||
});
|
||||
|
||||
test("fetch-mosh-binaries falls back to the legacy flat asset for older releases", async (t) => {
|
||||
const resDir = path.join(makeTmp(t), "resources", "mosh");
|
||||
const flat = Buffer.from("legacy-binary");
|
||||
fs.mkdirSync(path.join(resDir, "linux-x64", "terminfo", "78"), { recursive: true });
|
||||
fs.writeFileSync(path.join(resDir, "linux-x64", "terminfo", "78", "xterm-256color"), "stale");
|
||||
const baseUrl = await serveAssets(t, {
|
||||
"mosh-client-linux-x64": flat,
|
||||
SHA256SUMS: `${sha256(flat)} mosh-client-linux-x64\n`,
|
||||
@@ -471,6 +551,7 @@ test("fetch-mosh-binaries rejects symlinks inside Windows tarballs", { skip: pro
|
||||
MOSH_BIN_RELEASE: "test",
|
||||
MOSH_BIN_BASE_URL: baseUrl,
|
||||
MOSH_BIN_RES_DIR: path.join(makeTmp(t), "resources", "mosh"),
|
||||
MOSH_BIN_FORCE_WINDOWS_CYGWIN: "true",
|
||||
CI: "true",
|
||||
},
|
||||
stdio: "pipe",
|
||||
|
||||
@@ -57,21 +57,22 @@ function moshExtraResources(platform) {
|
||||
}
|
||||
|
||||
if (platform === "win32") {
|
||||
// Windows ships mosh-client.exe + Cygwin DLL bundle (cygwin1.dll,
|
||||
// cygcrypto-*.dll, etc.) plus the ncurses terminfo entry used by
|
||||
// TERM=xterm-256color.
|
||||
// Windows normally ships the pinned standalone mosh-client.exe. Keep
|
||||
// optional DLL/terminfo packaging so older Cygwin bundles remain usable.
|
||||
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 [];
|
||||
if (!hasFile(exe)) return [];
|
||||
const resources = [
|
||||
{ from: `resources/mosh/win32-${arch}/`, to: "mosh/", filter: ["mosh-client.exe"] },
|
||||
{
|
||||
];
|
||||
if (hasDir(dllDir)) {
|
||||
resources.push({
|
||||
from: `resources/mosh/win32-${arch}/mosh-client-win32-${arch}-dlls/`,
|
||||
to: `mosh/mosh-client-win32-${arch}-dlls/`,
|
||||
filter: ["**/*"],
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
const terminfoDir = path.join(moshRoot, `win32-${arch}`, "terminfo");
|
||||
if (hasDir(terminfoDir)) {
|
||||
resources.push({ from: `resources/mosh/win32-${arch}/terminfo/`, to: "mosh/terminfo/", filter: ["**/*"] });
|
||||
|
||||
@@ -8,7 +8,10 @@ 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 }));
|
||||
t.after(() => {
|
||||
if (process.cwd().startsWith(dir)) process.chdir(os.tmpdir());
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
return dir;
|
||||
}
|
||||
|
||||
@@ -104,3 +107,14 @@ test("moshExtraResources keeps legacy Windows bundles packageable", (t) => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("moshExtraResources packages standalone Windows mosh-client.exe", (t) => {
|
||||
const root = makeTmp(t);
|
||||
withCwdAndArch(t, root, "x64");
|
||||
writeFile(path.join(root, "resources", "mosh", "win32-x64", "mosh-client.exe"));
|
||||
|
||||
const got = moshExtraResources("win32");
|
||||
assert.deepEqual(got, [
|
||||
{ from: "resources/mosh/win32-x64/", to: "mosh/", filter: ["mosh-client.exe"] },
|
||||
]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user