editmamei 1.5.1 → 1.7.0

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.
Files changed (46) hide show
  1. package/README.md +22 -1
  2. package/dist/backends/detect-editors.js +79 -0
  3. package/dist/backends/gimp/backend.js +99 -0
  4. package/dist/backends/gimp/bridge/lib.py +1988 -0
  5. package/dist/backends/gimp/bridge/ops.py +3120 -0
  6. package/dist/backends/gimp/detect.js +86 -0
  7. package/dist/backends/gimp/errors.js +21 -0
  8. package/dist/backends/gimp/launcher.js +6 -0
  9. package/dist/backends/gimp/session.js +632 -0
  10. package/dist/bin/editmamei-core-darwin-arm64 +0 -0
  11. package/dist/bin/editmamei-core-darwin-x64 +0 -0
  12. package/dist/bin/editmamei-core-win-x64.exe +0 -0
  13. package/dist/cli/config.js +28 -0
  14. package/dist/core/preview-privacy.js +54 -0
  15. package/dist/core/server.js +98 -49
  16. package/dist/core/settings.js +25 -0
  17. package/dist/core/tool-activity.js +122 -0
  18. package/dist/core/tool-groups.js +24 -0
  19. package/dist/core/tool-tiers.js +24 -0
  20. package/dist/diagnostics/collect.js +4 -0
  21. package/dist/index.js +5 -1
  22. package/dist/kernel/kernel.js +3 -0
  23. package/dist/modules/gimp/index.js +43 -0
  24. package/dist/skills/editmamei-skill.zip +0 -0
  25. package/dist/telemetry/activity.js +1 -93
  26. package/dist/tools/adjustment-tools.js +2 -1
  27. package/dist/tools/gimp-adjustment-tools.js +385 -0
  28. package/dist/tools/gimp-checkpoint-tools.js +541 -0
  29. package/dist/tools/gimp-compose-tools.js +399 -0
  30. package/dist/tools/gimp-core-tools.js +302 -0
  31. package/dist/tools/gimp-document-tools.js +321 -0
  32. package/dist/tools/gimp-effect-tools.js +267 -0
  33. package/dist/tools/gimp-filter-tools.js +94 -0
  34. package/dist/tools/gimp-geometry-tools.js +223 -0
  35. package/dist/tools/gimp-inspect-tools.js +157 -0
  36. package/dist/tools/gimp-layer-tools.js +385 -0
  37. package/dist/tools/gimp-mask-tools.js +102 -0
  38. package/dist/tools/gimp-shared.js +58 -0
  39. package/dist/tools/gimp-verify-tools.js +409 -0
  40. package/dist/utils/gimp-path.js +27 -0
  41. package/dist/utils/operation-timeouts.js +24 -0
  42. package/dist/utils/session-log.js +19 -1
  43. package/dist/utils/tool-helpers.js +14 -0
  44. package/dist/utils/validate.js +11 -0
  45. package/dist/version.js +1 -1
  46. package/package.json +3 -3
@@ -0,0 +1,632 @@
1
+ import { spawn as nodeSpawn, spawnSync, } from 'node:child_process';
2
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, copyFileSync, } from 'node:fs';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { basename, dirname, join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { Logger } from '../../utils/logger.js';
7
+ import { userOwnedTempRoot } from '../../utils/temp.js';
8
+ import { GimpError, GIMP_ERROR_CODES } from './errors.js';
9
+ export const READY_TIMEOUT_MS = 180_000;
10
+ export const CALL_READY_WAIT_MS = 30_000;
11
+ const PING_TIMEOUT_MS = 10_000;
12
+ const DEFAULT_CALL_TIMEOUT_MS = 30_000;
13
+ export const SHUTDOWN_GRACE_MS = 5_000;
14
+ export const KILL_CONFIRM_MS = 2_000;
15
+ const CLOSE_AFTER_EXIT_GRACE_MS = 500;
16
+ const ORPHAN_MAX_AGE_MS = 24 * 60 * 60 * 1000;
17
+ const POLL_INTERVAL_MS = 2;
18
+ const MAX_CAPTURED_OUTPUT = 8 * 1024;
19
+ const RESP_READ_RETRY_ATTEMPTS = 5;
20
+ const RESP_READ_RETRY_DELAY_MS = 20;
21
+ const KNOWN_ERROR_CODES = new Set(GIMP_ERROR_CODES);
22
+ const LOST_WORK = 'every open image and unsaved filter is gone — reopen the file with gimp_open_document';
23
+ const RESTARTED_MESSAGE = `GIMP stopped; ${LOST_WORK}`;
24
+ export function hasExited(proc) {
25
+ return proc.exitCode !== null || proc.signalCode !== null;
26
+ }
27
+ export const BATCH_LINE = "import os; g = {}; exec(open(os.environ['EM_GIMP_OPS'], encoding='utf-8').read(), g); g['serve'](os.environ['EM_GIMP_SESSION'])";
28
+ const HERE = dirname(fileURLToPath(import.meta.url));
29
+ export function resolveOpsPyPath() {
30
+ return join(HERE, 'bridge', 'ops.py');
31
+ }
32
+ export function defaultRootDir() {
33
+ return join(userOwnedTempRoot(), 'gimp');
34
+ }
35
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
36
+ function isEnoentError(err) {
37
+ return typeof err === 'object' && err !== null && err.code === 'ENOENT';
38
+ }
39
+ function isRetryableReadError(err) {
40
+ const code = typeof err === 'object' && err !== null ? err.code : undefined;
41
+ return code === 'EBUSY' || code === 'EPERM';
42
+ }
43
+ const PYTHON_INTERPRETER_MISSING_RE = /The batch interpreter '[^']*' is not available\. Batch mode disabled\.|No batch interpreters are available\. Batch mode disabled\.|The procedure '[^']*' is not a valid batch interpreter\./;
44
+ export function defaultTreeKill(proc) {
45
+ if (hasExited(proc) || proc.pid === undefined)
46
+ return;
47
+ if (process.platform === 'win32') {
48
+ spawnSync('taskkill', ['/PID', String(proc.pid), '/T', '/F'], { windowsHide: true });
49
+ return;
50
+ }
51
+ try {
52
+ process.kill(-proc.pid, 'SIGKILL');
53
+ }
54
+ catch {
55
+ try {
56
+ proc.kill('SIGKILL');
57
+ }
58
+ catch {
59
+ }
60
+ }
61
+ }
62
+ export class GimpSession {
63
+ install;
64
+ rootDir;
65
+ spawnFn;
66
+ clock;
67
+ monotonicClock;
68
+ logger;
69
+ killTreeFn;
70
+ platform;
71
+ getuid;
72
+ statRootDir;
73
+ chmodRootDir;
74
+ readRespFile;
75
+ opsPyPath;
76
+ sessionState = 'idle';
77
+ version;
78
+ proc;
79
+ sessionDir;
80
+ rpcDir;
81
+ output = '';
82
+ nextId = 1;
83
+ queue = Promise.resolve();
84
+ deadGeneration = 0;
85
+ sweepDone = false;
86
+ startPromise;
87
+ everReady = false;
88
+ startOrigin;
89
+ unobservedStartFailure;
90
+ activeWaiters = 0;
91
+ restartNoticePending = false;
92
+ shutdownPromise;
93
+ shuttingDown = false;
94
+ stickyError;
95
+ lastFailure = new GimpError('gimp_session_restarted', RESTARTED_MESSAGE);
96
+ constructor(opts) {
97
+ this.install = opts.install;
98
+ this.rootDir = opts.rootDir ?? defaultRootDir();
99
+ this.spawnFn = opts.spawn ?? nodeSpawn;
100
+ this.clock = opts.now ?? Date.now;
101
+ this.monotonicClock = opts.nowMonotonic ?? (() => performance.now());
102
+ this.logger = opts.logger ?? new Logger('GimpSession');
103
+ this.killTreeFn = opts.killTree ?? defaultTreeKill;
104
+ this.platform = opts.platform ?? process.platform;
105
+ this.getuid = opts.getuid ?? (() => process.getuid?.());
106
+ this.statRootDir =
107
+ opts.statRootDir ??
108
+ ((path) => {
109
+ const st = statSync(path);
110
+ return { uid: st.uid, mode: st.mode };
111
+ });
112
+ this.chmodRootDir = opts.chmodRootDir ?? chmodSync;
113
+ this.readRespFile = opts.readRespFile ?? ((path) => readFileSync(path, 'utf8'));
114
+ this.opsPyPath = opts.opsPyPath ?? resolveOpsPyPath();
115
+ }
116
+ get state() {
117
+ return this.sessionState;
118
+ }
119
+ get gimpVersion() {
120
+ return this.version;
121
+ }
122
+ get lastStartOrigin() {
123
+ return this.startOrigin;
124
+ }
125
+ get generation() {
126
+ return this.deadGeneration;
127
+ }
128
+ latestPreviewPath() {
129
+ return join(this.rootDir, 'latest-preview.jpg');
130
+ }
131
+ tempPath(name) {
132
+ this.#ensureRootDir();
133
+ return join(this.rootDir, name);
134
+ }
135
+ copyToLatestPreview(src) {
136
+ this.#ensureRootDir();
137
+ const dest = this.latestPreviewPath();
138
+ const tmp = `${dest}.${process.pid}-${randomUUID()}.tmp`;
139
+ copyFileSync(src, tmp);
140
+ renameSync(tmp, dest);
141
+ }
142
+ async call(op, args = {}, callOpts = {}) {
143
+ const myGeneration = this.deadGeneration;
144
+ const issuedAt = this.monotonicClock();
145
+ const run = this.queue.then(() => this.#dispatch(op, args, callOpts.timeoutMs ?? DEFAULT_CALL_TIMEOUT_MS, myGeneration, issuedAt));
146
+ this.queue = run.then(() => undefined, () => undefined);
147
+ return run;
148
+ }
149
+ async shutdown() {
150
+ if (!this.shutdownPromise)
151
+ this.shutdownPromise = this.#shutdownOnce();
152
+ return this.shutdownPromise;
153
+ }
154
+ async #dispatch(op, args, timeoutMs, myGeneration, issuedAt) {
155
+ this.#assertOpen();
156
+ if (this.stickyError) {
157
+ throw this.stickyError;
158
+ }
159
+ if (this.sessionState === 'dead' && myGeneration < this.deadGeneration) {
160
+ throw this.lastFailure;
161
+ }
162
+ if (this.sessionState !== 'ready') {
163
+ await this.#joinOrStart(issuedAt);
164
+ this.#assertOpen();
165
+ }
166
+ return this.#send(op, args, timeoutMs);
167
+ }
168
+ async #joinOrStart(issuedAt) {
169
+ if (!this.startPromise) {
170
+ if (this.unobservedStartFailure) {
171
+ const err = this.unobservedStartFailure;
172
+ this.unobservedStartFailure = undefined;
173
+ throw err;
174
+ }
175
+ const newAttempt = this.#startFresh();
176
+ newAttempt.catch((err) => {
177
+ if (this.activeWaiters > 0)
178
+ return;
179
+ this.unobservedStartFailure =
180
+ err instanceof GimpError ? err : new GimpError('gimp_start_failed', String(err));
181
+ });
182
+ this.startPromise = newAttempt;
183
+ const clearIfCurrent = () => {
184
+ if (this.startPromise === newAttempt)
185
+ this.startPromise = undefined;
186
+ };
187
+ newAttempt.then(clearIfCurrent, clearIfCurrent);
188
+ }
189
+ const attempt = this.startPromise;
190
+ const remaining = Math.min(CALL_READY_WAIT_MS, Math.max(0, issuedAt + CALL_READY_WAIT_MS - this.monotonicClock()));
191
+ this.activeWaiters++;
192
+ let timer;
193
+ const timedOut = new Promise((resolve) => {
194
+ timer = setTimeout(() => resolve(true), remaining);
195
+ });
196
+ try {
197
+ const raced = await Promise.race([attempt.then(() => false), timedOut]);
198
+ if (raced === true) {
199
+ const restarting = this.startOrigin === 'restarted';
200
+ throw new GimpError('gimp_starting', restarting
201
+ ? 'GIMP is restarting after stopping unexpectedly. Call gimp_ping again in about 30 seconds.'
202
+ : 'GIMP is still starting. The first launch on a machine can take a few minutes while ' +
203
+ 'GIMP builds its caches. Call gimp_ping again in about 30 seconds.');
204
+ }
205
+ }
206
+ finally {
207
+ clearTimeout(timer);
208
+ this.activeWaiters--;
209
+ }
210
+ }
211
+ #assertOpen(procToKillIfTripped) {
212
+ if (!this.shuttingDown && this.sessionState !== 'closed')
213
+ return;
214
+ if (procToKillIfTripped)
215
+ this.killTreeFn(procToKillIfTripped);
216
+ throw new GimpError('gimp_session_restarted', 'session closed');
217
+ }
218
+ async #startFresh() {
219
+ this.#assertOpen();
220
+ this.startOrigin = this.everReady ? 'restarted' : 'cold';
221
+ const staleProc = this.proc;
222
+ const staleDir = this.sessionDir;
223
+ if (staleProc && !hasExited(staleProc)) {
224
+ await this.#killAndConfirmExit(staleProc);
225
+ this.#assertOpen();
226
+ }
227
+ if (staleDir) {
228
+ this.#removeSessionDir(staleDir);
229
+ }
230
+ this.sessionState = 'starting';
231
+ let sessionDir;
232
+ let rpcDir;
233
+ try {
234
+ this.#ensureRootDir();
235
+ if (!this.sweepDone) {
236
+ this.sweepDone = true;
237
+ this.#sweepOrphans();
238
+ }
239
+ sessionDir = mkdtempSync(join(this.rootDir, `session-${process.pid}-`));
240
+ rpcDir = join(sessionDir, 'rpc');
241
+ mkdirSync(rpcDir, { recursive: true });
242
+ this.sessionDir = sessionDir;
243
+ this.rpcDir = rpcDir;
244
+ this.nextId = 1;
245
+ this.output = '';
246
+ }
247
+ catch (err) {
248
+ const gimpErr = err instanceof GimpError
249
+ ? err
250
+ : new GimpError('gimp_start_failed', `could not prepare the GIMP session directory: ${this.#redact(err instanceof Error ? err.message : String(err))}`);
251
+ this.#markDead(gimpErr);
252
+ throw gimpErr;
253
+ }
254
+ const inherited = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.toUpperCase().startsWith('EDITMAMEI_')));
255
+ const env = {
256
+ ...inherited,
257
+ EM_GIMP_OPS: this.opsPyPath,
258
+ EM_GIMP_SESSION: sessionDir,
259
+ };
260
+ if (this.install.launch.command !== 'flatpak') {
261
+ env.EM_GIMP_PARENT_PID = String(process.pid);
262
+ }
263
+ const { command, args } = this.install.launch;
264
+ const fullArgs = [
265
+ ...args,
266
+ '-i',
267
+ '--batch-interpreter=python-fu-eval',
268
+ '-b',
269
+ BATCH_LINE,
270
+ '--quit',
271
+ ];
272
+ let proc;
273
+ try {
274
+ proc = this.spawnFn(command, fullArgs, {
275
+ stdio: ['ignore', 'pipe', 'pipe'],
276
+ windowsHide: true,
277
+ detached: this.platform !== 'win32',
278
+ env,
279
+ });
280
+ }
281
+ catch (err) {
282
+ const gimpErr = this.#classifySpawnFailure(err);
283
+ this.#markDead(gimpErr);
284
+ throw gimpErr;
285
+ }
286
+ this.proc = proc;
287
+ proc.stdout?.on('data', (d) => {
288
+ this.output = (this.output + String(d)).slice(-MAX_CAPTURED_OUTPUT);
289
+ });
290
+ proc.stderr?.on('data', (d) => {
291
+ this.output = (this.output + String(d)).slice(-MAX_CAPTURED_OUTPUT);
292
+ });
293
+ let spawnError;
294
+ let preReadyCloseCode;
295
+ let closeGraceTimer;
296
+ proc.on('exit', (code, signal) => {
297
+ if (proc !== this.proc)
298
+ return;
299
+ if (this.sessionState === 'dead' || this.sessionState === 'closed')
300
+ return;
301
+ if (this.shuttingDown) {
302
+ this.sessionState = 'closed';
303
+ return;
304
+ }
305
+ if (this.sessionState === 'starting') {
306
+ closeGraceTimer = setTimeout(() => {
307
+ if (preReadyCloseCode === undefined)
308
+ preReadyCloseCode = code;
309
+ }, CLOSE_AFTER_EXIT_GRACE_MS);
310
+ closeGraceTimer.unref?.();
311
+ return;
312
+ }
313
+ this.logger.warn('GIMP process exited unexpectedly', { code, signal });
314
+ this.#markDead();
315
+ });
316
+ proc.on('close', (code) => {
317
+ if (proc !== this.proc)
318
+ return;
319
+ if (this.sessionState !== 'starting')
320
+ return;
321
+ clearTimeout(closeGraceTimer);
322
+ preReadyCloseCode = code;
323
+ });
324
+ proc.on('error', (err) => {
325
+ if (proc !== this.proc)
326
+ return;
327
+ spawnError = err;
328
+ if (this.sessionState !== 'dead' && this.sessionState !== 'closed') {
329
+ this.logger.error('GIMP process error', err);
330
+ this.#markDead(this.#classifySpawnFailure(err));
331
+ }
332
+ });
333
+ try {
334
+ await this.#waitForReady(rpcDir, () => spawnError, () => preReadyCloseCode);
335
+ }
336
+ catch (err) {
337
+ this.#assertOpen();
338
+ const gimpErr = err;
339
+ this.#markDead(gimpErr);
340
+ this.killTreeFn(proc);
341
+ throw gimpErr;
342
+ }
343
+ this.#assertOpen(proc);
344
+ let ping;
345
+ try {
346
+ ping = await this.#send('ping', {}, PING_TIMEOUT_MS);
347
+ }
348
+ catch (err) {
349
+ this.#assertOpen();
350
+ const gimpErr = err;
351
+ this.#markDead(gimpErr);
352
+ this.killTreeFn(proc);
353
+ throw gimpErr;
354
+ }
355
+ this.#assertOpen(proc);
356
+ this.version = `${ping.major}.${ping.minor}.${ping.micro}`;
357
+ if (ping.major !== 3 || ping.minor < 2) {
358
+ const err = new GimpError('gimp_version_unsupported', `Editmamei needs GIMP 3.2 or newer; found ${this.version}`);
359
+ this.stickyError = err;
360
+ this.#markDead(err);
361
+ this.killTreeFn(proc);
362
+ throw err;
363
+ }
364
+ if (ping.minor > 2) {
365
+ this.logger.warn(`GIMP ${this.version} is newer than the tested 3.2.x line; continuing untested`);
366
+ }
367
+ this.#assertOpen(proc);
368
+ this.sessionState = 'ready';
369
+ if (this.startOrigin === 'restarted')
370
+ this.restartNoticePending = true;
371
+ this.everReady = true;
372
+ }
373
+ #ensureRootDir() {
374
+ mkdirSync(this.rootDir, { recursive: true, mode: 0o700 });
375
+ if (this.platform === 'win32')
376
+ return;
377
+ const st = this.statRootDir(this.rootDir);
378
+ const uid = this.getuid();
379
+ if (uid !== undefined && st.uid !== uid) {
380
+ throw new GimpError('gimp_start_failed', `refusing to use the GIMP session folder: owned by a different user (uid ${st.uid})`);
381
+ }
382
+ if ((st.mode & 0o022) !== 0) {
383
+ throw new GimpError('gimp_start_failed', `refusing to use the GIMP session folder: group- or other-writable (mode ${(st.mode & 0o777).toString(8)})`);
384
+ }
385
+ this.chmodRootDir(this.rootDir, 0o700);
386
+ }
387
+ #classifySpawnFailure(err) {
388
+ const command = this.install.launch.command;
389
+ const binary = basename(command);
390
+ this.logger.debug('GIMP spawn failed', { command, err });
391
+ if (isEnoentError(err)) {
392
+ const gimpErr = new GimpError('gimp_start_failed', `GIMP not found at "${binary}" (ENOENT) — check the install path`);
393
+ this.stickyError = gimpErr;
394
+ return gimpErr;
395
+ }
396
+ const message = (err instanceof Error ? err.message : String(err)).split(command).join(binary);
397
+ return new GimpError('gimp_start_failed', `could not start GIMP at "${binary}": ${message}`);
398
+ }
399
+ async #waitForReady(rpcDir, getSpawnError, getPreReadyCloseCode) {
400
+ const readyPath = join(rpcDir, 'ready');
401
+ const deadline = this.monotonicClock() + READY_TIMEOUT_MS;
402
+ while (!existsSync(readyPath)) {
403
+ this.#assertOpen();
404
+ const spawnErr = getSpawnError();
405
+ if (spawnErr)
406
+ throw this.#classifySpawnFailure(spawnErr);
407
+ const closeCode = getPreReadyCloseCode();
408
+ if (closeCode !== undefined)
409
+ throw this.#classifyPreReadyExit(closeCode);
410
+ if (this.monotonicClock() > deadline) {
411
+ throw new GimpError('gimp_start_failed', `GIMP did not become ready within ${READY_TIMEOUT_MS}ms`);
412
+ }
413
+ await sleep(POLL_INTERVAL_MS);
414
+ }
415
+ }
416
+ #classifyPreReadyExit(exitCode) {
417
+ this.logger.debug('GIMP exited before ready', { exitCode, tail: this.output.slice(-500) });
418
+ if (PYTHON_INTERPRETER_MISSING_RE.test(this.output)) {
419
+ const gimpErr = new GimpError('gimp_python_missing', 'this GIMP install has no Python support (python-fu-eval is missing) — reinstall GIMP with Python support enabled');
420
+ this.stickyError = gimpErr;
421
+ return gimpErr;
422
+ }
423
+ return new GimpError('gimp_start_failed', `GIMP exited (code ${exitCode}) before it became ready`);
424
+ }
425
+ async #send(op, args, timeoutMs) {
426
+ const proc = this.proc;
427
+ const rpcDir = this.rpcDir;
428
+ if (!proc || !rpcDir) {
429
+ throw new GimpError('gimp_start_failed', 'GIMP session has no active process');
430
+ }
431
+ const id = this.nextId++;
432
+ const reqPath = join(rpcDir, `req-${id}.json`);
433
+ const respPath = join(rpcDir, `resp-${id}.json`);
434
+ writeFileSync(`${reqPath}.tmp`, JSON.stringify({ id, op, args }));
435
+ renameSync(`${reqPath}.tmp`, reqPath);
436
+ const deadline = this.monotonicClock() + timeoutMs;
437
+ while (!existsSync(respPath)) {
438
+ if (hasExited(proc) || this.sessionState === 'dead') {
439
+ if (!this.shuttingDown)
440
+ this.#markDead();
441
+ throw new GimpError('gimp_session_restarted', RESTARTED_MESSAGE);
442
+ }
443
+ if (this.monotonicClock() > deadline) {
444
+ if (!this.shuttingDown)
445
+ this.#markDead();
446
+ this.killTreeFn(proc);
447
+ throw new GimpError('gimp_timeout', `${op} did not respond within ${timeoutMs}ms, so the GIMP session was stopped: ${LOST_WORK}`);
448
+ }
449
+ await sleep(POLL_INTERVAL_MS);
450
+ }
451
+ const raw = await this.#readResponse(respPath, op);
452
+ try {
453
+ unlinkSync(respPath);
454
+ }
455
+ catch {
456
+ }
457
+ if (!raw.ok) {
458
+ const code = raw.code && KNOWN_ERROR_CODES.has(raw.code)
459
+ ? raw.code
460
+ : 'gimp_op_failed';
461
+ this.logger.debug('GIMP op failed', op, raw.error, raw.trace);
462
+ if (this.restartNoticePending &&
463
+ code === 'invalid_argument' &&
464
+ /no open image with id/.test(raw.error ?? '')) {
465
+ this.restartNoticePending = false;
466
+ throw new GimpError('gimp_session_restarted', `that image id is not open. The GIMP session restarted, so ${LOST_WORK}`);
467
+ }
468
+ throw new GimpError(code, this.#redact(raw.error ?? `${op} failed`));
469
+ }
470
+ if (op === 'open')
471
+ this.restartNoticePending = false;
472
+ return raw.result;
473
+ }
474
+ async #readResponse(respPath, op) {
475
+ let raw;
476
+ let lastErr;
477
+ for (let attempt = 0; attempt < RESP_READ_RETRY_ATTEMPTS; attempt++) {
478
+ try {
479
+ raw = this.readRespFile(respPath);
480
+ break;
481
+ }
482
+ catch (err) {
483
+ lastErr = err;
484
+ if (!isRetryableReadError(err)) {
485
+ throw new GimpError('gimp_op_failed', `${op}: could not read the response file (${this.#redact(err.message)})`);
486
+ }
487
+ await sleep(RESP_READ_RETRY_DELAY_MS);
488
+ }
489
+ }
490
+ if (raw === undefined) {
491
+ const message = lastErr instanceof Error ? lastErr.message : String(lastErr);
492
+ throw new GimpError('gimp_op_failed', `${op}: could not read the response file after ${RESP_READ_RETRY_ATTEMPTS} attempts (${this.#redact(message)})`);
493
+ }
494
+ let parsed;
495
+ try {
496
+ parsed = JSON.parse(raw);
497
+ }
498
+ catch (err) {
499
+ throw new GimpError('gimp_op_failed', `${op}: response was not valid JSON (${err.message})`);
500
+ }
501
+ if (typeof parsed !== 'object' ||
502
+ parsed === null ||
503
+ typeof parsed.ok !== 'boolean') {
504
+ throw new GimpError('gimp_op_failed', `${op}: response was not a well-formed object`);
505
+ }
506
+ return parsed;
507
+ }
508
+ async #shutdownOnce() {
509
+ this.shuttingDown = true;
510
+ const proc = this.proc;
511
+ if (!proc) {
512
+ this.sessionState = 'closed';
513
+ if (this.sessionDir)
514
+ this.#removeSessionDir(this.sessionDir);
515
+ return;
516
+ }
517
+ if (hasExited(proc)) {
518
+ this.sessionState = 'closed';
519
+ if (this.sessionDir)
520
+ this.#removeSessionDir(this.sessionDir);
521
+ return;
522
+ }
523
+ if (this.sessionState === 'dead') {
524
+ await this.#killAndConfirmExit(proc);
525
+ this.sessionState = 'closed';
526
+ if (this.sessionDir)
527
+ this.#removeSessionDir(this.sessionDir);
528
+ return;
529
+ }
530
+ try {
531
+ writeFileSync(join(this.rpcDir, 'shutdown'), '');
532
+ }
533
+ catch {
534
+ }
535
+ let graceTimer;
536
+ const graceExpired = new Promise((resolve) => {
537
+ graceTimer = setTimeout(() => resolve(true), SHUTDOWN_GRACE_MS);
538
+ });
539
+ const exited = new Promise((resolve) => proc.once('exit', () => resolve(false)));
540
+ const timedOut = await Promise.race([exited, graceExpired]);
541
+ clearTimeout(graceTimer);
542
+ if (timedOut) {
543
+ await this.#killAndConfirmExit(proc, exited);
544
+ }
545
+ this.sessionState = 'closed';
546
+ if (this.sessionDir)
547
+ this.#removeSessionDir(this.sessionDir);
548
+ }
549
+ async #killAndConfirmExit(proc, existingExited) {
550
+ this.killTreeFn(proc);
551
+ const exited = existingExited ?? new Promise((resolve) => proc.once('exit', () => resolve(false)));
552
+ let confirmTimer;
553
+ const confirmTimedOut = new Promise((resolve) => {
554
+ confirmTimer = setTimeout(() => resolve(true), KILL_CONFIRM_MS);
555
+ });
556
+ const stillRunning = await Promise.race([exited.then(() => false), confirmTimedOut]);
557
+ clearTimeout(confirmTimer);
558
+ if (stillRunning) {
559
+ this.logger.warn('GIMP process did not confirm exit after tree-kill; proceeding anyway', {
560
+ sessionDir: this.sessionDir,
561
+ });
562
+ }
563
+ }
564
+ #markDead(err) {
565
+ if (this.sessionState === 'dead' || this.sessionState === 'closed')
566
+ return;
567
+ this.sessionState = 'dead';
568
+ this.deadGeneration++;
569
+ this.lastFailure = err ?? new GimpError('gimp_session_restarted', RESTARTED_MESSAGE);
570
+ }
571
+ #redact(text) {
572
+ const spellings = [
573
+ ...new Set([
574
+ this.rootDir.replace(/\\/g, '\\\\'),
575
+ this.rootDir,
576
+ this.rootDir.replace(/\\/g, '/'),
577
+ ]),
578
+ ].sort((a, b) => b.length - a.length);
579
+ return spellings.reduce((out, s) => out.split(s).join('<GIMP session folder>'), text);
580
+ }
581
+ #removeSessionDir(dir) {
582
+ try {
583
+ rmSync(dir, { recursive: true, force: true });
584
+ }
585
+ catch (err) {
586
+ this.logger.warn('Could not remove GIMP session dir', dir, err);
587
+ }
588
+ }
589
+ #sweepOrphans() {
590
+ let entries;
591
+ try {
592
+ entries = readdirSync(this.rootDir);
593
+ }
594
+ catch {
595
+ return;
596
+ }
597
+ const cutoff = this.clock() - ORPHAN_MAX_AGE_MS;
598
+ for (const name of entries) {
599
+ const match = /^session-(\d+)-/.exec(name);
600
+ if (!match)
601
+ continue;
602
+ const pid = Number(match[1]);
603
+ const dir = join(this.rootDir, name);
604
+ let mtimeMs;
605
+ try {
606
+ mtimeMs = statSync(dir).mtimeMs;
607
+ }
608
+ catch {
609
+ continue;
610
+ }
611
+ if (mtimeMs >= cutoff)
612
+ continue;
613
+ if (this.#isPidAlive(pid))
614
+ continue;
615
+ try {
616
+ rmSync(dir, { recursive: true, force: true });
617
+ }
618
+ catch (err) {
619
+ this.logger.warn('Could not remove orphaned GIMP session dir', dir, err);
620
+ }
621
+ }
622
+ }
623
+ #isPidAlive(pid) {
624
+ try {
625
+ process.kill(pid, 0);
626
+ return true;
627
+ }
628
+ catch (err) {
629
+ return err.code !== 'ESRCH';
630
+ }
631
+ }
632
+ }
Binary file
Binary file
@@ -1,4 +1,5 @@
1
1
  import { loadSettings, saveSettings, } from '../core/settings.js';
2
+ import { requireAbsoluteGimpPath } from '../utils/tool-helpers.js';
2
3
  function coerceBool(raw) {
3
4
  const v = raw.trim().toLowerCase();
4
5
  if (['true', '1', 'on', 'yes'].includes(v))
@@ -11,6 +12,19 @@ function coercePath(raw) {
11
12
  const v = raw.trim();
12
13
  return v === '' || v.toLowerCase() === 'null' ? null : v;
13
14
  }
15
+ function coerceGimpPath(raw) {
16
+ const v = raw.trim();
17
+ if (v === '' || v.toLowerCase() === 'null')
18
+ return null;
19
+ requireAbsoluteGimpPath('gimp_path', v);
20
+ return v;
21
+ }
22
+ function coerceEditorPin(raw) {
23
+ const v = raw.trim().toLowerCase();
24
+ if (v === 'auto' || v === 'photoshop' || v === 'gimp')
25
+ return v;
26
+ throw new Error(`expected one of auto|photoshop|gimp, got "${raw}"`);
27
+ }
14
28
  const KEYS = {
15
29
  'telemetry.usage': {
16
30
  get: (s) => s.telemetry.usage,
@@ -50,6 +64,20 @@ const KEYS = {
50
64
  },
51
65
  coerce: coerceBool,
52
66
  },
67
+ editor: {
68
+ get: (s) => s.editor,
69
+ set: (s, v) => {
70
+ s.editor = v;
71
+ },
72
+ coerce: coerceEditorPin,
73
+ },
74
+ gimp_path: {
75
+ get: (s) => s.gimp_path,
76
+ set: (s, v) => {
77
+ s.gimp_path = v;
78
+ },
79
+ coerce: coerceGimpPath,
80
+ },
53
81
  };
54
82
  function knownKeysHint() {
55
83
  return `Known keys:\n${Object.keys(KEYS)