zerogterm 0.5.0-alpha.1 → 0.7.0-alpha1
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.
- package/LICENSE +21 -21
- package/README.md +232 -193
- package/bin/zerogterm.cjs +23 -23
- package/dist/main/main/external-links.js +79 -0
- package/dist/main/main/local-fs.js +115 -0
- package/dist/main/main/main.js +91 -1
- package/dist/main/main/preload.cjs +58 -33
- package/dist/main/main/session-service.js +38 -7
- package/dist/main/main/sftp-protocol.js +279 -0
- package/dist/main/main/sftp-service.js +465 -0
- package/dist/main/main/shell-catalog.js +45 -0
- package/dist/main/shared/ansi.js +34 -0
- package/dist/main/shared/files.js +88 -0
- package/dist/renderer/assets/index-D5eoFnzh.css +1 -0
- package/dist/renderer/assets/index-EEjSxqRY.js +19 -0
- package/dist/renderer/index.html +4 -4
- package/package.json +67 -67
- package/dist/renderer/assets/index-BJsoL2QL.js +0 -17
- package/dist/renderer/assets/index-BPfYUrvm.css +0 -1
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// The local half of the transfer panel: directory listings and the three edits
|
|
2
|
+
// the panel can make locally.
|
|
3
|
+
//
|
|
4
|
+
// Nothing here reads file *contents*. The renderer is sandboxed and has no
|
|
5
|
+
// filesystem access of its own, and this module is deliberately the narrowest
|
|
6
|
+
// widening of that boundary that a file browser needs: names, sizes, kinds, and
|
|
7
|
+
// explicit create/rename/delete on a path the user pointed at.
|
|
8
|
+
import { constants } from 'node:fs';
|
|
9
|
+
import { access, lstat, mkdir, readdir, rename, rm, rmdir } from 'node:fs/promises';
|
|
10
|
+
import { homedir } from 'node:os';
|
|
11
|
+
import { isAbsolute, join, normalize, resolve } from 'node:path';
|
|
12
|
+
import { sortEntries } from '../shared/files.js';
|
|
13
|
+
/**
|
|
14
|
+
* A path this process will act on.
|
|
15
|
+
*
|
|
16
|
+
* Paths arrive from the renderer, so they are re-derived here rather than
|
|
17
|
+
* trusted: NUL terminates the path for the C library underneath and can make a
|
|
18
|
+
* check and the later operation disagree about which file is meant, and a
|
|
19
|
+
* relative path would resolve against whatever directory the app happens to
|
|
20
|
+
* have — never something the user chose.
|
|
21
|
+
*/
|
|
22
|
+
export function resolveLocalPath(input) {
|
|
23
|
+
if (typeof input !== 'string' || !input)
|
|
24
|
+
throw new Error('A filesystem path is required.');
|
|
25
|
+
if (input.includes('\0'))
|
|
26
|
+
throw new Error('That path is not valid.');
|
|
27
|
+
if (input.length > 4096)
|
|
28
|
+
throw new Error('That path is too long.');
|
|
29
|
+
const expanded = input === '~' || input.startsWith('~/') || input.startsWith('~\\')
|
|
30
|
+
? join(homedir(), input.slice(1))
|
|
31
|
+
: input;
|
|
32
|
+
if (!isAbsolute(expanded))
|
|
33
|
+
throw new Error('Only absolute paths can be browsed.');
|
|
34
|
+
return resolve(normalize(expanded));
|
|
35
|
+
}
|
|
36
|
+
export function localHome() {
|
|
37
|
+
return homedir();
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* List a directory.
|
|
41
|
+
*
|
|
42
|
+
* A stat per entry is what makes kind and size real, and it is allowed to fail:
|
|
43
|
+
* a broken symlink or a file that has just been deleted should leave a row in
|
|
44
|
+
* the listing rather than emptying the pane.
|
|
45
|
+
*
|
|
46
|
+
* Entries are stat'd with lstat, so a symlink is reported as a symlink whatever
|
|
47
|
+
* it points at. Reporting a link to a directory as a directory instead would be
|
|
48
|
+
* a lie with consequences: deleting it would use `rmdir`, which refuses a
|
|
49
|
+
* symlink, so the row could not be removed at all. It also matches what the
|
|
50
|
+
* remote side shows, since `ls -l` describes the link rather than its target.
|
|
51
|
+
* Entering one still works — see isNavigable — because opening it simply reads
|
|
52
|
+
* through, and a link to a file reports that it is not a directory.
|
|
53
|
+
*/
|
|
54
|
+
export async function listLocalDirectory(path) {
|
|
55
|
+
const target = path ? resolveLocalPath(path) : homedir();
|
|
56
|
+
await access(target, constants.R_OK);
|
|
57
|
+
const dirents = await readdir(target, { withFileTypes: true });
|
|
58
|
+
const entries = await Promise.all(dirents.map(async (dirent) => {
|
|
59
|
+
const full = join(target, dirent.name);
|
|
60
|
+
try {
|
|
61
|
+
const info = await lstat(full);
|
|
62
|
+
return {
|
|
63
|
+
name: dirent.name,
|
|
64
|
+
kind: info.isSymbolicLink() ? 'symlink' : info.isDirectory() ? 'directory' : 'file',
|
|
65
|
+
size: info.size,
|
|
66
|
+
modified: info.mtime.toISOString()
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return { name: dirent.name, kind: dirent.isSymbolicLink() ? 'symlink' : dirent.isDirectory() ? 'directory' : 'file', size: 0 };
|
|
71
|
+
}
|
|
72
|
+
}));
|
|
73
|
+
return { path: target, entries: sortEntries(entries) };
|
|
74
|
+
}
|
|
75
|
+
export async function createLocalDirectory(path) {
|
|
76
|
+
// No recursive create: the panel creates one folder in the directory on
|
|
77
|
+
// screen, and a mistyped path should fail rather than build a tree.
|
|
78
|
+
await mkdir(resolveLocalPath(path));
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Rename an entry, refusing to land on a name that is already taken.
|
|
82
|
+
*
|
|
83
|
+
* rename() would silently replace an existing file, and the panel's rename is a
|
|
84
|
+
* relabel rather than an overwrite. The check is not atomic: nothing in Node's
|
|
85
|
+
* API offers a no-clobber rename on both platforms, so a different process that
|
|
86
|
+
* creates that name in the moment between the check and the rename would still
|
|
87
|
+
* be overwritten. That is a guard against the user's own mistake — the name is
|
|
88
|
+
* already in the folder they are looking at — not a guarantee against another
|
|
89
|
+
* writer, and anything able to win that race could equally overwrite the file
|
|
90
|
+
* directly.
|
|
91
|
+
*/
|
|
92
|
+
export async function renameLocalEntry(from, to) {
|
|
93
|
+
const source = resolveLocalPath(from);
|
|
94
|
+
const destination = resolveLocalPath(to);
|
|
95
|
+
const clash = await lstat(destination).then(() => true, () => false);
|
|
96
|
+
if (clash)
|
|
97
|
+
throw new Error('Something with that name already exists here.');
|
|
98
|
+
await rename(source, destination);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Delete one entry.
|
|
102
|
+
*
|
|
103
|
+
* Directories use rmdir, so a non-empty one fails: this panel deletes what the
|
|
104
|
+
* user pointed at, and a recursive local delete triggered by a single click is
|
|
105
|
+
* not a mistake worth making possible.
|
|
106
|
+
*/
|
|
107
|
+
export async function removeLocalEntry(path, kind) {
|
|
108
|
+
const target = resolveLocalPath(path);
|
|
109
|
+
if (target === resolve(homedir()))
|
|
110
|
+
throw new Error('Refusing to delete the home directory.');
|
|
111
|
+
if (kind === 'directory')
|
|
112
|
+
await rmdir(target);
|
|
113
|
+
else
|
|
114
|
+
await rm(target, { force: false });
|
|
115
|
+
}
|
package/dist/main/main/main.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { app, BrowserWindow, clipboard, ipcMain, Menu, session } from 'electron';
|
|
1
|
+
import { app, BrowserWindow, clipboard, ipcMain, Menu, session, shell } from 'electron';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { writeClipboardText } from './clipboard.js';
|
|
@@ -6,10 +6,16 @@ import { ScreenService, parseWslDistributions } from './session-service.js';
|
|
|
6
6
|
import { discoverShellBackends } from './shell-catalog.js';
|
|
7
7
|
import { SessionHistoryStore, defaultHistoryPath } from './session-history.js';
|
|
8
8
|
import { buildRemoteScreenAttachArgs, buildRemoteScreenDiscoveryArgs, listKnownConnections, parseRemoteScreenList, validateKnownConnection } from './ssh-inventory.js';
|
|
9
|
+
import { createLocalDirectory, listLocalDirectory, localHome, removeLocalEntry, renameLocalEntry } from './local-fs.js';
|
|
10
|
+
import { decideExternalLink, isApplicationUrl } from './external-links.js';
|
|
11
|
+
import { SftpService } from './sftp-service.js';
|
|
9
12
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
10
13
|
const history = new SessionHistoryStore({ filePath: defaultHistoryPath(app.getPath('userData')) });
|
|
11
14
|
const service = new ScreenService({ onEvent: (event, session, available) => { void history.record(event, session, available); } });
|
|
12
15
|
let win;
|
|
16
|
+
// Transfer connections outlive any single panel opening, so the panel can be
|
|
17
|
+
// closed and reopened without re-authenticating to the host.
|
|
18
|
+
const sftp = new SftpService({ onEvent: (event) => win?.webContents.send('sftp:event', event) });
|
|
13
19
|
/** A pane's measured size, as it arrives from the renderer. */
|
|
14
20
|
function parsePtySize(value) {
|
|
15
21
|
if (!value || typeof value !== 'object')
|
|
@@ -41,6 +47,29 @@ function createWindow() {
|
|
|
41
47
|
sandbox: true
|
|
42
48
|
}
|
|
43
49
|
});
|
|
50
|
+
// A link in a pane belongs in the user's browser, not in a window of this app.
|
|
51
|
+
//
|
|
52
|
+
// Electron's default answer to window.open is a new BrowserWindow, and xterm
|
|
53
|
+
// activates an OSC 8 hyperlink by calling exactly that — so clicking a link in
|
|
54
|
+
// a terminal opened a bare Electron window with no address bar, no profile and
|
|
55
|
+
// no extensions, which is nobody's browser. Both handlers below refuse to
|
|
56
|
+
// navigate and hand the URL to the desktop instead.
|
|
57
|
+
//
|
|
58
|
+
// They are also the backstop for the renderer's own link handling: whatever
|
|
59
|
+
// asks for a window here, from any code path now or later, cannot get one.
|
|
60
|
+
win.webContents.setWindowOpenHandler(({ url }) => {
|
|
61
|
+
openExternalLink(url);
|
|
62
|
+
return { action: 'deny' };
|
|
63
|
+
});
|
|
64
|
+
// The same for a link that would replace the workspace with a web page, which
|
|
65
|
+
// would take every pane down with it.
|
|
66
|
+
const contents = win.webContents;
|
|
67
|
+
contents.on('will-navigate', (event, url) => {
|
|
68
|
+
if (isApplicationUrl(url, contents.getURL()))
|
|
69
|
+
return;
|
|
70
|
+
event.preventDefault();
|
|
71
|
+
openExternalLink(url);
|
|
72
|
+
});
|
|
44
73
|
win.webContents.on('did-fail-load', (_event, code, desc, url) => {
|
|
45
74
|
console.error('[zerog] did-fail-load', { code, desc, url });
|
|
46
75
|
});
|
|
@@ -61,11 +90,41 @@ function createWindow() {
|
|
|
61
90
|
win.on('closed', () => {
|
|
62
91
|
win = undefined;
|
|
63
92
|
service.detachAll();
|
|
93
|
+
sftp.closeAll();
|
|
64
94
|
});
|
|
65
95
|
}
|
|
66
96
|
function isRecord(value) {
|
|
67
97
|
return typeof value === 'object' && value !== null;
|
|
68
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* Hand a link to the desktop, if it is one this should open.
|
|
101
|
+
*
|
|
102
|
+
* The decision is in external-links.ts and is an allowlist of schemes: the URL
|
|
103
|
+
* comes from terminal output, and the operating system will do a great deal more
|
|
104
|
+
* with `file:` or an application's own scheme than open a web page. A refusal is
|
|
105
|
+
* reported in the status bar rather than silently dropped, so a click that does
|
|
106
|
+
* nothing still says why.
|
|
107
|
+
*/
|
|
108
|
+
function openExternalLink(url) {
|
|
109
|
+
const decision = decideExternalLink(url);
|
|
110
|
+
if (!decision.open) {
|
|
111
|
+
win?.webContents.send('links:refused', decision.reason);
|
|
112
|
+
console.warn('[zerog] refused to open link', { url, reason: decision.reason });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
void shell.openExternal(decision.url).catch((error) => {
|
|
116
|
+
win?.webContents.send('links:refused', 'That link could not be opened.');
|
|
117
|
+
console.error('[zerog] openExternal failed', error);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
ipcMain.handle('links:openExternal', (_event, url) => {
|
|
121
|
+
const decision = decideExternalLink(url);
|
|
122
|
+
// Rejecting rather than resolving quietly: the renderer puts the reason in the
|
|
123
|
+
// status bar, so the user is never told nothing at all.
|
|
124
|
+
if (!decision.open)
|
|
125
|
+
throw new Error(decision.reason);
|
|
126
|
+
return shell.openExternal(decision.url);
|
|
127
|
+
});
|
|
69
128
|
ipcMain.handle('sessions:list', () => service.list());
|
|
70
129
|
ipcMain.handle('sessions:history', () => history.list());
|
|
71
130
|
ipcMain.handle('sessions:historyRemove', (_event, entryId) => history.remove(entryId));
|
|
@@ -146,6 +205,37 @@ ipcMain.handle('clipboard:writeText', (_event, text) => {
|
|
|
146
205
|
}
|
|
147
206
|
});
|
|
148
207
|
ipcMain.handle('clipboard:readText', () => clipboard.readText());
|
|
208
|
+
/** A string that crossed the IPC boundary and is about to be used as one. */
|
|
209
|
+
function requireString(value, field) {
|
|
210
|
+
if (typeof value !== 'string' || !value)
|
|
211
|
+
throw new Error(`${field} is required.`);
|
|
212
|
+
return value;
|
|
213
|
+
}
|
|
214
|
+
function requireEntryKind(value) {
|
|
215
|
+
if (value === 'file' || value === 'directory' || value === 'symlink')
|
|
216
|
+
return value;
|
|
217
|
+
throw new Error('An entry kind of file, directory, or symlink is required.');
|
|
218
|
+
}
|
|
219
|
+
ipcMain.handle('fs:localHome', () => localHome());
|
|
220
|
+
ipcMain.handle('fs:listLocal', (_event, path) => listLocalDirectory(typeof path === 'string' && path ? path : undefined));
|
|
221
|
+
ipcMain.handle('fs:mkdirLocal', (_event, path) => createLocalDirectory(requireString(path, 'A folder path')));
|
|
222
|
+
ipcMain.handle('fs:renameLocal', (_event, from, to) => renameLocalEntry(requireString(from, 'The current path'), requireString(to, 'The new path')));
|
|
223
|
+
ipcMain.handle('fs:removeLocal', (_event, path, kind) => removeLocalEntry(requireString(path, 'A path'), requireEntryKind(kind)));
|
|
224
|
+
ipcMain.handle('sftp:open', (_event, target, cwd) => sftp.open(requireString(target, 'An SSH target'), typeof cwd === 'string' && cwd ? cwd : undefined));
|
|
225
|
+
ipcMain.handle('sftp:list', (_event, id, path) => sftp.list(requireString(id, 'A transfer connection'), typeof path === 'string' && path ? path : undefined));
|
|
226
|
+
ipcMain.handle('sftp:mkdir', (_event, id, path) => sftp.mkdir(requireString(id, 'A transfer connection'), requireString(path, 'A folder path')));
|
|
227
|
+
ipcMain.handle('sftp:rename', (_event, id, from, to) => sftp.rename(requireString(id, 'A transfer connection'), requireString(from, 'The current path'), requireString(to, 'The new path')));
|
|
228
|
+
ipcMain.handle('sftp:remove', (_event, id, path, kind) => sftp.remove(requireString(id, 'A transfer connection'), requireString(path, 'A path'), requireEntryKind(kind)));
|
|
229
|
+
ipcMain.handle('sftp:upload', (_event, id, localPath, remoteDir) => sftp.upload(requireString(id, 'A transfer connection'), requireString(localPath, 'A local file'), requireString(remoteDir, 'A remote folder')));
|
|
230
|
+
ipcMain.handle('sftp:download', (_event, id, remotePath, localDir, kind) => sftp.download(requireString(id, 'A transfer connection'), requireString(remotePath, 'A remote path'), requireString(localDir, 'A local folder'), requireEntryKind(kind)));
|
|
231
|
+
// The answer is a secret in two of the three cases, so it is passed straight
|
|
232
|
+
// through and never returned, logged, or kept.
|
|
233
|
+
ipcMain.handle('sftp:answerPrompt', (_event, id, answer) => {
|
|
234
|
+
if (typeof answer !== 'string')
|
|
235
|
+
throw new Error('An answer is required.');
|
|
236
|
+
sftp.answerPrompt(requireString(id, 'A transfer connection'), answer);
|
|
237
|
+
});
|
|
238
|
+
ipcMain.handle('sftp:close', (_event, id) => sftp.close(requireString(id, 'A transfer connection')));
|
|
149
239
|
ipcMain.handle('ai:suggest', () => ({
|
|
150
240
|
command: 'git status --short',
|
|
151
241
|
explanation: 'Read-only preview of changed files in the active workspace.'
|
|
@@ -1,33 +1,58 @@
|
|
|
1
|
-
const { contextBridge, ipcRenderer } = require('electron');
|
|
2
|
-
|
|
3
|
-
const api = {
|
|
4
|
-
listSessions: () => ipcRenderer.invoke('sessions:list'),
|
|
5
|
-
listHistory: () => ipcRenderer.invoke('sessions:history'),
|
|
6
|
-
removeHistory: (entryId) => ipcRenderer.invoke('sessions:historyRemove', entryId),
|
|
7
|
-
listBackends: () => ipcRenderer.invoke('sessions:backends'),
|
|
8
|
-
listWslDistributions: () => ipcRenderer.invoke('sessions:wslDistributions'),
|
|
9
|
-
createLocalSession: (request) => ipcRenderer.invoke('sessions:createLocal', request),
|
|
10
|
-
createSshSession: (request) => ipcRenderer.invoke('sessions:createSsh', request),
|
|
11
|
-
listKnownConnections: () => ipcRenderer.invoke('connections:listKnown'),
|
|
12
|
-
discoverRemoteScreens: (connection) => ipcRenderer.invoke('screens:discoverRemote', connection),
|
|
13
|
-
buildRemoteScreenAttach: (connection, screenName) => ipcRenderer.invoke('screens:attachRemote', connection, screenName),
|
|
14
|
-
attachSession: (id, size) => ipcRenderer.invoke('sessions:attach', id, size),
|
|
15
|
-
closeSession: (id) => ipcRenderer.invoke('sessions:close', id),
|
|
16
|
-
write: (sessionId, data) => ipcRenderer.send('terminal:write', sessionId, data),
|
|
17
|
-
resize: (sessionId, cols, rows) => ipcRenderer.send('terminal:resize', sessionId, cols, rows),
|
|
18
|
-
copyText: (text) => ipcRenderer.invoke('clipboard:writeText', text),
|
|
19
|
-
readText: () => ipcRenderer.invoke('clipboard:readText'),
|
|
20
|
-
onData: (callback) => {
|
|
21
|
-
const listener = (_event, sessionId, data) => callback(sessionId, data);
|
|
22
|
-
ipcRenderer.on('terminal:data', listener);
|
|
23
|
-
return () => ipcRenderer.removeListener('terminal:data', listener);
|
|
24
|
-
},
|
|
25
|
-
onStatus: (callback) => {
|
|
26
|
-
const listener = (_event, sessionId, message) => callback(sessionId, message);
|
|
27
|
-
ipcRenderer.on('terminal:status', listener);
|
|
28
|
-
return () => ipcRenderer.removeListener('terminal:status', listener);
|
|
29
|
-
},
|
|
30
|
-
requestAiCommand: () => ipcRenderer.invoke('ai:suggest')
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
1
|
+
const { contextBridge, ipcRenderer } = require('electron');
|
|
2
|
+
|
|
3
|
+
const api = {
|
|
4
|
+
listSessions: () => ipcRenderer.invoke('sessions:list'),
|
|
5
|
+
listHistory: () => ipcRenderer.invoke('sessions:history'),
|
|
6
|
+
removeHistory: (entryId) => ipcRenderer.invoke('sessions:historyRemove', entryId),
|
|
7
|
+
listBackends: () => ipcRenderer.invoke('sessions:backends'),
|
|
8
|
+
listWslDistributions: () => ipcRenderer.invoke('sessions:wslDistributions'),
|
|
9
|
+
createLocalSession: (request) => ipcRenderer.invoke('sessions:createLocal', request),
|
|
10
|
+
createSshSession: (request) => ipcRenderer.invoke('sessions:createSsh', request),
|
|
11
|
+
listKnownConnections: () => ipcRenderer.invoke('connections:listKnown'),
|
|
12
|
+
discoverRemoteScreens: (connection) => ipcRenderer.invoke('screens:discoverRemote', connection),
|
|
13
|
+
buildRemoteScreenAttach: (connection, screenName) => ipcRenderer.invoke('screens:attachRemote', connection, screenName),
|
|
14
|
+
attachSession: (id, size) => ipcRenderer.invoke('sessions:attach', id, size),
|
|
15
|
+
closeSession: (id) => ipcRenderer.invoke('sessions:close', id),
|
|
16
|
+
write: (sessionId, data) => ipcRenderer.send('terminal:write', sessionId, data),
|
|
17
|
+
resize: (sessionId, cols, rows) => ipcRenderer.send('terminal:resize', sessionId, cols, rows),
|
|
18
|
+
copyText: (text) => ipcRenderer.invoke('clipboard:writeText', text),
|
|
19
|
+
readText: () => ipcRenderer.invoke('clipboard:readText'),
|
|
20
|
+
onData: (callback) => {
|
|
21
|
+
const listener = (_event, sessionId, data) => callback(sessionId, data);
|
|
22
|
+
ipcRenderer.on('terminal:data', listener);
|
|
23
|
+
return () => ipcRenderer.removeListener('terminal:data', listener);
|
|
24
|
+
},
|
|
25
|
+
onStatus: (callback) => {
|
|
26
|
+
const listener = (_event, sessionId, message) => callback(sessionId, message);
|
|
27
|
+
ipcRenderer.on('terminal:status', listener);
|
|
28
|
+
return () => ipcRenderer.removeListener('terminal:status', listener);
|
|
29
|
+
},
|
|
30
|
+
requestAiCommand: () => ipcRenderer.invoke('ai:suggest'),
|
|
31
|
+
listLocalDirectory: (path) => ipcRenderer.invoke('fs:listLocal', path),
|
|
32
|
+
localHome: () => ipcRenderer.invoke('fs:localHome'),
|
|
33
|
+
createLocalDirectory: (path) => ipcRenderer.invoke('fs:mkdirLocal', path),
|
|
34
|
+
renameLocalEntry: (from, to) => ipcRenderer.invoke('fs:renameLocal', from, to),
|
|
35
|
+
removeLocalEntry: (path, kind) => ipcRenderer.invoke('fs:removeLocal', path, kind),
|
|
36
|
+
sftpOpen: (target, cwd) => ipcRenderer.invoke('sftp:open', target, cwd),
|
|
37
|
+
sftpList: (sessionId, path) => ipcRenderer.invoke('sftp:list', sessionId, path),
|
|
38
|
+
sftpMkdir: (sessionId, path) => ipcRenderer.invoke('sftp:mkdir', sessionId, path),
|
|
39
|
+
sftpRename: (sessionId, from, to) => ipcRenderer.invoke('sftp:rename', sessionId, from, to),
|
|
40
|
+
sftpRemove: (sessionId, path, kind) => ipcRenderer.invoke('sftp:remove', sessionId, path, kind),
|
|
41
|
+
sftpUpload: (sessionId, localPath, remoteDir) => ipcRenderer.invoke('sftp:upload', sessionId, localPath, remoteDir),
|
|
42
|
+
sftpDownload: (sessionId, remotePath, localDir, kind) => ipcRenderer.invoke('sftp:download', sessionId, remotePath, localDir, kind),
|
|
43
|
+
sftpAnswerPrompt: (sessionId, answer) => ipcRenderer.invoke('sftp:answerPrompt', sessionId, answer),
|
|
44
|
+
sftpClose: (sessionId) => ipcRenderer.invoke('sftp:close', sessionId),
|
|
45
|
+
onSftpEvent: (callback) => {
|
|
46
|
+
const listener = (_event, payload) => callback(payload);
|
|
47
|
+
ipcRenderer.on('sftp:event', listener);
|
|
48
|
+
return () => ipcRenderer.removeListener('sftp:event', listener);
|
|
49
|
+
},
|
|
50
|
+
openExternal: (url) => ipcRenderer.invoke('links:openExternal', url),
|
|
51
|
+
onLinkRefused: (callback) => {
|
|
52
|
+
const listener = (_event, reason) => callback(reason);
|
|
53
|
+
ipcRenderer.on('links:refused', listener);
|
|
54
|
+
return () => ipcRenderer.removeListener('links:refused', listener);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
contextBridge.exposeInMainWorld('zerog', api);
|
|
@@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
|
-
import { defaultShellBackend, isLocalShellBackend, resolveShellBackend } from './shell-catalog.js';
|
|
6
|
+
import { defaultShellBackend, findOpenSshTool, isLocalShellBackend, resolveShellBackend } from './shell-catalog.js';
|
|
7
7
|
const execFileAsync = promisify(execFile);
|
|
8
8
|
const require = createRequire(import.meta.url);
|
|
9
9
|
const NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,48}$/;
|
|
@@ -41,7 +41,13 @@ export function validateSessionName(name) {
|
|
|
41
41
|
}
|
|
42
42
|
return value;
|
|
43
43
|
}
|
|
44
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Split an SSH target into the pieces a client needs, rejecting anything that
|
|
46
|
+
* is not one. Shared with the SFTP side (see sftp-protocol.ts) so that both
|
|
47
|
+
* clients are handed destinations vetted by the same rule — a target ssh would
|
|
48
|
+
* read as an option must not become an sftp option either.
|
|
49
|
+
*/
|
|
50
|
+
export function parseSshTarget(input) {
|
|
45
51
|
const value = input.trim();
|
|
46
52
|
const match = value.match(SSH_TARGET);
|
|
47
53
|
if (!match) {
|
|
@@ -54,13 +60,38 @@ export function validateSshTarget(input) {
|
|
|
54
60
|
throw new Error('SSH port must be between 1 and 65535.');
|
|
55
61
|
}
|
|
56
62
|
}
|
|
57
|
-
|
|
63
|
+
return {
|
|
64
|
+
target: value,
|
|
65
|
+
destination: user ? `${user}@${host}` : host,
|
|
66
|
+
...(user ? { user } : {}),
|
|
67
|
+
host,
|
|
68
|
+
...(portText ? { port: portText } : {})
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
export function validateSshTarget(input) {
|
|
72
|
+
const { target, destination, port } = parseSshTarget(input);
|
|
58
73
|
const args = ['-tt'];
|
|
59
|
-
if (
|
|
60
|
-
args.push('-p',
|
|
74
|
+
if (port)
|
|
75
|
+
args.push('-p', port);
|
|
61
76
|
// '--' ends option parsing, so the destination can never be read as a flag.
|
|
62
77
|
args.push('--', destination);
|
|
63
|
-
return { target
|
|
78
|
+
return { target, args };
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The ssh client, as a path a pty can actually start.
|
|
82
|
+
*
|
|
83
|
+
* node-pty passes the file straight to CreateProcess on Windows, which does not
|
|
84
|
+
* append `.exe` — so a pty asked for `ssh` fails with "File not found" on a
|
|
85
|
+
* machine that has it. Resolving it here also turns a missing client into a
|
|
86
|
+
* sentence rather than that message. Where OpenSSH is looked for lives in
|
|
87
|
+
* findOpenSshTool, shared with the transfer side's sftpExecutable.
|
|
88
|
+
*/
|
|
89
|
+
export function sshExecutable(options = {}) {
|
|
90
|
+
const file = findOpenSshTool('ssh', options);
|
|
91
|
+
if (!file) {
|
|
92
|
+
throw new Error('The OpenSSH client was not found. Install the OpenSSH client tools to connect over SSH.');
|
|
93
|
+
}
|
|
94
|
+
return file;
|
|
64
95
|
}
|
|
65
96
|
export function parseWslDistributions(output) {
|
|
66
97
|
return output.split(/\r?\n/).slice(1).map((line) => line.replace(/^\*?\s*/, '').trim())
|
|
@@ -240,7 +271,7 @@ export class ScreenService {
|
|
|
240
271
|
session.status = 'connected';
|
|
241
272
|
session.lastSeen = new Date().toISOString();
|
|
242
273
|
try {
|
|
243
|
-
this.spawnCommand(id,
|
|
274
|
+
this.spawnCommand(id, sshExecutable(), args, onData, onExit, homedir(), size);
|
|
244
275
|
}
|
|
245
276
|
catch (error) {
|
|
246
277
|
session.status = 'error';
|