From 1d91c8fbf4e22d48fe2ff0130784fadfe2062853 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=A4=A7=E7=8C=AB?= <16399091+binaricat@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:15:57 +0800 Subject: [PATCH] [codex] Add telnet URL deep link support (#1988) * Add telnet URL deep link support * Fix telnet deep link review issues * Keep ssh deep links independent from telnet registration * Address Codex telnet deep link feedback --- App.tsx | 47 ++++ application/i18n/locales/en/core.ts | 1 + application/i18n/locales/en/vault.ts | 6 +- application/i18n/locales/ru/core.ts | 1 + application/i18n/locales/ru/vault.ts | 6 +- application/i18n/locales/zh-CN/core.ts | 1 + application/i18n/locales/zh-CN/terminal.ts | 6 +- application/i18n/locales/zh-TW/core.ts | 1 + application/i18n/locales/zh-TW/terminal.ts | 6 +- application/state/sessionFactories.test.ts | 35 +++ domain/telnetDeepLink.test.ts | 274 +++++++++++++++++++++ domain/telnetDeepLink.ts | 167 +++++++++++++ electron-builder.config.cjs | 4 + electron/deepLink.cjs | 45 +++- electron/deepLink.test.cjs | 91 ++++++- electron/main.cjs | 81 ++++++ electron/preload/api.cjs | 5 + scripts/electron-builder-config.test.cjs | 6 +- types/global/netcatty-bridge-app.d.ts | 1 + 19 files changed, 769 insertions(+), 15 deletions(-) create mode 100644 application/state/sessionFactories.test.ts create mode 100644 domain/telnetDeepLink.test.ts create mode 100644 domain/telnetDeepLink.ts diff --git a/App.tsx b/App.tsx index 02ee1cd3f..adcb52d07 100755 --- a/App.tsx +++ b/App.tsx @@ -26,6 +26,7 @@ import { resolveGroupDefaults, applyGroupDefaults } from './domain/groupConfig'; import { upsertKnownHost } from './domain/knownHosts'; import { materializeHostProxyProfile } from './domain/proxyProfiles'; import { buildSshDeepLinkConnectionHost, buildSshDeepLinkEphemeralHost, buildSshDeepLinkEphemeralHostFromSaved, buildSshDeepLinkHostDraft, findSshDeepLinkHost, parseSshDeepLink } from './domain/sshDeepLink'; +import { buildTelnetDeepLinkConnectionHost, buildTelnetDeepLinkEphemeralHostFromSaved, buildTelnetDeepLinkOpenHost, findTelnetDeepLinkHost, materializeTelnetDeepLinkMatchHost, parseTelnetDeepLink } from './domain/telnetDeepLink'; import { buildJmsDeepLinkEphemeralHost, isSupportedJmsProtocol, parseJmsDeepLink } from './domain/jmsDeepLink'; import { applyEphemeralHostsUpdate, splitHostsUpdateByEphemeral } from './domain/ephemeralHosts'; import { resolveHostAuth } from './domain/sshAuth'; @@ -1062,6 +1063,52 @@ function App({ settings }: { settings: SettingsState }) { }); }, [isPeerSessionWindow]); + const _handleTelnetDeepLink = useEffectEvent((payload: { url?: string }) => { + const rawUrl = payload?.url || ''; + const target = parseTelnetDeepLink(rawUrl); + if (!target) { + toast.warning(t('deepLink.telnet.invalid')); + return; + } + + const effectiveHosts = hosts.map((host) => + materializeTelnetDeepLinkMatchHost(resolveEffectiveHost(host), identities), + ); + const matchedEffectiveHost = findTelnetDeepLinkHost(effectiveHosts, target, { + ignoreTargetUsername: Boolean(target.password), + }); + + if (matchedEffectiveHost) { + if (target.password) { + const ephemeralHost = buildTelnetDeepLinkEphemeralHostFromSaved(matchedEffectiveHost, target, { + id: crypto.randomUUID(), + now: Date.now(), + }); + setEphemeralHosts((prev) => [...prev, ephemeralHost]); + handleConnectToHost(ephemeralHost); + return; + } + handleConnectToHost(buildTelnetDeepLinkConnectionHost(matchedEffectiveHost)); + return; + } + + const ephemeralHost = buildTelnetDeepLinkOpenHost(effectiveHosts, target, { + id: crypto.randomUUID(), + now: Date.now(), + }); + setEphemeralHosts((prev) => [...prev, ephemeralHost]); + handleConnectToHost(ephemeralHost); + }); + + useEffect(() => { + if (isPeerSessionWindow) return; + const bridge = netcattyBridge.get(); + if (!bridge?.onTelnetDeepLink) return; + return bridge.onTelnetDeepLink((payload) => { + _handleTelnetDeepLink(payload); + }); + }, [isPeerSessionWindow]); + const _handleJmsDeepLink = useEffectEvent((payload: { url?: string }) => { const rawUrl = payload?.url || ''; const target = parseJmsDeepLink(rawUrl); diff --git a/application/i18n/locales/en/core.ts b/application/i18n/locales/en/core.ts index a8ebe70b7..0904083c7 100644 --- a/application/i18n/locales/en/core.ts +++ b/application/i18n/locales/en/core.ts @@ -771,6 +771,7 @@ export const enCoreMessages: Messages = { 'vault.hosts.empty.title': 'Set up your hosts', 'vault.hosts.empty.desc': 'Save hosts to quickly connect to your servers, VMs, and containers.', 'deepLink.ssh.invalid': 'Invalid ssh:// link', + 'deepLink.telnet.invalid': 'Invalid telnet:// link', 'deepLink.jms.invalid': 'Invalid JumpServer link', 'deepLink.jms.unsupported': 'Unsupported JumpServer protocol: {{protocol}}', diff --git a/application/i18n/locales/en/vault.ts b/application/i18n/locales/en/vault.ts index 5534e1c2d..6a3dd77e2 100644 --- a/application/i18n/locales/en/vault.ts +++ b/application/i18n/locales/en/vault.ts @@ -382,9 +382,9 @@ export const enVaultMessages: Messages = { 'settings.sftp.doubleClickBehavior.transfer': 'Transfer to other pane', 'settings.sftp.doubleClickBehavior.openDesc': 'Open the file in the default application', 'settings.sftp.doubleClickBehavior.transferDesc': 'Transfer the file to the other pane\'s active host', - 'settings.sshDeepLink.title': 'SSH links', - 'settings.sshDeepLink.enable': 'Open ssh:// links with Netcatty', - 'settings.sshDeepLink.enableDesc': 'Allow Netcatty to handle ssh:// links from browsers and other apps.', + 'settings.sshDeepLink.title': 'SSH and Telnet links', + 'settings.sshDeepLink.enable': 'Open ssh:// and telnet:// links with Netcatty', + 'settings.sshDeepLink.enableDesc': 'Allow Netcatty to handle ssh:// and telnet:// links from browsers and other apps.', 'settings.jmsDeepLink.title': 'JumpServer Links', 'settings.jmsDeepLink.enable': 'Handle jms:// links', 'settings.jmsDeepLink.enableDesc': 'Register Netcatty as the handler for JumpServer jms:// client links. May conflict with the official JumpServer client.', diff --git a/application/i18n/locales/ru/core.ts b/application/i18n/locales/ru/core.ts index c32654969..beff32848 100644 --- a/application/i18n/locales/ru/core.ts +++ b/application/i18n/locales/ru/core.ts @@ -737,6 +737,7 @@ export const ruCoreMessages: Messages = { 'vault.hosts.header.entries': 'Записей: {count}', 'vault.hosts.header.live': 'Активных: {count}', 'deepLink.ssh.invalid': 'Недопустимая ссылка ssh://', + 'deepLink.telnet.invalid': 'Недопустимая ссылка telnet://', 'deepLink.jms.invalid': 'Недопустимая ссылка JumpServer', 'deepLink.jms.unsupported': 'Неподдерживаемый протокол JumpServer: {{protocol}}', diff --git a/application/i18n/locales/ru/vault.ts b/application/i18n/locales/ru/vault.ts index 1b532a8b0..ba65d6593 100644 --- a/application/i18n/locales/ru/vault.ts +++ b/application/i18n/locales/ru/vault.ts @@ -417,9 +417,9 @@ export const ruVaultMessages: Messages = { 'settings.sftp.doubleClickBehavior.transfer': 'Передать в другую панель', 'settings.sftp.doubleClickBehavior.openDesc': 'Открыть файл в приложении по умолчанию', 'settings.sftp.doubleClickBehavior.transferDesc': 'Передать файл на активный хост другой панели', - 'settings.sshDeepLink.title': 'SSH-ссылки', - 'settings.sshDeepLink.enable': 'Открывать ssh:// ссылки в Netcatty', - 'settings.sshDeepLink.enableDesc': 'Разрешить Netcatty обрабатывать ssh:// ссылки из браузеров и других приложений.', + 'settings.sshDeepLink.title': 'SSH- и Telnet-ссылки', + 'settings.sshDeepLink.enable': 'Открывать ssh:// и telnet:// ссылки в Netcatty', + 'settings.sshDeepLink.enableDesc': 'Разрешить Netcatty обрабатывать ssh:// и telnet:// ссылки из браузеров и других приложений.', 'settings.jmsDeepLink.title': 'Ссылки JumpServer', 'settings.jmsDeepLink.enable': 'Обрабатывать ссылки jms://', 'settings.jmsDeepLink.enableDesc': 'Зарегистрировать Netcatty как обработчик клиентских ссылок JumpServer jms://. Может конфликтовать с официальным клиентом JumpServer.', diff --git a/application/i18n/locales/zh-CN/core.ts b/application/i18n/locales/zh-CN/core.ts index 4684e97f0..912d092c1 100644 --- a/application/i18n/locales/zh-CN/core.ts +++ b/application/i18n/locales/zh-CN/core.ts @@ -498,6 +498,7 @@ export const zhCNCoreMessages: Messages = { 'vault.hosts.empty.title': '设置你的主机', 'vault.hosts.empty.desc': '保存主机以快速连接到你的服务器、虚拟机和容器。', 'deepLink.ssh.invalid': '无效的 ssh:// 链接', + 'deepLink.telnet.invalid': '无效的 telnet:// 链接', 'deepLink.jms.invalid': '无效的 JumpServer 链接', 'deepLink.jms.unsupported': '不支持的 JumpServer 协议:{{protocol}}', diff --git a/application/i18n/locales/zh-CN/terminal.ts b/application/i18n/locales/zh-CN/terminal.ts index 871d3a6fa..015afd88a 100644 --- a/application/i18n/locales/zh-CN/terminal.ts +++ b/application/i18n/locales/zh-CN/terminal.ts @@ -104,9 +104,9 @@ export const zhCNTerminalMessages: Messages = { 'settings.sftp.doubleClickBehavior.transfer': '传输到另一侧', 'settings.sftp.doubleClickBehavior.openDesc': '使用默认应用程序打开文件', 'settings.sftp.doubleClickBehavior.transferDesc': '将文件传输到另一窗格的活动主机', - 'settings.sshDeepLink.title': 'SSH 链接', - 'settings.sshDeepLink.enable': '用 Netcatty 打开 ssh:// 链接', - 'settings.sshDeepLink.enableDesc': '允许 Netcatty 接管来自浏览器和其他应用的 ssh:// 链接。', + 'settings.sshDeepLink.title': 'SSH 和 Telnet 链接', + 'settings.sshDeepLink.enable': '用 Netcatty 打开 ssh:// 和 telnet:// 链接', + 'settings.sshDeepLink.enableDesc': '允许 Netcatty 接管来自浏览器和其他应用的 ssh://、telnet:// 链接。', 'settings.jmsDeepLink.title': 'JumpServer 链接', 'settings.jmsDeepLink.enable': '处理 jms:// 链接', 'settings.jmsDeepLink.enableDesc': '将 Netcatty 注册为 JumpServer jms:// 客户端链接的处理程序,可能与官方 JumpServer 客户端冲突。', diff --git a/application/i18n/locales/zh-TW/core.ts b/application/i18n/locales/zh-TW/core.ts index b67750e2a..f1bf1ac23 100644 --- a/application/i18n/locales/zh-TW/core.ts +++ b/application/i18n/locales/zh-TW/core.ts @@ -498,6 +498,7 @@ export const zhTWCoreMessages: Messages = { 'vault.hosts.empty.title': '設定你的主機', 'vault.hosts.empty.desc': '儲存主機以快速連線到你的伺服器、虛擬機器和容器。', 'deepLink.ssh.invalid': '無效的 ssh:// 連結', + 'deepLink.telnet.invalid': '無效的 telnet:// 連結', 'deepLink.jms.invalid': '無效的 JumpServer 連結', 'deepLink.jms.unsupported': '不支援的 JumpServer 協定:{{protocol}}', diff --git a/application/i18n/locales/zh-TW/terminal.ts b/application/i18n/locales/zh-TW/terminal.ts index 3a1441439..73dc0192c 100644 --- a/application/i18n/locales/zh-TW/terminal.ts +++ b/application/i18n/locales/zh-TW/terminal.ts @@ -104,9 +104,9 @@ export const zhTWTerminalMessages: Messages = { 'settings.sftp.doubleClickBehavior.transfer': '傳輸到另一側', 'settings.sftp.doubleClickBehavior.openDesc': '使用預設應用程式開啟檔案', 'settings.sftp.doubleClickBehavior.transferDesc': '將檔案傳輸到另一窗格的使用中主機', - 'settings.sshDeepLink.title': 'SSH 連結', - 'settings.sshDeepLink.enable': '用 Netcatty 開啟 ssh:// 連結', - 'settings.sshDeepLink.enableDesc': '允許 Netcatty 接管來自瀏覽器和其他應用程式的 ssh:// 連結。', + 'settings.sshDeepLink.title': 'SSH 和 Telnet 連結', + 'settings.sshDeepLink.enable': '用 Netcatty 開啟 ssh:// 和 telnet:// 連結', + 'settings.sshDeepLink.enableDesc': '允許 Netcatty 接管來自瀏覽器和其他應用程式的 ssh://、telnet:// 連結。', 'settings.jmsDeepLink.title': 'JumpServer 連結', 'settings.jmsDeepLink.enable': '處理 jms:// 連結', 'settings.jmsDeepLink.enableDesc': '將 Netcatty 註冊為 JumpServer jms:// 用戶端連結的處理程式,可能與官方 JumpServer 用戶端衝突。', diff --git a/application/state/sessionFactories.test.ts b/application/state/sessionFactories.test.ts new file mode 100644 index 000000000..59b030eec --- /dev/null +++ b/application/state/sessionFactories.test.ts @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { Host } from "../../domain/models"; +import { buildTelnetDeepLinkConnectionHost } from "../../domain/telnetDeepLink"; +import { createHostTerminalSession } from "./sessionFactories"; + +const host = (overrides: Partial): Host => ({ + id: "host-1", + label: "Host", + hostname: "example.com", + username: "alice", + port: 22, + group: "", + tags: [], + os: "linux", + protocol: "ssh", + createdAt: 1, + ...overrides, +}); + +test("createHostTerminalSession keeps telnet deep-link default port for ssh hosts with telnet enabled", () => { + const connectionHost = buildTelnetDeepLinkConnectionHost( + host({ + protocol: "ssh", + telnetEnabled: true, + telnetPort: undefined, + }), + ); + + const session = createHostTerminalSession("session-1", connectionHost); + + assert.equal(session.protocol, "telnet"); + assert.equal(session.port, 23); +}); diff --git a/domain/telnetDeepLink.test.ts b/domain/telnetDeepLink.test.ts new file mode 100644 index 000000000..4c2a5a0b8 --- /dev/null +++ b/domain/telnetDeepLink.test.ts @@ -0,0 +1,274 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import type { Host } from "./models"; +import { + buildTelnetDeepLinkConnectionHost, + buildTelnetDeepLinkEphemeralHostFromSaved, + buildTelnetDeepLinkHostDraft, + buildTelnetDeepLinkOpenHost, + findTelnetDeepLinkHost, + materializeTelnetDeepLinkMatchHost, + parseTelnetDeepLink, + shouldHandleTelnetDeepLink, +} from "./telnetDeepLink"; + +const host = (overrides: Partial): Host => ({ + id: overrides.id || "host-1", + label: overrides.label || "Example", + hostname: overrides.hostname || "example.com", + username: overrides.username ?? "", + port: overrides.port, + group: "", + tags: [], + os: "linux", + protocol: overrides.protocol ?? "telnet", + ...overrides, +}); + +test("parseTelnetDeepLink accepts host and port", () => { + assert.deepEqual(parseTelnetDeepLink("telnet://router.example.com:2001"), { + rawUrl: "telnet://router.example.com:2001", + hostname: "router.example.com", + port: 2001, + }); +}); + +test("parseTelnetDeepLink accepts credentials and IPv6 hosts", () => { + assert.deepEqual(parseTelnetDeepLink("telnet://admin:p%40ss@[2001:db8::10]:2323"), { + rawUrl: "telnet://admin:p%40ss@[2001:db8::10]:2323", + username: "admin", + password: "p@ss", + hostname: "2001:db8::10", + port: 2323, + }); +}); + +test("parseTelnetDeepLink rejects unsupported or incomplete links", () => { + assert.equal(parseTelnetDeepLink("ssh://example.com"), null); + assert.equal(parseTelnetDeepLink("telnet://"), null); + assert.equal(parseTelnetDeepLink("telnet://example.com:99999"), null); +}); + +test("shouldHandleTelnetDeepLink respects the shared deep link setting", () => { + assert.equal(shouldHandleTelnetDeepLink("telnet://example.com:23", true), true); + assert.equal(shouldHandleTelnetDeepLink("telnet://example.com:23", false), false); + assert.equal(shouldHandleTelnetDeepLink("https://example.com", true), false); +}); + +test("findTelnetDeepLinkHost matches saved telnet hosts by hostname and port", () => { + const hosts = [ + host({ id: "wrong-port", hostname: "example.com", port: 23 }), + host({ id: "match", hostname: "example.com", port: 2001 }), + host({ id: "ssh", hostname: "example.com", port: 2001, protocol: "ssh" }), + ]; + + const match = findTelnetDeepLinkHost(hosts, parseTelnetDeepLink("telnet://example.com:2001")!); + + assert.equal(match?.id, "match"); +}); + +test("findTelnetDeepLinkHost treats omitted ports as the telnet default", () => { + const hosts = [ + host({ id: "custom-port", hostname: "example.com", port: 2323 }), + host({ id: "default-port", hostname: "example.com" }), + ]; + + const match = findTelnetDeepLinkHost(hosts, parseTelnetDeepLink("telnet://example.com")!); + + assert.equal(match?.id, "default-port"); +}); + +test("findTelnetDeepLinkHost avoids ambiguous saved hosts", () => { + const hosts = [ + host({ id: "one", hostname: "example.com", port: 23 }), + host({ id: "two", hostname: "example.com", telnetEnabled: true, protocol: "ssh", telnetPort: 23 }), + ]; + + const match = findTelnetDeepLinkHost(hosts, parseTelnetDeepLink("telnet://example.com")!); + + assert.equal(match, null); +}); + +test("findTelnetDeepLinkHost can ignore URL username for one-time credential links", () => { + const hosts = [ + host({ + id: "saved", + hostname: "example.com", + port: 23, + telnetUsername: "vault-user", + }), + ]; + + const strictMatch = findTelnetDeepLinkHost( + hosts, + parseTelnetDeepLink("telnet://link-user:link-password@example.com")!, + ); + const credentialMatch = findTelnetDeepLinkHost( + hosts, + parseTelnetDeepLink("telnet://link-user:link-password@example.com")!, + { ignoreTargetUsername: true }, + ); + + assert.equal(strictMatch, null); + assert.equal(credentialMatch?.id, "saved"); +}); + +test("buildTelnetDeepLinkConnectionHost forces a saved host to open with telnet", () => { + const savedHost = host({ + id: "saved", + hostname: "example.com", + protocol: "ssh", + telnetEnabled: true, + telnetPort: 2323, + moshEnabled: true, + etEnabled: true, + }); + + const connectionHost = buildTelnetDeepLinkConnectionHost(savedHost); + + assert.equal(connectionHost.protocol, "telnet"); + assert.equal(connectionHost.telnetEnabled, true); + assert.equal(connectionHost.telnetPort, 2323); + assert.equal(connectionHost.moshEnabled, false); + assert.equal(connectionHost.etEnabled, false); +}); + +test("buildTelnetDeepLinkConnectionHost uses the telnet default for ssh hosts with telnet enabled", () => { + const savedHost = host({ + id: "saved", + hostname: "example.com", + protocol: "ssh", + port: 22, + telnetEnabled: true, + }); + + const connectionHost = buildTelnetDeepLinkConnectionHost(savedHost); + + assert.equal(connectionHost.protocol, "telnet"); + assert.equal(connectionHost.port, 23); + assert.equal(connectionHost.telnetPort, 23); +}); + +test("buildTelnetDeepLinkEphemeralHostFromSaved keeps saved settings but uses URL credentials", () => { + const savedHost = host({ + id: "saved", + label: "Saved", + hostname: "example.com", + username: "vault-user", + port: 22, + protocol: "ssh", + telnetEnabled: true, + telnetUsername: "vault-telnet-user", + telnetPassword: "vault-password", + telnetIdentityId: "identity-1", + proxyProfileId: "proxy-1", + charset: "gb18030", + group: "network", + }); + + const ephemeral = buildTelnetDeepLinkEphemeralHostFromSaved( + savedHost, + parseTelnetDeepLink("telnet://link-user:link-password@example.com")!, + { id: "ephemeral-id", now: 789 }, + ); + + assert.equal(ephemeral.id, "ephemeral-id"); + assert.equal(ephemeral.ephemeral, true); + assert.equal(ephemeral.protocol, "telnet"); + assert.equal(ephemeral.telnetEnabled, true); + assert.equal(ephemeral.telnetPort, 23); + assert.equal(ephemeral.username, "link-user"); + assert.equal(ephemeral.telnetUsername, "link-user"); + assert.equal(ephemeral.telnetPassword, "link-password"); + assert.equal(ephemeral.telnetIdentityId, undefined); + assert.equal(ephemeral.savePassword, false); + assert.equal(ephemeral.group, ""); + assert.equal(ephemeral.proxyProfileId, "proxy-1"); + assert.equal(ephemeral.charset, "gb18030"); +}); + +test("buildTelnetDeepLinkHostDraft prepares an ephemeral telnet host", () => { + const draft = buildTelnetDeepLinkHostDraft( + parseTelnetDeepLink("telnet://admin:secret@example.com:2323")!, + { id: "new-id", now: 123 }, + ); + + assert.deepEqual(draft, { + id: "new-id", + label: "admin@example.com", + hostname: "example.com", + username: "admin", + port: 2323, + group: "", + tags: [], + os: "linux", + protocol: "telnet", + telnetEnabled: true, + telnetPort: 2323, + telnetUsername: "admin", + telnetPassword: "secret", + savePassword: false, + ephemeral: true, + moshEnabled: false, + etEnabled: false, + createdAt: 123, + }); +}); + +test("materializeTelnetDeepLinkMatchHost makes telnet identity usernames matchable", () => { + const savedHost = host({ + id: "saved", + hostname: "example.com", + username: "", + protocol: "ssh", + telnetEnabled: true, + telnetIdentityId: "identity-1", + }); + + const materialized = materializeTelnetDeepLinkMatchHost(savedHost, [ + { id: "identity-1", username: "identity-user" }, + ]); + const match = findTelnetDeepLinkHost( + [materialized], + parseTelnetDeepLink("telnet://identity-user@example.com")!, + ); + + assert.equal(match?.id, "saved"); +}); + +test("buildTelnetDeepLinkOpenHost falls back to a draft host when no saved host matches", () => { + const openHost = buildTelnetDeepLinkOpenHost( + [], + parseTelnetDeepLink("telnet://missing.example.com:2001")!, + { id: "draft-id", now: 456 }, + ); + + assert.equal(openHost.id, "draft-id"); + assert.equal(openHost.hostname, "missing.example.com"); + assert.equal(openHost.port, 2001); + assert.equal(openHost.protocol, "telnet"); + assert.equal(openHost.telnetEnabled, true); + assert.equal(openHost.ephemeral, true); +}); + +test("buildTelnetDeepLinkOpenHost keeps URL password instead of strict matching a saved host", () => { + const openHost = buildTelnetDeepLinkOpenHost( + [ + host({ + id: "saved-alice", + hostname: "example.com", + port: 23, + telnetUsername: "alice", + telnetPassword: "saved-password", + }), + ], + parseTelnetDeepLink("telnet://alice:new-password@example.com")!, + { id: "draft-id", now: 456 }, + ); + + assert.equal(openHost.id, "draft-id"); + assert.equal(openHost.username, "alice"); + assert.equal(openHost.telnetUsername, "alice"); + assert.equal(openHost.telnetPassword, "new-password"); + assert.equal(openHost.ephemeral, true); +}); diff --git a/domain/telnetDeepLink.ts b/domain/telnetDeepLink.ts new file mode 100644 index 000000000..eb5efaac8 --- /dev/null +++ b/domain/telnetDeepLink.ts @@ -0,0 +1,167 @@ +import type { Host, Identity } from "./models"; + +export interface TelnetDeepLinkTarget { + rawUrl: string; + username?: string; + password?: string; + hostname: string; + port?: number; +} + +export interface TelnetDeepLinkDraftOptions { + id: string; + now: number; +} + +const DEFAULT_TELNET_PORT = 23; + +const normalizeHostname = (value: string): string => + value.trim().replace(/^\[(.*)\]$/, "$1").toLowerCase(); + +const decodeUrlComponent = (value: string): string => { + try { + return decodeURIComponent(value); + } catch { + return value; + } +}; + +const getHostPort = (host: Host): number => + host.telnetPort ?? (host.protocol === "telnet" ? host.port : undefined) ?? DEFAULT_TELNET_PORT; + +const getHostUsername = (host: Host): string => + (host.telnetUsername !== undefined ? host.telnetUsername : host.username || "").trim(); + +const isTelnetHost = (host: Host): boolean => + host.protocol === "telnet" || host.telnetEnabled === true; + +export const materializeTelnetDeepLinkMatchHost = ( + host: Host, + identities: Pick[], +): Host => { + if (!host.telnetIdentityId) return host; + const identity = identities.find((item) => item.id === host.telnetIdentityId); + const username = identity?.username?.trim(); + return username ? { ...host, telnetUsername: username } : host; +}; + +export const parseTelnetDeepLink = (rawUrl: string): TelnetDeepLinkTarget | null => { + if (typeof rawUrl !== "string") return null; + const trimmed = rawUrl.trim(); + if (!trimmed) return null; + + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + return null; + } + + if (parsed.protocol !== "telnet:") return null; + + const hostname = normalizeHostname(parsed.hostname); + if (!hostname) return null; + + const portText = parsed.port; + const port = portText ? Number(portText) : undefined; + if (port !== undefined && (!Number.isInteger(port) || port < 1 || port > 65535)) { + return null; + } + + const username = parsed.username + ? decodeUrlComponent(parsed.username).trim() + : undefined; + const password = parsed.password + ? decodeUrlComponent(parsed.password) + : undefined; + + return { + rawUrl: trimmed, + ...(username ? { username } : {}), + ...(password ? { password } : {}), + hostname, + ...(port ? { port } : {}), + }; +}; + +export const shouldHandleTelnetDeepLink = (rawUrl: string, enabled: boolean): boolean => + enabled && parseTelnetDeepLink(rawUrl) !== null; + +export const findTelnetDeepLinkHost = ( + hosts: Host[], + target: TelnetDeepLinkTarget, + options?: { ignoreTargetUsername?: boolean }, +): Host | null => { + const targetHost = normalizeHostname(target.hostname); + const targetPort = target.port ?? DEFAULT_TELNET_PORT; + const candidates = hosts.filter((host) => { + if (!isTelnetHost(host)) return false; + if (normalizeHostname(host.hostname) !== targetHost) return false; + if (!options?.ignoreTargetUsername && target.username && getHostUsername(host) !== target.username) return false; + if (getHostPort(host) !== targetPort) return false; + return true; + }); + + return candidates.length === 1 ? candidates[0] : null; +}; + +export const buildTelnetDeepLinkConnectionHost = (host: Host): Host => ({ + ...host, + protocol: "telnet", + port: getHostPort(host), + telnetEnabled: true, + telnetPort: getHostPort(host), + moshEnabled: false, + etEnabled: false, +}); + +export const buildTelnetDeepLinkEphemeralHostFromSaved = ( + effectiveSavedHost: Host, + target: TelnetDeepLinkTarget, + options: TelnetDeepLinkDraftOptions, +): Host => ({ + ...buildTelnetDeepLinkConnectionHost(effectiveSavedHost), + id: options.id, + createdAt: options.now, + ...(target.username ? { username: target.username, telnetUsername: target.username } : {}), + ...(target.password ? { telnetPassword: target.password } : {}), + telnetIdentityId: undefined, + savePassword: false, + group: "", + ephemeral: true, +}); + +export const buildTelnetDeepLinkHostDraft = ( + target: TelnetDeepLinkTarget, + options: TelnetDeepLinkDraftOptions, +): Host => ({ + id: options.id, + label: target.username ? `${target.username}@${target.hostname}` : target.hostname, + hostname: target.hostname, + username: target.username || "", + port: target.port ?? DEFAULT_TELNET_PORT, + group: "", + tags: [], + os: "linux", + protocol: "telnet", + telnetEnabled: true, + telnetPort: target.port ?? DEFAULT_TELNET_PORT, + ...(target.username ? { telnetUsername: target.username } : {}), + ...(target.password ? { telnetPassword: target.password } : {}), + savePassword: false, + ephemeral: true, + moshEnabled: false, + etEnabled: false, + createdAt: options.now, +}); + +export const buildTelnetDeepLinkOpenHost = ( + hosts: Host[], + target: TelnetDeepLinkTarget, + options: TelnetDeepLinkDraftOptions, +): Host => { + const matchedHost = target.password ? null : findTelnetDeepLinkHost(hosts, target); + return buildTelnetDeepLinkConnectionHost( + matchedHost ?? buildTelnetDeepLinkHostDraft(target, options), + ); +}; diff --git a/electron-builder.config.cjs b/electron-builder.config.cjs index c2a44c1f0..353a9be69 100644 --- a/electron-builder.config.cjs +++ b/electron-builder.config.cjs @@ -13,6 +13,10 @@ module.exports = { name: 'SSH URL', schemes: ['ssh'] }, + { + name: 'Telnet URL', + schemes: ['telnet'] + }, { name: 'JumpServer URL', schemes: ['jms'] diff --git a/electron/deepLink.cjs b/electron/deepLink.cjs index 11287b192..f22a3af03 100644 --- a/electron/deepLink.cjs +++ b/electron/deepLink.cjs @@ -2,8 +2,10 @@ const fs = require("node:fs"); const path = require("node:path"); const SSH_DEEP_LINK_CHANNEL = "netcatty:deepLink:ssh"; +const TELNET_DEEP_LINK_CHANNEL = "netcatty:deepLink:telnet"; const JMS_DEEP_LINK_CHANNEL = "netcatty:deepLink:jms"; const SSH_PROTOCOL = "ssh"; +const TELNET_PROTOCOL = "telnet"; const JMS_PROTOCOL = "jms"; const SSH_DEEP_LINK_PREFERENCES_FILE = "ssh-deep-link-preferences.json"; const JMS_DEEP_LINK_PREFERENCES_FILE = "jms-deep-link-preferences.json"; @@ -22,6 +24,10 @@ function isSshDeepLinkUrl(rawUrl) { return isDeepLinkUrl(rawUrl, SSH_PROTOCOL); } +function isTelnetDeepLinkUrl(rawUrl) { + return isDeepLinkUrl(rawUrl, TELNET_PROTOCOL); +} + function isJmsDeepLinkUrl(rawUrl) { return isDeepLinkUrl(rawUrl, JMS_PROTOCOL); } @@ -30,6 +36,10 @@ function collectSshDeepLinkUrls(argv) { return collectDeepLinkUrls(argv, SSH_PROTOCOL); } +function collectTelnetDeepLinkUrls(argv) { + return collectDeepLinkUrls(argv, TELNET_PROTOCOL); +} + function collectJmsDeepLinkUrls(argv) { return collectDeepLinkUrls(argv, JMS_PROTOCOL); } @@ -78,10 +88,18 @@ function registerSshProtocolClient(options = {}) { return registerProtocolClient({ ...options, protocol: SSH_PROTOCOL }); } +function registerTelnetProtocolClient(options = {}) { + return registerProtocolClient({ ...options, protocol: TELNET_PROTOCOL }); +} + function removeSshProtocolClient(options = {}) { return removeProtocolClient({ ...options, protocol: SSH_PROTOCOL }); } +function removeTelnetProtocolClient(options = {}) { + return removeProtocolClient({ ...options, protocol: TELNET_PROTOCOL }); +} + function registerJmsProtocolClient(options = {}) { return registerProtocolClient({ ...options, protocol: JMS_PROTOCOL }); } @@ -205,12 +223,27 @@ function applyDeepLinkProtocolClientPreference({ } function applySshProtocolClientPreference(options = {}) { - return applyDeepLinkProtocolClientPreference({ + const sshApplied = applyDeepLinkProtocolClientPreference({ enabled: options.enabled, registerClient: registerSshProtocolClient, removeClient: removeSshProtocolClient, options, }); + const telnetApplied = applyDeepLinkProtocolClientPreference({ + enabled: options.enabled, + registerClient: registerTelnetProtocolClient, + removeClient: removeTelnetProtocolClient, + options, + }); + if (sshApplied !== true && telnetApplied === true) { + applyDeepLinkProtocolClientPreference({ + enabled: options.enabled === false, + registerClient: registerTelnetProtocolClient, + removeClient: removeTelnetProtocolClient, + options, + }); + } + return sshApplied === true; } function applyJmsProtocolClientPreference(options = {}) { @@ -270,6 +303,10 @@ function shouldDeliverSshDeepLink(options = {}) { return shouldDeliverDeepLink(options); } +function shouldDeliverTelnetDeepLink(options = {}) { + return shouldDeliverDeepLink(options); +} + function shouldDeliverJmsDeepLink(options = {}) { return shouldDeliverDeepLink(options); } @@ -314,6 +351,7 @@ function applyInitialJmsDeepLinkPreference(options = {}) { module.exports = { SSH_DEEP_LINK_CHANNEL, + TELNET_DEEP_LINK_CHANNEL, JMS_DEEP_LINK_CHANNEL, applyInitialJmsDeepLinkPreference, applyInitialSshDeepLinkPreference, @@ -321,16 +359,21 @@ module.exports = { applySshProtocolClientPreference, collectJmsDeepLinkUrls, collectSshDeepLinkUrls, + collectTelnetDeepLinkUrls, isJmsDeepLinkUrl, isSshDeepLinkUrl, + isTelnetDeepLinkUrl, readJmsDeepLinkEnabledPreference, readSshDeepLinkEnabledPreference, registerJmsProtocolClient, registerSshProtocolClient, + registerTelnetProtocolClient, removeJmsProtocolClient, removeSshProtocolClient, + removeTelnetProtocolClient, shouldDeliverJmsDeepLink, shouldDeliverSshDeepLink, + shouldDeliverTelnetDeepLink, updateJmsDeepLinkEnabledPreference, updateSshDeepLinkEnabledPreference, writeJmsDeepLinkEnabledPreference, diff --git a/electron/deepLink.test.cjs b/electron/deepLink.test.cjs index caf3d9f77..314d05ce2 100644 --- a/electron/deepLink.test.cjs +++ b/electron/deepLink.test.cjs @@ -8,8 +8,10 @@ const { applySshProtocolClientPreference, collectJmsDeepLinkUrls, collectSshDeepLinkUrls, + collectTelnetDeepLinkUrls, isJmsDeepLinkUrl, isSshDeepLinkUrl, + isTelnetDeepLinkUrl, readJmsDeepLinkEnabledPreference, readSshDeepLinkEnabledPreference, shouldDeliverJmsDeepLink, @@ -41,7 +43,7 @@ test("collectSshDeepLinkUrls extracts ssh URLs from process arguments", () => { ); }); -test("applySshProtocolClientPreference registers or removes the ssh handler", () => { +test("applySshProtocolClientPreference registers or removes ssh and telnet handlers", () => { const calls = []; const app = { setAsDefaultProtocolClient: (...args) => { @@ -58,10 +60,97 @@ test("applySshProtocolClientPreference registers or removes the ssh handler", () assert.equal(applySshProtocolClientPreference({ app, enabled: false, isDev: false }), true); assert.deepEqual(calls, [ ["set", "ssh"], + ["set", "telnet"], ["remove", "ssh"], + ["remove", "telnet"], ]); }); +test("applySshProtocolClientPreference keeps ssh successful when telnet registration fails", () => { + const calls = []; + const app = { + setAsDefaultProtocolClient: (protocol) => { + calls.push(["set", protocol]); + return protocol === "ssh"; + }, + removeAsDefaultProtocolClient: (protocol) => { + calls.push(["remove", protocol]); + return protocol === "ssh"; + }, + }; + + assert.equal(applySshProtocolClientPreference({ app, enabled: true, isDev: false }), true); + assert.equal(applySshProtocolClientPreference({ app, enabled: false, isDev: false }), true); + assert.deepEqual(calls, [ + ["set", "ssh"], + ["set", "telnet"], + ["remove", "ssh"], + ["remove", "telnet"], + ]); +}); + +test("applySshProtocolClientPreference rolls back telnet registration when ssh enable fails", () => { + const calls = []; + const app = { + setAsDefaultProtocolClient: (protocol) => { + calls.push(["set", protocol]); + return protocol === "telnet"; + }, + removeAsDefaultProtocolClient: (protocol) => { + calls.push(["remove", protocol]); + return true; + }, + }; + + assert.equal(applySshProtocolClientPreference({ app, enabled: true, isDev: false }), false); + assert.deepEqual(calls, [ + ["set", "ssh"], + ["set", "telnet"], + ["remove", "telnet"], + ]); +}); + +test("applySshProtocolClientPreference restores telnet registration when ssh disable fails", () => { + const calls = []; + const app = { + setAsDefaultProtocolClient: (protocol) => { + calls.push(["set", protocol]); + return true; + }, + removeAsDefaultProtocolClient: (protocol) => { + calls.push(["remove", protocol]); + return protocol === "telnet"; + }, + }; + + assert.equal(applySshProtocolClientPreference({ app, enabled: false, isDev: false }), false); + assert.deepEqual(calls, [ + ["remove", "ssh"], + ["remove", "telnet"], + ["set", "telnet"], + ]); +}); + +test("isTelnetDeepLinkUrl accepts only telnet URLs", () => { + assert.equal(isTelnetDeepLinkUrl("telnet://example.com:2001"), true); + assert.equal(isTelnetDeepLinkUrl("TELNET://example.com:2001"), true); + assert.equal(isTelnetDeepLinkUrl("ssh://alice@example.com"), false); + assert.equal(isTelnetDeepLinkUrl("--flag"), false); +}); + +test("collectTelnetDeepLinkUrls extracts telnet URLs from process arguments", () => { + assert.deepEqual( + collectTelnetDeepLinkUrls([ + "/Applications/Netcatty.app/Contents/MacOS/Netcatty", + "--flag", + "telnet://example.com:2001", + "file:///tmp/example", + "telnet://router.local:23", + ]), + ["telnet://example.com:2001", "telnet://router.local:23"], + ); +}); + test("ssh deep link enabled preference persists outside renderer localStorage", () => { const fs = require("node:fs"); const os = require("node:os"); diff --git a/electron/main.cjs b/electron/main.cjs index 2b7d52b85..a05956515 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -65,6 +65,7 @@ const fs = require("node:fs"); const { getCliDiscoveryFilePath } = require("./cli/discoveryPath.cjs"); const { SSH_DEEP_LINK_CHANNEL, + TELNET_DEEP_LINK_CHANNEL, JMS_DEEP_LINK_CHANNEL, applyInitialJmsDeepLinkPreference, applyInitialSshDeepLinkPreference, @@ -72,12 +73,15 @@ const { applySshProtocolClientPreference, collectJmsDeepLinkUrls, collectSshDeepLinkUrls, + collectTelnetDeepLinkUrls, isJmsDeepLinkUrl, isSshDeepLinkUrl, + isTelnetDeepLinkUrl, readJmsDeepLinkEnabledPreference, readSshDeepLinkEnabledPreference, shouldDeliverJmsDeepLink, shouldDeliverSshDeepLink, + shouldDeliverTelnetDeepLink, updateJmsDeepLinkEnabledPreference, updateSshDeepLinkEnabledPreference, writeJmsDeepLinkEnabledPreference, @@ -539,8 +543,10 @@ async function createAndShowMainWindow() { let sshDeepLinkEnabled = readSshDeepLinkEnabledPreference({ app }); const pendingSshDeepLinkUrls = sshDeepLinkEnabled ? collectSshDeepLinkUrls(process.argv) : []; +const pendingTelnetDeepLinkUrls = sshDeepLinkEnabled ? collectTelnetDeepLinkUrls(process.argv) : []; const pendingOpenTerminalPaths = resolveOpenTerminalPathsFromArgs(process.argv); let flushingSshDeepLinks = false; +let flushingTelnetDeepLinks = false; let flushingOpenTerminalPaths = false; let sshDeepLinkDeliveryGeneration = 0; @@ -558,6 +564,15 @@ function queueSshDeepLink(rawUrl) { } } +function queueTelnetDeepLink(rawUrl) { + if (!sshDeepLinkEnabled) return; + if (!isTelnetDeepLinkUrl(rawUrl)) return; + pendingTelnetDeepLinkUrls.push(rawUrl); + if (app.isReady?.()) { + void flushPendingTelnetDeepLinks(); + } +} + function queueOpenTerminalPath(rawPath, options = {}) { const resolvedPath = resolveOpenTerminalPath(rawPath, options); if (!resolvedPath) return; @@ -584,6 +599,7 @@ ipcMain?.handle?.("netcatty:deepLink:ssh:setEnabled", async (_event, payload) => writePreference: (nextEnabled) => writeSshDeepLinkEnabledPreference({ app, enabled: nextEnabled }), clearPending: () => { pendingSshDeepLinkUrls.length = 0; + pendingTelnetDeepLinkUrls.length = 0; sshDeepLinkDeliveryGeneration += 1; }, }); @@ -705,6 +721,39 @@ async function deliverSshDeepLink(rawUrl, expectedGeneration = sshDeepLinkDelive } } +async function deliverTelnetDeepLink(rawUrl, expectedGeneration = sshDeepLinkDeliveryGeneration) { + if (!shouldDeliverTelnetDeepLink({ + enabled: sshDeepLinkEnabled, + deliveryGeneration: sshDeepLinkDeliveryGeneration, + expectedGeneration, + })) return; + const win = await createAndShowMainWindow(); + if (!shouldDeliverTelnetDeepLink({ + enabled: sshDeepLinkEnabled, + deliveryGeneration: sshDeepLinkDeliveryGeneration, + expectedGeneration, + })) return; + focusMainWindow(); + const windowManager = getWindowManager(); + const result = await windowManager.sendWhenRendererReady?.( + win, + TELNET_DEEP_LINK_CHANNEL, + { url: rawUrl }, + { + timeoutMs: isDev ? 30000 : 15000, + shouldSend: () => shouldDeliverTelnetDeepLink({ + enabled: sshDeepLinkEnabled, + deliveryGeneration: sshDeepLinkDeliveryGeneration, + expectedGeneration, + }), + cancelReason: "telnet-deep-link-disabled", + }, + ); + if (result && result.success === false && result.reason !== "telnet-deep-link-disabled") { + console.warn("[Main] Failed to deliver telnet:// deep link:", result.error || result.reason); + } +} + async function flushPendingSshDeepLinks() { if (flushingSshDeepLinks) return; flushingSshDeepLinks = true; @@ -724,6 +773,25 @@ async function flushPendingSshDeepLinks() { } } +async function flushPendingTelnetDeepLinks() { + if (flushingTelnetDeepLinks) return; + flushingTelnetDeepLinks = true; + try { + while (sshDeepLinkEnabled && pendingTelnetDeepLinkUrls.length > 0) { + const rawUrl = pendingTelnetDeepLinkUrls.shift(); + if (!rawUrl) continue; + await deliverTelnetDeepLink(rawUrl, sshDeepLinkDeliveryGeneration); + } + } catch (err) { + console.warn("[Main] Failed to process telnet:// deep link:", err); + } finally { + flushingTelnetDeepLinks = false; + if (sshDeepLinkEnabled && pendingTelnetDeepLinkUrls.length > 0) { + void flushPendingTelnetDeepLinks(); + } + } +} + async function deliverOpenTerminalPath(targetPath) { const win = await createAndShowMainWindow(); focusMainWindow(); @@ -796,6 +864,10 @@ if (!gotLock) { queueJmsDeepLink(rawUrl); return; } + if (isTelnetDeepLinkUrl(rawUrl)) { + queueTelnetDeepLink(rawUrl); + return; + } queueSshDeepLink(rawUrl); }); @@ -806,6 +878,7 @@ if (!gotLock) { app.on("second-instance", (_event, argv, workingDirectory) => { const jmsDeepLinkUrls = collectJmsDeepLinkUrls(argv); + const telnetDeepLinkUrls = collectTelnetDeepLinkUrls(argv); const sshDeepLinkUrls = collectSshDeepLinkUrls(argv); if (jmsDeepLinkUrls.length > 0) { if (jmsDeepLinkEnabled) { @@ -813,6 +886,12 @@ if (!gotLock) { } return; } + if (telnetDeepLinkUrls.length > 0) { + if (sshDeepLinkEnabled) { + telnetDeepLinkUrls.forEach(queueTelnetDeepLink); + } + return; + } if (sshDeepLinkUrls.length > 0) { if (sshDeepLinkEnabled) { sshDeepLinkUrls.forEach(queueSshDeepLink); @@ -845,6 +924,7 @@ if (!gotLock) { applyPreference: (enabled) => applySshProtocolClientPreference({ app, enabled, isDev }), clearPending: () => { pendingSshDeepLinkUrls.length = 0; + pendingTelnetDeepLinkUrls.length = 0; sshDeepLinkDeliveryGeneration += 1; }, }); @@ -964,6 +1044,7 @@ if (!gotLock) { // Create the main window void createAndShowMainWindow().then(() => { void flushPendingSshDeepLinks(); + void flushPendingTelnetDeepLinks(); void flushPendingJmsDeepLinks(); void flushPendingOpenTerminalPaths(); diff --git a/electron/preload/api.cjs b/electron/preload/api.cjs index 1432c0894..ff7abfaed 100644 --- a/electron/preload/api.cjs +++ b/electron/preload/api.cjs @@ -692,6 +692,11 @@ function createPreloadApi(ctx) { ipcRenderer.on("netcatty:deepLink:ssh", handler); return () => ipcRenderer.removeListener("netcatty:deepLink:ssh", handler); }, + onTelnetDeepLink: (callback) => { + const handler = (_event, payload) => callback(payload); + ipcRenderer.on("netcatty:deepLink:telnet", handler); + return () => ipcRenderer.removeListener("netcatty:deepLink:telnet", handler); + }, onOpenTerminalPath: (callback) => { const handler = (_event, payload) => callback(payload); ipcRenderer.on("netcatty:openTerminalPath", handler); diff --git a/scripts/electron-builder-config.test.cjs b/scripts/electron-builder-config.test.cjs index 3994a4671..28c505fb3 100644 --- a/scripts/electron-builder-config.test.cjs +++ b/scripts/electron-builder-config.test.cjs @@ -95,12 +95,16 @@ test("beforePack installs missing Cursor SDK platform runtime packages", () => { assert.equal(config.beforePack, "./scripts/beforePackCursorSdk.cjs"); }); -test("packaged app declares ssh and jms URL protocol support", () => { +test("packaged app declares ssh, telnet, and jms URL protocol support", () => { assert.deepEqual(config.protocols, [ { name: "SSH URL", schemes: ["ssh"], }, + { + name: "Telnet URL", + schemes: ["telnet"], + }, { name: "JumpServer URL", schemes: ["jms"], diff --git a/types/global/netcatty-bridge-app.d.ts b/types/global/netcatty-bridge-app.d.ts index 90e805009..4c29e4974 100644 --- a/types/global/netcatty-bridge-app.d.ts +++ b/types/global/netcatty-bridge-app.d.ts @@ -32,6 +32,7 @@ declare global { // Fired when an install was requested but blocked by unsaved editors (#1215). onUpdateNeedsSave?(cb: () => void): () => void; onSshDeepLink?(cb: (payload: { url?: string }) => void): () => void; + onTelnetDeepLink?(cb: (payload: { url?: string }) => void): () => void; onOpenTerminalPath?(cb: (payload: { path?: string }) => void): () => void; setSshDeepLinkEnabled?(enabled: boolean): Promise; getSshDeepLinkEnabled?(): Promise;