zerogterm 0.7.0-alpha2 → 0.8.0-alpha
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 +244 -21
- package/bin/zerogterm.cjs +23 -23
- package/dist/main/main/ai-protocol.js +225 -0
- package/dist/main/main/ai-service.js +148 -0
- package/dist/main/main/command-history-store.js +231 -0
- package/dist/main/main/main.js +228 -9
- package/dist/main/main/port-forward-protocol.js +161 -0
- package/dist/main/main/port-forward-service.js +230 -0
- package/dist/main/main/port-forward-store.js +133 -0
- package/dist/main/main/preload.cjs +30 -1
- package/dist/main/main/secret-store.js +156 -0
- package/dist/main/main/session-service.js +22 -4
- package/dist/main/main/shell-catalog.js +63 -2
- package/dist/main/main/ssh-inventory.js +11 -0
- package/dist/main/main/workspace-store.js +213 -0
- package/dist/main/main/wsl-home.js +62 -0
- package/dist/main/shared/endpoints.js +73 -0
- package/dist/main/shared/version.js +32 -0
- package/dist/renderer/assets/index-BPS6JAQV.js +142 -0
- package/dist/renderer/assets/index-p0gJDyuE.css +1 -0
- package/dist/renderer/index.html +7 -4
- package/package.json +32 -2
- package/dist/renderer/assets/index-BjXtNztF.css +0 -1
- package/dist/renderer/assets/index-Ogyjvi0e.js +0 -19
|
@@ -15,8 +15,69 @@
|
|
|
15
15
|
// absolute path, which is what node-pty needs on Windows — it does not apply
|
|
16
16
|
// PATHEXT, so a bare `bash` fails there with "File not found:".
|
|
17
17
|
import { statSync } from 'node:fs';
|
|
18
|
+
import { homedir } from 'node:os';
|
|
18
19
|
/** Distro names reach `wsl.exe -d <name>`; a leading '-' would read as a flag. */
|
|
19
20
|
const WSL_DISTRIBUTION = /^[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}$/;
|
|
21
|
+
/**
|
|
22
|
+
* Where a WSL shell should start.
|
|
23
|
+
*
|
|
24
|
+
* `wsl.exe` inherits the Windows directory it was launched from, so without this
|
|
25
|
+
* a new WSL pane opens in `/mnt/c/Users/<name>` — the Windows home seen through
|
|
26
|
+
* the mount, which is almost never where anyone wants to be working. `--cd`
|
|
27
|
+
* takes a Linux path, and `~` is the distro user's own home.
|
|
28
|
+
*
|
|
29
|
+
* Passed as a separate argv element, never through a shell, so the tilde reaches
|
|
30
|
+
* `wsl.exe` literally rather than being expanded on the way.
|
|
31
|
+
*/
|
|
32
|
+
export const WSL_HOME = '~';
|
|
33
|
+
/**
|
|
34
|
+
* `--cd` needs WSL 0.51.2 or newer — Windows 11, or the Store build on Windows
|
|
35
|
+
* 10. On an older inbox WSL the flag is rejected and the pane shows that, which
|
|
36
|
+
* is visible rather than silent. Judged worth it: the flag is four years old,
|
|
37
|
+
* Windows 10 is out of support, and the alternative was a capability probe on
|
|
38
|
+
* every session for a configuration this project does not target.
|
|
39
|
+
*/
|
|
40
|
+
function wslStartArgs(startDirectory = WSL_HOME) {
|
|
41
|
+
return ['--cd', startDirectory];
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Point a resolved WSL shell at a directory other than the distro's home.
|
|
45
|
+
*
|
|
46
|
+
* Kept separate from resolveShellBackend because that is also what a reattach
|
|
47
|
+
* uses, where the right answer is the home directory again — a fresh shell, not
|
|
48
|
+
* wherever the last one wandered to.
|
|
49
|
+
*/
|
|
50
|
+
export function withWslStartDirectory(shell, startDirectory) {
|
|
51
|
+
if (shell.backend !== 'wsl')
|
|
52
|
+
return shell;
|
|
53
|
+
const args = [...shell.args];
|
|
54
|
+
const at = args.indexOf('--cd');
|
|
55
|
+
if (at >= 0 && at + 1 < args.length)
|
|
56
|
+
args[at + 1] = startDirectory;
|
|
57
|
+
else
|
|
58
|
+
args.push(...wslStartArgs(startDirectory));
|
|
59
|
+
return { ...shell, args };
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* A directory a pty can actually be started in.
|
|
63
|
+
*
|
|
64
|
+
* A session reports the directory its *shell* is in, which for WSL is a path
|
|
65
|
+
* inside the distro — `~`, or `/home/name` once the shell says so. node-pty
|
|
66
|
+
* hands its cwd straight to the OS, and neither of those is a directory Windows
|
|
67
|
+
* can start a process in. Where the shell itself begins is settled by its
|
|
68
|
+
* arguments, so anything unusable here falls back to the home directory rather
|
|
69
|
+
* than failing the spawn.
|
|
70
|
+
*/
|
|
71
|
+
export function ptyStartDirectory(cwd, options = {}) {
|
|
72
|
+
const home = options.home ?? homedir();
|
|
73
|
+
if (!cwd)
|
|
74
|
+
return home;
|
|
75
|
+
const windows = (options.platform ?? process.platform) === 'win32';
|
|
76
|
+
const usable = windows
|
|
77
|
+
? /^[A-Za-z]:[\\/]/.test(cwd) || cwd.startsWith('\\\\')
|
|
78
|
+
: cwd.startsWith('/');
|
|
79
|
+
return usable ? cwd : home;
|
|
80
|
+
}
|
|
20
81
|
const LOCAL_SHELL_BACKENDS = [
|
|
21
82
|
'bash', 'zsh', 'fish', 'sh', 'powershell', 'pwsh', 'cmd', 'wsl'
|
|
22
83
|
];
|
|
@@ -155,7 +216,7 @@ function shellCandidates(windows) {
|
|
|
155
216
|
{ backend: 'powershell', label: 'Windows PowerShell', command: 'powershell.exe' },
|
|
156
217
|
{ backend: 'pwsh', label: 'PowerShell 7', command: 'pwsh.exe' },
|
|
157
218
|
{ backend: 'cmd', label: 'Command Prompt', command: 'cmd.exe' },
|
|
158
|
-
{ backend: 'wsl', label: 'WSL', command: 'wsl.exe' },
|
|
219
|
+
{ backend: 'wsl', label: 'WSL', command: 'wsl.exe', args: wslStartArgs() },
|
|
159
220
|
// Git for Windows ships bash; useful, but not a login shell for the OS.
|
|
160
221
|
{ backend: 'bash', label: 'Git Bash', command: 'bash.exe' }
|
|
161
222
|
];
|
|
@@ -234,5 +295,5 @@ export function resolveShellBackend(backend, distribution, options = {}) {
|
|
|
234
295
|
if (!WSL_DISTRIBUTION.test(trimmed)) {
|
|
235
296
|
throw new Error('WSL distribution names may contain letters, numbers, spaces, _, ., and - only.');
|
|
236
297
|
}
|
|
237
|
-
return { ...match, args: ['-d', trimmed], label: `WSL · ${trimmed}`, wslDistribution: trimmed };
|
|
298
|
+
return { ...match, args: ['-d', trimmed, ...wslStartArgs()], label: `WSL · ${trimmed}`, wslDistribution: trimmed };
|
|
238
299
|
}
|
|
@@ -2,6 +2,17 @@ import { homedir } from 'node:os';
|
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
3
|
const TOKEN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
4
4
|
const HOST = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,253}$/;
|
|
5
|
+
/**
|
|
6
|
+
* Is this a hostname ZeroG will put in front of an SSH client?
|
|
7
|
+
*
|
|
8
|
+
* Exported so the port-forwarding side vets the far end of a tunnel by the same
|
|
9
|
+
* rule that vets a HostName here, rather than carrying a second copy of it that
|
|
10
|
+
* can drift. The leading-alphanumeric requirement is the load-bearing part: the
|
|
11
|
+
* value ends up in an argv element, and one starting with '-' reads as an option.
|
|
12
|
+
*/
|
|
13
|
+
export function isSshHostName(value) {
|
|
14
|
+
return HOST.test(value);
|
|
15
|
+
}
|
|
5
16
|
// Must start alphanumeric like TOKEN/HOST above: the user is concatenated into
|
|
6
17
|
// `user@host`, so a leading '-' makes the whole destination look like an option
|
|
7
18
|
// to ssh's getopt (`-Fevil.cfg@host` reads an attacker-chosen config file).
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
const SCHEMA_VERSION = 1;
|
|
5
|
+
const MAX_WORKSPACES = 32;
|
|
6
|
+
const MAX_MEMBERS = 4;
|
|
7
|
+
/**
|
|
8
|
+
* Main-process-only, best-effort workspace persistence.
|
|
9
|
+
*
|
|
10
|
+
* Never stores cwd, command arguments, or credentials — the same guarantee
|
|
11
|
+
* SessionHistoryStore makes, and for the same reason: this file sits in plain
|
|
12
|
+
* JSON in the user's profile.
|
|
13
|
+
*/
|
|
14
|
+
export class WorkspaceStore {
|
|
15
|
+
filePath;
|
|
16
|
+
file = { version: SCHEMA_VERSION, workspaces: [] };
|
|
17
|
+
loaded = false;
|
|
18
|
+
writeQueue = Promise.resolve();
|
|
19
|
+
constructor(options) {
|
|
20
|
+
this.filePath = options.filePath;
|
|
21
|
+
}
|
|
22
|
+
async load() {
|
|
23
|
+
await this.ensureLoaded();
|
|
24
|
+
return normalizeFile(this.file) ?? { version: SCHEMA_VERSION, workspaces: [] };
|
|
25
|
+
}
|
|
26
|
+
async save(input) {
|
|
27
|
+
await this.ensureLoaded();
|
|
28
|
+
// Validate on the way in as well as out. The renderer is the only caller,
|
|
29
|
+
// but this is an IPC boundary and a stored bad value would come back on
|
|
30
|
+
// every launch from then on.
|
|
31
|
+
const normalized = normalizeFile(input);
|
|
32
|
+
if (!normalized)
|
|
33
|
+
throw new Error('Invalid workspace layout.');
|
|
34
|
+
this.file = normalized;
|
|
35
|
+
await this.persist();
|
|
36
|
+
return normalized;
|
|
37
|
+
}
|
|
38
|
+
async ensureLoaded() {
|
|
39
|
+
if (this.loaded)
|
|
40
|
+
return;
|
|
41
|
+
this.loaded = true;
|
|
42
|
+
try {
|
|
43
|
+
const parsed = JSON.parse(await readFile(this.filePath, 'utf8'));
|
|
44
|
+
const normalized = normalizeFile(parsed);
|
|
45
|
+
if (normalized)
|
|
46
|
+
this.file = normalized;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// Missing, unreadable, or corrupt: start from an empty set rather than
|
|
50
|
+
// leaving the app unable to open a window.
|
|
51
|
+
this.file = { version: SCHEMA_VERSION, workspaces: [] };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async persist() {
|
|
55
|
+
const snapshot = this.file;
|
|
56
|
+
this.writeQueue = this.writeQueue.then(async () => {
|
|
57
|
+
try {
|
|
58
|
+
await mkdir(dirname(this.filePath), { recursive: true });
|
|
59
|
+
const temp = join(dirname(this.filePath), `.workspaces.tmp-${process.pid}-${randomUUID()}`);
|
|
60
|
+
await writeFile(temp, JSON.stringify(snapshot, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
61
|
+
await rename(temp, this.filePath);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// Layout memory must never affect terminal operation.
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
await this.writeQueue;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const LAYOUTS = ['stack', 'split-v', 'split-h', 'grid'];
|
|
71
|
+
const SPLITS = ['split-v', 'split-h', 'grid'];
|
|
72
|
+
/** Strip control characters and cap length; the file is user-editable. */
|
|
73
|
+
function safeText(value, limit = 256) {
|
|
74
|
+
return value.replace(/[\u0000-\u001f\u007f]/g, '').slice(0, limit);
|
|
75
|
+
}
|
|
76
|
+
function optionalText(value, limit = 256) {
|
|
77
|
+
if (typeof value !== 'string')
|
|
78
|
+
return undefined;
|
|
79
|
+
const text = safeText(value, limit);
|
|
80
|
+
return text ? text : undefined;
|
|
81
|
+
}
|
|
82
|
+
function normalizeMember(value) {
|
|
83
|
+
if (!value || typeof value !== 'object')
|
|
84
|
+
return undefined;
|
|
85
|
+
const item = value;
|
|
86
|
+
if (typeof item.sessionId !== 'string' || typeof item.name !== 'string')
|
|
87
|
+
return undefined;
|
|
88
|
+
if (item.kind !== 'local' && item.kind !== 'ssh')
|
|
89
|
+
return undefined;
|
|
90
|
+
const sessionId = safeText(item.sessionId);
|
|
91
|
+
const name = safeText(item.name, 64);
|
|
92
|
+
if (!sessionId || !name)
|
|
93
|
+
return undefined;
|
|
94
|
+
const member = { sessionId, kind: item.kind, name };
|
|
95
|
+
const host = optionalText(item.host);
|
|
96
|
+
const screenName = optionalText(item.screenName, 64);
|
|
97
|
+
const sshTarget = optionalText(item.sshTarget);
|
|
98
|
+
const backend = optionalText(item.backend, 32);
|
|
99
|
+
if (host)
|
|
100
|
+
member.host = host;
|
|
101
|
+
if (screenName)
|
|
102
|
+
member.screenName = screenName;
|
|
103
|
+
if (sshTarget)
|
|
104
|
+
member.sshTarget = sshTarget;
|
|
105
|
+
if (backend)
|
|
106
|
+
member.backend = backend;
|
|
107
|
+
return member;
|
|
108
|
+
}
|
|
109
|
+
function normalizeView(value, memberIds) {
|
|
110
|
+
const item = (value && typeof value === 'object' ? value : {});
|
|
111
|
+
const layout = typeof item.layout === 'string' && LAYOUTS.includes(item.layout) ? item.layout : 'stack';
|
|
112
|
+
const lastSplit = typeof item.lastSplit === 'string' && SPLITS.includes(item.lastSplit) ? item.lastSplit : 'split-v';
|
|
113
|
+
// A view may only name sessions the workspace actually holds, so a hand-edited
|
|
114
|
+
// file cannot point a pane at something that is not there.
|
|
115
|
+
const member = (candidate) => {
|
|
116
|
+
const text = optionalText(candidate);
|
|
117
|
+
return text && memberIds.has(text) ? text : undefined;
|
|
118
|
+
};
|
|
119
|
+
const view = { layout, lastSplit, maximizedSessionId: member(item.maximizedSessionId) ?? null };
|
|
120
|
+
const active = member(item.activeSessionId);
|
|
121
|
+
const focused = member(item.focusedSessionId);
|
|
122
|
+
if (active)
|
|
123
|
+
view.activeSessionId = active;
|
|
124
|
+
if (focused)
|
|
125
|
+
view.focusedSessionId = focused;
|
|
126
|
+
const browsers = normalizeBrowsers(item.browsers, memberIds);
|
|
127
|
+
if (browsers)
|
|
128
|
+
view.browsers = browsers;
|
|
129
|
+
return view;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Directory browser state, keyed only by panes the workspace holds.
|
|
133
|
+
*
|
|
134
|
+
* The same rule the pane references above follow: a hand-edited file may not
|
|
135
|
+
* carry state for a session this workspace does not own. A malformed entry is
|
|
136
|
+
* dropped rather than repaired — a pane with no entry shows no browser, which is
|
|
137
|
+
* the safe reading of a file we cannot trust.
|
|
138
|
+
*/
|
|
139
|
+
function normalizeBrowsers(value, memberIds) {
|
|
140
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
141
|
+
return undefined;
|
|
142
|
+
const out = {};
|
|
143
|
+
for (const [id, raw] of Object.entries(value)) {
|
|
144
|
+
if (!memberIds.has(id) || !raw || typeof raw !== 'object')
|
|
145
|
+
continue;
|
|
146
|
+
const entry = raw;
|
|
147
|
+
if (typeof entry.open !== 'boolean')
|
|
148
|
+
continue;
|
|
149
|
+
const ratio = typeof entry.ratio === 'number' && Number.isFinite(entry.ratio) && entry.ratio > 0 && entry.ratio < 100
|
|
150
|
+
? entry.ratio
|
|
151
|
+
: undefined;
|
|
152
|
+
out[id] = ratio === undefined ? { open: entry.open } : { open: entry.open, ratio };
|
|
153
|
+
}
|
|
154
|
+
return Object.keys(out).length ? out : undefined;
|
|
155
|
+
}
|
|
156
|
+
function normalizeWorkspace(value) {
|
|
157
|
+
if (!value || typeof value !== 'object')
|
|
158
|
+
return undefined;
|
|
159
|
+
const item = value;
|
|
160
|
+
if (typeof item.id !== 'string' || typeof item.name !== 'string')
|
|
161
|
+
return undefined;
|
|
162
|
+
const id = safeText(item.id, 64);
|
|
163
|
+
const name = safeText(item.name, 64);
|
|
164
|
+
if (!id || !name)
|
|
165
|
+
return undefined;
|
|
166
|
+
const rawMembers = Array.isArray(item.members) ? item.members : [];
|
|
167
|
+
const members = [];
|
|
168
|
+
const seen = new Set();
|
|
169
|
+
for (const raw of rawMembers) {
|
|
170
|
+
const member = normalizeMember(raw);
|
|
171
|
+
// One pane per session: a duplicated id would render the same terminal
|
|
172
|
+
// twice and the two panes would fight over the pty size.
|
|
173
|
+
if (!member || seen.has(member.sessionId))
|
|
174
|
+
continue;
|
|
175
|
+
seen.add(member.sessionId);
|
|
176
|
+
members.push(member);
|
|
177
|
+
if (members.length >= MAX_MEMBERS)
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
return { id, name, members, view: normalizeView(item.view, seen) };
|
|
181
|
+
}
|
|
182
|
+
export function normalizeFile(value) {
|
|
183
|
+
if (!value || typeof value !== 'object')
|
|
184
|
+
return undefined;
|
|
185
|
+
const item = value;
|
|
186
|
+
if (item.version !== SCHEMA_VERSION)
|
|
187
|
+
return undefined;
|
|
188
|
+
if (!Array.isArray(item.workspaces))
|
|
189
|
+
return undefined;
|
|
190
|
+
const workspaces = [];
|
|
191
|
+
const seen = new Set();
|
|
192
|
+
for (const raw of item.workspaces) {
|
|
193
|
+
const workspace = normalizeWorkspace(raw);
|
|
194
|
+
if (!workspace || seen.has(workspace.id))
|
|
195
|
+
continue;
|
|
196
|
+
seen.add(workspace.id);
|
|
197
|
+
workspaces.push(workspace);
|
|
198
|
+
if (workspaces.length >= MAX_WORKSPACES)
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
const file = { version: SCHEMA_VERSION, workspaces };
|
|
202
|
+
// An active id naming nothing would leave the app with no workspace on
|
|
203
|
+
// screen, so it falls back to the first rather than being kept.
|
|
204
|
+
const active = optionalText(item.activeWorkspaceId, 64);
|
|
205
|
+
if (active && seen.has(active))
|
|
206
|
+
file.activeWorkspaceId = active;
|
|
207
|
+
else if (workspaces.length)
|
|
208
|
+
file.activeWorkspaceId = workspaces[0].id;
|
|
209
|
+
return file;
|
|
210
|
+
}
|
|
211
|
+
export function defaultWorkspacePath(userDataPath) {
|
|
212
|
+
return join(userDataPath, 'workspaces.json');
|
|
213
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// What "~" means inside a WSL distribution.
|
|
2
|
+
//
|
|
3
|
+
// A WSL pane's shell reports its directory as `~` unless shell integration is
|
|
4
|
+
// installed, and `~` is a symbol rather than a path: it cannot be turned into
|
|
5
|
+
// the `\wsl.localhost\...` share path a listing needs. Asking the distro is the
|
|
6
|
+
// only way to find out, and it is asked once per distribution and cached.
|
|
7
|
+
//
|
|
8
|
+
// Deliberately not done by typing `pwd` into the user's pane. That would put a
|
|
9
|
+
// command they did not run into their shell history, which is the rule
|
|
10
|
+
// cwd-tracker.ts states and keeps. This is a separate, short-lived process.
|
|
11
|
+
import { execFile } from 'node:child_process';
|
|
12
|
+
/**
|
|
13
|
+
* Distribution names, as they arrive from a session record.
|
|
14
|
+
*
|
|
15
|
+
* The first character must be alphanumeric: a name beginning with a dash would
|
|
16
|
+
* be read by `wsl.exe` as another option rather than as the distribution, and
|
|
17
|
+
* nothing that reaches an argv list should be able to become a flag.
|
|
18
|
+
*/
|
|
19
|
+
const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
20
|
+
/**
|
|
21
|
+
* The home directory out of the command's output.
|
|
22
|
+
*
|
|
23
|
+
* Anything that is not a single absolute POSIX path is rejected rather than
|
|
24
|
+
* repaired: this value becomes part of a path that is listed, and a distro that
|
|
25
|
+
* answered something surprising is a distro to give up on.
|
|
26
|
+
*/
|
|
27
|
+
export function parseWslHome(output) {
|
|
28
|
+
const text = output.trim();
|
|
29
|
+
if (!text || text.length > 4096)
|
|
30
|
+
return null;
|
|
31
|
+
if (!text.startsWith('/'))
|
|
32
|
+
return null;
|
|
33
|
+
if (/[\r\n\0]/.test(text))
|
|
34
|
+
return null;
|
|
35
|
+
return text;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Ask a distribution where its home directory is.
|
|
39
|
+
*
|
|
40
|
+
* Null on anything unexpected — a distro that is not installed, a name this
|
|
41
|
+
* process will not pass on, a slow or silent answer. The caller shows the pane's
|
|
42
|
+
* "not said where it is yet" state, which is the honest outcome.
|
|
43
|
+
*/
|
|
44
|
+
export async function wslHomeDirectory(distribution, run = execFile) {
|
|
45
|
+
if (!NAME.test(distribution))
|
|
46
|
+
return null;
|
|
47
|
+
return new Promise((resolve) => {
|
|
48
|
+
// argv, so the distribution name is an argument rather than part of a
|
|
49
|
+
// command line. `printf %s` rather than `echo` because it adds no newline
|
|
50
|
+
// and has no flags to be confused by a path.
|
|
51
|
+
const child = run('wsl.exe', ['-d', distribution, '--', 'sh', '-c', 'printf %s "$HOME"'], { timeout: 5000, windowsHide: true }, (error, stdout) => {
|
|
52
|
+
if (error) {
|
|
53
|
+
resolve(null);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
// WSL writes UTF-16 in some configurations; the NULs that leaves are
|
|
57
|
+
// stripped rather than treated as a malformed answer.
|
|
58
|
+
resolve(parseWslHome(String(stdout).split('\0').join('')));
|
|
59
|
+
});
|
|
60
|
+
child.on('error', () => resolve(null));
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// What counts as an endpoint ZeroG will make a request to, and what is worth
|
|
2
|
+
// saying about one before it is used.
|
|
3
|
+
//
|
|
4
|
+
// Shared rather than owned by the speech side because the AI suggestion path now
|
|
5
|
+
// asks the same three questions of the same kind of URL, and the answers must
|
|
6
|
+
// match: a settings panel that warns a key travels in the clear for one feature
|
|
7
|
+
// and not the other would be worse than either behaviour on its own.
|
|
8
|
+
//
|
|
9
|
+
// The host is the operator's choice — loopback, the LAN, or a hosted service —
|
|
10
|
+
// and nothing here refuses one on that basis. What is enforced is the shape: an
|
|
11
|
+
// http(s) URL with a host, so a typo or a file:// path cannot become a request.
|
|
12
|
+
// See SECURITY.md.
|
|
13
|
+
const LOOPBACK_HOSTNAMES = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
|
|
14
|
+
/** The endpoint as a URL, or null when it is not one a request can be made to. */
|
|
15
|
+
export function endpointOf(url) {
|
|
16
|
+
try {
|
|
17
|
+
const parsed = new URL(url);
|
|
18
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
|
|
19
|
+
return null;
|
|
20
|
+
// http and https are special schemes, so a missing host is a parse failure
|
|
21
|
+
// rather than an empty hostname — every spelling of `http://` throws above.
|
|
22
|
+
// The check is kept anyway: this is the one gate before a request is made,
|
|
23
|
+
// and it should not rest on a parser detail holding everywhere.
|
|
24
|
+
if (!parsed.hostname)
|
|
25
|
+
return null;
|
|
26
|
+
return parsed;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function hostnameOf(url) {
|
|
33
|
+
const parsed = endpointOf(url);
|
|
34
|
+
return parsed === null ? null : parsed.hostname.toLowerCase();
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Can a request be made to this URL?
|
|
38
|
+
*
|
|
39
|
+
* Any reachable host is allowed — loopback, LAN, or the public internet — but it
|
|
40
|
+
* has to be an http(s) URL naming a host. A `file://` path, a `ws://` URL or a
|
|
41
|
+
* half-typed address is refused here rather than turning into a request that
|
|
42
|
+
* fails obscurely later.
|
|
43
|
+
*/
|
|
44
|
+
export function isSupportedEndpoint(url) {
|
|
45
|
+
return hostnameOf(url) !== null;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Does this endpoint keep the payload on this machine?
|
|
49
|
+
*
|
|
50
|
+
* Nothing is refused on this basis; the settings panel uses it to say plainly
|
|
51
|
+
* when recorded speech or terminal output is about to leave the machine.
|
|
52
|
+
* `*.localhost` resolves to loopback by specification, so it counts.
|
|
53
|
+
*/
|
|
54
|
+
export function isLoopbackEndpoint(url) {
|
|
55
|
+
const hostname = hostnameOf(url);
|
|
56
|
+
if (hostname === null)
|
|
57
|
+
return false;
|
|
58
|
+
return LOOPBACK_HOSTNAMES.has(hostname) || hostname.endsWith('.localhost');
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Would a key sent to this endpoint travel in the clear?
|
|
62
|
+
*
|
|
63
|
+
* True for plain http to anywhere but this machine. Nothing is blocked on this
|
|
64
|
+
* basis — a self-hosted server on the LAN commonly has no certificate, and
|
|
65
|
+
* refusing to authenticate to it would break the ordinary case — but it is worth
|
|
66
|
+
* saying out loud next to the field where the key is typed.
|
|
67
|
+
*/
|
|
68
|
+
export function sendsKeyInClear(url) {
|
|
69
|
+
const parsed = endpointOf(url);
|
|
70
|
+
if (parsed === null)
|
|
71
|
+
return false;
|
|
72
|
+
return parsed.protocol === 'http:' && !isLoopbackEndpoint(url);
|
|
73
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// How the running version is shown.
|
|
2
|
+
//
|
|
3
|
+
// The version comes from Electron's own reading of package.json, so what the
|
|
4
|
+
// title bar says is what is actually running — including in a packaged build,
|
|
5
|
+
// where a string compiled in from the repo could be a release behind. Nothing
|
|
6
|
+
// here invents or shortens a version: the point of reading it from the app is
|
|
7
|
+
// that it cannot drift, and a prettified form would be a second version string
|
|
8
|
+
// to keep in step.
|
|
9
|
+
/** How long a version string may plausibly be before it is not one. */
|
|
10
|
+
const MAX_LENGTH = 40;
|
|
11
|
+
/**
|
|
12
|
+
* The version as the title bar shows it, or null when there is nothing to show.
|
|
13
|
+
*
|
|
14
|
+
* Null rather than a placeholder: an older preload with no version channel, or a
|
|
15
|
+
* main process that answered oddly, should leave the title as it was rather than
|
|
16
|
+
* putting "vunknown" beside the name.
|
|
17
|
+
*/
|
|
18
|
+
export function formatVersion(raw) {
|
|
19
|
+
if (typeof raw !== 'string')
|
|
20
|
+
return null;
|
|
21
|
+
const trimmed = raw.trim();
|
|
22
|
+
if (!trimmed || trimmed.length > MAX_LENGTH)
|
|
23
|
+
return null;
|
|
24
|
+
// A leading `v` is presentation, so it is added if package.json omitted it and
|
|
25
|
+
// not doubled if it did not.
|
|
26
|
+
return trimmed.startsWith('v') || trimmed.startsWith('V') ? trimmed : `v${trimmed}`;
|
|
27
|
+
}
|
|
28
|
+
/** The full name and version, for the tooltip on the wordmark. */
|
|
29
|
+
export function versionLabel(raw) {
|
|
30
|
+
const version = formatVersion(raw);
|
|
31
|
+
return version ? `ZeroG Terminal ${version}` : 'ZeroG Terminal';
|
|
32
|
+
}
|