/** * User crontab at ~/.crontab — five fields (minute hour dom month dow) + command. * Registered as bare-initd service `bare-cron`. */ import { registerBareInitdDisposer, registerBareService } from './bare-initd.js' /** @param {Date} d */ function msToNextMinuteBoundary(d = new Date()) { return 60000 - (d.getSeconds() * 1000 + d.getMilliseconds()) } /** * @param {string} spec * @param {number} value * @param {number} min * @param {number} max */ export function fieldMatches(spec, value, min, max) { const s = spec.trim() if (!s) return false for (const part of s.split(',')) { const p = part.trim() if (!p) continue if (partMatches(p, value, min, max)) return true } return false } /** * @param {string} p * @param {number} value * @param {number} min * @param {number} max */ function partMatches(p, value, min, max) { if (p === '*') return true const slash = p.indexOf('/') if (slash !== -1) { const range = p.slice(0, slash) const step = Number.parseInt(p.slice(slash + 1), 10) if (!Number.isFinite(step) || step < 1) return false let lo let hi if (range === '*') { lo = min hi = max } else if (range.includes('-')) { const [a, b] = range.split('-').map((x) => Number.parseInt(x.trim(), 10)) lo = Math.min(a, b) hi = Math.max(a, b) } else { lo = hi = Number.parseInt(range, 10) } if (!Number.isFinite(lo) || !Number.isFinite(hi)) return false return value >= lo && value <= hi && (value - lo) % step === 0 } if (p.includes('-')) { const [a, b] = p.split('-').map((x) => Number.parseInt(x.trim(), 10)) const lo = Math.min(a, b) const hi = Math.max(a, b) return value >= lo && value <= hi } const n = Number.parseInt(p, 10) return Number.isFinite(n) && value === n } /** * @param {string} spec * @param {number} jsDow 0=Sun … 6=Sat (Date#getDay) */ export function dowFieldMatches(spec, jsDow) { const s = spec.trim() if (!s) return false for (const part of s.split(',')) { const p = part.trim() if (!p) continue if (dowPartMatches(p, jsDow)) return true } return false } /** * @param {string} p * @param {number} jsDow */ function dowPartMatches(p, jsDow) { if (p === '*') return true const slash = p.indexOf('/') if (slash !== -1) { const range = p.slice(0, slash) const step = Number.parseInt(p.slice(slash + 1), 10) if (!Number.isFinite(step) || step < 1) return false if (range === '*') { for (let js = 0; js <= 6; js += step) { if (jsDow === js) return true } return false } if (range.includes('-')) { const [a, b] = range.split('-').map((x) => Number.parseInt(x.trim(), 10)) const lo = Math.min(a, b) const hi = Math.max(a, b) for (let k = lo; k <= hi; k++) { if ((k - lo) % step !== 0) continue const js = k === 7 ? 0 : k if (js >= 0 && js <= 6 && jsDow === js) return true } return false } const k = Number.parseInt(range.trim(), 10) if (!Number.isFinite(k)) return false const js = k === 7 ? 0 : k return js >= 0 && js <= 6 && jsDow === js } if (p.includes('-')) { const [a, b] = p.split('-').map((x) => Number.parseInt(x.trim(), 10)) const lo = Math.min(a, b) const hi = Math.max(a, b) for (let k = lo; k <= hi; k++) { const js = k === 7 ? 0 : k if (js === jsDow) return true } return false } const n = Number.parseInt(p, 10) if (!Number.isFinite(n)) return false if (n === 7) return jsDow === 0 return jsDow === n } /** * @param {string} line * @returns {{ minute: string, hour: string, dom: string, month: string, dow: string, command: string } | null} */ export function parseCronLine(line) { const t = line.trim() if (!t || t.startsWith('#')) return null const parts = t.split(/\s+/) if (parts.length < 6) return null const [minute, hour, dom, month, dow, ...rest] = parts const command = rest.join(' ') if (!command) return null return { minute, hour, dom, month, dow, command } } /** * @param {{ minute: string, hour: string, dom: string, month: string, dow: string }} job * @param {Date} date */ export function jobMatchesDate(job, date) { return ( fieldMatches(job.minute, date.getMinutes(), 0, 59) && fieldMatches(job.hour, date.getHours(), 0, 23) && fieldMatches(job.dom, date.getDate(), 1, 31) && fieldMatches(job.month, date.getMonth() + 1, 1, 12) && dowFieldMatches(job.dow, date.getDay()) ) } /** * @param {Record} ctx */ async function readUserCrontabText(ctx) { const vfs = ctx.vfs if (!vfs || typeof vfs.readFile !== 'function') return '' const home = vfs.env?.HOME || '/home/guest' const h = String(home).replace(/\/$/, '') const path = `${h}/.crontab` try { const b = await vfs.readFile(path) if (!b) return '' return ctx.b4a.toString(b) } catch { return '' } } /** * @param {Record} ctx */ function startBareCron(ctx) { /** @type {Set} */ const running = new Set() let timeoutId = null let intervalId = null async function tick() { let text try { text = await readUserCrontabText(ctx) } catch { return } if (!text.trim()) return const lines = text.split(/\r?\n/) /** @type {{ lineIndex: number, minute: string, hour: string, dom: string, month: string, dow: string, command: string }[]} */ const jobs = [] for (let i = 0; i < lines.length; i++) { const job = parseCronLine(lines[i]) if (job) jobs.push({ lineIndex: i, ...job }) } const now = new Date() for (const job of jobs) { if (!jobMatchesDate(job, now)) continue const key = job.lineIndex if (running.has(key)) continue running.add(key) void (async () => { try { const execLine = ctx.execLine if (typeof execLine !== 'function') return await execLine(job.command) } catch (e) { const msg = e?.message || String(e) try { ctx.console?.error?.(`[bare-cron] ${msg}`) } catch { /* ignore */ } } finally { running.delete(key) } })() } } registerBareInitdDisposer(() => { if (timeoutId != null) clearTimeout(timeoutId) if (intervalId != null) clearInterval(intervalId) timeoutId = null intervalId = null }) const delay = msToNextMinuteBoundary() timeoutId = setTimeout(() => { timeoutId = null void tick() intervalId = setInterval(() => { void tick() }, 60000) }, delay) } registerBareService({ name: 'bare-cron', start: startBareCron })