buddy-workbench 0.1.109 → 0.1.111
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/package.json +1 -1
- package/server/config.js +2 -0
- package/server/repositories/cron-jobs.js +16 -0
- package/server/repositories/reminders.js +16 -0
- package/server/routes/cron-jobs.js +157 -0
- package/server/routes/group-tasks.js +13 -0
- package/server/routes/reminders.js +167 -0
- package/server/services/clipboard-history.js +226 -82
- package/server/services/cron-jobs.js +216 -0
- package/server/services/mac-clipboard-helper.swift +116 -0
- package/server.js +6 -0
- package/ui/dist/assets/index-DwGMSLko.css +1 -0
- package/ui/dist/assets/index-PNo3z4be.js +622 -0
- package/ui/dist/assets/{xmindAdapter-BdZT81CQ.js → xmindAdapter-CwHBvxZN.js} +3 -3
- package/ui/dist/index.html +2 -2
- package/ui/dist/sw.js +2 -2
- package/ui/dist/assets/index-DEzykfvG.js +0 -622
- package/ui/dist/assets/index-DSfTk13k.css +0 -1
|
@@ -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
|
-
//
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
+
}
|