@linxin666/dsh-ssh 0.4.3 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,6 +7,33 @@ import { type ClusterResult, type ExecResult, type HostPayload, type ImportResul
7
7
  export declare class SshApiError extends Error {
8
8
  constructor(message: string);
9
9
  }
10
+ /** The location facts a terminal socket URL is built from. */
11
+ interface PageLocation {
12
+ /** Page scheme with its colon (for example `https:` or `dsh-app:`). */
13
+ protocol: string;
14
+ /** Page authority, empty for a scheme that carries none. */
15
+ host: string;
16
+ }
17
+ /**
18
+ * The WebSocket URL the terminal route is reached at, or undefined when this
19
+ * page cannot carry one.
20
+ *
21
+ * A page delivered by an application on this machine (the official DSH
22
+ * Desktop shell serves its Web GUI from `dsh-app://app/`) has no WebSocket
23
+ * transport: its scheme handler forwards HTTP requests to the local host but
24
+ * cannot upgrade a socket, so `ws://app/...` never connects and the terminal
25
+ * reports "connection error" (issue #1744). Every other page is a web page and
26
+ * resolves the socket against its own origin, exactly as before.
27
+ *
28
+ * A blank authority is treated the same as an application scheme: there is
29
+ * nothing to dial, and the caller gets the actionable reason instead of a
30
+ * socket that can only fail.
31
+ *
32
+ * @param location - the page location to read.
33
+ * @param search - the query string carrying `alias` or `session`.
34
+ * @returns the absolute `ws:`/`wss:` URL, or undefined when the page cannot carry one.
35
+ */
36
+ export declare function terminalSocketUrl(location: PageLocation, search: string): string | undefined;
10
37
  /** One open terminal connection (WebSocket JSON frames). */
11
38
  export interface TerminalConnection {
12
39
  /** Fired on the ready frame (shell is up); carries the host session id and alias. */
@@ -100,3 +127,4 @@ export declare class SshApi {
100
127
  /** One terminal socket over either an alias (open) or a session id (attach). */
101
128
  private terminalSocket;
102
129
  }
130
+ export {};
@@ -89,6 +89,7 @@ export declare const zh: {
89
89
  readonly 'terminal.ready': "终端已连接({alias})";
90
90
  readonly 'terminal.exited': "终端已退出({alias})";
91
91
  readonly 'terminal.error': "终端错误:{error}";
92
+ readonly 'terminal.noWebSocket': "当前页面不支持 WebSocket(应用外壳只转发 HTTP),请改用浏览器打开的 Web 界面使用终端。";
92
93
  readonly 'terminal.auth.title': "二次身份验证 (2FA / 交互式认证)";
93
94
  readonly 'terminal.auth.submit': "提交验证码";
94
95
  readonly 'terminal.auth.cancel': "取消";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-ssh",
3
3
  "description": "Remote SSH operations for the dsh web GUI: host config store (~/.dsh/dsh-ssh.json, import from ~/.ssh/config), a persistent ssh2 connection pool with jump-host support, exec / PTY web terminal / SFTP transfer / local port-forward tunnels / cluster execution, plus agent tools (ssh_list, ssh_exec, ssh_upload, ssh_download, ssh_tunnel, ssh_cluster). Hot-pluggable — mounted via ~/.dsh/cordis.patch.yml + a profile node_modules symlink, no dsh source changes.",
4
- "version": "0.4.3",
4
+ "version": "0.4.4",
5
5
  "icon": "icon.svg",
6
6
  "type": "module",
7
7
  "engines": {
@@ -27,7 +27,7 @@
27
27
  },
28
28
  "dsh": {
29
29
  "engines": {
30
- "dsh": ">=0.1.7-rc.2"
30
+ "dsh": ">=0.2.0-rc.1"
31
31
  },
32
32
  "bundle": {
33
33
  "patch": "./cordis.patch.yml"
@@ -50,26 +50,26 @@
50
50
  "ws": "^8.18.0"
51
51
  },
52
52
  "peerDependencies": {
53
- "@deepseek-ai/dsh": ">=0.1.7-rc.2",
53
+ "@deepseek-ai/dsh": ">=0.2.0-rc.1",
54
54
  "react": "^18.2.0",
55
55
  "react-dom": "^18.2.0"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@deepseek-ai/cordis": "^4.0.4",
59
- "@deepseek-ai/dsh-client-connection": "^0.1.7-rc.2",
60
- "@deepseek-ai/dsh-client-locale": "^0.1.7-rc.2",
61
- "@deepseek-ai/dsh-client-ui-renderer": "^0.1.7-rc.2",
62
- "@deepseek-ai/dsh-client-ui-layout": "^0.1.7-rc.2",
63
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.7-rc.2",
64
- "@deepseek-ai/dsh-client-ui-sidebar": "^0.1.7-rc.2",
65
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.7-rc.2",
66
- "@deepseek-ai/dsh-host-webserver": "^0.1.7-rc.2",
67
- "@deepseek-ai/dsh-llm": "^0.1.7-rc.2",
68
- "@deepseek-ai/dsh-scope": "^0.1.7-rc.2",
69
- "@deepseek-ai/dsh-settings": "^0.1.7-rc.2",
70
- "@deepseek-ai/dsh-system-prompt": "^0.1.7-rc.2",
71
- "@deepseek-ai/dsh-timeout": "^0.1.7-rc.2",
72
- "@deepseek-ai/dsh-tools": "^0.1.7-rc.2",
59
+ "@deepseek-ai/dsh-client-connection": "^0.2.0-rc.1",
60
+ "@deepseek-ai/dsh-client-locale": "^0.2.0-rc.1",
61
+ "@deepseek-ai/dsh-client-ui-renderer": "^0.2.0-rc.1",
62
+ "@deepseek-ai/dsh-client-ui-layout": "^0.2.0-rc.1",
63
+ "@deepseek-ai/dsh-client-ui-settings": "^0.2.0-rc.1",
64
+ "@deepseek-ai/dsh-client-ui-sidebar": "^0.2.0-rc.1",
65
+ "@deepseek-ai/dsh-client-ui-slots": "^0.2.0-rc.1",
66
+ "@deepseek-ai/dsh-host-webserver": "^0.2.0-rc.1",
67
+ "@deepseek-ai/dsh-llm": "^0.2.0-rc.1",
68
+ "@deepseek-ai/dsh-scope": "^0.2.0-rc.1",
69
+ "@deepseek-ai/dsh-settings": "^0.2.0-rc.1",
70
+ "@deepseek-ai/dsh-system-prompt": "^0.2.0-rc.1",
71
+ "@deepseek-ai/dsh-timeout": "^0.2.0-rc.1",
72
+ "@deepseek-ai/dsh-tools": "^0.2.0-rc.1",
73
73
  "@types/node": "^22.20.0",
74
74
  "@types/react": "~18.3.1",
75
75
  "@types/react-dom": "^18.3.5",
package/src/client/api.ts CHANGED
@@ -76,6 +76,79 @@ function query(params: Record<string, string | number | undefined>): string {
76
76
  return text === '' ? '' : '?' + text
77
77
  }
78
78
 
79
+ /** The location facts a terminal socket URL is built from. */
80
+ interface PageLocation {
81
+ /** Page scheme with its colon (for example `https:` or `dsh-app:`). */
82
+ protocol: string
83
+ /** Page authority, empty for a scheme that carries none. */
84
+ host: string
85
+ }
86
+
87
+ /**
88
+ * The WebSocket URL the terminal route is reached at, or undefined when this
89
+ * page cannot carry one.
90
+ *
91
+ * A page delivered by an application on this machine (the official DSH
92
+ * Desktop shell serves its Web GUI from `dsh-app://app/`) has no WebSocket
93
+ * transport: its scheme handler forwards HTTP requests to the local host but
94
+ * cannot upgrade a socket, so `ws://app/...` never connects and the terminal
95
+ * reports "connection error" (issue #1744). Every other page is a web page and
96
+ * resolves the socket against its own origin, exactly as before.
97
+ *
98
+ * A blank authority is treated the same as an application scheme: there is
99
+ * nothing to dial, and the caller gets the actionable reason instead of a
100
+ * socket that can only fail.
101
+ *
102
+ * @param location - the page location to read.
103
+ * @param search - the query string carrying `alias` or `session`.
104
+ * @returns the absolute `ws:`/`wss:` URL, or undefined when the page cannot carry one.
105
+ */
106
+ export function terminalSocketUrl(location: PageLocation, search: string): string | undefined {
107
+ if (WEB_PAGE_PROTOCOLS.includes(location.protocol)) {
108
+ const scheme = location.protocol === 'https:' ? 'wss' : 'ws'
109
+ return scheme + '://' + location.host + SSH_API.terminal + search
110
+ }
111
+ return undefined
112
+ }
113
+
114
+ /**
115
+ * Schemes a network page can be delivered with. Every other scheme belongs to
116
+ * an application on this machine, which is the same classification the remote
117
+ * channel and the update seat use (`isWebPageProtocol` / `isApplicationDeliveredPage`).
118
+ */
119
+ const WEB_PAGE_PROTOCOLS: readonly string[] = [
120
+ 'http:',
121
+ 'https:',
122
+ 'blob:',
123
+ 'data:',
124
+ 'about:',
125
+ 'filesystem:',
126
+ ]
127
+
128
+ /**
129
+ * A terminal connection that never opened: it reports one failure once the
130
+ * view has attached its `onExit`, so the tab shows why instead of sitting on a
131
+ * spinner, and every later frame is a no-op.
132
+ */
133
+ function failedTerminal(reason: string): TerminalConnection {
134
+ const connection: TerminalConnection = {
135
+ onReady: undefined,
136
+ onOutput: undefined,
137
+ onExit: undefined,
138
+ onAuthPrompt: undefined,
139
+ send: () => undefined,
140
+ resize: () => undefined,
141
+ sendAuthResponse: () => undefined,
142
+ detach: () => undefined,
143
+ close: () => undefined,
144
+ }
145
+ // The view binds onExit after the open() call returns, so the failure is
146
+ // delivered on the next microtask - never synchronously, which would race
147
+ // the handler the caller is about to assign.
148
+ queueMicrotask(() => { connection.onExit?.(null, reason) })
149
+ return connection
150
+ }
151
+
79
152
  /** One open terminal connection (WebSocket JSON frames). */
80
153
  export interface TerminalConnection {
81
154
  /** Fired on the ready frame (shell is up); carries the host session id and alias. */
@@ -375,9 +448,19 @@ export class SshApi {
375
448
 
376
449
  /** One terminal socket over either an alias (open) or a session id (attach). */
377
450
  private terminalSocket(search: string): TerminalConnection {
378
- const scheme = window.location.protocol === 'https:' ? 'wss' : 'ws'
379
- const url = scheme + '://' + window.location.host + SSH_API.terminal + search
380
- const socket = new WebSocket(url)
451
+ const target = terminalSocketUrl(window.location, search)
452
+ if (target === undefined) {
453
+ // This page has no WebSocket transport at all (the desktop shell's
454
+ // custom scheme forwards HTTP only). Say so plainly instead of opening
455
+ // a socket that can only report "connection error".
456
+ return failedTerminal(tt('terminal.noWebSocket'))
457
+ }
458
+ let socket: WebSocket
459
+ try {
460
+ socket = new WebSocket(target)
461
+ } catch (error) {
462
+ return failedTerminal(error instanceof Error ? error.message : String(error))
463
+ }
381
464
  // A deliberate detach/close must not surface as a transport error: the
382
465
  // view tears down silently, and only an UNEXPECTED close reports exit.
383
466
  let leaving = false
@@ -54,7 +54,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
54
54
  }
55
55
 
56
56
  /** Required services (fiber inject waiting — the runtime must be up first). */
57
- export const inject = ['slots', 'locale', 'configForms']
57
+ export const inject = ['slots', 'locale', 'configForms', 'layout']
58
58
 
59
59
  /** Type-only surface (export discipline: no value exports beyond the plugin contract). */
60
60
  export type { PanelControllerSnapshot } from './panel/controller.ts'
@@ -98,7 +98,7 @@ export function apply(ctx: ClientContext): void {
98
98
  const controller = new PanelController({
99
99
  panel: {
100
100
  select: panelId => {
101
- const layout = ctx.get('layout') as { selectPanel?: (id: string | null) => void } | undefined
101
+ const layout = (ctx as unknown as { get?(name: string): unknown }).get?.('layout') as { selectPanel?: (id: string | null) => void } | undefined
102
102
  layout?.selectPanel?.(panelId)
103
103
  },
104
104
  },
@@ -122,7 +122,7 @@ export function apply(ctx: ClientContext): void {
122
122
  disposers.push(registerSshPanel(ctx, controller, api, terminalFont))
123
123
  // The layout's panel selection is reconciled back into the controller, so
124
124
  // the panel follows the user clicking another panel row.
125
- const layoutFace = ctx.get('layout') as { panelInfo?: { subscribe(listener: () => void): () => void; getSnapshot(): { activePanelId: unknown } } } | undefined
125
+ const layoutFace = (ctx as unknown as { get?(name: string): unknown }).get?.('layout') as { panelInfo?: { subscribe(listener: () => void): () => void; getSnapshot(): { activePanelId: unknown } } } | undefined
126
126
  if (layoutFace?.panelInfo !== undefined) {
127
127
  const sync = (): void => {
128
128
  const active = layoutFace.panelInfo!.getSnapshot().activePanelId
@@ -93,6 +93,7 @@ export const zh = {
93
93
  'terminal.ready': '终端已连接({alias})',
94
94
  'terminal.exited': '终端已退出({alias})',
95
95
  'terminal.error': '终端错误:{error}',
96
+ 'terminal.noWebSocket': '当前页面不支持 WebSocket(应用外壳只转发 HTTP),请改用浏览器打开的 Web 界面使用终端。',
96
97
  'terminal.auth.title': '二次身份验证 (2FA / 交互式认证)',
97
98
  'terminal.auth.submit': '提交验证码',
98
99
  'terminal.auth.cancel': '取消',
@@ -253,6 +254,7 @@ export const en: Record<keyof typeof zh, string> = {
253
254
  'terminal.ready': 'Terminal connected ({alias})',
254
255
  'terminal.exited': 'Terminal exited ({alias})',
255
256
  'terminal.error': 'Terminal error: {error}',
257
+ 'terminal.noWebSocket': 'This page cannot carry a WebSocket (the application shell forwards HTTP only). Open the Web UI in a browser to use the terminal.',
256
258
  'terminal.auth.title': 'Two-Factor Authentication (2FA)',
257
259
  'terminal.auth.submit': 'Submit Code',
258
260
  'terminal.auth.cancel': 'Cancel',