buddy-workbench 0.1.110 → 0.1.112

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.
@@ -1,6 +1,6 @@
1
- import { execFile } from 'node:child_process';
1
+ import { execFile, spawn } from 'node:child_process';
2
2
  import { createHash, randomUUID } from 'node:crypto';
3
- import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
3
+ import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { promisify } from 'node:util';
6
6
  import { fileURLToPath } from 'node:url';
@@ -22,6 +22,10 @@ let suppressImageCaptureUntil = 0;
22
22
  // recompiling Swift and incrementing the same error counter every poll.
23
23
  let swiftClipboardRetryAt = 0;
24
24
  let mozjpegWasmModule = null;
25
+ let macClipboardHelper = null;
26
+ let macClipboardHelperRestartTimer = null;
27
+ let macClipboardHelperWatchdog = null;
28
+ let macClipboardHelperExitHookRegistered = false;
25
29
  const clipboardErrorCounts = new Map();
26
30
  const clipboardErrorLoggedStages = new Set();
27
31
 
@@ -192,6 +196,212 @@ if let data = pb.data(forType: .png) {
192
196
  return null;
193
197
  }
194
198
 
199
+ function persistCapturedClipboardImage(buffer, hash, extension = 'jpg') {
200
+ if (!buffer?.length || !hash || hash === lastImageHash) return;
201
+
202
+ let isDuplicate = false;
203
+ for (const date of clipboardDates()) {
204
+ if (dayItems(date).some((item) => item.imageHash === hash || item.fileHash === hash || item.imageHashes?.includes(hash))) {
205
+ isDuplicate = true;
206
+ break;
207
+ }
208
+ }
209
+
210
+ if (isDuplicate) {
211
+ lastImageHash = hash;
212
+ return;
213
+ }
214
+
215
+ saveClipboardImage(buffer, extension, hash);
216
+ lastImageHash = hash;
217
+ markClipboardSuccess('image save');
218
+ }
219
+
220
+ function handleMacClipboardHelperMessage(state, line) {
221
+ let message;
222
+ try {
223
+ message = JSON.parse(line);
224
+ } catch (error) {
225
+ logClipboardError('native image helper protocol', error);
226
+ return;
227
+ }
228
+ state.lastMessageAt = Date.now();
229
+
230
+ if (message.kind === 'ready') {
231
+ state.ready = true;
232
+ markClipboardSuccess('native image helper startup', 'native image helper process');
233
+ return;
234
+ }
235
+ if (message.kind === 'heartbeat') {
236
+ markClipboardSuccess('native image helper stall');
237
+ return;
238
+ }
239
+ if (message.kind === 'error') {
240
+ const stage = `native clipboard ${String(message.stage || 'capture')}`;
241
+ const elapsedMs = Number(message.elapsedMs);
242
+ const elapsedNote = Number.isFinite(elapsedMs) ? ` (${elapsedMs} ms)` : '';
243
+ logClipboardError(stage, new Error(`${String(message.message || 'Native clipboard capture failed.')}${elapsedNote}`), 1);
244
+ return;
245
+ }
246
+ if (message.kind !== 'image') return;
247
+
248
+ const filename = String(message.filename || '');
249
+ const validFilename = /^capture-[0-9a-f-]+\.jpg$/i.test(filename);
250
+ if (validFilename && Date.now() < suppressImageCaptureUntil) {
251
+ try { unlinkSync(join(paths.clipboardDir, 'content', filename)); } catch {}
252
+ return;
253
+ }
254
+ const tempPath = validFilename ? join(paths.clipboardDir, 'content', filename) : null;
255
+ const width = Number(message.width);
256
+ const height = Number(message.height);
257
+ const reportedBytes = Number(message.bytes);
258
+ if (!validFilename
259
+ || !Number.isInteger(width) || width <= 0
260
+ || !Number.isInteger(height) || height <= 0
261
+ || width * height > 40_000_000
262
+ || !Number.isInteger(reportedBytes) || reportedBytes <= 0 || reportedBytes > macClipboardCommandMaxBuffer) {
263
+ if (tempPath) { try { unlinkSync(tempPath); } catch {} }
264
+ logClipboardError('native image helper protocol', new Error('Native image helper returned invalid image metadata.'));
265
+ return;
266
+ }
267
+
268
+ try {
269
+ const jpeg = readFileSync(tempPath);
270
+ if (!jpeg.length || jpeg.length !== reportedBytes || jpeg.length > macClipboardCommandMaxBuffer) {
271
+ throw new Error('Temporary JPEG size did not match the helper metadata.');
272
+ }
273
+ const hash = createHash('md5').update(jpeg).digest('hex');
274
+ persistCapturedClipboardImage(jpeg, hash, 'jpg');
275
+ const elapsedMs = Number(message.elapsedMs);
276
+ if (Number.isFinite(elapsedMs) && elapsedMs >= 3000) {
277
+ addErrorRecord({
278
+ source: 'Clipboard Monitor',
279
+ level: 'warning',
280
+ message: `Clipboard image capture took ${elapsedMs} ms.`,
281
+ details: `${width} × ${height}, source type ${String(message.sourceType || 'unknown')}, JPEG ${jpeg.length} bytes.`
282
+ });
283
+ }
284
+ markClipboardSuccess(
285
+ 'native clipboard pasteboard read',
286
+ 'native clipboard image validation',
287
+ 'native clipboard image decode',
288
+ 'native clipboard JPEG encode',
289
+ 'native clipboard JPEG save'
290
+ );
291
+ } catch (error) {
292
+ logClipboardError('native clipboard JPEG handoff', error);
293
+ } finally {
294
+ try { unlinkSync(tempPath); } catch {}
295
+ }
296
+ }
297
+
298
+ function scheduleMacClipboardHelperRestart() {
299
+ if (macClipboardHelperRestartTimer) return;
300
+ macClipboardHelperRestartTimer = setTimeout(() => {
301
+ macClipboardHelperRestartTimer = null;
302
+ startMacClipboardImageHelper();
303
+ }, 5000);
304
+ macClipboardHelperRestartTimer.unref();
305
+ }
306
+
307
+ function stopMacClipboardImageHelper() {
308
+ if (macClipboardHelperRestartTimer) {
309
+ clearTimeout(macClipboardHelperRestartTimer);
310
+ macClipboardHelperRestartTimer = null;
311
+ }
312
+ const active = macClipboardHelper;
313
+ macClipboardHelper = null;
314
+ if (active) active.child.kill('SIGTERM');
315
+ }
316
+
317
+ function startMacClipboardImageHelper() {
318
+ if (process.platform !== 'darwin' || macClipboardHelper || macClipboardHelperRestartTimer) return;
319
+ const outputDirectory = join(paths.clipboardDir, 'content');
320
+ mkdirSync(outputDirectory, { recursive: true, mode: 0o700 });
321
+ try { chmodSync(outputDirectory, 0o700); } catch (error) {
322
+ logClipboardError('native image helper directory permissions', error);
323
+ return;
324
+ }
325
+ const scriptPath = fileURLToPath(new URL('./mac-clipboard-helper.swift', import.meta.url));
326
+ let child;
327
+ try {
328
+ child = spawn('swift', [scriptPath, outputDirectory], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
329
+ } catch (error) {
330
+ logClipboardError('native image helper startup', error);
331
+ scheduleMacClipboardHelperRestart();
332
+ return;
333
+ }
334
+
335
+ const state = { child, ready: false, lastMessageAt: Date.now(), lineBuffer: '', stderr: '' };
336
+ macClipboardHelper = state;
337
+ child.stdout.setEncoding('utf8');
338
+ child.stdout.on('data', (chunk) => {
339
+ state.lineBuffer += chunk;
340
+ if (state.lineBuffer.length > 64 * 1024) {
341
+ logClipboardError('native image helper protocol', new Error('Native image helper message exceeded 64 KiB.'));
342
+ child.kill('SIGTERM');
343
+ return;
344
+ }
345
+ let newline;
346
+ while ((newline = state.lineBuffer.indexOf('\n')) >= 0) {
347
+ const line = state.lineBuffer.slice(0, newline);
348
+ state.lineBuffer = state.lineBuffer.slice(newline + 1);
349
+ if (line) handleMacClipboardHelperMessage(state, line);
350
+ }
351
+ });
352
+ child.stderr.setEncoding('utf8');
353
+ child.stderr.on('data', (chunk) => {
354
+ state.stderr = (state.stderr + chunk).slice(-4000);
355
+ });
356
+ child.on('error', (error) => {
357
+ logClipboardError('native image helper startup', error);
358
+ });
359
+ child.on('exit', (code, signal) => {
360
+ if (macClipboardHelper !== state) return;
361
+ macClipboardHelper = null;
362
+ if (code !== 0) {
363
+ const details = state.stderr.trim();
364
+ logClipboardError('native image helper process', new Error(details || `Helper exited with code ${code ?? signal ?? 'unknown'}.`));
365
+ }
366
+ scheduleMacClipboardHelperRestart();
367
+ });
368
+
369
+ if (!macClipboardHelperWatchdog) {
370
+ macClipboardHelperWatchdog = setInterval(() => {
371
+ const active = macClipboardHelper;
372
+ if (!active?.ready || Date.now() - active.lastMessageAt < 30_000) return;
373
+ logClipboardError('native image helper stall', new Error('Native image helper stopped responding for 30 seconds.'));
374
+ active.child.kill('SIGTERM');
375
+ }, 5000);
376
+ macClipboardHelperWatchdog.unref();
377
+ }
378
+
379
+ if (!macClipboardHelperExitHookRegistered) {
380
+ macClipboardHelperExitHookRegistered = true;
381
+ process.once('exit', () => {
382
+ try { macClipboardHelper?.child.kill('SIGTERM'); } catch {}
383
+ });
384
+ }
385
+ }
386
+
387
+ async function captureMacClipboardImageLegacy() {
388
+ try {
389
+ const imageData = await getMacClipboardImageData();
390
+ if (imageData?.rgbaBuf?.length) {
391
+ const hash = createHash('md5').update(imageData.rgbaBuf).digest('hex');
392
+ if (hash === lastImageHash) return;
393
+ const jpeg = await compressMozjpeg(imageData.rgbaBuf, imageData.width, imageData.height, 75);
394
+ persistCapturedClipboardImage(jpeg, hash, 'jpg');
395
+ } else if (imageData?.rawBuffer?.length) {
396
+ const hash = createHash('md5').update(imageData.rawBuffer).digest('hex');
397
+ if (hash === lastImageHash) return;
398
+ persistCapturedClipboardImage(imageData.rawBuffer, hash, 'png');
399
+ }
400
+ } catch (error) {
401
+ logClipboardError('legacy clipboard image capture', error);
402
+ }
403
+ }
404
+
195
405
  function today() { return new Intl.DateTimeFormat('en-CA').format(new Date()); }
196
406
  function dayFile(date) { const [year, month, day] = date.split('-'); return join(paths.clipboardDir, year, month, `${day}.json`); }
197
407
  function readJson(file, fallback) { try { return existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : fallback; } catch { return fallback; } }
@@ -351,6 +561,13 @@ export function deleteClipboardItem(date, id) {
351
561
 
352
562
  export async function captureClipboard() {
353
563
  const settings = readSettings();
564
+ if (process.platform === 'darwin') {
565
+ if (settings.clipboardEnabled !== false && settings.clipboardImageEnabled !== false) {
566
+ startMacClipboardImageHelper();
567
+ } else {
568
+ stopMacClipboardImageHelper();
569
+ }
570
+ }
354
571
  if (settings.clipboardEnabled === false) return;
355
572
 
356
573
  // 1. Text capture
@@ -409,86 +626,13 @@ export async function captureClipboard() {
409
626
  }
410
627
  } catch {}
411
628
 
412
- // 2. Image capture (Mac only & clipboardImageEnabled !== false)
413
- if (process.platform === 'darwin' && settings.clipboardImageEnabled !== false && Date.now() >= suppressImageCaptureUntil) {
414
- try {
415
- const imageData = await getMacClipboardImageData();
416
- if (!imageData) {
417
- // No image on clipboard (or transient capture failed and will retry next tick)
418
- return;
419
- }
420
-
421
- if (imageData.rgbaBuf && imageData.rgbaBuf.length > 0) {
422
- const hash = createHash('md5').update(imageData.rgbaBuf).digest('hex');
423
- if (hash !== lastImageHash) {
424
- let isDuplicate = false;
425
- const dates = clipboardDates();
426
- for (const d of dates) {
427
- const dayList = dayItems(d);
428
- if (dayList.some(item => {
429
- if (item.imageHash === hash || item.fileHash === hash) return true;
430
- if (Array.isArray(item.imageHashes) && item.imageHashes.includes(hash)) return true;
431
- return false;
432
- })) {
433
- isDuplicate = true;
434
- break;
435
- }
436
- }
437
-
438
- if (!isDuplicate) {
439
- try {
440
- const mozjpegBuf = await compressMozjpeg(imageData.rgbaBuf, imageData.width, imageData.height, 75);
441
- saveClipboardImage(mozjpegBuf, 'jpg', hash);
442
- lastImageHash = hash;
443
- markClipboardSuccess('image compression or save (WASM/JPEG)', 'image save');
444
- } catch (error) {
445
- logClipboardError('image compression or save (WASM/JPEG)', error);
446
- const rawBuf = await getMacClipboardImageBuffer();
447
- if (rawBuf) {
448
- try {
449
- saveClipboardImage(rawBuf, 'png', hash);
450
- lastImageHash = hash;
451
- markClipboardSuccess('raw image save after compression fallback', 'image save');
452
- } catch (fallbackError) {
453
- logClipboardError('raw image save after compression fallback', fallbackError);
454
- }
455
- }
456
- }
457
- } else {
458
- lastImageHash = hash;
459
- }
460
- }
461
- } else if (imageData.rawBuffer && imageData.rawBuffer.length > 0) {
462
- const hash = createHash('md5').update(imageData.rawBuffer).digest('hex');
463
- if (hash !== lastImageHash) {
464
- let isDuplicate = false;
465
- const dates = clipboardDates();
466
- for (const d of dates) {
467
- const dayList = dayItems(d);
468
- if (dayList.some(item => {
469
- if (item.imageHash === hash || item.fileHash === hash) return true;
470
- if (Array.isArray(item.imageHashes) && item.imageHashes.includes(hash)) return true;
471
- return false;
472
- })) {
473
- isDuplicate = true;
474
- break;
475
- }
476
- }
477
-
478
- if (!isDuplicate) {
479
- try {
480
- saveClipboardImage(imageData.rawBuffer, 'png', hash);
481
- lastImageHash = hash;
482
- markClipboardSuccess('image save');
483
- } catch (error) {
484
- logClipboardError('image save', error);
485
- }
486
- } else {
487
- lastImageHash = hash;
488
- }
489
- }
490
- }
491
- } catch {}
629
+ // While the native helper is compiling or unavailable, retain the prior
630
+ // capture path as a compatibility fallback.
631
+ if (process.platform === 'darwin'
632
+ && settings.clipboardImageEnabled !== false
633
+ && Date.now() >= suppressImageCaptureUntil
634
+ && macClipboardHelper === null) {
635
+ await captureMacClipboardImageLegacy();
492
636
  }
493
637
  }
494
638
 
@@ -0,0 +1,216 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { listCronJobs, saveCronJobs } from '../repositories/cron-jobs.js';
3
+ import { listGroupTasks } from '../repositories/group-tasks.js';
4
+ import { listLaunchers } from '../repositories/launchers.js';
5
+ import { readPackageScripts } from './package-scripts.js';
6
+ import { runScript } from './process-manager.js';
7
+
8
+ const MINUTE = 60 * 1000;
9
+ const schedulerState = { started: false, timer: null, runningJobs: new Set() };
10
+
11
+ function validTime(value) {
12
+ const time = String(value || '').trim();
13
+ return /^([01]\d|2[0-3]):[0-5]\d$/.test(time) ? time : null;
14
+ }
15
+
16
+ function setLocalTime(date, time) {
17
+ const [hours, minutes] = time.split(':').map(Number);
18
+ const next = new Date(date);
19
+ next.setSeconds(0, 0);
20
+ next.setHours(hours, minutes, 0, 0);
21
+ return next;
22
+ }
23
+
24
+ function nextPresetRun(job, now = new Date()) {
25
+ const time = validTime(job.time);
26
+ if (!time) return null;
27
+ const candidate = setLocalTime(now, time);
28
+
29
+ if (job.scheduleType === 'daily') {
30
+ if (candidate <= now) candidate.setDate(candidate.getDate() + 1);
31
+ return candidate.toISOString();
32
+ }
33
+
34
+ if (job.scheduleType === 'weekdays') {
35
+ if (candidate <= now) candidate.setDate(candidate.getDate() + 1);
36
+ while ([0, 6].includes(candidate.getDay())) candidate.setDate(candidate.getDate() + 1);
37
+ return candidate.toISOString();
38
+ }
39
+
40
+ const weekday = Number(job.weekday);
41
+ if (job.scheduleType !== 'weekly' || !Number.isInteger(weekday) || weekday < 0 || weekday > 6) return null;
42
+ let daysAhead = (weekday - candidate.getDay() + 7) % 7;
43
+ if (daysAhead === 0 && candidate <= now) daysAhead = 7;
44
+ candidate.setDate(candidate.getDate() + daysAhead);
45
+ return candidate.toISOString();
46
+ }
47
+
48
+ function parseCronField(field, min, max) {
49
+ const values = new Set();
50
+ for (const part of String(field || '').split(',')) {
51
+ const token = part.trim();
52
+ if (!token) return null;
53
+ const [rangeToken, stepToken] = token.split('/');
54
+ const step = stepToken === undefined ? 1 : Number(stepToken);
55
+ if (!Number.isInteger(step) || step < 1) return null;
56
+ let start;
57
+ let end;
58
+ if (rangeToken === '*') {
59
+ start = min;
60
+ end = max;
61
+ } else if (/^\d+$/.test(rangeToken)) {
62
+ start = Number(rangeToken);
63
+ end = start;
64
+ } else {
65
+ const range = rangeToken.match(/^(\d+)-(\d+)$/);
66
+ if (!range) return null;
67
+ start = Number(range[1]);
68
+ end = Number(range[2]);
69
+ }
70
+ if (start < min || end > max || start > end) return null;
71
+ for (let value = start; value <= end; value += step) values.add(value);
72
+ }
73
+ return values;
74
+ }
75
+
76
+ function parseCronExpression(expression) {
77
+ const fields = String(expression || '').trim().split(/\s+/);
78
+ if (fields.length !== 5) return null;
79
+ const ranges = [[0, 59], [0, 23], [1, 31], [1, 12], [0, 6]];
80
+ const parsed = fields.map((field, index) => parseCronField(field, ...ranges[index]));
81
+ return parsed.some((field) => !field) ? null : parsed;
82
+ }
83
+
84
+ function nextCronRun(expression, now = new Date()) {
85
+ const parsed = parseCronExpression(expression);
86
+ if (!parsed) return null;
87
+ const candidate = new Date(now);
88
+ candidate.setSeconds(0, 0);
89
+ candidate.setMinutes(candidate.getMinutes() + 1);
90
+ const limit = candidate.getTime() + 366 * 24 * 60 * MINUTE;
91
+ const [minutes, hours, days, months, weekdays] = parsed;
92
+ const domWildcard = String(expression).trim().split(/\s+/)[2] === '*';
93
+ const dowWildcard = String(expression).trim().split(/\s+/)[4] === '*';
94
+ while (candidate.getTime() <= limit) {
95
+ const dayMatches = days.has(candidate.getDate());
96
+ const weekdayMatches = weekdays.has(candidate.getDay());
97
+ const calendarMatches = domWildcard || dowWildcard ? dayMatches && weekdayMatches : dayMatches || weekdayMatches;
98
+ if (minutes.has(candidate.getMinutes()) && hours.has(candidate.getHours()) && months.has(candidate.getMonth() + 1) && calendarMatches) return candidate.toISOString();
99
+ candidate.setMinutes(candidate.getMinutes() + 1);
100
+ }
101
+ return null;
102
+ }
103
+
104
+ export function nextCronJobRun(job, now = new Date()) {
105
+ if (job.scheduleType === 'once') {
106
+ const scheduledAt = new Date(job.scheduledAt);
107
+ return Number.isNaN(scheduledAt.getTime()) ? null : scheduledAt.toISOString();
108
+ }
109
+ if (job.scheduleType === 'cron') return nextCronRun(job.cronExpression, now);
110
+ return nextPresetRun(job, now);
111
+ }
112
+
113
+ function resolveLauncherScript(launcher, scriptId) {
114
+ const custom = launcher?.scripts?.find((script) => script.id === scriptId);
115
+ if (custom) return custom;
116
+ const packageScript = readPackageScripts(launcher?.folder || '').find((script) => script.id === scriptId);
117
+ return packageScript ? { ...packageScript, command: `${launcher.executor} run ${packageScript.name}` } : null;
118
+ }
119
+
120
+ function appendArguments(script, args) {
121
+ const suffix = String(args || '').trim();
122
+ return suffix ? { ...script, command: `${script.command} ${suffix}` } : script;
123
+ }
124
+
125
+ function runTarget(job) {
126
+ const launchers = listLaunchers();
127
+ if (job.targetType === 'custom') {
128
+ if (!job.command?.trim()) throw new Error('Custom command is empty.');
129
+ if (!job.workingDirectory || !existsSync(job.workingDirectory)) throw new Error('The custom script working directory does not exist.');
130
+ runScript({ id: `cron-${job.id}`, alias: `Cron Job · ${job.name}`, folder: job.workingDirectory, executor: 'shell' }, { id: 'run', name: job.name, command: `${job.command}${job.args ? ` ${job.args}` : ''}` });
131
+ return;
132
+ }
133
+
134
+ if (job.targetType === 'launcher-script') {
135
+ const launcher = launchers.find((item) => item.id === job.launcherId);
136
+ const script = resolveLauncherScript(launcher, job.scriptId);
137
+ if (!launcher) throw new Error('The selected Launcher no longer exists.');
138
+ if (!script) throw new Error('The selected Launcher script no longer exists.');
139
+ runScript(launcher, appendArguments(script, job.args));
140
+ return;
141
+ }
142
+
143
+ if (job.targetType === 'quick-launch') {
144
+ const task = listGroupTasks().find((item) => item.id === job.quickLaunchId);
145
+ if (!task) throw new Error('The selected Quick Launch no longer exists.');
146
+ const failures = [];
147
+ for (const item of task.items) {
148
+ const launcher = launchers.find((entry) => entry.id === item.launcherId);
149
+ const script = resolveLauncherScript(launcher, item.scriptId);
150
+ if (!launcher || !script) {
151
+ failures.push('A Launcher or script is missing.');
152
+ continue;
153
+ }
154
+ try { runScript(launcher, appendArguments(script, job.args)); } catch (error) { failures.push(error.message); }
155
+ }
156
+ if (failures.length === task.items.length) throw new Error(failures[0]);
157
+ return;
158
+ }
159
+
160
+ throw new Error('Unsupported Cron Job target.');
161
+ }
162
+
163
+ export function executeCronJob(id) {
164
+ const jobs = listCronJobs();
165
+ const job = jobs.find((item) => item.id === id);
166
+ if (!job) throw new Error('Cron Job not found.');
167
+ if (schedulerState.runningJobs.has(id)) throw new Error('This Cron Job is already running.');
168
+ schedulerState.runningJobs.add(id);
169
+ try {
170
+ runTarget(job);
171
+ const now = new Date().toISOString();
172
+ const latest = listCronJobs();
173
+ const index = latest.findIndex((item) => item.id === id);
174
+ if (index >= 0) {
175
+ latest[index] = { ...latest[index], lastStatus: 'started', lastError: '', lastRunAt: now, updatedAt: now };
176
+ saveCronJobs(latest);
177
+ }
178
+ return { ok: true };
179
+ } catch (error) {
180
+ const now = new Date().toISOString();
181
+ const latest = listCronJobs();
182
+ const index = latest.findIndex((item) => item.id === id);
183
+ if (index >= 0) {
184
+ latest[index] = { ...latest[index], lastStatus: 'failed', lastError: error.message, lastRunAt: now, updatedAt: now };
185
+ saveCronJobs(latest);
186
+ }
187
+ throw error;
188
+ } finally {
189
+ schedulerState.runningJobs.delete(id);
190
+ }
191
+ }
192
+
193
+ function tick() {
194
+ const now = new Date();
195
+ const jobs = listCronJobs();
196
+ let changed = false;
197
+ for (const job of jobs) {
198
+ if (!job.enabled || !job.nextRunAt || new Date(job.nextRunAt) > now || schedulerState.runningJobs.has(job.id)) continue;
199
+ const index = jobs.findIndex((item) => item.id === job.id);
200
+ const nextRunAt = job.scheduleType === 'once' ? null : nextCronJobRun(job, new Date(now.getTime() + 1000));
201
+ jobs[index] = { ...job, enabled: job.scheduleType === 'once' ? false : job.enabled, nextRunAt, lastStatus: 'queued', lastError: '', updatedAt: now.toISOString() };
202
+ changed = true;
203
+ setImmediate(() => {
204
+ try { executeCronJob(job.id); } catch { /* The failure is stored on the job. */ }
205
+ });
206
+ }
207
+ if (changed) saveCronJobs(jobs);
208
+ }
209
+
210
+ export function startCronJobScheduler() {
211
+ if (schedulerState.started) return;
212
+ schedulerState.started = true;
213
+ tick();
214
+ schedulerState.timer = setInterval(tick, 1000);
215
+ schedulerState.timer.unref?.();
216
+ }
@@ -0,0 +1,116 @@
1
+ import AppKit
2
+ import CoreGraphics
3
+ import ImageIO
4
+ import Foundation
5
+
6
+ // Keep this process alive so Swift is compiled once. Pasteboard image bytes
7
+ // are requested only after the pasteboard ownership changes.
8
+ let outputDirectory = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true)
9
+ let maxInputBytes = 100 * 1024 * 1024
10
+ let maxPixelCount = 40_000_000
11
+ let imageTypes: [NSPasteboard.PasteboardType] = [
12
+ .png,
13
+ NSPasteboard.PasteboardType("public.jpeg"),
14
+ .tiff,
15
+ NSPasteboard.PasteboardType("public.heic")
16
+ ]
17
+
18
+ func emit(_ value: [String: Any]) {
19
+ guard let data = try? JSONSerialization.data(withJSONObject: value),
20
+ let line = String(data: data, encoding: .utf8) else { return }
21
+ FileHandle.standardOutput.write(Data((line + "\n").utf8))
22
+ }
23
+
24
+ // Returns false only when the pasteboard advertised image data but could not
25
+ // provide it yet. That case is retried briefly for delayed clipboard owners.
26
+ func captureImage(_ pasteboard: NSPasteboard, changeCount: Int, startedAt: Date) -> Bool {
27
+ guard let type = pasteboard.types?.first(where: { imageTypes.contains($0) }) else { return true }
28
+ guard let input = pasteboard.data(forType: type) else {
29
+ return false
30
+ }
31
+ guard !input.isEmpty, input.count <= maxInputBytes else {
32
+ emit(["kind": "error", "stage": "image validation", "message": "Image data was empty or exceeded the 100 MiB input limit.", "changeCount": changeCount, "elapsedMs": Int(Date().timeIntervalSince(startedAt) * 1000)])
33
+ return true
34
+ }
35
+ guard let source = CGImageSourceCreateWithData(input as CFData, nil),
36
+ let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any],
37
+ let width = (properties[kCGImagePropertyPixelWidth] as? NSNumber)?.intValue,
38
+ let height = (properties[kCGImagePropertyPixelHeight] as? NSNumber)?.intValue,
39
+ width > 0, height > 0,
40
+ width <= maxPixelCount / height else {
41
+ emit(["kind": "error", "stage": "image validation", "message": "Image dimensions were invalid or exceeded the 40 megapixel limit.", "changeCount": changeCount, "elapsedMs": Int(Date().timeIntervalSince(startedAt) * 1000)])
42
+ return true
43
+ }
44
+ guard let image = CGImageSourceCreateImageAtIndex(source, 0, [kCGImageSourceShouldCache: true] as CFDictionary) else {
45
+ emit(["kind": "error", "stage": "image decode", "message": "ImageIO could not decode the clipboard image.", "changeCount": changeCount, "elapsedMs": Int(Date().timeIntervalSince(startedAt) * 1000)])
46
+ return true
47
+ }
48
+
49
+ let jpeg = NSMutableData()
50
+ guard let destination = CGImageDestinationCreateWithData(jpeg, "public.jpeg" as CFString, 1, nil) else {
51
+ emit(["kind": "error", "stage": "JPEG encode", "message": "ImageIO could not create a JPEG encoder.", "changeCount": changeCount, "elapsedMs": Int(Date().timeIntervalSince(startedAt) * 1000)])
52
+ return true
53
+ }
54
+ CGImageDestinationAddImage(destination, image, [kCGImageDestinationLossyCompressionQuality: 0.75] as CFDictionary)
55
+ guard CGImageDestinationFinalize(destination), jpeg.length > 0 else {
56
+ emit(["kind": "error", "stage": "JPEG encode", "message": "ImageIO could not finish JPEG encoding.", "changeCount": changeCount, "elapsedMs": Int(Date().timeIntervalSince(startedAt) * 1000)])
57
+ return true
58
+ }
59
+
60
+ let filename = "capture-\(UUID().uuidString).jpg"
61
+ let outputURL = outputDirectory.appendingPathComponent(filename, isDirectory: false)
62
+ do {
63
+ try (jpeg as Data).write(to: outputURL, options: .atomic)
64
+ try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: outputURL.path)
65
+ emit([
66
+ "kind": "image",
67
+ "changeCount": changeCount,
68
+ "sourceType": type.rawValue,
69
+ "width": width,
70
+ "height": height,
71
+ "bytes": jpeg.length,
72
+ "elapsedMs": Int(Date().timeIntervalSince(startedAt) * 1000),
73
+ "filename": filename
74
+ ])
75
+ } catch {
76
+ try? FileManager.default.removeItem(at: outputURL)
77
+ emit(["kind": "error", "stage": "JPEG save", "message": "Could not write the temporary JPEG.", "changeCount": changeCount, "elapsedMs": Int(Date().timeIntervalSince(startedAt) * 1000)])
78
+ }
79
+ return true
80
+ }
81
+
82
+ emit(["kind": "ready"])
83
+ let pasteboard = NSPasteboard.general
84
+ var lastChangeCount: Int?
85
+ var pendingChangeCount: Int?
86
+ var pendingStartedAt = Date()
87
+ var attemptsForPendingChange = 0
88
+ var lastHeartbeat = Date()
89
+
90
+ while true {
91
+ let now = Date()
92
+ let currentChangeCount = pasteboard.changeCount
93
+ if lastChangeCount == nil || currentChangeCount != lastChangeCount {
94
+ lastChangeCount = currentChangeCount
95
+ pendingChangeCount = currentChangeCount
96
+ pendingStartedAt = now
97
+ attemptsForPendingChange = 0
98
+ }
99
+
100
+ if let pending = pendingChangeCount {
101
+ attemptsForPendingChange += 1
102
+ let completed = captureImage(pasteboard, changeCount: pending, startedAt: pendingStartedAt)
103
+ if !completed && attemptsForPendingChange >= 12 {
104
+ emit(["kind": "error", "stage": "pasteboard read", "message": "Image data remained unavailable after 12 retries.", "changeCount": pending, "elapsedMs": Int(Date().timeIntervalSince(pendingStartedAt) * 1000)])
105
+ }
106
+ if completed || attemptsForPendingChange >= 12 {
107
+ pendingChangeCount = nil
108
+ }
109
+ }
110
+
111
+ if now.timeIntervalSince(lastHeartbeat) >= 2 {
112
+ emit(["kind": "heartbeat"])
113
+ lastHeartbeat = now
114
+ }
115
+ Thread.sleep(forTimeInterval: 0.25)
116
+ }