617 lines
20 KiB
JavaScript
617 lines
20 KiB
JavaScript
/**
|
||
* User initd config on the personal drive: disabled units and optional drop-in ordering.
|
||
* Paths are logical VFS paths under $HOME.
|
||
*/
|
||
|
||
import b4a from 'b4a'
|
||
|
||
/** One unit per line; blank lines and `#…` comments ignored. */
|
||
export const BARE_INITD_DISABLED_FILE = '~/.config/bare-os/initd/disabled.txt'
|
||
|
||
/** Masked units cannot be started until `systemctl unmask` (unioned with disabled for boot order). */
|
||
export const BARE_INITD_MASKED_FILE = '~/.config/bare-os/initd/masked.txt'
|
||
|
||
/**
|
||
* Optional `name.unit` files under this directory. Supports `[Unit]` keys:
|
||
* After=, Requires=, Wants=, TimeoutStartSec=, TimeoutStopSec=, Restart=, RestartSec=, ExecStartPost=, SocketActivationIpc=,
|
||
* IdleSec= (with SocketActivationIpc: stop after idle seconds; requires unit `stop`),
|
||
* ReadinessPath= (VFS path until exists), or `exec:<execLine>` (bounded by ReadinessTimeoutSec=),
|
||
* ConditionPathExists= (skip unit start if path missing), AssertPathExists= (fail start if path missing),
|
||
* ConditionPathIsDirectory= / AssertPathIsDirectory= (same semantics for directories),
|
||
* ReadinessTimeoutSec=,
|
||
* ExecHealthCmd=, HealthIntervalSec=, HealthFailureThreshold=, BareMaxExecDepth=,
|
||
* Before= (reverse edge: listed units start after this one), OnFailure= (execLine after restart exhausted),
|
||
* FailureAction=exec|none (default exec when OnFailure is set),
|
||
* RestartMaxAttempts= (1–32; overrides default 3 for Restart=on-failure|always).
|
||
*/
|
||
export const BARE_INITD_UNITS_DIR = '~/.config/bare-os/units'
|
||
|
||
/** `systemctl --user` style overrides (merged on top of {@link BARE_INITD_UNITS_DIR}). */
|
||
export const BARE_INITD_SYSTEMCTL_USER_UNITS_DIR = '~/.config/bare-init/units'
|
||
|
||
/**
|
||
* Optional fragments: `~/.config/bare-os/units.d/<unitName>/*.conf` (sorted), merged after the main `.unit` and before {@link BARE_INITD_SYSTEMCTL_USER_UNITS_DIR}.
|
||
*/
|
||
export const BARE_INITD_UNITS_D_BASE = '~/.config/bare-os/units.d'
|
||
|
||
/**
|
||
* Timer drop-ins: `[Timer]` with OnCalendar= (five cron fields), EveryMs=, or OnInactiveSec=
|
||
* (seconds between end of last run and next start), optional Persistent=false (run once), ExecLine=.
|
||
*/
|
||
export const BARE_INITD_TIMERS_DIR = '~/.config/bare-os/timers'
|
||
|
||
/** Built-in ordering when no drop-in file exists (kernel-logger before cron). */
|
||
export const BARE_INITD_DEFAULT_AFTER = Object.freeze({
|
||
'bare-cron': ['kernel-logger'],
|
||
'bare-openssh': ['kernel-logger', 'bare-cron']
|
||
})
|
||
|
||
/**
|
||
* @typedef {{
|
||
* after: string[],
|
||
* requires: string[],
|
||
* wants: string[],
|
||
* timeoutStartSec: number | null,
|
||
* timeoutStopSec: number | null,
|
||
* restart: string | null,
|
||
* restartSec: number | null,
|
||
* execStartPost: string | null,
|
||
* socketActivationIpc: string | null,
|
||
* idleSec: number | null,
|
||
* readinessPath: string | null,
|
||
* readinessTimeoutSec: number | null,
|
||
* execHealthCmd: string | null,
|
||
* healthIntervalSec: number | null,
|
||
* healthFailureThreshold: number | null,
|
||
* bareMaxExecDepth: number | null,
|
||
* before: string[],
|
||
* onFailure: string | null,
|
||
* failureAction: 'exec' | 'none',
|
||
* restartMaxAttempts: number | null,
|
||
* conditionPathExists: string | null,
|
||
* assertPathExists: string | null,
|
||
* conditionPathIsDirectory: string | null,
|
||
* assertPathIsDirectory: string | null
|
||
* }} BareInitdUnitDropIn
|
||
*/
|
||
|
||
/** @returns {BareInitdUnitDropIn} */
|
||
export function emptyUnitDropIn() {
|
||
return {
|
||
after: [],
|
||
requires: [],
|
||
wants: [],
|
||
timeoutStartSec: null,
|
||
timeoutStopSec: null,
|
||
restart: null,
|
||
restartSec: null,
|
||
execStartPost: null,
|
||
socketActivationIpc: null,
|
||
idleSec: null,
|
||
readinessPath: null,
|
||
readinessTimeoutSec: null,
|
||
execHealthCmd: null,
|
||
healthIntervalSec: null,
|
||
healthFailureThreshold: null,
|
||
bareMaxExecDepth: null,
|
||
before: [],
|
||
onFailure: null,
|
||
failureAction: 'exec',
|
||
restartMaxAttempts: null,
|
||
conditionPathExists: null,
|
||
assertPathExists: null,
|
||
conditionPathIsDirectory: null,
|
||
assertPathIsDirectory: null
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param {string} val
|
||
* @returns {string[]}
|
||
*/
|
||
function parseList(val) {
|
||
return val
|
||
.split(/[\s,]+/)
|
||
.map((s) => s.trim())
|
||
.filter(Boolean)
|
||
}
|
||
|
||
/**
|
||
* Parse a systemd-inspired `.unit` snippet (only `[Unit]` keys are honored).
|
||
* @param {string} text
|
||
* @returns {BareInitdUnitDropIn}
|
||
*/
|
||
export function parseUnitDropInText(text) {
|
||
const out = emptyUnitDropIn()
|
||
let section = ''
|
||
for (const raw of text.split(/\r?\n/)) {
|
||
const line = raw.trim()
|
||
if (!line || line.startsWith('#')) continue
|
||
if (/^\[[^\]]+\]$/.test(line)) {
|
||
section = line.slice(1, -1).toLowerCase()
|
||
continue
|
||
}
|
||
if (section !== 'unit') continue
|
||
const eq = line.indexOf('=')
|
||
if (eq < 1) continue
|
||
const key = line.slice(0, eq).trim().toLowerCase()
|
||
const val = line.slice(eq + 1).trim()
|
||
if (key === 'after') out.after.push(...parseList(val))
|
||
else if (key === 'requires') out.requires.push(...parseList(val))
|
||
else if (key === 'wants') out.wants.push(...parseList(val))
|
||
else if (key === 'timeoutstartsec') {
|
||
const n = Number.parseInt(val, 10)
|
||
if (Number.isFinite(n) && n > 0) out.timeoutStartSec = n
|
||
} else if (key === 'timeoutstopsec') {
|
||
const n = Number.parseInt(val, 10)
|
||
if (Number.isFinite(n) && n > 0) out.timeoutStopSec = n
|
||
} else if (key === 'restart') {
|
||
out.restart = val.toLowerCase()
|
||
} else if (key === 'restartsec') {
|
||
const n = Number.parseInt(val, 10)
|
||
if (Number.isFinite(n) && n >= 0) out.restartSec = n
|
||
} else if (key === 'execstartpost') {
|
||
out.execStartPost = val
|
||
} else if (key === 'socketactivationipc') {
|
||
if (/^[a-zA-Z0-9._-]+$/.test(val)) out.socketActivationIpc = val
|
||
} else if (key === 'idlesec') {
|
||
const n = Number.parseInt(val, 10)
|
||
if (Number.isFinite(n) && n > 0 && n <= 86400) out.idleSec = n
|
||
} else if (key === 'readinesspath') {
|
||
if (val.trim()) out.readinessPath = val.trim()
|
||
} else if (key === 'readinesstimeoutsec') {
|
||
const n = Number.parseInt(val, 10)
|
||
if (Number.isFinite(n) && n > 0) out.readinessTimeoutSec = n
|
||
} else if (key === 'exechealthcmd') {
|
||
if (val.trim()) out.execHealthCmd = val.trim()
|
||
} else if (key === 'healthintervalsec') {
|
||
const n = Number.parseInt(val, 10)
|
||
if (Number.isFinite(n) && n > 0) out.healthIntervalSec = n
|
||
} else if (key === 'healthfailurethreshold') {
|
||
const n = Number.parseInt(val, 10)
|
||
if (Number.isFinite(n) && n > 0) out.healthFailureThreshold = n
|
||
} else if (key === 'baremaxexecdepth') {
|
||
const n = Number.parseInt(val, 10)
|
||
if (Number.isFinite(n) && n > 0) out.bareMaxExecDepth = n
|
||
} else if (key === 'before') {
|
||
out.before.push(...parseList(val))
|
||
} else if (key === 'onfailure') {
|
||
if (val.trim()) out.onFailure = val
|
||
} else if (key === 'failureaction') {
|
||
const v = val.toLowerCase()
|
||
if (v === 'none') out.failureAction = 'none'
|
||
else out.failureAction = 'exec'
|
||
} else if (key === 'restartmaxattempts') {
|
||
const n = Number.parseInt(val, 10)
|
||
if (Number.isFinite(n) && n >= 1 && n <= 32) out.restartMaxAttempts = n
|
||
} else if (key === 'conditionpathexists') {
|
||
const t = val.trim()
|
||
if (t.length > 0 && t.length <= 512) out.conditionPathExists = t
|
||
} else if (key === 'assertpathexists') {
|
||
const t = val.trim()
|
||
if (t.length > 0 && t.length <= 512) out.assertPathExists = t
|
||
} else if (key === 'conditionpathisdirectory') {
|
||
const t = val.trim()
|
||
if (t.length > 0 && t.length <= 512) out.conditionPathIsDirectory = t
|
||
} else if (key === 'assertpathisdirectory') {
|
||
const t = val.trim()
|
||
if (t.length > 0 && t.length <= 512) out.assertPathIsDirectory = t
|
||
}
|
||
}
|
||
out.after = [...new Set(out.after)]
|
||
out.requires = [...new Set(out.requires)]
|
||
out.wants = [...new Set(out.wants)]
|
||
out.before = [...new Set(out.before)]
|
||
return out
|
||
}
|
||
|
||
/**
|
||
* @param {BareInitdUnitDropIn} base
|
||
* @param {BareInitdUnitDropIn} user
|
||
* @returns {BareInitdUnitDropIn}
|
||
*/
|
||
export function mergeUnitDropIns(base, user) {
|
||
const pickArr = (u, b) =>
|
||
u.length ? [...new Set(u)] : [...new Set(b)]
|
||
return {
|
||
after: pickArr(user.after, base.after),
|
||
requires: pickArr(user.requires, base.requires),
|
||
wants: pickArr(user.wants, base.wants),
|
||
timeoutStartSec: user.timeoutStartSec ?? base.timeoutStartSec,
|
||
timeoutStopSec: user.timeoutStopSec ?? base.timeoutStopSec,
|
||
restart: user.restart ?? base.restart,
|
||
restartSec: user.restartSec ?? base.restartSec,
|
||
execStartPost: user.execStartPost ?? base.execStartPost,
|
||
socketActivationIpc: user.socketActivationIpc ?? base.socketActivationIpc,
|
||
idleSec: user.idleSec ?? base.idleSec,
|
||
readinessPath: user.readinessPath ?? base.readinessPath,
|
||
readinessTimeoutSec:
|
||
user.readinessTimeoutSec ?? base.readinessTimeoutSec,
|
||
execHealthCmd: user.execHealthCmd ?? base.execHealthCmd,
|
||
healthIntervalSec: user.healthIntervalSec ?? base.healthIntervalSec,
|
||
healthFailureThreshold:
|
||
user.healthFailureThreshold ?? base.healthFailureThreshold,
|
||
bareMaxExecDepth: user.bareMaxExecDepth ?? base.bareMaxExecDepth,
|
||
before: pickArr(user.before, base.before),
|
||
onFailure: user.onFailure ?? base.onFailure,
|
||
failureAction: user.failureAction ?? base.failureAction,
|
||
restartMaxAttempts: user.restartMaxAttempts ?? base.restartMaxAttempts,
|
||
conditionPathExists:
|
||
user.conditionPathExists ?? base.conditionPathExists,
|
||
assertPathExists: user.assertPathExists ?? base.assertPathExists,
|
||
conditionPathIsDirectory:
|
||
user.conditionPathIsDirectory ?? base.conditionPathIsDirectory,
|
||
assertPathIsDirectory:
|
||
user.assertPathIsDirectory ?? base.assertPathIsDirectory
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param {{ readFile: (p: string) => Promise<Uint8Array | null> }} vfs
|
||
* @param {string} logicalPath
|
||
* @returns {Promise<BareInitdUnitDropIn>}
|
||
*/
|
||
export async function readUnitDropInFromPath(vfs, logicalPath) {
|
||
try {
|
||
const buf = await vfs.readFile(logicalPath)
|
||
if (!buf) return emptyUnitDropIn()
|
||
return parseUnitDropInText(b4a.toString(buf, 'utf8'))
|
||
} catch {
|
||
return emptyUnitDropIn()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param {{ readFile: (p: string) => Promise<Uint8Array | null> }} vfs
|
||
* @returns {Promise<Set<string>>}
|
||
*/
|
||
export async function readInitdDisabledSet(vfs) {
|
||
const out = new Set()
|
||
const envRaw =
|
||
typeof globalThis.process !== 'undefined' &&
|
||
globalThis.process.env &&
|
||
typeof globalThis.process.env.BARE_OS_INITD_DISABLED === 'string'
|
||
? globalThis.process.env.BARE_OS_INITD_DISABLED
|
||
: ''
|
||
if (envRaw.trim()) {
|
||
for (const part of envRaw.split(/[\s,]+/)) {
|
||
const t = part.trim()
|
||
if (t) out.add(t)
|
||
}
|
||
}
|
||
try {
|
||
const buf = await vfs.readFile(BARE_INITD_DISABLED_FILE)
|
||
if (!buf) return out
|
||
const text = b4a.toString(buf, 'utf8')
|
||
for (const line of text.split(/\r?\n/)) {
|
||
const t = line.trim()
|
||
if (!t || t.startsWith('#')) continue
|
||
out.add(t)
|
||
}
|
||
} catch {
|
||
/* missing file */
|
||
}
|
||
return out
|
||
}
|
||
|
||
/**
|
||
* @param {{
|
||
* mkdir: (p: string, opts?: { recursive?: boolean }) => Promise<unknown>,
|
||
* writeFile: (p: string, buf: Uint8Array) => Promise<unknown>,
|
||
* unlink: (p: string) => Promise<unknown>
|
||
* }} vfs
|
||
* @param {Set<string>} disabled
|
||
*/
|
||
export async function writeInitdDisabledSet(vfs, disabled) {
|
||
await vfs.mkdir('~/.config/bare-os/initd', { recursive: true })
|
||
if (disabled.size === 0) {
|
||
try {
|
||
await vfs.unlink(BARE_INITD_DISABLED_FILE)
|
||
} catch {
|
||
/* absent */
|
||
}
|
||
return
|
||
}
|
||
const body = [...disabled].sort().join('\n') + '\n'
|
||
await vfs.writeFile(BARE_INITD_DISABLED_FILE, b4a.from(body, 'utf8'))
|
||
}
|
||
|
||
/**
|
||
* @param {{ readFile: (p: string) => Promise<Uint8Array | null> }} vfs
|
||
* @returns {Promise<Set<string>>}
|
||
*/
|
||
export async function readInitdMaskedSet(vfs) {
|
||
const out = new Set()
|
||
try {
|
||
const buf = await vfs.readFile(BARE_INITD_MASKED_FILE)
|
||
if (!buf) return out
|
||
const text = b4a.toString(buf, 'utf8')
|
||
for (const line of text.split(/\r?\n/)) {
|
||
const t = line.trim()
|
||
if (!t || t.startsWith('#')) continue
|
||
out.add(t)
|
||
}
|
||
} catch {
|
||
/* missing */
|
||
}
|
||
return out
|
||
}
|
||
|
||
/**
|
||
* @param {{
|
||
* mkdir: (p: string, opts?: { recursive?: boolean }) => Promise<unknown>,
|
||
* writeFile: (p: string, buf: Uint8Array) => Promise<unknown>,
|
||
* unlink: (p: string) => Promise<unknown>
|
||
* }} vfs
|
||
* @param {Set<string>} masked
|
||
*/
|
||
export async function writeInitdMaskedSet(vfs, masked) {
|
||
await vfs.mkdir('~/.config/bare-os/initd', { recursive: true })
|
||
if (masked.size === 0) {
|
||
try {
|
||
await vfs.unlink(BARE_INITD_MASKED_FILE)
|
||
} catch {
|
||
/* absent */
|
||
}
|
||
return
|
||
}
|
||
const body = [...masked].sort().join('\n') + '\n'
|
||
await vfs.writeFile(BARE_INITD_MASKED_FILE, b4a.from(body, 'utf8'))
|
||
}
|
||
|
||
/**
|
||
* @param {{ readFile: (p: string) => Promise<Uint8Array | null>, readdir?: (p: string) => Promise<string[]> }} vfs
|
||
* @param {string} unitName
|
||
* @returns {Promise<BareInitdUnitDropIn>}
|
||
*/
|
||
export async function readUnitDropIn(vfs, unitName) {
|
||
if (!/^[a-zA-Z0-9._-]+$/.test(unitName)) return emptyUnitDropIn()
|
||
let merged = await readUnitDropInFromPath(
|
||
vfs,
|
||
`${BARE_INITD_UNITS_DIR}/${unitName}.unit`
|
||
)
|
||
const fragDir = `${BARE_INITD_UNITS_D_BASE}/${unitName}`
|
||
if (typeof vfs.readdir === 'function') {
|
||
try {
|
||
const names = await vfs.readdir(fragDir)
|
||
const confs = names
|
||
.filter((n) => typeof n === 'string' && n.endsWith('.conf'))
|
||
.sort()
|
||
for (const n of confs) {
|
||
const frag = await readUnitDropInFromPath(vfs, `${fragDir}/${n}`)
|
||
merged = mergeUnitDropIns(merged, frag)
|
||
}
|
||
} catch {
|
||
/* missing dir */
|
||
}
|
||
}
|
||
const usr = await readUnitDropInFromPath(
|
||
vfs,
|
||
`${BARE_INITD_SYSTEMCTL_USER_UNITS_DIR}/${unitName}.unit`
|
||
)
|
||
return mergeUnitDropIns(merged, usr)
|
||
}
|
||
|
||
/**
|
||
* @param {{ readFile: (p: string) => Promise<Uint8Array | null> }} vfs
|
||
* @param {string} unitName
|
||
* @returns {Promise<string[]>}
|
||
*/
|
||
export async function readUnitAfterFromDropIn(vfs, unitName) {
|
||
const d = await readUnitDropIn(vfs, unitName)
|
||
return d.after
|
||
}
|
||
|
||
/**
|
||
* @param {{ readFile: (p: string) => Promise<Uint8Array | null> }} vfs
|
||
* @param {{ name: string }[]} services
|
||
* @param {Readonly<Record<string, string[]>>} defaultAfter
|
||
* @returns {Promise<Map<string, BareInitdUnitDropIn>>}
|
||
*/
|
||
export async function loadInitdUnitDropIns(vfs, services, defaultAfter) {
|
||
/** @type {Map<string, BareInitdUnitDropIn>} */
|
||
const map = new Map()
|
||
for (const s of services) {
|
||
const fromFile = await readUnitDropIn(vfs, s.name)
|
||
const def = defaultAfter[s.name] || []
|
||
fromFile.after = [...new Set([...def, ...fromFile.after])]
|
||
map.set(s.name, fromFile)
|
||
}
|
||
for (const [name, di] of map) {
|
||
for (const b of di.before) {
|
||
if (!/^[a-zA-Z0-9._-]+$/.test(b)) continue
|
||
const target = map.get(b)
|
||
if (target) {
|
||
target.after = [...new Set([...target.after, name])]
|
||
}
|
||
}
|
||
}
|
||
return map
|
||
}
|
||
|
||
/**
|
||
* @param {{ readFile: (p: string) => Promise<Uint8Array | null> }} vfs
|
||
* @param {{ name: string }[]} services
|
||
* @param {Readonly<Record<string, string[]>>} defaultAfter
|
||
* @returns {Promise<Map<string, string[]>>}
|
||
*/
|
||
export async function loadInitdAfterMap(vfs, services, defaultAfter) {
|
||
const full = await loadInitdUnitDropIns(vfs, services, defaultAfter)
|
||
const map = new Map()
|
||
for (const [k, v] of full) map.set(k, v.after)
|
||
return map
|
||
}
|
||
|
||
/**
|
||
* @template T extends { name: string }
|
||
* @param {T[]} services
|
||
* @param {Set<string>} disabled
|
||
* @param {Map<string, string[]>} requiresMap
|
||
*/
|
||
function filterSatisfiedRequires(services, disabled, requiresMap) {
|
||
const names = new Set(services.map((s) => s.name))
|
||
return services.filter((s) => {
|
||
for (const r of requiresMap.get(s.name) || []) {
|
||
if (!names.has(r)) return false
|
||
if (disabled.has(r)) return false
|
||
}
|
||
return true
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Topological sort; cycles fall back to registration order for the remainder.
|
||
* @template T extends { name: string }
|
||
* @param {T[]} services
|
||
* @param {Set<string>} disabled
|
||
* @param {Map<string, string[]>} afterMap
|
||
* @param {Map<string, string[]>} [requiresMap]
|
||
* @param {Map<string, string[]>} [wantsMap]
|
||
* @returns {T[]}
|
||
*/
|
||
export function sortServicesForBoot(
|
||
services,
|
||
disabled,
|
||
afterMap,
|
||
requiresMap,
|
||
wantsMap
|
||
) {
|
||
const reqM = requiresMap || new Map()
|
||
const wanM = wantsMap || new Map()
|
||
let active = services.filter((s) => !disabled.has(s.name))
|
||
active = filterSatisfiedRequires(active, disabled, reqM)
|
||
const nameSet = new Set(active.map((s) => s.name))
|
||
/** @type {Map<string, Set<string>>} */
|
||
const prereq = new Map()
|
||
for (const s of active) {
|
||
const inc = new Set()
|
||
for (const a of afterMap.get(s.name) || []) {
|
||
if (nameSet.has(a)) inc.add(a)
|
||
}
|
||
for (const r of reqM.get(s.name) || []) {
|
||
if (nameSet.has(r)) inc.add(r)
|
||
}
|
||
for (const w of wanM.get(s.name) || []) {
|
||
if (nameSet.has(w)) inc.add(w)
|
||
}
|
||
prereq.set(s.name, inc)
|
||
}
|
||
const remaining = new Set(active.map((s) => s.name))
|
||
/** @type {T[]} */
|
||
const ordered = []
|
||
while (remaining.size) {
|
||
const ready = [...remaining].filter((n) => {
|
||
for (const p of prereq.get(n) || []) {
|
||
if (remaining.has(p)) return false
|
||
}
|
||
return true
|
||
})
|
||
if (!ready.length) {
|
||
const regOrder = active.map((s) => s.name).filter((n) => remaining.has(n))
|
||
for (const n of regOrder) {
|
||
const svc = active.find((s) => s.name === n)
|
||
if (svc) ordered.push(svc)
|
||
remaining.delete(n)
|
||
}
|
||
break
|
||
}
|
||
ready.sort()
|
||
for (const n of ready) {
|
||
const svc = active.find((s) => s.name === n)
|
||
if (svc) ordered.push(svc)
|
||
remaining.delete(n)
|
||
}
|
||
}
|
||
return ordered
|
||
}
|
||
|
||
/**
|
||
* @typedef {{
|
||
* kind: 'calendar',
|
||
* onCalendar: string,
|
||
* execLine: string,
|
||
* jitterSec: number
|
||
* } | {
|
||
* kind: 'everyMs',
|
||
* everyMs: number,
|
||
* execLine: string,
|
||
* jitterSec: number
|
||
* } | {
|
||
* kind: 'onInactiveSec',
|
||
* inactiveSec: number,
|
||
* execLine: string,
|
||
* jitterSec: number,
|
||
* persistent: boolean
|
||
* }} BareOsTimerDropIn
|
||
*/
|
||
|
||
/**
|
||
* Parse `~/.config/bare-os/timers/*.timer` — `[Timer]` with OnCalendar=, EveryMs=, or OnInactiveSec= and ExecLine=.
|
||
* @param {string} text
|
||
* @returns {BareOsTimerDropIn | null}
|
||
*/
|
||
export function parseBareOsTimerFile(text) {
|
||
let onCalendar = ''
|
||
/** @type {number | null} */
|
||
let everyMs = null
|
||
/** @type {number | null} */
|
||
let inactiveSec = null
|
||
let persistent = true
|
||
/** @type {number | null} */
|
||
let jitterSec = null
|
||
let execLine = ''
|
||
let section = ''
|
||
for (const raw of text.split(/\r?\n/)) {
|
||
const line = raw.trim()
|
||
if (!line || line.startsWith('#')) continue
|
||
if (/^\[[^\]]+\]$/.test(line)) {
|
||
section = line.slice(1, -1).toLowerCase()
|
||
continue
|
||
}
|
||
if (section !== 'timer') continue
|
||
const eq = line.indexOf('=')
|
||
if (eq < 1) continue
|
||
const key = line.slice(0, eq).trim().toLowerCase()
|
||
const val = line.slice(eq + 1).trim()
|
||
if (key === 'oncalendar') onCalendar = val
|
||
else if (key === 'everyms') {
|
||
const n = Number.parseInt(val, 10)
|
||
if (Number.isFinite(n) && n >= 1000 && n <= 86400000) everyMs = n
|
||
} else if (key === 'oninactivesec') {
|
||
const n = Number.parseInt(val, 10)
|
||
if (Number.isFinite(n) && n >= 1 && n <= 86400) inactiveSec = n
|
||
} else if (key === 'persistent') {
|
||
const v = val.toLowerCase()
|
||
persistent = !(
|
||
v === '0' ||
|
||
v === 'false' ||
|
||
v === 'no' ||
|
||
v === 'off'
|
||
)
|
||
} else if (key === 'jittersec') {
|
||
const n = Number.parseInt(val, 10)
|
||
if (Number.isFinite(n) && n >= 0 && n <= 86400) jitterSec = n
|
||
} else if (key === 'execline') execLine = val
|
||
}
|
||
const el = execLine.trim()
|
||
if (!el) return null
|
||
const j = jitterSec != null ? jitterSec : 0
|
||
if (everyMs != null)
|
||
return { kind: 'everyMs', everyMs, execLine: el, jitterSec: j }
|
||
if (inactiveSec != null)
|
||
return {
|
||
kind: 'onInactiveSec',
|
||
inactiveSec,
|
||
execLine: el,
|
||
jitterSec: j,
|
||
persistent
|
||
}
|
||
if (!onCalendar.trim()) return null
|
||
return {
|
||
kind: 'calendar',
|
||
onCalendar: onCalendar.trim(),
|
||
execLine: el,
|
||
jitterSec: j
|
||
}
|
||
}
|