zerogterm 0.7.0-alpha2 → 0.7.0-alpha3
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 +238 -20
- 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 +156 -5
- 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-C2vLQpCh.js +85 -0
- package/dist/renderer/assets/index-Qi5a1Xc8.css +1 -0
- package/dist/renderer/index.html +7 -4
- package/package.json +1 -1
- package/dist/renderer/assets/index-BjXtNztF.css +0 -1
- package/dist/renderer/assets/index-Ogyjvi0e.js +0 -19
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// The request to an OpenAI-compatible endpoint.
|
|
2
|
+
//
|
|
3
|
+
// In the main process rather than the renderer, for two reasons. A renderer
|
|
4
|
+
// fetch is a cross-origin request, and Ollama refuses those unless
|
|
5
|
+
// OLLAMA_ORIGINS is set — so the most obvious local setup would fail for a
|
|
6
|
+
// reason the user cannot see. And the API key never has to enter the renderer at
|
|
7
|
+
// all: it is read here, used for one request, and not held.
|
|
8
|
+
//
|
|
9
|
+
// Bounded and cancellable, as CONTEXT.md asks of AI output capture: every call
|
|
10
|
+
// carries an AbortController and a timeout, so a server that accepts a
|
|
11
|
+
// connection and then says nothing cannot leave a dialog waiting forever.
|
|
12
|
+
import { AI_TIMEOUT_MS, ERROR_BODY_CHARS, buildSuggestionRequest, chatCompletionsUrl, modelsUrl, parseModelList, parseSuggestion } from './ai-protocol.js';
|
|
13
|
+
import { isSupportedEndpoint } from '../shared/endpoints.js';
|
|
14
|
+
export class AiService {
|
|
15
|
+
readApiKey;
|
|
16
|
+
fetchImpl;
|
|
17
|
+
timeoutMs;
|
|
18
|
+
/** The request in flight, so a new one supersedes it rather than racing it. */
|
|
19
|
+
inFlight = null;
|
|
20
|
+
constructor(options) {
|
|
21
|
+
this.readApiKey = options.readApiKey;
|
|
22
|
+
this.fetchImpl = options.fetch ?? ((url, init) => fetch(url, init));
|
|
23
|
+
this.timeoutMs = options.timeoutMs ?? AI_TIMEOUT_MS;
|
|
24
|
+
}
|
|
25
|
+
/** Abandon whatever is in flight. The dialog closing is a reason to. */
|
|
26
|
+
cancel() {
|
|
27
|
+
this.inFlight?.abort();
|
|
28
|
+
this.inFlight = null;
|
|
29
|
+
}
|
|
30
|
+
async suggest(config, request) {
|
|
31
|
+
requireEndpoint(config.baseUrl);
|
|
32
|
+
const body = buildSuggestionRequest({
|
|
33
|
+
prompt: request.prompt,
|
|
34
|
+
model: config.model,
|
|
35
|
+
context: request.context
|
|
36
|
+
});
|
|
37
|
+
// Only one suggestion is ever wanted at a time, and the old one's answer
|
|
38
|
+
// would arrive against a dialog that has moved on.
|
|
39
|
+
this.cancel();
|
|
40
|
+
const controller = new AbortController();
|
|
41
|
+
this.inFlight = controller;
|
|
42
|
+
try {
|
|
43
|
+
const payload = await this.send(chatCompletionsUrl(config.baseUrl), body, controller);
|
|
44
|
+
return parseSuggestion(payload);
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
if (this.inFlight === controller)
|
|
48
|
+
this.inFlight = null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async listModels(baseUrl) {
|
|
52
|
+
requireEndpoint(baseUrl);
|
|
53
|
+
const controller = new AbortController();
|
|
54
|
+
const payload = await this.send(modelsUrl(baseUrl), undefined, controller);
|
|
55
|
+
return parseModelList(payload);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Ask the endpoint for one token, and report what happened in a sentence.
|
|
59
|
+
*
|
|
60
|
+
* A real completion rather than a reachability check: a server can accept a
|
|
61
|
+
* connection, list models, and still refuse to run the model that is
|
|
62
|
+
* configured. That is the failure worth catching before the user needs it.
|
|
63
|
+
*/
|
|
64
|
+
async test(config) {
|
|
65
|
+
try {
|
|
66
|
+
requireEndpoint(config.baseUrl);
|
|
67
|
+
if (!config.model.trim())
|
|
68
|
+
throw new Error('Choose a model first.');
|
|
69
|
+
const controller = new AbortController();
|
|
70
|
+
const payload = await this.send(chatCompletionsUrl(config.baseUrl), { model: config.model.trim(), max_tokens: 8, messages: [{ role: 'user', content: 'Reply with: ok' }] }, controller);
|
|
71
|
+
const suggestion = parseSuggestion(payload);
|
|
72
|
+
// A reply that did not parse is still a working endpoint: the test is
|
|
73
|
+
// whether the model answered, not whether it answered in the shape a
|
|
74
|
+
// suggestion needs.
|
|
75
|
+
return { ok: true, message: `${config.model} replied${suggestion.explanation ? `: ${trim(suggestion.explanation)}` : '.'}` };
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
return { ok: false, message: error instanceof Error ? error.message : String(error) };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
async send(url, body, controller) {
|
|
82
|
+
const key = await this.readApiKey();
|
|
83
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
84
|
+
try {
|
|
85
|
+
const response = await this.fetchImpl(url, {
|
|
86
|
+
method: body === undefined ? 'GET' : 'POST',
|
|
87
|
+
headers: {
|
|
88
|
+
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
|
89
|
+
// Only when there is one: a local Ollama wants no header at all, and
|
|
90
|
+
// an empty Bearer is worse than none.
|
|
91
|
+
...(key ? { Authorization: `Bearer ${key}` } : {})
|
|
92
|
+
},
|
|
93
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
94
|
+
signal: controller.signal
|
|
95
|
+
});
|
|
96
|
+
if (!response.ok)
|
|
97
|
+
throw new Error(await describeFailure(response));
|
|
98
|
+
try {
|
|
99
|
+
return await response.json();
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// A server that answers 200 with HTML is usually a proxy or a wrong
|
|
103
|
+
// path, and saying so beats "Unexpected token <".
|
|
104
|
+
throw new Error('The endpoint answered with something other than JSON. Check the base URL ends in /v1.');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
109
|
+
throw new Error(`The endpoint did not answer within ${Math.round(this.timeoutMs / 1000)}s.`);
|
|
110
|
+
}
|
|
111
|
+
if (error instanceof TypeError) {
|
|
112
|
+
// What fetch throws when nothing is listening, with a message that names
|
|
113
|
+
// no host and helps nobody.
|
|
114
|
+
throw new Error(`Could not reach ${url}. Is the server running?`);
|
|
115
|
+
}
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
clearTimeout(timer);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function requireEndpoint(baseUrl) {
|
|
124
|
+
if (!isSupportedEndpoint(baseUrl)) {
|
|
125
|
+
throw new Error('Set an http(s) base URL in Settings, such as http://127.0.0.1:11434/v1.');
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* A failing response, as a sentence.
|
|
130
|
+
*
|
|
131
|
+
* The status alone does not distinguish a wrong key from a missing model, and
|
|
132
|
+
* these servers put the difference in the body — so the body is read, trimmed,
|
|
133
|
+
* and quoted.
|
|
134
|
+
*/
|
|
135
|
+
async function describeFailure(response) {
|
|
136
|
+
const detail = await response.text().then(trim).catch(() => '');
|
|
137
|
+
if (response.status === 401 || response.status === 403) {
|
|
138
|
+
return `The endpoint refused the key (${response.status}).${detail ? ` ${detail}` : ''}`;
|
|
139
|
+
}
|
|
140
|
+
if (response.status === 404) {
|
|
141
|
+
return `Not found (404). Check the base URL ends in /v1 and the model exists.${detail ? ` ${detail}` : ''}`;
|
|
142
|
+
}
|
|
143
|
+
return `The endpoint returned ${response.status}.${detail ? ` ${detail}` : ''}`;
|
|
144
|
+
}
|
|
145
|
+
function trim(text) {
|
|
146
|
+
const clean = text.replace(/\s+/g, ' ').trim();
|
|
147
|
+
return clean.length > ERROR_BODY_CHARS ? `${clean.slice(0, ERROR_BODY_CHARS - 1)}…` : clean;
|
|
148
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
const SCHEMA_VERSION = 1;
|
|
5
|
+
/**
|
|
6
|
+
* How many commands to keep.
|
|
7
|
+
*
|
|
8
|
+
* Large enough that a month of work fits, small enough that the whole file is
|
|
9
|
+
* read, ranked and written in memory without anyone noticing. JSON per
|
|
10
|
+
* CONTEXT.md's answered question 7; SQLite remains the option that doc says it
|
|
11
|
+
* is, if this ceiling ever becomes the thing that hurts.
|
|
12
|
+
*/
|
|
13
|
+
const MAX_ENTRIES = 5000;
|
|
14
|
+
const MAX_COMMAND_CHARS = 1000;
|
|
15
|
+
/**
|
|
16
|
+
* The command history on disk.
|
|
17
|
+
*
|
|
18
|
+
* The only ZeroG store that holds what the user typed, which is why it is off
|
|
19
|
+
* until asked for and why nothing reaches it that has not been through
|
|
20
|
+
* command-redaction. That check runs in the renderer, next to the capture; this
|
|
21
|
+
* side enforces the shape and the ceiling.
|
|
22
|
+
*/
|
|
23
|
+
export class CommandHistoryStore {
|
|
24
|
+
filePath;
|
|
25
|
+
file = { version: SCHEMA_VERSION, entries: [] };
|
|
26
|
+
loaded = false;
|
|
27
|
+
writeQueue = Promise.resolve();
|
|
28
|
+
constructor(options) {
|
|
29
|
+
this.filePath = options.filePath;
|
|
30
|
+
this.now = options.now ?? (() => new Date());
|
|
31
|
+
}
|
|
32
|
+
now;
|
|
33
|
+
async list() {
|
|
34
|
+
await this.ensureLoaded();
|
|
35
|
+
return this.file.entries.map((entry) => ({ ...entry }));
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Record a run, or note another one of something already known.
|
|
39
|
+
*
|
|
40
|
+
* Upserted on command *and* directory, so `runs` counts what it says it does
|
|
41
|
+
* and the directory a command belongs to is not overwritten by the next place
|
|
42
|
+
* it happens to be typed.
|
|
43
|
+
*/
|
|
44
|
+
async record(input) {
|
|
45
|
+
await this.ensureLoaded();
|
|
46
|
+
const command = safeText(input.command, MAX_COMMAND_CHARS);
|
|
47
|
+
if (!command)
|
|
48
|
+
return null;
|
|
49
|
+
const cwd = optionalText(input.cwd, 1024);
|
|
50
|
+
const existing = this.file.entries.find((entry) => entry.command === command && entry.cwd === cwd);
|
|
51
|
+
const lastRun = this.now().toISOString();
|
|
52
|
+
if (existing) {
|
|
53
|
+
existing.runs += 1;
|
|
54
|
+
existing.lastRun = lastRun;
|
|
55
|
+
// The latest outcome replaces the last, including replacing a known status
|
|
56
|
+
// with none: a command that used to work and now reports nothing should
|
|
57
|
+
// not keep claiming success.
|
|
58
|
+
if (input.exitCode === undefined)
|
|
59
|
+
delete existing.exitCode;
|
|
60
|
+
else
|
|
61
|
+
existing.exitCode = input.exitCode;
|
|
62
|
+
await this.persist();
|
|
63
|
+
return { ...existing };
|
|
64
|
+
}
|
|
65
|
+
const entry = {
|
|
66
|
+
id: `cmd:${randomUUID()}`,
|
|
67
|
+
command,
|
|
68
|
+
...(cwd ? { cwd } : {}),
|
|
69
|
+
...(optionalText(input.host, 256) ? { host: String(input.host) } : {}),
|
|
70
|
+
...(input.kind === 'ssh' || input.kind === 'local' ? { kind: input.kind } : {}),
|
|
71
|
+
...(input.exitCode === undefined ? {} : { exitCode: input.exitCode }),
|
|
72
|
+
lastRun,
|
|
73
|
+
runs: 1,
|
|
74
|
+
picks: 0
|
|
75
|
+
};
|
|
76
|
+
this.file.entries.push(entry);
|
|
77
|
+
this.evict();
|
|
78
|
+
await this.persist();
|
|
79
|
+
return { ...entry };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Note that an entry was chosen from the palette.
|
|
83
|
+
*
|
|
84
|
+
* McFly's most useful signal, and the reason the ranking improves with use:
|
|
85
|
+
* being picked says more about what someone wants than being run does, because
|
|
86
|
+
* running happens by habit and picking happens on purpose.
|
|
87
|
+
*/
|
|
88
|
+
async pick(id) {
|
|
89
|
+
await this.ensureLoaded();
|
|
90
|
+
const entry = this.file.entries.find((candidate) => candidate.id === id);
|
|
91
|
+
if (!entry)
|
|
92
|
+
return;
|
|
93
|
+
entry.picks += 1;
|
|
94
|
+
entry.lastRun = this.now().toISOString();
|
|
95
|
+
await this.persist();
|
|
96
|
+
}
|
|
97
|
+
/** Forget everything, and take the file with it. */
|
|
98
|
+
async clear() {
|
|
99
|
+
this.loaded = true;
|
|
100
|
+
this.file = { version: SCHEMA_VERSION, entries: [] };
|
|
101
|
+
// Emptied *and* removed: a file left holding `{"entries":[]}` looks like a
|
|
102
|
+
// feature still running, and "clear my history" should leave nothing behind.
|
|
103
|
+
this.writeQueue = this.writeQueue.then(async () => {
|
|
104
|
+
try {
|
|
105
|
+
await unlink(this.filePath);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
/* never existed */
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
await this.writeQueue;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Drop the least valuable entries once over the ceiling.
|
|
115
|
+
*
|
|
116
|
+
* Least recently run goes first, but anything ever picked from the palette is
|
|
117
|
+
* kept ahead of anything never picked: a command someone deliberately chose
|
|
118
|
+
* three weeks ago is worth more than one that scrolled past yesterday.
|
|
119
|
+
*/
|
|
120
|
+
evict() {
|
|
121
|
+
if (this.file.entries.length <= MAX_ENTRIES)
|
|
122
|
+
return;
|
|
123
|
+
const ranked = [...this.file.entries].sort((a, b) => {
|
|
124
|
+
if ((a.picks > 0) !== (b.picks > 0))
|
|
125
|
+
return a.picks > 0 ? -1 : 1;
|
|
126
|
+
return b.lastRun.localeCompare(a.lastRun);
|
|
127
|
+
});
|
|
128
|
+
this.file.entries = ranked.slice(0, MAX_ENTRIES);
|
|
129
|
+
}
|
|
130
|
+
async ensureLoaded() {
|
|
131
|
+
if (this.loaded)
|
|
132
|
+
return;
|
|
133
|
+
this.loaded = true;
|
|
134
|
+
try {
|
|
135
|
+
const parsed = JSON.parse(await readFile(this.filePath, 'utf8'));
|
|
136
|
+
const normalized = normalizeFile(parsed);
|
|
137
|
+
if (normalized)
|
|
138
|
+
this.file = normalized;
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
this.file = { version: SCHEMA_VERSION, entries: [] };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
async persist() {
|
|
145
|
+
const snapshot = this.file;
|
|
146
|
+
this.writeQueue = this.writeQueue.then(async () => {
|
|
147
|
+
try {
|
|
148
|
+
await mkdir(dirname(this.filePath), { recursive: true });
|
|
149
|
+
const temp = join(dirname(this.filePath), `.command-history.tmp-${process.pid}-${randomUUID()}`);
|
|
150
|
+
await writeFile(temp, JSON.stringify(snapshot, null, 1), { encoding: 'utf8', mode: 0o600 });
|
|
151
|
+
await rename(temp, this.filePath);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
// History must never affect terminal operation.
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
await this.writeQueue;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/** Strip control characters and cap length; the file is user-editable. */
|
|
161
|
+
function safeText(value, limit) {
|
|
162
|
+
if (typeof value !== 'string')
|
|
163
|
+
return '';
|
|
164
|
+
return value.replace(CONTROL_CHARACTERS, '').trim().slice(0, limit);
|
|
165
|
+
}
|
|
166
|
+
function optionalText(value, limit) {
|
|
167
|
+
const text = safeText(value, limit);
|
|
168
|
+
return text ? text : undefined;
|
|
169
|
+
}
|
|
170
|
+
// Built from character codes rather than a regex literal: a control character
|
|
171
|
+
// inside a literal is invisible in the source and easily destroyed by a later
|
|
172
|
+
// edit. remote-screens.ts does the same, for the same reason.
|
|
173
|
+
const CONTROL_CHARACTERS = new RegExp('[' + String.fromCharCode(0) + '-' + String.fromCharCode(31) + String.fromCharCode(127) + ']', 'g');
|
|
174
|
+
function normalizeEntry(value) {
|
|
175
|
+
if (!value || typeof value !== 'object')
|
|
176
|
+
return undefined;
|
|
177
|
+
const item = value;
|
|
178
|
+
const id = safeText(item.id, 64);
|
|
179
|
+
const command = safeText(item.command, MAX_COMMAND_CHARS);
|
|
180
|
+
const lastRun = safeText(item.lastRun, 40);
|
|
181
|
+
if (!id || !command || !Number.isFinite(Date.parse(lastRun)))
|
|
182
|
+
return undefined;
|
|
183
|
+
const entry = {
|
|
184
|
+
id,
|
|
185
|
+
command,
|
|
186
|
+
lastRun: new Date(Date.parse(lastRun)).toISOString(),
|
|
187
|
+
runs: count(item.runs, 1),
|
|
188
|
+
picks: count(item.picks, 0)
|
|
189
|
+
};
|
|
190
|
+
const cwd = optionalText(item.cwd, 1024);
|
|
191
|
+
const host = optionalText(item.host, 256);
|
|
192
|
+
if (cwd)
|
|
193
|
+
entry.cwd = cwd;
|
|
194
|
+
if (host)
|
|
195
|
+
entry.host = host;
|
|
196
|
+
if (item.kind === 'ssh' || item.kind === 'local')
|
|
197
|
+
entry.kind = item.kind;
|
|
198
|
+
if (typeof item.exitCode === 'number' && Number.isInteger(item.exitCode) && item.exitCode >= 0 && item.exitCode <= 255) {
|
|
199
|
+
entry.exitCode = item.exitCode;
|
|
200
|
+
}
|
|
201
|
+
return entry;
|
|
202
|
+
}
|
|
203
|
+
function count(value, fallback) {
|
|
204
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0)
|
|
205
|
+
return fallback;
|
|
206
|
+
return Math.min(value, Number.MAX_SAFE_INTEGER);
|
|
207
|
+
}
|
|
208
|
+
export function normalizeFile(value) {
|
|
209
|
+
if (!value || typeof value !== 'object')
|
|
210
|
+
return undefined;
|
|
211
|
+
const item = value;
|
|
212
|
+
if (item.version !== SCHEMA_VERSION)
|
|
213
|
+
return undefined;
|
|
214
|
+
if (!Array.isArray(item.entries))
|
|
215
|
+
return undefined;
|
|
216
|
+
const entries = [];
|
|
217
|
+
const seen = new Set();
|
|
218
|
+
for (const raw of item.entries) {
|
|
219
|
+
const entry = normalizeEntry(raw);
|
|
220
|
+
if (!entry || seen.has(entry.id))
|
|
221
|
+
continue;
|
|
222
|
+
seen.add(entry.id);
|
|
223
|
+
entries.push(entry);
|
|
224
|
+
if (entries.length >= MAX_ENTRIES)
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
return { version: SCHEMA_VERSION, entries };
|
|
228
|
+
}
|
|
229
|
+
export function defaultCommandHistoryPath(userDataPath) {
|
|
230
|
+
return join(userDataPath, 'command-history.json');
|
|
231
|
+
}
|
package/dist/main/main/main.js
CHANGED
|
@@ -1,21 +1,40 @@
|
|
|
1
|
-
import { app, BrowserWindow, clipboard, ipcMain, Menu, session, shell } from 'electron';
|
|
1
|
+
import { app, BrowserWindow, clipboard, ipcMain, Menu, safeStorage, 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';
|
|
5
5
|
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
|
+
import { CommandHistoryStore, defaultCommandHistoryPath } from './command-history-store.js';
|
|
9
|
+
import { WorkspaceStore, defaultWorkspacePath } from './workspace-store.js';
|
|
10
|
+
import { PortForwardService } from './port-forward-service.js';
|
|
11
|
+
import { PortForwardStore, defaultPortForwardPath } from './port-forward-store.js';
|
|
8
12
|
import { buildRemoteScreenAttachArgs, buildRemoteScreenDiscoveryArgs, listKnownConnections, parseRemoteScreenList, validateKnownConnection } from './ssh-inventory.js';
|
|
9
13
|
import { createLocalDirectory, listLocalDirectory, localHome, removeLocalEntry, renameLocalEntry } from './local-fs.js';
|
|
14
|
+
import { wslHomeDirectory } from './wsl-home.js';
|
|
10
15
|
import { decideExternalLink, isApplicationUrl } from './external-links.js';
|
|
11
16
|
import { SftpService } from './sftp-service.js';
|
|
17
|
+
import { AI_API_KEY, SPEECH_API_KEY, SecretStore, defaultSecretsPath } from './secret-store.js';
|
|
18
|
+
import { AiService } from './ai-service.js';
|
|
12
19
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
13
20
|
const history = new SessionHistoryStore({ filePath: defaultHistoryPath(app.getPath('userData')) });
|
|
21
|
+
// The only store holding what the user typed. Nothing reaches it that has not
|
|
22
|
+
// been through command-redaction in the renderer, and it holds nothing at all
|
|
23
|
+
// until the setting is turned on.
|
|
24
|
+
const commands = new CommandHistoryStore({ filePath: defaultCommandHistoryPath(app.getPath('userData')) });
|
|
25
|
+
const workspaceStore = new WorkspaceStore({ filePath: defaultWorkspacePath(app.getPath('userData')) });
|
|
26
|
+
const forwardStore = new PortForwardStore({ filePath: defaultPortForwardPath(app.getPath('userData')) });
|
|
27
|
+
// Tunnels outlive the Ports view being closed: authenticating again is a real
|
|
28
|
+
// cost to pay for having looked away.
|
|
29
|
+
const forwards = new PortForwardService({ onEvent: (event) => win?.webContents.send('forwards:event', event) });
|
|
14
30
|
const service = new ScreenService({ onEvent: (event, session, available) => { void history.record(event, session, available); } });
|
|
15
31
|
let win;
|
|
16
32
|
// Transfer connections outlive any single panel opening, so the panel can be
|
|
17
33
|
// closed and reopened without re-authenticating to the host.
|
|
18
34
|
const sftp = new SftpService({ onEvent: (event) => win?.webContents.send('sftp:event', event) });
|
|
35
|
+
// API keys for speech servers. safeStorage is only usable after the app is
|
|
36
|
+
// ready, which every IPC call here already is.
|
|
37
|
+
const secrets = new SecretStore({ filePath: defaultSecretsPath(app.getPath('userData')), crypto: safeStorage });
|
|
19
38
|
/** A pane's measured size, as it arrives from the renderer. */
|
|
20
39
|
function parsePtySize(value) {
|
|
21
40
|
if (!value || typeof value !== 'object')
|
|
@@ -91,6 +110,9 @@ function createWindow() {
|
|
|
91
110
|
win = undefined;
|
|
92
111
|
service.detachAll();
|
|
93
112
|
sftp.closeAll();
|
|
113
|
+
// A tunnel is a listening socket on someone's machine. It does not outlive
|
|
114
|
+
// the window that opened it.
|
|
115
|
+
forwards.closeAll();
|
|
94
116
|
});
|
|
95
117
|
}
|
|
96
118
|
function isRecord(value) {
|
|
@@ -128,6 +150,18 @@ ipcMain.handle('links:openExternal', (_event, url) => {
|
|
|
128
150
|
ipcMain.handle('sessions:list', () => service.list());
|
|
129
151
|
ipcMain.handle('sessions:history', () => history.list());
|
|
130
152
|
ipcMain.handle('sessions:historyRemove', (_event, entryId) => history.remove(entryId));
|
|
153
|
+
ipcMain.handle('commands:list', () => commands.list());
|
|
154
|
+
ipcMain.handle('commands:record', (_event, record) => commands.record(requireCommandRecord(record)));
|
|
155
|
+
ipcMain.handle('commands:pick', (_event, id) => commands.pick(requireString(id, 'A command')));
|
|
156
|
+
ipcMain.handle('commands:clear', () => commands.clear());
|
|
157
|
+
ipcMain.handle('forwards:list', () => forwards.list());
|
|
158
|
+
ipcMain.handle('forwards:open', (_event, request) => forwards.open(requireForwardRequest(request)));
|
|
159
|
+
ipcMain.handle('forwards:close', (_event, id) => forwards.close(requireString(id, 'A shared port')));
|
|
160
|
+
ipcMain.handle('forwards:answerPrompt', (_event, id, answer) => forwards.answerPrompt(requireString(id, 'A shared port'), requireString(answer, 'An answer')));
|
|
161
|
+
ipcMain.handle('forwards:load', () => forwardStore.load());
|
|
162
|
+
ipcMain.handle('forwards:save', (_event, file) => forwardStore.save(file));
|
|
163
|
+
ipcMain.handle('workspaces:load', () => workspaceStore.load());
|
|
164
|
+
ipcMain.handle('workspaces:save', (_event, file) => workspaceStore.save(file));
|
|
131
165
|
ipcMain.handle('sessions:backends', () => discoverShellBackends());
|
|
132
166
|
ipcMain.handle('sessions:wslDistributions', async () => {
|
|
133
167
|
try {
|
|
@@ -211,12 +245,100 @@ function requireString(value, field) {
|
|
|
211
245
|
throw new Error(`${field} is required.`);
|
|
212
246
|
return value;
|
|
213
247
|
}
|
|
248
|
+
/**
|
|
249
|
+
* A captured command, shaped.
|
|
250
|
+
*
|
|
251
|
+
* Only the shape is checked here. Whether the text is safe to store was decided
|
|
252
|
+
* in the renderer by command-redaction, next to the capture that produced it —
|
|
253
|
+
* that is where the rules and their tests live.
|
|
254
|
+
*/
|
|
255
|
+
function requireCommandRecord(value) {
|
|
256
|
+
if (!isRecord(value))
|
|
257
|
+
throw new Error('A command is required.');
|
|
258
|
+
const text = (field) => (typeof field === 'string' && field ? field : undefined);
|
|
259
|
+
return {
|
|
260
|
+
command: requireString(value.command, 'A command'),
|
|
261
|
+
...(text(value.cwd) ? { cwd: String(value.cwd) } : {}),
|
|
262
|
+
...(text(value.host) ? { host: String(value.host) } : {}),
|
|
263
|
+
...(value.kind === 'ssh' || value.kind === 'local' ? { kind: value.kind } : {}),
|
|
264
|
+
...(typeof value.exitCode === 'number' ? { exitCode: value.exitCode } : {})
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
function requireAiConfig(value) {
|
|
268
|
+
if (!isRecord(value))
|
|
269
|
+
throw new Error('An AI endpoint is required.');
|
|
270
|
+
return {
|
|
271
|
+
baseUrl: typeof value.baseUrl === 'string' ? value.baseUrl : '',
|
|
272
|
+
model: typeof value.model === 'string' ? value.model : ''
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* A suggestion request, shaped.
|
|
277
|
+
*
|
|
278
|
+
* The prompt and the context are checked for type here and for content by
|
|
279
|
+
* buildSuggestionRequest, which is where those rules are tested. The captured
|
|
280
|
+
* output is deliberately not inspected: it is untrusted by design, and
|
|
281
|
+
* ai-protocol is what makes it safe to include.
|
|
282
|
+
*/
|
|
283
|
+
function requireSuggestionRequest(value) {
|
|
284
|
+
if (!isRecord(value))
|
|
285
|
+
throw new Error('A request is required.');
|
|
286
|
+
const context = isRecord(value.context) ? value.context : {};
|
|
287
|
+
const text = (field) => (typeof field === 'string' && field ? field : undefined);
|
|
288
|
+
return {
|
|
289
|
+
prompt: typeof value.prompt === 'string' ? value.prompt : '',
|
|
290
|
+
...(text(value.sessionId) ? { sessionId: String(value.sessionId) } : {}),
|
|
291
|
+
context: {
|
|
292
|
+
...(text(context.shell) ? { shell: String(context.shell) } : {}),
|
|
293
|
+
...(text(context.cwd) ? { cwd: String(context.cwd) } : {}),
|
|
294
|
+
...(text(context.host) ? { host: String(context.host) } : {}),
|
|
295
|
+
...(context.kind === 'ssh' || context.kind === 'local' ? { kind: context.kind } : {}),
|
|
296
|
+
...(text(context.output) ? { output: String(context.output) } : {})
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* A forwarding request, shaped.
|
|
302
|
+
*
|
|
303
|
+
* Only the shape is checked here: the values are vetted by buildForwardArgs,
|
|
304
|
+
* which is where the rules live and where they are tested, and which throws a
|
|
305
|
+
* sentence the renderer can show as-is.
|
|
306
|
+
*/
|
|
307
|
+
function requireForwardRequest(value) {
|
|
308
|
+
if (!isRecord(value))
|
|
309
|
+
throw new Error('A shared port is required.');
|
|
310
|
+
return {
|
|
311
|
+
target: requireString(value.target, 'An SSH target'),
|
|
312
|
+
direction: value.direction,
|
|
313
|
+
bind: value.bind,
|
|
314
|
+
listenPort: value.listenPort,
|
|
315
|
+
destinationPort: value.destinationPort,
|
|
316
|
+
...(typeof value.destinationHost === 'string' && value.destinationHost ? { destinationHost: value.destinationHost } : {}),
|
|
317
|
+
...(typeof value.id === 'string' && value.id ? { id: value.id } : {})
|
|
318
|
+
};
|
|
319
|
+
}
|
|
214
320
|
function requireEntryKind(value) {
|
|
215
321
|
if (value === 'file' || value === 'directory' || value === 'symlink')
|
|
216
322
|
return value;
|
|
217
323
|
throw new Error('An entry kind of file, directory, or symlink is required.');
|
|
218
324
|
}
|
|
325
|
+
// Electron's own reading of package.json, so the title bar shows what is
|
|
326
|
+
// actually running rather than a string compiled in from the repo.
|
|
327
|
+
ipcMain.handle('app:version', () => app.getVersion());
|
|
219
328
|
ipcMain.handle('fs:localHome', () => localHome());
|
|
329
|
+
// Cached on success only, so a distribution that was not running when it was
|
|
330
|
+
// first asked can answer later.
|
|
331
|
+
const wslHomes = new Map();
|
|
332
|
+
ipcMain.handle('fs:wslHome', async (_event, distribution) => {
|
|
333
|
+
const name = typeof distribution === 'string' ? distribution : '';
|
|
334
|
+
const known = wslHomes.get(name);
|
|
335
|
+
if (known)
|
|
336
|
+
return known;
|
|
337
|
+
const home = await wslHomeDirectory(name);
|
|
338
|
+
if (home)
|
|
339
|
+
wslHomes.set(name, home);
|
|
340
|
+
return home;
|
|
341
|
+
});
|
|
220
342
|
ipcMain.handle('fs:listLocal', (_event, path) => listLocalDirectory(typeof path === 'string' && path ? path : undefined));
|
|
221
343
|
ipcMain.handle('fs:mkdirLocal', (_event, path) => createLocalDirectory(requireString(path, 'A folder path')));
|
|
222
344
|
ipcMain.handle('fs:renameLocal', (_event, from, to) => renameLocalEntry(requireString(from, 'The current path'), requireString(to, 'The new path')));
|
|
@@ -236,10 +358,39 @@ ipcMain.handle('sftp:answerPrompt', (_event, id, answer) => {
|
|
|
236
358
|
sftp.answerPrompt(requireString(id, 'A transfer connection'), answer);
|
|
237
359
|
});
|
|
238
360
|
ipcMain.handle('sftp:close', (_event, id) => sftp.close(requireString(id, 'A transfer connection')));
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
361
|
+
// The key is read here, per request, and never handed to the renderer.
|
|
362
|
+
const ai = new AiService({ readApiKey: () => secrets.get(AI_API_KEY) });
|
|
363
|
+
ipcMain.handle('ai:suggest', (_event, config, request) => ai.suggest(requireAiConfig(config), requireSuggestionRequest(request)));
|
|
364
|
+
ipcMain.handle('ai:models', (_event, baseUrl) => ai.listModels(requireString(baseUrl, 'A base URL')));
|
|
365
|
+
ipcMain.handle('ai:test', (_event, config) => ai.test(requireAiConfig(config)));
|
|
366
|
+
ipcMain.handle('ai:cancel', () => ai.cancel());
|
|
367
|
+
async function aiKeyStatus() {
|
|
368
|
+
return { stored: await secrets.has(AI_API_KEY), encryptionAvailable: secrets.encryptionAvailable() };
|
|
369
|
+
}
|
|
370
|
+
ipcMain.handle('aiKey:status', () => aiKeyStatus());
|
|
371
|
+
ipcMain.handle('aiKey:save', async (_event, key) => {
|
|
372
|
+
await secrets.set(AI_API_KEY, typeof key === 'string' ? key.trim() : '');
|
|
373
|
+
return aiKeyStatus();
|
|
374
|
+
});
|
|
375
|
+
ipcMain.handle('aiKey:clear', async () => {
|
|
376
|
+
await secrets.clear(AI_API_KEY);
|
|
377
|
+
return aiKeyStatus();
|
|
378
|
+
});
|
|
379
|
+
async function speechKeyStatus() {
|
|
380
|
+
return { stored: await secrets.has(SPEECH_API_KEY), encryptionAvailable: secrets.encryptionAvailable() };
|
|
381
|
+
}
|
|
382
|
+
ipcMain.handle('speechKey:status', () => speechKeyStatus());
|
|
383
|
+
ipcMain.handle('speechKey:save', async (_event, key) => {
|
|
384
|
+
// An empty key means "clear", which SecretStore already does — but the
|
|
385
|
+
// string still has to be a string, and a pasted key often carries newlines.
|
|
386
|
+
await secrets.set(SPEECH_API_KEY, typeof key === 'string' ? key.trim() : '');
|
|
387
|
+
return speechKeyStatus();
|
|
388
|
+
});
|
|
389
|
+
ipcMain.handle('speechKey:clear', async () => {
|
|
390
|
+
await secrets.clear(SPEECH_API_KEY);
|
|
391
|
+
return speechKeyStatus();
|
|
392
|
+
});
|
|
393
|
+
ipcMain.handle('speechKey:read', () => secrets.get(SPEECH_API_KEY));
|
|
243
394
|
app.whenReady().then(() => {
|
|
244
395
|
// Voice input captures the local microphone only. Electron denies all
|
|
245
396
|
// permission requests unless a handler answers, so grant media explicitly.
|