/** * 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' import { BARE_INITD_TIMERS_DIR, parseBareOsTimerFile } from './bare-initd-user.js' import { appendVarLog, CRON_LOG } from './bare-os-var-log.js' /** @type {ReturnType | null} */ let cronTimeoutId = null /** @type {ReturnType | null} */ let cronIntervalId = null /** @type {Record | null} */ let cronExecCtx = null export function stopBareCron() { if (cronTimeoutId != null) { clearTimeout(cronTimeoutId) cronTimeoutId = null } if (cronIntervalId != null) { clearInterval(cronIntervalId) cronIntervalId = null } } /** @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 * @returns {Promise} */ async function readSystemCrontabText(ctx) { const vfs = ctx.vfs if (!vfs || typeof vfs.readFile !== 'function') return '' try { const b = await vfs.readFile('/etc/bare-os/crontab') if (!b) return '' return ctx.b4a.toString(b) } catch { return '' } } /** * @param {Record} ctx * @returns {Promise<{ lineIndex: number, minute: string, hour: string, dom: string, month: string, dow: string, command: string }[]>} */ async function readTimerJobs(ctx) { const vfs = ctx.vfs if (!vfs || typeof vfs.readdir !== 'function' || !vfs.readFile) return [] /** @type {string[]} */ let names = [] try { names = await vfs.readdir(BARE_INITD_TIMERS_DIR) } catch { return [] } /** @type {{ lineIndex: number, minute: string, hour: string, dom: string, month: string, dow: string, command: string }[]} */ const jobs = [] let idx = 100000 for (const name of names) { if (!name.endsWith('.timer')) continue const p = `${BARE_INITD_TIMERS_DIR}/${name}` let buf try { buf = await vfs.readFile(p) } catch { continue } if (!buf) continue const text = ctx.b4a.toString(buf) const t = parseBareOsTimerFile(text) if (!t) { void appendVarLog(ctx, CRON_LOG, 'timer', `invalid ${p}`) continue } const synthetic = `${t.onCalendar} ${t.execLine}` const job = parseCronLine(synthetic) if (job) jobs.push({ lineIndex: idx++, ...job }) else void appendVarLog(ctx, CRON_LOG, 'timer', `bad schedule ${p}`) } return jobs } /** @type {Set} */ const cronRunningJobs = new Set() /** * @param {Record} ctx */ function startBareCron(ctx) { stopBareCron() cronExecCtx = ctx async function tick() { const c = cronExecCtx if (!c) return let userText = '' let sysText = '' try { userText = await readUserCrontabText(c) } catch { return } try { sysText = await readSystemCrontabText(c) } catch { /* ignore */ } /** @type {{ lineIndex: number, minute: string, hour: string, dom: string, month: string, dow: string, command: string }[]} */ const jobs = [] const pushValidatedLines = (text, baseIndex) => { const lines = text.split(/\r?\n/) for (let i = 0; i < lines.length; i++) { const raw = lines[i].trim() if (!raw || raw.startsWith('#')) continue const job = parseCronLine(lines[i]) if (job) jobs.push({ lineIndex: baseIndex + i, ...job }) else { void appendVarLog( c, CRON_LOG, 'crontab', `invalid line ${i + 1}: ${raw.slice(0, 100)}` ) } } } pushValidatedLines(sysText, 0) pushValidatedLines(userText, 10000) let timerJobs = [] try { timerJobs = await readTimerJobs(c) } catch { timerJobs = [] } jobs.push(...timerJobs) if (!jobs.length) return const now = new Date() for (const job of jobs) { if (!jobMatchesDate(job, now)) continue const key = job.lineIndex if (cronRunningJobs.has(key)) continue cronRunningJobs.add(key) void (async () => { try { const execLine = c.execLine if (typeof execLine !== 'function') return await execLine(job.command) } catch (e) { const msg = e?.message || String(e) try { c.console?.error?.(`[bare-cron] ${msg}`) } catch { /* ignore */ } void appendVarLog(c, CRON_LOG, 'error', msg) } finally { cronRunningJobs.delete(key) } })() } } const delay = msToNextMinuteBoundary() cronTimeoutId = setTimeout(() => { cronTimeoutId = null void tick() cronIntervalId = setInterval(() => { void tick() }, 60000) }, delay) } registerBareInitdDisposer(stopBareCron) registerBareService({ name: 'bare-cron', description: 'User crontab scheduler (minute-aligned; ~/.crontab)', logPath: CRON_LOG, start: startBareCron, stop: stopBareCron })