Compare commits

...
6 changed files with 471 additions and 101 deletions

No files matched your search

+31
View File
@@ -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,
+34
View File
@@ -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
View File
@@ -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;
+1 -1
View File
@@ -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\)/);
});
+156
View File
@@ -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`);
});
}
+114
View File
@@ -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");
});
}