zerogterm 0.6.0-alpha.1 → 0.7.0-alpha2
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 +44 -3
- 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 +26 -1
- package/dist/main/main/session-service.js +38 -20
- 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-BjXtNztF.css +1 -0
- package/dist/renderer/assets/index-Ogyjvi0e.js +19 -0
- package/dist/renderer/index.html +4 -4
- package/package.json +2 -2
- package/dist/renderer/assets/index-BJsoL2QL.js +0 -17
- package/dist/renderer/assets/index-BPfYUrvm.css +0 -1
package/LICENSE
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 Stephen Phillips
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Stephen Phillips
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -7,13 +7,16 @@ ZeroG Terminal is an alpha project, but it is already useful as a multi-session
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
9
9
|
- Multi-pane workspaces with stack, vertical split, horizontal split, and four-pane grid layouts.
|
|
10
|
-
- Draggable dividers between panes and beside the sidebar, so a split does not have to be an even one. Sizes are remembered between launches.
|
|
10
|
+
- Draggable dividers between panes and beside the sidebar, so a split does not have to be an even one. Sizes are remembered between launches.
|
|
11
|
+
- A compact navigation rail for sessions, overview, settings, SSH connections, and opening a new local terminal, while keeping the sessions sidebar collapsible.
|
|
12
|
+
- Layout controls that restore a split, even up its pane sizes, and maximize or restore a pane in a predictable sequence.
|
|
11
13
|
- Maximize a focused pane and cycle between sessions without losing the other panes.
|
|
12
14
|
- Local sessions powered by Bash, PowerShell, WSL, and other supported shell backends; persistent sessions use `screen` where available, with a process-only fallback when it is unavailable.
|
|
13
15
|
- SSH sessions for hosts, `user@host`, and `user@host:port` targets.
|
|
14
16
|
- SSH configuration discovery from known connections, including remote `screen` session discovery.
|
|
15
17
|
- Reconnect to existing local or remote `screen` sessions from the Screens view.
|
|
16
18
|
- Remote screen attachment that waits for SSH readiness before sending commands, including host and port-aware matching.
|
|
19
|
+
- An SFTP transfer panel, opened from the ⇅ button above the panes: local files on the left, the active SSH session's host on the right, with upload, download, new folder, rename, and delete. It connects to the host that session is already using and opens at the directory its shell is standing in, so a file can go straight to the project being worked on.
|
|
17
20
|
- Session history for reconnecting to sessions after a relaunch, with bounded structured history and no stored secrets.
|
|
18
21
|
- Workspaces for grouping sessions and quickly switching between projects or tasks.
|
|
19
22
|
- Session overview, collapsible sidebar, keyboard shortcuts, and light/dark themes.
|
|
@@ -31,7 +34,7 @@ See the project walkthrough on [YouTube](https://youtu.be/4aJZCxLHD14).
|
|
|
31
34
|
|
|
32
35
|
## Release status
|
|
33
36
|
|
|
34
|
-
ZeroG Terminal is currently a public alpha. The current release is `0.
|
|
37
|
+
ZeroG Terminal is currently a public alpha. The current release is `0.7.0-alpha2`; the version history is tracked in [versions.txt](versions.txt).
|
|
35
38
|
|
|
36
39
|
The npm package contains the built Electron application and project documentation. It is intended for early adopters and testing rather than production use.
|
|
37
40
|
|
|
@@ -47,6 +50,13 @@ The npm package contains the built Electron application and project documentatio
|
|
|
47
50
|
- `Ctrl+Shift+,` — settings
|
|
48
51
|
- `Esc` — close overview / dialogs, cancel voice recording
|
|
49
52
|
|
|
53
|
+
Clicking a link in a pane opens it in your own browser rather than in a window
|
|
54
|
+
of ZeroG. Hovering one first shows where it actually goes in the status bar,
|
|
55
|
+
which matters because a terminal hyperlink can be labelled with anything. Only
|
|
56
|
+
web links and `mailto:` addresses are opened; anything else says so in the
|
|
57
|
+
status bar instead, since a link in terminal output can name a scheme that would
|
|
58
|
+
start local software.
|
|
59
|
+
|
|
50
60
|
Selecting text with the mouse also copies it, and programs running inside a
|
|
51
61
|
terminal can copy to the system clipboard themselves through the OSC 52 escape
|
|
52
62
|
sequence — this is how TUI tools such as CLI coding agents, tmux and Neovim put
|
|
@@ -64,6 +74,37 @@ a time, and Enter or a double-click puts it back in the middle. One divider
|
|
|
64
74
|
position is shared by every layout, so a split you set up in the vertical split
|
|
65
75
|
is the same split you get in the four-pane grid.
|
|
66
76
|
|
|
77
|
+
## Transferring files over SFTP
|
|
78
|
+
|
|
79
|
+
The ⇅ button above the panes opens a two-pane transfer panel: this computer on
|
|
80
|
+
the left, the active SSH session's host on the right. It is only available while
|
|
81
|
+
an SSH session is selected, and the button says why when it is not.
|
|
82
|
+
|
|
83
|
+
The connection is made with the system `sftp` client, so it uses the same
|
|
84
|
+
`~/.ssh/config`, agent, keys, and `known_hosts` as the terminal beside it. A
|
|
85
|
+
password, a key passphrase, or an unknown host key is asked for inside the
|
|
86
|
+
panel — ZeroG never answers a host-key question on your behalf, and the
|
|
87
|
+
fingerprint is shown with the question. Nothing typed there is stored.
|
|
88
|
+
|
|
89
|
+
The remote side opens at the directory the terminal's shell is currently in,
|
|
90
|
+
where that can be known without disturbing the session. ZeroG reads it from
|
|
91
|
+
OSC 7 — the sequence a shell emits to report its directory — and otherwise from
|
|
92
|
+
the path in the prompt; it never types `pwd` into your session to find out. When
|
|
93
|
+
neither is available the panel opens at the login directory.
|
|
94
|
+
|
|
95
|
+
Select files with a click, or several with Ctrl-click or Shift-click, then Upload or Download.
|
|
96
|
+
Double-click a folder to open it, or type a path into the folder box. New folder,
|
|
97
|
+
rename, and delete act on one selected item; deleting asks first, and a folder
|
|
98
|
+
must be empty, so a single click can never remove a tree. Remote folders can be
|
|
99
|
+
downloaded recursively with the system `sftp` client; uploads remain file-only,
|
|
100
|
+
so a local folder is never copied accidentally as a different job.
|
|
101
|
+
|
|
102
|
+
Filenames containing quotes, backslashes, or the wildcard characters `* ? [ ]`
|
|
103
|
+
are refused with a message rather than acted on. The `sftp` client re-reads its
|
|
104
|
+
own arguments through a glob pass, and there is no encoding of those characters
|
|
105
|
+
that is provably correct for every command — being approximately right about
|
|
106
|
+
which file to delete is not good enough.
|
|
107
|
+
|
|
67
108
|
## Settings
|
|
68
109
|
|
|
69
110
|
Settings open from the gear at the bottom of the left rail, the avatar in the
|
|
@@ -180,7 +221,7 @@ backend, and a remote host with `screen` still gives persistent sessions there.
|
|
|
180
221
|
The current main branch has the following local verification coverage:
|
|
181
222
|
|
|
182
223
|
- `npm run typecheck`: passes.
|
|
183
|
-
- `npm test`: passes (
|
|
224
|
+
- `npm test`: passes (251 tests covering session service behaviour and PTY sizing, shell discovery, SSH inventory and argument validation, remote-screen parsing and prompt readiness, session history, the session dialog, settings, terminal clipboard and OSC 52 handling, dialog dismissal, the speech and voice helpers, external link handling, and the SFTP transfer path — command quoting, listing and error parsing, authentication prompts, local filesystem operations, and working-directory detection; one further test needs a real `screen` and is opt-in through `ZEROG_LIVE_SCREEN=1`).
|
|
184
225
|
- `npm run build`: passes and writes `dist/main` plus `dist/renderer`.
|
|
185
226
|
- `npm audit --omit=dev`: production dependency auditing is part of the project quality checks.
|
|
186
227
|
|
package/bin/zerogterm.cjs
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
const { spawn } = require('node:child_process');
|
|
4
|
-
const path = require('node:path');
|
|
5
|
-
const electron = require('electron');
|
|
6
|
-
|
|
7
|
-
const packageRoot = path.resolve(__dirname, '..');
|
|
8
|
-
const child = spawn(electron, [packageRoot, ...process.argv.slice(2)], {
|
|
9
|
-
stdio: 'inherit'
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
child.on('error', (error) => {
|
|
13
|
-
console.error(`Failed to start ZeroG Terminal: ${error.message}`);
|
|
14
|
-
process.exitCode = 1;
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
child.on('close', (code, signal) => {
|
|
18
|
-
if (signal) {
|
|
19
|
-
process.kill(process.pid, signal);
|
|
20
|
-
return;
|
|
21
|
-
}
|
|
22
|
-
process.exitCode = code ?? 1;
|
|
23
|
-
});
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { spawn } = require('node:child_process');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const electron = require('electron');
|
|
6
|
+
|
|
7
|
+
const packageRoot = path.resolve(__dirname, '..');
|
|
8
|
+
const child = spawn(electron, [packageRoot, ...process.argv.slice(2)], {
|
|
9
|
+
stdio: 'inherit'
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
child.on('error', (error) => {
|
|
13
|
+
console.error(`Failed to start ZeroG Terminal: ${error.message}`);
|
|
14
|
+
process.exitCode = 1;
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
child.on('close', (code, signal) => {
|
|
18
|
+
if (signal) {
|
|
19
|
+
process.kill(process.pid, signal);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
process.exitCode = code ?? 1;
|
|
23
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Deciding which links may leave the application, and by which door.
|
|
2
|
+
//
|
|
3
|
+
// A link in a pane is not something the user wrote: it arrives in terminal
|
|
4
|
+
// output, which SECURITY.md is explicit about treating as untrusted. Worse, OSC 8
|
|
5
|
+
// lets a remote host display one thing and link to another, so the URL handed
|
|
6
|
+
// over here has no relationship to the text the user clicked on.
|
|
7
|
+
//
|
|
8
|
+
// The operating system will do a great deal with a URL. `file:` reaches the local
|
|
9
|
+
// disk; on Windows `smb:` reaches a network share and leaks credentials to it;
|
|
10
|
+
// and installed applications register their own schemes, some of which take a
|
|
11
|
+
// path or a command. Handing any of those to the shell on a single click would
|
|
12
|
+
// make a remote host's output into a way of starting local software. So this is
|
|
13
|
+
// an allowlist, not a blocklist: the schemes a person means when they click a
|
|
14
|
+
// link in a terminal, and nothing else.
|
|
15
|
+
//
|
|
16
|
+
// Kept pure and free of Electron so the rule can be tested directly.
|
|
17
|
+
/** Schemes worth opening: the web, and an address to write to. */
|
|
18
|
+
const ALLOWED_PROTOCOLS = new Set(['http:', 'https:', 'mailto:']);
|
|
19
|
+
/**
|
|
20
|
+
* A URL is only long enough to be suspicious once it is absurd. The limit is
|
|
21
|
+
* here because the string is passed to the operating system, not because a real
|
|
22
|
+
* link is ever near it.
|
|
23
|
+
*/
|
|
24
|
+
const MAX_URL = 2048;
|
|
25
|
+
/** Written as escapes: a literal control character in source is invisible. */
|
|
26
|
+
const CONTROL_CHARACTER = /[\u0000-\u001f\u007f-\u009f]/;
|
|
27
|
+
/**
|
|
28
|
+
* Should this link be handed to the system browser?
|
|
29
|
+
*
|
|
30
|
+
* The URL is re-parsed rather than pattern-matched: a scheme is only what the
|
|
31
|
+
* parser says it is, and `java\tscript:` or a leading space are not the checks a
|
|
32
|
+
* regex tends to get right. The parsed form is what gets returned, so the value
|
|
33
|
+
* that reaches the shell is the one that was inspected.
|
|
34
|
+
*/
|
|
35
|
+
export function decideExternalLink(value) {
|
|
36
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
37
|
+
return { open: false, reason: 'That link is empty.' };
|
|
38
|
+
}
|
|
39
|
+
if (value.length > MAX_URL) {
|
|
40
|
+
return { open: false, reason: 'That link is too long to open.' };
|
|
41
|
+
}
|
|
42
|
+
// A control character cannot appear in a URL, and its presence means the value
|
|
43
|
+
// was assembled to be read one way and used another.
|
|
44
|
+
if (CONTROL_CHARACTER.test(value)) {
|
|
45
|
+
return { open: false, reason: 'That link contains control characters.' };
|
|
46
|
+
}
|
|
47
|
+
let parsed;
|
|
48
|
+
try {
|
|
49
|
+
parsed = new URL(value);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return { open: false, reason: 'That is not a link this can open.' };
|
|
53
|
+
}
|
|
54
|
+
if (!ALLOWED_PROTOCOLS.has(parsed.protocol)) {
|
|
55
|
+
// Name the scheme: the user clicked something that looked like a link, and
|
|
56
|
+
// "refused" without saying why is not enough to judge what just happened.
|
|
57
|
+
return { open: false, reason: `ZeroG only opens web links; this one is ${parsed.protocol.replace(':', '')}.` };
|
|
58
|
+
}
|
|
59
|
+
return { open: true, url: parsed.href };
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Is this URL the application's own window content?
|
|
63
|
+
*
|
|
64
|
+
* Used to tell a genuine outward link from the app navigating itself — the dev
|
|
65
|
+
* server and the packaged `file:` index are the only two things the window is
|
|
66
|
+
* ever meant to be showing.
|
|
67
|
+
*/
|
|
68
|
+
export function isApplicationUrl(value, current) {
|
|
69
|
+
if (value === current)
|
|
70
|
+
return true;
|
|
71
|
+
try {
|
|
72
|
+
const target = new URL(value);
|
|
73
|
+
const app = new URL(current);
|
|
74
|
+
return target.origin === app.origin && target.pathname === app.pathname;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -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.'
|
|
@@ -27,7 +27,32 @@ const api = {
|
|
|
27
27
|
ipcRenderer.on('terminal:status', listener);
|
|
28
28
|
return () => ipcRenderer.removeListener('terminal:status', listener);
|
|
29
29
|
},
|
|
30
|
-
requestAiCommand: () => ipcRenderer.invoke('ai:suggest')
|
|
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
|
+
}
|
|
31
56
|
};
|
|
32
57
|
|
|
33
58
|
contextBridge.exposeInMainWorld('zerog', api);
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import { existsSync } from 'node:fs';
|
|
4
3
|
import { createRequire } from 'node:module';
|
|
5
4
|
import { homedir } from 'node:os';
|
|
6
|
-
import { join } from 'node:path';
|
|
7
5
|
import { promisify } from 'node:util';
|
|
8
|
-
import { defaultShellBackend, isLocalShellBackend, resolveShellBackend } from './shell-catalog.js';
|
|
6
|
+
import { defaultShellBackend, findOpenSshTool, isLocalShellBackend, resolveShellBackend } from './shell-catalog.js';
|
|
9
7
|
const execFileAsync = promisify(execFile);
|
|
10
8
|
const require = createRequire(import.meta.url);
|
|
11
9
|
const NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,48}$/;
|
|
@@ -16,17 +14,6 @@ const NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,48}$/;
|
|
|
16
14
|
* -chosen config file (hence ProxyCommand) without any shell involvement.
|
|
17
15
|
*/
|
|
18
16
|
const SSH_TARGET = /^(?:([A-Za-z0-9][A-Za-z0-9._-]*)@)?([A-Za-z0-9][A-Za-z0-9.-]*)(?::(\d{1,5}))?$/;
|
|
19
|
-
function resolveSshExecutable() {
|
|
20
|
-
if (process.platform !== 'win32')
|
|
21
|
-
return 'ssh';
|
|
22
|
-
const windowsRoot = process.env.SystemRoot ?? 'C:\\Windows';
|
|
23
|
-
const candidates = [
|
|
24
|
-
join(windowsRoot, 'System32', 'OpenSSH', 'ssh.exe'),
|
|
25
|
-
join(process.env.ProgramFiles ?? 'C:\\Program Files', 'Git', 'usr', 'bin', 'ssh.exe'),
|
|
26
|
-
join(process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)', 'Git', 'usr', 'bin', 'ssh.exe')
|
|
27
|
-
];
|
|
28
|
-
return candidates.find((candidate) => existsSync(candidate)) ?? 'ssh.exe';
|
|
29
|
-
}
|
|
30
17
|
/**
|
|
31
18
|
* Size a pty starts at when the pane could not be measured — a placeholder,
|
|
32
19
|
* not a preference. Panes pass their real size through attach().
|
|
@@ -54,7 +41,13 @@ export function validateSessionName(name) {
|
|
|
54
41
|
}
|
|
55
42
|
return value;
|
|
56
43
|
}
|
|
57
|
-
|
|
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) {
|
|
58
51
|
const value = input.trim();
|
|
59
52
|
const match = value.match(SSH_TARGET);
|
|
60
53
|
if (!match) {
|
|
@@ -67,13 +60,38 @@ export function validateSshTarget(input) {
|
|
|
67
60
|
throw new Error('SSH port must be between 1 and 65535.');
|
|
68
61
|
}
|
|
69
62
|
}
|
|
70
|
-
|
|
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);
|
|
71
73
|
const args = ['-tt'];
|
|
72
|
-
if (
|
|
73
|
-
args.push('-p',
|
|
74
|
+
if (port)
|
|
75
|
+
args.push('-p', port);
|
|
74
76
|
// '--' ends option parsing, so the destination can never be read as a flag.
|
|
75
77
|
args.push('--', destination);
|
|
76
|
-
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;
|
|
77
95
|
}
|
|
78
96
|
export function parseWslDistributions(output) {
|
|
79
97
|
return output.split(/\r?\n/).slice(1).map((line) => line.replace(/^\*?\s*/, '').trim())
|
|
@@ -253,7 +271,7 @@ export class ScreenService {
|
|
|
253
271
|
session.status = 'connected';
|
|
254
272
|
session.lastSeen = new Date().toISOString();
|
|
255
273
|
try {
|
|
256
|
-
this.spawnCommand(id,
|
|
274
|
+
this.spawnCommand(id, sshExecutable(), args, onData, onExit, homedir(), size);
|
|
257
275
|
}
|
|
258
276
|
catch (error) {
|
|
259
277
|
session.status = 'error';
|