mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-07 08:31:00 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a830b3f713 | ||
|
|
fa0d4ea85b | ||
|
|
29bb57e036 | ||
|
|
017d84b946 |
No files matched your search
@@ -65,6 +65,36 @@ function collectPuttyStyleDeepLinkUrls(argv) {
|
||||
return { ssh: [parsed.url], telnet: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the pending-queue items for ssh/telnet connections requested by the
|
||||
* current launch arguments. PuTTY-style CLI args (-ssh user@host -P 22 -pw
|
||||
* pass) are an explicit launch method and must connect regardless of whether
|
||||
* Netcatty is the registered ssh:// protocol client, so those items are only
|
||||
* gated by includeSchemeUrls through the scheme-URL part of the queue.
|
||||
*/
|
||||
function collectSshDeepLinkQueueItems(argv, { includeSchemeUrls = true } = {}) {
|
||||
const puttyStyleDeepLinks = collectPuttyStyleDeepLinkUrls(argv);
|
||||
const queueItems = {
|
||||
ssh: [],
|
||||
telnet: [],
|
||||
};
|
||||
if (includeSchemeUrls) {
|
||||
collectSshDeepLinkUrls(argv).forEach((rawUrl) => {
|
||||
queueItems.ssh.push({ rawUrl, viaCommandLine: false });
|
||||
});
|
||||
collectTelnetDeepLinkUrls(argv).forEach((rawUrl) => {
|
||||
queueItems.telnet.push({ rawUrl, viaCommandLine: false });
|
||||
});
|
||||
}
|
||||
puttyStyleDeepLinks.ssh.forEach((rawUrl) => {
|
||||
queueItems.ssh.push({ rawUrl, viaCommandLine: true });
|
||||
});
|
||||
puttyStyleDeepLinks.telnet.forEach((rawUrl) => {
|
||||
queueItems.telnet.push({ rawUrl, viaCommandLine: true });
|
||||
});
|
||||
return queueItems;
|
||||
}
|
||||
|
||||
function registerProtocolClient({
|
||||
app,
|
||||
protocol,
|
||||
@@ -407,6 +437,7 @@ module.exports = {
|
||||
getSshDeepLinkRendererReadyTimeoutMs,
|
||||
collectJmsDeepLinkUrls,
|
||||
collectPuttyStyleDeepLinkUrls,
|
||||
collectSshDeepLinkQueueItems,
|
||||
collectSshDeepLinkUrls,
|
||||
collectTelnetDeepLinkUrls,
|
||||
redactPuttyCommandLinePasswords,
|
||||
|
||||
@@ -9,6 +9,7 @@ const {
|
||||
getSshDeepLinkRendererReadyTimeoutMs,
|
||||
collectJmsDeepLinkUrls,
|
||||
collectPuttyStyleDeepLinkUrls,
|
||||
collectSshDeepLinkQueueItems,
|
||||
collectSshDeepLinkUrls,
|
||||
collectTelnetDeepLinkUrls,
|
||||
isJmsDeepLinkUrl,
|
||||
@@ -74,6 +75,39 @@ test("collectPuttyStyleDeepLinkUrls leaves ssh:// tokens to the existing collect
|
||||
);
|
||||
});
|
||||
|
||||
test("collectSshDeepLinkQueueItems keeps PuTTY CLI launches when scheme URLs are disabled", () => {
|
||||
// Warm second-instance case: the running app forwarded another launch's
|
||||
// argv while the ssh:// protocol-client preference is disabled.
|
||||
assert.deepEqual(
|
||||
collectSshDeepLinkQueueItems([
|
||||
String.raw`C:\Program Files\Netcatty\Netcatty.exe`,
|
||||
"-ssh",
|
||||
"alice@10.0.0.8",
|
||||
"-P",
|
||||
"2222",
|
||||
"-pw",
|
||||
"s3cret",
|
||||
], { includeSchemeUrls: false }),
|
||||
{ ssh: [{ rawUrl: "ssh://alice:s3cret@10.0.0.8:2222", viaCommandLine: true }], telnet: [] },
|
||||
);
|
||||
});
|
||||
|
||||
test("collectSshDeepLinkQueueItems keeps scheme URL gating separate from CLI launches", () => {
|
||||
const queueItems = collectSshDeepLinkQueueItems(
|
||||
["/Applications/Netcatty.app/Contents/MacOS/Netcatty", "ssh://alice@example.com"],
|
||||
{ includeSchemeUrls: true },
|
||||
);
|
||||
assert.deepEqual(queueItems.ssh, [{ rawUrl: "ssh://alice@example.com", viaCommandLine: false }]);
|
||||
assert.deepEqual(queueItems.telnet, []);
|
||||
|
||||
const disabledQueueItems = collectSshDeepLinkQueueItems(
|
||||
["/Applications/Netcatty.app/Contents/MacOS/Netcatty", "ssh://alice@example.com"],
|
||||
{ includeSchemeUrls: false },
|
||||
);
|
||||
assert.deepEqual(disabledQueueItems.ssh, []);
|
||||
assert.deepEqual(disabledQueueItems.telnet, []);
|
||||
});
|
||||
|
||||
test("applySshProtocolClientPreference registers or removes ssh and telnet handlers", () => {
|
||||
const calls = [];
|
||||
const app = {
|
||||
|
||||
+135
-100
@@ -73,10 +73,8 @@ const {
|
||||
applyJmsProtocolClientPreference,
|
||||
applySshProtocolClientPreference,
|
||||
collectJmsDeepLinkUrls,
|
||||
collectPuttyStyleDeepLinkUrls,
|
||||
collectSshDeepLinkUrls,
|
||||
collectSshDeepLinkQueueItems,
|
||||
getSshDeepLinkRendererReadyTimeoutMs,
|
||||
collectTelnetDeepLinkUrls,
|
||||
redactPuttyCommandLinePasswords,
|
||||
isJmsDeepLinkUrl,
|
||||
isSshDeepLinkUrl,
|
||||
@@ -675,23 +673,28 @@ async function createAndShowMainWindow() {
|
||||
}
|
||||
|
||||
let sshDeepLinkEnabled = readSshDeepLinkEnabledPreference({ app });
|
||||
const puttyStyleDeepLinks = sshDeepLinkEnabled
|
||||
? collectPuttyStyleDeepLinkUrls(process.argv)
|
||||
: { ssh: [], telnet: [] };
|
||||
const pendingSshDeepLinkUrls = sshDeepLinkEnabled
|
||||
? [...collectSshDeepLinkUrls(process.argv), ...puttyStyleDeepLinks.ssh]
|
||||
: [];
|
||||
const pendingTelnetDeepLinkUrls = sshDeepLinkEnabled
|
||||
? [...collectTelnetDeepLinkUrls(process.argv), ...puttyStyleDeepLinks.telnet]
|
||||
: [];
|
||||
if (sshDeepLinkEnabled) {
|
||||
redactPuttyCommandLinePasswords(process.argv);
|
||||
}
|
||||
// PuTTY-style CLI args (-ssh user@host -P 22 -pw pass) are an explicit launch
|
||||
// method for bastion/PAM callers (#3044). They must connect even when Netcatty
|
||||
// is not the registered ssh:// protocol client (or that registration failed),
|
||||
// including when a second launch hands its argv to an already-running
|
||||
// instance. Only scheme URLs (ssh:// … / telnet:// …) follow the preference.
|
||||
const initialDeepLinkQueueItems = collectSshDeepLinkQueueItems(process.argv, {
|
||||
includeSchemeUrls: sshDeepLinkEnabled,
|
||||
});
|
||||
const pendingSshDeepLinkUrls = [...initialDeepLinkQueueItems.ssh];
|
||||
const pendingTelnetDeepLinkUrls = [...initialDeepLinkQueueItems.telnet];
|
||||
// Snapshot the pristine argv before scrubbing it in place below: the
|
||||
// single-instance handoff forwards this list to the running instance and a
|
||||
// redacted copy would make warm PuTTY-style launches authenticate with the
|
||||
// masked password.
|
||||
const rawLaunchArgvForHandoff = [...process.argv];
|
||||
redactPuttyCommandLinePasswords(process.argv);
|
||||
const pendingOpenTerminalPaths = resolveOpenTerminalPathsFromArgs(process.argv);
|
||||
let flushingSshDeepLinks = false;
|
||||
let flushingTelnetDeepLinks = false;
|
||||
let flushingOpenTerminalPaths = false;
|
||||
let sshDeepLinkDeliveryGeneration = 0;
|
||||
// Only scheme-originated requests are invalidated by the protocol preference.
|
||||
let sshSchemeDeliveryGeneration = 0;
|
||||
|
||||
let jmsDeepLinkEnabled = readJmsDeepLinkEnabledPreference({ app });
|
||||
const pendingJmsDeepLinkUrls = jmsDeepLinkEnabled ? collectJmsDeepLinkUrls(process.argv) : [];
|
||||
@@ -700,19 +703,19 @@ let jmsDeepLinkDeliveryGeneration = 0;
|
||||
|
||||
let explorerContextMenuEnabled = resolveExplorerContextMenuEnabled({ app }).enabled === true;
|
||||
|
||||
function queueSshDeepLink(rawUrl) {
|
||||
if (!sshDeepLinkEnabled) return;
|
||||
function queueSshDeepLink(rawUrl, { viaCommandLine = false } = {}) {
|
||||
if (!viaCommandLine && !sshDeepLinkEnabled) return;
|
||||
if (!isSshDeepLinkUrl(rawUrl)) return;
|
||||
pendingSshDeepLinkUrls.push(rawUrl);
|
||||
pendingSshDeepLinkUrls.push({ rawUrl, viaCommandLine });
|
||||
if (app.isReady?.()) {
|
||||
void flushPendingSshDeepLinks();
|
||||
}
|
||||
}
|
||||
|
||||
function queueTelnetDeepLink(rawUrl) {
|
||||
if (!sshDeepLinkEnabled) return;
|
||||
function queueTelnetDeepLink(rawUrl, { viaCommandLine = false } = {}) {
|
||||
if (!viaCommandLine && !sshDeepLinkEnabled) return;
|
||||
if (!isTelnetDeepLinkUrl(rawUrl)) return;
|
||||
pendingTelnetDeepLinkUrls.push(rawUrl);
|
||||
pendingTelnetDeepLinkUrls.push({ rawUrl, viaCommandLine });
|
||||
if (app.isReady?.()) {
|
||||
void flushPendingTelnetDeepLinks();
|
||||
}
|
||||
@@ -735,6 +738,22 @@ function queueResolvedOpenTerminalPaths(paths) {
|
||||
}
|
||||
}
|
||||
|
||||
// Drops preference-gated pending links while keeping command-line launches
|
||||
// queued — CLI args may still be delivered after the ssh:// preference flips.
|
||||
function dropSchemePendingDeepLinks() {
|
||||
// Permanently cancel requests already waiting for a window or renderer,
|
||||
// even if protocol handling is enabled again before that wait completes.
|
||||
sshSchemeDeliveryGeneration += 1;
|
||||
for (let index = pendingSshDeepLinkUrls.length - 1; index >= 0; index -= 1) {
|
||||
if (pendingSshDeepLinkUrls[index]?.viaCommandLine === true) continue;
|
||||
pendingSshDeepLinkUrls.splice(index, 1);
|
||||
}
|
||||
for (let index = pendingTelnetDeepLinkUrls.length - 1; index >= 0; index -= 1) {
|
||||
if (pendingTelnetDeepLinkUrls[index]?.viaCommandLine === true) continue;
|
||||
pendingTelnetDeepLinkUrls.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain?.handle?.("netcatty:deepLink:ssh:setEnabled", async (_event, payload) => {
|
||||
const enabled = payload?.enabled !== false;
|
||||
const result = updateSshDeepLinkEnabledPreference({
|
||||
@@ -742,11 +761,7 @@ ipcMain?.handle?.("netcatty:deepLink:ssh:setEnabled", async (_event, payload) =>
|
||||
enabled,
|
||||
applyPreference: (nextEnabled) => applySshProtocolClientPreference({ app, enabled: nextEnabled, isDev }),
|
||||
writePreference: (nextEnabled) => writeSshDeepLinkEnabledPreference({ app, enabled: nextEnabled }),
|
||||
clearPending: () => {
|
||||
pendingSshDeepLinkUrls.length = 0;
|
||||
pendingTelnetDeepLinkUrls.length = 0;
|
||||
sshDeepLinkDeliveryGeneration += 1;
|
||||
},
|
||||
clearPending: dropSchemePendingDeepLinks,
|
||||
});
|
||||
sshDeepLinkEnabled = result.enabled;
|
||||
return result;
|
||||
@@ -879,20 +894,20 @@ async function flushPendingJmsDeepLinks() {
|
||||
}
|
||||
}
|
||||
|
||||
async function deliverSshDeepLink(rawUrl, expectedGeneration = sshDeepLinkDeliveryGeneration) {
|
||||
if (!shouldDeliverSshDeepLink({
|
||||
async function deliverSshDeepLink(rawUrl, expectedGeneration = sshSchemeDeliveryGeneration, { viaCommandLine = false } = {}) {
|
||||
// The ssh:// preference can flip while a delivery is waiting for the
|
||||
// renderer, so re-check it at every gate instead of reusing the queue-time
|
||||
// snapshot. Command-line (PuTTY-style) launches bypass the preference.
|
||||
const shouldDeliver = () => viaCommandLine === true || shouldDeliverSshDeepLink({
|
||||
enabled: sshDeepLinkEnabled,
|
||||
deliveryGeneration: sshDeepLinkDeliveryGeneration,
|
||||
deliveryGeneration: sshSchemeDeliveryGeneration,
|
||||
expectedGeneration,
|
||||
})) {
|
||||
});
|
||||
if (!shouldDeliver()) {
|
||||
return { success: false, reason: "ssh-deep-link-disabled" };
|
||||
}
|
||||
const win = await createAndShowMainWindow();
|
||||
if (!shouldDeliverSshDeepLink({
|
||||
enabled: sshDeepLinkEnabled,
|
||||
deliveryGeneration: sshDeepLinkDeliveryGeneration,
|
||||
expectedGeneration,
|
||||
})) {
|
||||
if (!shouldDeliver()) {
|
||||
return { success: false, reason: "ssh-deep-link-disabled" };
|
||||
}
|
||||
focusMainWindow();
|
||||
@@ -905,11 +920,7 @@ async function deliverSshDeepLink(rawUrl, expectedGeneration = sshDeepLinkDelive
|
||||
{ url: rawUrl },
|
||||
{
|
||||
timeoutMs: getSshDeepLinkRendererReadyTimeoutMs({ isDev }),
|
||||
shouldSend: () => shouldDeliverSshDeepLink({
|
||||
enabled: sshDeepLinkEnabled,
|
||||
deliveryGeneration: sshDeepLinkDeliveryGeneration,
|
||||
expectedGeneration,
|
||||
}),
|
||||
shouldSend: shouldDeliver,
|
||||
cancelReason: "ssh-deep-link-disabled",
|
||||
},
|
||||
);
|
||||
@@ -919,18 +930,18 @@ async function deliverSshDeepLink(rawUrl, expectedGeneration = sshDeepLinkDelive
|
||||
return result || { success: true };
|
||||
}
|
||||
|
||||
async function deliverTelnetDeepLink(rawUrl, expectedGeneration = sshDeepLinkDeliveryGeneration) {
|
||||
if (!shouldDeliverTelnetDeepLink({
|
||||
async function deliverTelnetDeepLink(rawUrl, expectedGeneration = sshSchemeDeliveryGeneration, { viaCommandLine = false } = {}) {
|
||||
// Mirror deliverSshDeepLink: gate on the live preference so disabling
|
||||
// protocol handling cancels in-flight scheme deliveries, while
|
||||
// command-line launches stay deliverable.
|
||||
const shouldDeliver = () => viaCommandLine === true || shouldDeliverTelnetDeepLink({
|
||||
enabled: sshDeepLinkEnabled,
|
||||
deliveryGeneration: sshDeepLinkDeliveryGeneration,
|
||||
deliveryGeneration: sshSchemeDeliveryGeneration,
|
||||
expectedGeneration,
|
||||
})) return;
|
||||
});
|
||||
if (!shouldDeliver()) return;
|
||||
const win = await createAndShowMainWindow();
|
||||
if (!shouldDeliverTelnetDeepLink({
|
||||
enabled: sshDeepLinkEnabled,
|
||||
deliveryGeneration: sshDeepLinkDeliveryGeneration,
|
||||
expectedGeneration,
|
||||
})) return;
|
||||
if (!shouldDeliver()) return;
|
||||
focusMainWindow();
|
||||
const windowManager = getWindowManager();
|
||||
const result = await windowManager.sendWhenRendererReady?.(
|
||||
@@ -939,11 +950,7 @@ async function deliverTelnetDeepLink(rawUrl, expectedGeneration = sshDeepLinkDel
|
||||
{ url: rawUrl },
|
||||
{
|
||||
timeoutMs: 0,
|
||||
shouldSend: () => shouldDeliverTelnetDeepLink({
|
||||
enabled: sshDeepLinkEnabled,
|
||||
deliveryGeneration: sshDeepLinkDeliveryGeneration,
|
||||
expectedGeneration,
|
||||
}),
|
||||
shouldSend: shouldDeliver,
|
||||
cancelReason: "telnet-deep-link-disabled",
|
||||
},
|
||||
);
|
||||
@@ -958,20 +965,26 @@ async function flushPendingSshDeepLinks() {
|
||||
flushingSshDeepLinks = true;
|
||||
let requeueDelayMs = 0;
|
||||
try {
|
||||
while (sshDeepLinkEnabled && pendingSshDeepLinkUrls.length > 0) {
|
||||
const rawUrl = pendingSshDeepLinkUrls.shift();
|
||||
if (!rawUrl) continue;
|
||||
const result = await deliverSshDeepLink(rawUrl, sshDeepLinkDeliveryGeneration);
|
||||
while (pendingSshDeepLinkUrls.length > 0) {
|
||||
const item = pendingSshDeepLinkUrls.shift();
|
||||
if (!item?.rawUrl) continue;
|
||||
// Command-line launches bypass the ssh:// protocol-client preference;
|
||||
// scheme-originated deliveries gate on it and are cancelled once it is
|
||||
// disabled (deliverSshDeepLink re-checks the live preference).
|
||||
const expectedGeneration = sshSchemeDeliveryGeneration;
|
||||
const result = await deliverSshDeepLink(item.rawUrl, expectedGeneration, {
|
||||
viaCommandLine: item.viaCommandLine === true,
|
||||
});
|
||||
if (shouldRequeueFailedSshDeepLinkDelivery({
|
||||
enabled: sshDeepLinkEnabled,
|
||||
deliveryGeneration: sshDeepLinkDeliveryGeneration,
|
||||
expectedGeneration: sshDeepLinkDeliveryGeneration,
|
||||
enabled: item.viaCommandLine === true || sshDeepLinkEnabled,
|
||||
deliveryGeneration: item.viaCommandLine === true ? expectedGeneration : sshSchemeDeliveryGeneration,
|
||||
expectedGeneration,
|
||||
result,
|
||||
cancelReason: "ssh-deep-link-disabled",
|
||||
})) {
|
||||
// Window died or delivery failed while the link is still valid — keep it
|
||||
// queued for the next successful window/renderer ready cycle.
|
||||
pendingSshDeepLinkUrls.unshift(rawUrl);
|
||||
pendingSshDeepLinkUrls.unshift(item);
|
||||
requeueDelayMs = 1000;
|
||||
break;
|
||||
}
|
||||
@@ -980,7 +993,7 @@ async function flushPendingSshDeepLinks() {
|
||||
console.warn("[Main] Failed to process ssh:// deep link:", err);
|
||||
} finally {
|
||||
flushingSshDeepLinks = false;
|
||||
if (sshDeepLinkEnabled && pendingSshDeepLinkUrls.length > 0) {
|
||||
if (pendingSshDeepLinkUrls.length > 0) {
|
||||
if (requeueDelayMs > 0) {
|
||||
setTimeout(() => {
|
||||
void flushPendingSshDeepLinks();
|
||||
@@ -997,18 +1010,24 @@ async function flushPendingTelnetDeepLinks() {
|
||||
flushingTelnetDeepLinks = true;
|
||||
let requeueDelayMs = 0;
|
||||
try {
|
||||
while (sshDeepLinkEnabled && pendingTelnetDeepLinkUrls.length > 0) {
|
||||
const rawUrl = pendingTelnetDeepLinkUrls.shift();
|
||||
if (!rawUrl) continue;
|
||||
const result = await deliverTelnetDeepLink(rawUrl, sshDeepLinkDeliveryGeneration);
|
||||
while (pendingTelnetDeepLinkUrls.length > 0) {
|
||||
const item = pendingTelnetDeepLinkUrls.shift();
|
||||
if (!item?.rawUrl) continue;
|
||||
// Command-line launches bypass the ssh:// protocol-client preference;
|
||||
// scheme-originated deliveries gate on it and are cancelled once it is
|
||||
// disabled (deliverTelnetDeepLink re-checks the live preference).
|
||||
const expectedGeneration = sshSchemeDeliveryGeneration;
|
||||
const result = await deliverTelnetDeepLink(item.rawUrl, expectedGeneration, {
|
||||
viaCommandLine: item.viaCommandLine === true,
|
||||
});
|
||||
if (shouldRequeueFailedSshDeepLinkDelivery({
|
||||
enabled: sshDeepLinkEnabled,
|
||||
deliveryGeneration: sshDeepLinkDeliveryGeneration,
|
||||
expectedGeneration: sshDeepLinkDeliveryGeneration,
|
||||
enabled: item.viaCommandLine === true || sshDeepLinkEnabled,
|
||||
deliveryGeneration: item.viaCommandLine === true ? expectedGeneration : sshSchemeDeliveryGeneration,
|
||||
expectedGeneration,
|
||||
result,
|
||||
cancelReason: "telnet-deep-link-disabled",
|
||||
})) {
|
||||
pendingTelnetDeepLinkUrls.unshift(rawUrl);
|
||||
pendingTelnetDeepLinkUrls.unshift(item);
|
||||
requeueDelayMs = 1000;
|
||||
break;
|
||||
}
|
||||
@@ -1017,7 +1036,7 @@ async function flushPendingTelnetDeepLinks() {
|
||||
console.warn("[Main] Failed to process telnet:// deep link:", err);
|
||||
} finally {
|
||||
flushingTelnetDeepLinks = false;
|
||||
if (sshDeepLinkEnabled && pendingTelnetDeepLinkUrls.length > 0) {
|
||||
if (pendingTelnetDeepLinkUrls.length > 0) {
|
||||
if (requeueDelayMs > 0) {
|
||||
setTimeout(() => {
|
||||
void flushPendingTelnetDeepLinks();
|
||||
@@ -1141,7 +1160,15 @@ function showStartupError(err) {
|
||||
// Ensure single-instance behavior — must run before app.whenReady() so
|
||||
// the second instance never attempts to register the app:// protocol or
|
||||
// create a BrowserWindow (which would fail with ERR_FAILED).
|
||||
const gotLock = app.requestSingleInstanceLock();
|
||||
// The raw argument list rides along as additionalData: the second-instance
|
||||
// event delivers Chromium-regrouped argv (all dash switches before positional
|
||||
// args), which separates PuTTY-style flags from their values and breaks the
|
||||
// CLI connection parser. The second process's own process.argv preserves the
|
||||
// original order.
|
||||
const gotLock = app.requestSingleInstanceLock({ rawLaunchArgv: rawLaunchArgvForHandoff.slice(1) });
|
||||
// Electron has synchronously copied the handoff data. Release our pristine
|
||||
// snapshot so a primary instance does not retain the launch password forever.
|
||||
rawLaunchArgvForHandoff.length = 0;
|
||||
if (!gotLock) {
|
||||
app.quit();
|
||||
} else {
|
||||
@@ -1163,19 +1190,27 @@ if (!gotLock) {
|
||||
queueOpenTerminalPath(filePath);
|
||||
});
|
||||
|
||||
app.on("second-instance", (_event, argv, workingDirectory) => {
|
||||
const jmsDeepLinkUrls = collectJmsDeepLinkUrls(argv);
|
||||
const puttyStyleDeepLinks = collectPuttyStyleDeepLinkUrls(argv);
|
||||
const telnetDeepLinkUrls = [
|
||||
...collectTelnetDeepLinkUrls(argv),
|
||||
...puttyStyleDeepLinks.telnet,
|
||||
];
|
||||
const sshDeepLinkUrls = [
|
||||
...collectSshDeepLinkUrls(argv),
|
||||
...puttyStyleDeepLinks.ssh,
|
||||
];
|
||||
if (jmsDeepLinkUrls.length > 0 || sshDeepLinkUrls.length > 0 || telnetDeepLinkUrls.length > 0) {
|
||||
redactPuttyCommandLinePasswords(argv);
|
||||
app.on("second-instance", (_event, argv, workingDirectory, additionalData) => {
|
||||
// Prefer the raw argument list forwarded by the second process (see the
|
||||
// requestSingleInstanceLock comment) — the regrouped event argv separates
|
||||
// PuTTY-style flags from their values, so fall back only when the second
|
||||
// instance predates the raw-argv handoff.
|
||||
const rawLaunchArgv = Array.isArray(additionalData?.rawLaunchArgv)
|
||||
? additionalData.rawLaunchArgv
|
||||
: null;
|
||||
const secondInstanceArgv = rawLaunchArgv ? [argv[0], ...rawLaunchArgv] : argv;
|
||||
const jmsDeepLinkUrls = collectJmsDeepLinkUrls(secondInstanceArgv);
|
||||
// Scheme URLs follow the ssh:// protocol-client preference; PuTTY-style
|
||||
// CLI args from a bastion/PAM launch always queue (viaCommandLine).
|
||||
const deepLinkQueueItems = collectSshDeepLinkQueueItems(secondInstanceArgv, {
|
||||
includeSchemeUrls: sshDeepLinkEnabled,
|
||||
});
|
||||
if (jmsDeepLinkUrls.length > 0 || deepLinkQueueItems.ssh.length > 0 || deepLinkQueueItems.telnet.length > 0) {
|
||||
redactPuttyCommandLinePasswords(secondInstanceArgv);
|
||||
// The regrouped event argv still holds the raw -pw value; scrub it too.
|
||||
if (secondInstanceArgv !== argv) {
|
||||
redactPuttyCommandLinePasswords(argv);
|
||||
}
|
||||
}
|
||||
if (jmsDeepLinkUrls.length > 0) {
|
||||
if (jmsDeepLinkEnabled) {
|
||||
@@ -1183,21 +1218,21 @@ if (!gotLock) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (telnetDeepLinkUrls.length > 0) {
|
||||
if (sshDeepLinkEnabled) {
|
||||
telnetDeepLinkUrls.forEach(queueTelnetDeepLink);
|
||||
}
|
||||
if (deepLinkQueueItems.telnet.length > 0) {
|
||||
deepLinkQueueItems.telnet.forEach((item) => {
|
||||
queueTelnetDeepLink(item.rawUrl, { viaCommandLine: item.viaCommandLine });
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (sshDeepLinkUrls.length > 0) {
|
||||
if (sshDeepLinkEnabled) {
|
||||
sshDeepLinkUrls.forEach(queueSshDeepLink);
|
||||
}
|
||||
if (deepLinkQueueItems.ssh.length > 0) {
|
||||
deepLinkQueueItems.ssh.forEach((item) => {
|
||||
queueSshDeepLink(item.rawUrl, { viaCommandLine: item.viaCommandLine });
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (collectOpenTerminalPathArgs(argv).length > 0) {
|
||||
if (collectOpenTerminalPathArgs(secondInstanceArgv).length > 0) {
|
||||
const baseDirectory = typeof workingDirectory === "string" ? workingDirectory : undefined;
|
||||
const openTerminalPaths = resolveOpenTerminalPathsFromArgs(argv, { baseDirectory });
|
||||
const openTerminalPaths = resolveOpenTerminalPathsFromArgs(secondInstanceArgv, { baseDirectory });
|
||||
if (openTerminalPaths.length > 0) {
|
||||
queueResolvedOpenTerminalPaths(openTerminalPaths);
|
||||
} else {
|
||||
@@ -1230,10 +1265,10 @@ if (!gotLock) {
|
||||
const initialSshDeepLinkPreference = applyInitialSshDeepLinkPreference({
|
||||
enabled: sshDeepLinkEnabled,
|
||||
applyPreference: (enabled) => applySshProtocolClientPreference({ app, enabled, isDev }),
|
||||
// Failed protocol registration must not drop command-line launch intents
|
||||
// (they do not depend on being the ssh:// protocol client).
|
||||
clearPending: () => {
|
||||
pendingSshDeepLinkUrls.length = 0;
|
||||
pendingTelnetDeepLinkUrls.length = 0;
|
||||
sshDeepLinkDeliveryGeneration += 1;
|
||||
dropSchemePendingDeepLinks();
|
||||
},
|
||||
});
|
||||
sshDeepLinkEnabled = initialSshDeepLinkPreference.enabled;
|
||||
|
||||
@@ -30,7 +30,7 @@ test("failed locked launch-intent deliveries return to their queues", () => {
|
||||
const terminalFlush = readFunction("flushPendingOpenTerminalPaths", "hasPendingColdStartLaunchIntents");
|
||||
|
||||
assert.match(jmsFlush, /pendingJmsDeepLinkUrls\.unshift\(rawUrl\)/);
|
||||
assert.match(telnetFlush, /pendingTelnetDeepLinkUrls\.unshift\(rawUrl\)/);
|
||||
assert.match(telnetFlush, /pendingTelnetDeepLinkUrls\.unshift\(item\)/);
|
||||
assert.match(terminalFlush, /pendingOpenTerminalPaths\.unshift\(targetPath\)/);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const { readFileSync } = require("node:fs");
|
||||
const test = require("node:test");
|
||||
const vm = require("node:vm");
|
||||
const deepLink = require("./deepLink.cjs");
|
||||
|
||||
const source = readFileSync(require.resolve("./main.cjs"), "utf8");
|
||||
|
||||
function section(start, end) {
|
||||
const from = source.indexOf(start);
|
||||
const to = source.indexOf(end, from);
|
||||
assert.ok(from >= 0 && to > from);
|
||||
return source.slice(from, to);
|
||||
}
|
||||
|
||||
// Execute the main process's actual queue, preference handler, and delivery
|
||||
// functions. Only desktop services and renderer readiness are controlled here.
|
||||
function createHarness() {
|
||||
const handlers = new Map();
|
||||
const deliveries = [];
|
||||
const waiting = [];
|
||||
const timers = [];
|
||||
const sandbox = {
|
||||
...deepLink,
|
||||
process: { argv: ["netcatty"] },
|
||||
app: { isReady: () => false },
|
||||
ipcMain: { handle: (name, handler) => handlers.set(name, handler) },
|
||||
isDev: false,
|
||||
readSshDeepLinkEnabledPreference: () => true,
|
||||
readJmsDeepLinkEnabledPreference: () => false,
|
||||
resolveOpenTerminalPathsFromArgs: () => [],
|
||||
resolveExplorerContextMenuEnabled: () => ({ enabled: false }),
|
||||
applySshProtocolClientPreference: () => true,
|
||||
writeSshDeepLinkEnabledPreference: () => true,
|
||||
createAndShowMainWindow: async () => ({}),
|
||||
focusMainWindow: () => {},
|
||||
getWindowManager: () => ({
|
||||
sendWhenRendererReady: (_win, channel, payload, options) => new Promise((resolve) => {
|
||||
waiting.push({ channel, payload, options, resolve });
|
||||
}),
|
||||
}),
|
||||
setTimeout: (callback) => timers.push(callback),
|
||||
console: { warn: () => {} },
|
||||
};
|
||||
const context = vm.createContext(sandbox);
|
||||
vm.runInContext([
|
||||
section("let sshDeepLinkEnabled =", 'ipcMain?.handle?.("netcatty:deepLink:jms:setEnabled"'),
|
||||
section("async function deliverSshDeepLink(", "async function deliverOpenTerminalPath("),
|
||||
`globalThis.launch = {
|
||||
ssh: { queue: queueSshDeepLink, flush: flushPendingSshDeepLinks, pending: pendingSshDeepLinkUrls },
|
||||
telnet: { queue: queueTelnetDeepLink, flush: flushPendingTelnetDeepLinks, pending: pendingTelnetDeepLinkUrls },
|
||||
};`,
|
||||
].join("\n"), context);
|
||||
|
||||
return {
|
||||
launch: context.launch,
|
||||
deliveries,
|
||||
timers,
|
||||
async setEnabled(enabled) {
|
||||
await handlers.get("netcatty:deepLink:ssh:setEnabled")(null, { enabled });
|
||||
},
|
||||
async nextDelivery() {
|
||||
for (let attempt = 0; attempt < 20 && waiting.length === 0; attempt++) await Promise.resolve();
|
||||
assert.ok(waiting.length > 0, "delivery must reach renderer readiness");
|
||||
const item = waiting.shift();
|
||||
return {
|
||||
finish(result) {
|
||||
if (result) return item.resolve(result);
|
||||
if (!item.options.shouldSend()) {
|
||||
return item.resolve({ success: false, reason: item.options.cancelReason });
|
||||
}
|
||||
deliveries.push({ channel: item.channel, url: item.payload.url });
|
||||
item.resolve({ success: true });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
for (const protocol of ["ssh", "telnet"]) {
|
||||
test(`${protocol}: disable/re-enable permanently cancels an in-flight scheme request`, async () => {
|
||||
const h = createHarness();
|
||||
const queue = h.launch[protocol];
|
||||
queue.queue(`${protocol}://old.example`);
|
||||
const flushing = queue.flush();
|
||||
const old = await h.nextDelivery();
|
||||
await h.setEnabled(false);
|
||||
await h.setEnabled(true);
|
||||
old.finish();
|
||||
await flushing;
|
||||
assert.deepEqual(h.deliveries, []);
|
||||
assert.equal(queue.pending.length, 0);
|
||||
assert.equal(h.timers.length, 0);
|
||||
});
|
||||
|
||||
test(`${protocol}: cancelled schemes cannot return through a failed-delivery retry`, async () => {
|
||||
const h = createHarness();
|
||||
const queue = h.launch[protocol];
|
||||
queue.queue(`${protocol}://old.example`);
|
||||
const flushing = queue.flush();
|
||||
const old = await h.nextDelivery();
|
||||
await h.setEnabled(false);
|
||||
await h.setEnabled(true);
|
||||
old.finish({ success: false, reason: "window-destroyed" });
|
||||
await flushing;
|
||||
assert.equal(queue.pending.length, 0, "a cancelled request must not be requeued");
|
||||
assert.equal(h.timers.length, 0);
|
||||
});
|
||||
|
||||
test(`${protocol}: disabling removes queued schemes but preserves queued CLI and new requests`, async () => {
|
||||
const h = createHarness();
|
||||
const queue = h.launch[protocol];
|
||||
queue.queue(`${protocol}://old.example`);
|
||||
queue.queue(`${protocol}://queued-old.example`);
|
||||
queue.queue(`${protocol}://cli.example`, { viaCommandLine: true });
|
||||
const flushing = queue.flush();
|
||||
const old = await h.nextDelivery();
|
||||
await h.setEnabled(false);
|
||||
await h.setEnabled(true);
|
||||
queue.queue(`${protocol}://new.example`);
|
||||
old.finish();
|
||||
(await h.nextDelivery()).finish();
|
||||
(await h.nextDelivery()).finish();
|
||||
await flushing;
|
||||
assert.deepEqual(h.deliveries.map((item) => item.url), [
|
||||
`${protocol}://cli.example`, `${protocol}://new.example`,
|
||||
]);
|
||||
});
|
||||
|
||||
test(`${protocol}: an in-flight CLI request survives disabling and re-enabling schemes`, async () => {
|
||||
const h = createHarness();
|
||||
const queue = h.launch[protocol];
|
||||
queue.queue(`${protocol}://cli.example`, { viaCommandLine: true });
|
||||
const flushing = queue.flush();
|
||||
const cli = await h.nextDelivery();
|
||||
await h.setEnabled(false);
|
||||
await h.setEnabled(true);
|
||||
cli.finish();
|
||||
await flushing;
|
||||
assert.deepEqual(h.deliveries.map((item) => item.url), [`${protocol}://cli.example`]);
|
||||
});
|
||||
|
||||
test(`${protocol}: CLI retries remain queued when schemes are disabled`, async () => {
|
||||
const h = createHarness();
|
||||
const queue = h.launch[protocol];
|
||||
queue.queue(`${protocol}://cli.example`, { viaCommandLine: true });
|
||||
const flushing = queue.flush();
|
||||
const cli = await h.nextDelivery();
|
||||
await h.setEnabled(false);
|
||||
cli.finish({ success: false, reason: "window-destroyed" });
|
||||
await flushing;
|
||||
assert.equal(queue.pending.length, 1);
|
||||
assert.equal(h.timers.length, 1);
|
||||
assert.equal(queue.pending[0].rawUrl, `${protocol}://cli.example`);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
|
||||
const { readFileSync } = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const vm = require("node:vm");
|
||||
const {
|
||||
collectSshDeepLinkQueueItems,
|
||||
redactPuttyCommandLinePasswords,
|
||||
} = require("./deepLink.cjs");
|
||||
|
||||
test("second instance forwards its raw argv through the single-instance lock", () => {
|
||||
const source = readFileSync(path.join(__dirname, "main.cjs"), "utf8");
|
||||
|
||||
// Chromium regroups the second launch's command line into dash switches
|
||||
// followed by positional args, which splits PuTTY-style flags from their
|
||||
// values (-ssh host ... -P 22 -pw pass) and breaks the CLI parser. The
|
||||
// launching process therefore sends its own pristine argv slice as
|
||||
// additionalData (captured before passwords get redacted in place) and the
|
||||
// handler prefers it.
|
||||
assert.match(
|
||||
source,
|
||||
/app\.requestSingleInstanceLock\(\{ rawLaunchArgv: rawLaunchArgvForHandoff\.slice\(1\) \}\)/,
|
||||
"lock acquisition must forward the raw launch argv",
|
||||
);
|
||||
const redactIndex = source.indexOf("redactPuttyCommandLinePasswords(process.argv)");
|
||||
const snapshotIndex = source.indexOf("const rawLaunchArgvForHandoff");
|
||||
assert.notEqual(redactIndex, -1);
|
||||
assert.notEqual(snapshotIndex, -1);
|
||||
assert.ok(
|
||||
snapshotIndex < redactIndex,
|
||||
"the handoff argv snapshot must be captured before passwords are redacted",
|
||||
);
|
||||
const handlerIndex = source.indexOf('app.on("second-instance"');
|
||||
assert.notEqual(handlerIndex, -1, "second-instance handler must exist");
|
||||
const handlerSource = source.slice(handlerIndex, handlerIndex + 2000);
|
||||
assert.match(handlerSource, /additionalData\?\.rawLaunchArgv/);
|
||||
assert.match(handlerSource, /secondInstanceArgv/);
|
||||
});
|
||||
|
||||
test("command-line PuTTY launches queue regardless of the ssh:// protocol-client preference", () => {
|
||||
// Warm second instance after the ssh:// protocol registration failed:
|
||||
// scheme URLs are dropped but the CLI connection must still be queued.
|
||||
const puttyItems = collectSshDeepLinkQueueItems(
|
||||
[
|
||||
String.raw`C:\Program Files\Netcatty\Netcatty.exe`,
|
||||
"-ssh",
|
||||
"alice@10.0.0.8",
|
||||
"-P",
|
||||
"2222",
|
||||
"-pw",
|
||||
"s3cret",
|
||||
],
|
||||
{ includeSchemeUrls: false },
|
||||
);
|
||||
assert.deepEqual(puttyItems, {
|
||||
ssh: [{ rawUrl: "ssh://alice:s3cret@10.0.0.8:2222", viaCommandLine: true }],
|
||||
telnet: [],
|
||||
});
|
||||
|
||||
const source = readFileSync(path.join(__dirname, "main.cjs"), "utf8");
|
||||
// Delivery bypasses the preference when the item came from the command line.
|
||||
const flushIndex = source.indexOf("async function flushPendingSshDeepLinks");
|
||||
assert.notEqual(flushIndex, -1);
|
||||
const flushSource = source.slice(
|
||||
flushIndex,
|
||||
source.indexOf("async function deliverOpenTerminalPath", flushIndex),
|
||||
);
|
||||
assert.match(flushSource, /viaCommandLine === true \|\| sshDeepLinkEnabled/);
|
||||
// The protocol registration failure path must not drop CLI launch intents.
|
||||
assert.match(source, /dropSchemePendingDeepLinks\(\)/);
|
||||
});
|
||||
|
||||
test("failed protocol registration keeps command-line launch intents queued", () => {
|
||||
const source = readFileSync(path.join(__dirname, "main.cjs"), "utf8");
|
||||
const initialIndex = source.indexOf("applyInitialSshDeepLinkPreference({");
|
||||
assert.notEqual(initialIndex, -1);
|
||||
const callSource = source.slice(initialIndex, initialIndex + 500);
|
||||
assert.match(callSource, /clearPending: \(\) => \{/);
|
||||
assert.match(callSource, /dropSchemePendingDeepLinks\(\)/);
|
||||
});
|
||||
|
||||
for (const gotLock of [true, false]) {
|
||||
test(`launch password snapshot is released after handoff (primary=${gotLock})`, () => {
|
||||
const source = readFileSync(path.join(__dirname, "main.cjs"), "utf8");
|
||||
const snapshotStart = source.indexOf("const rawLaunchArgvForHandoff");
|
||||
const snapshotEnd = source.indexOf("const pendingOpenTerminalPaths", snapshotStart);
|
||||
const lockStart = source.indexOf("const gotLock = app.requestSingleInstanceLock");
|
||||
const lockEnd = source.indexOf("if (!gotLock)", lockStart);
|
||||
assert.ok(snapshotStart >= 0 && snapshotEnd > snapshotStart);
|
||||
assert.ok(lockStart >= 0 && lockEnd > lockStart);
|
||||
const argv = ["netcatty", "-ssh", "alice@localhost", "-pw", "test password:@"];
|
||||
let handedOff;
|
||||
const context = vm.createContext({
|
||||
process: { argv },
|
||||
redactPuttyCommandLinePasswords,
|
||||
app: {
|
||||
requestSingleInstanceLock(data) {
|
||||
// Electron serializes additionalData synchronously in this call.
|
||||
handedOff = JSON.parse(JSON.stringify(data));
|
||||
return gotLock;
|
||||
},
|
||||
},
|
||||
});
|
||||
vm.runInContext([
|
||||
source.slice(snapshotStart, snapshotEnd),
|
||||
source.slice(lockStart, lockEnd),
|
||||
"globalThis.remainingArgs = rawLaunchArgvForHandoff.length;",
|
||||
].join("\n"), context);
|
||||
assert.equal(handedOff.rawLaunchArgv.at(-1), "test password:@", "forward the real password");
|
||||
assert.notEqual(argv.at(-1), "test password:@", "scrub process.argv");
|
||||
assert.equal(context.remainingArgs, 0, "do not retain a plaintext handoff snapshot");
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user