Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions scheduler/api/scheduler.api
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,14 @@ public final class dev/nucleusframework/scheduler/RetryPolicy$Linear : dev/nucle
public fun toString ()Ljava/lang/String;
}

public final class dev/nucleusframework/scheduler/SchedulerConfig {
public static final field INSTANCE Ldev/nucleusframework/scheduler/SchedulerConfig;
public static final fun getExecutableArguments ()Ljava/util/List;
public static final fun getExecutablePath ()Ljava/lang/String;
public static final fun setExecutableArguments (Ljava/util/List;)V
public static final fun setExecutablePath (Ljava/lang/String;)V
}

public final class dev/nucleusframework/scheduler/TaskContext {
public synthetic fun <init> (Ljava/lang/String;Ldev/nucleusframework/scheduler/TaskData;IILkotlin/jvm/internal/DefaultConstructorMarker;)V
public synthetic fun <init> (Ljava/lang/String;Ldev/nucleusframework/scheduler/TaskData;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ import java.util.logging.Logger
* val scheduler = DesktopTaskScheduler.getInstance()
* scheduler.enqueue(TaskRequest.periodic("sync", 1.hours))
* ```
*
* Tasks wake up the running executable by default; apps that boot through a custom
* launcher can point the OS at it via [SchedulerConfig].
*/
@OptIn(InternalSchedulerApi::class)
public object DesktopTaskScheduler {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package dev.nucleusframework.scheduler

/**
* Optional overrides for the command the OS scheduler invokes.
*
* By default every backend wakes up the current executable, resolved from
* `ProcessHandle.current().info().command()`. Apps that bootstrap through a custom
* launcher can point the scheduler at that launcher instead:
*
* ```kotlin
* SchedulerConfig.executablePath = "/opt/myapp/myapp-launcher"
* SchedulerConfig.executableArguments = listOf("--background")
*
* DesktopTaskScheduler.enqueue(TaskRequest.periodic(TaskId("sync"), 1.hours))
* ```
*
* The resulting invocation is
* `<executablePath> <executableArguments…> --nucleus-scheduler-run <taskId>`.
*
* Configure this before the first [DesktopTaskScheduler.enqueue] call — the values are
* baked into the generated unit/plist/task at enqueue time, so changing them later only
* affects tasks scheduled afterwards.
*/
public object SchedulerConfig {
/**
* Absolute path to the program the OS scheduler should invoke.
*
* Must be an absolute path: the generated wrapper scripts check that this file still
* exists and unregister the scheduled task when it is gone (e.g. after an uninstall).
* If `null` or blank, resolved from `ProcessHandle.current().info().command()`.
*/
@JvmStatic
public var executablePath: String? = null

/**
* Arguments inserted before the `--nucleus-scheduler-run <taskId>` flag, for launchers
* that need flags of their own. Empty by default.
*/
@JvmStatic
public var executableArguments: List<String> = emptyList()
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,6 @@ internal object LinuxSystemdScheduler : PlatformScheduler {
private val appId: String
get() = NucleusApp.appId

private val executablePath: String?
get() =
ProcessHandle
.current()
.info()
.command()
.orElse(null)

// -- Unit naming ----------------------------------------------------------

internal fun unitBaseName(taskId: TaskId): String = "$UNIT_PREFIX-$appId-${taskId.value}"
Expand Down Expand Up @@ -85,7 +77,7 @@ internal object LinuxSystemdScheduler : PlatformScheduler {
}
}

val execPath = executablePath
val execPath = SchedulerExecutable.path
if (execPath == null) {
logger.warning("Cannot resolve executable path — task '${request.taskId}' not scheduled")
return false
Expand All @@ -104,6 +96,7 @@ internal object LinuxSystemdScheduler : PlatformScheduler {
appId = appId,
taskId = request.taskId,
execPath = execPath,
execArgs = SchedulerExecutable.arguments,
timerUnit = timerFileName(request.taskId),
serviceUnit = serviceFileName(request.taskId),
serviceFilePath = serviceFile.absolutePath,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ import java.util.logging.Logger
@Suppress("TooManyFunctions")
internal object MacOSLaunchdScheduler : PlatformScheduler {
private val logger = Logger.getLogger(MacOSLaunchdScheduler::class.java.name)
private const val SCHEDULER_ARG = "--nucleus-scheduler-run"
private const val COMMAND_TIMEOUT_SECONDS = 10L
private const val LABEL_PREFIX = "dev.nucleusframework"
private const val CAL_NOT_SET = -1
Expand All @@ -39,14 +38,6 @@ internal object MacOSLaunchdScheduler : PlatformScheduler {
private val appId: String
get() = NucleusApp.appId

private val executablePath: String?
get() =
ProcessHandle
.current()
.info()
.command()
.orElse(null)

// -- Naming ---------------------------------------------------------------

internal fun label(taskId: TaskId): String = "$LABEL_PREFIX.$appId.${taskId.value}"
Expand Down Expand Up @@ -170,8 +161,8 @@ internal object MacOSLaunchdScheduler : PlatformScheduler {
}
}

val execPath = executablePath
if (execPath == null) {
val command = SchedulerExecutable.commandLine(request.taskId)
if (command == null) {
logger.warning("Cannot resolve executable path — task '${request.taskId}' not scheduled")
return false
}
Expand All @@ -184,18 +175,18 @@ internal object MacOSLaunchdScheduler : PlatformScheduler {
persistMetadata(request)

return if (useNative) {
enqueueNative(request, execPath)
enqueueNative(request, command)
} else {
enqueueShell(request, execPath)
enqueueShell(request, command)
}
}

private fun enqueueNative(
request: TaskRequest,
execPath: String,
command: List<String>,
): Boolean {
val plistPath = plistFile(request.taskId).absolutePath
val programArgs = arrayOf(execPath, SCHEDULER_ARG, request.taskId.value)
val programArgs = command.toTypedArray()

var intervalSeconds = 0
var calDay = CAL_NOT_SET
Expand Down Expand Up @@ -261,13 +252,13 @@ internal object MacOSLaunchdScheduler : PlatformScheduler {

private fun enqueueShell(
request: TaskRequest,
execPath: String,
command: List<String>,
): Boolean {
launchAgentsDir.mkdirs()

val plistContent =
try {
buildPlist(request, execPath)
buildPlist(request, command)
} catch (e: IllegalArgumentException) {
logger.warning("Task '${request.taskId}' not scheduled: ${e.message}")
return false
Expand Down Expand Up @@ -360,25 +351,25 @@ internal object MacOSLaunchdScheduler : PlatformScheduler {
taskId: TaskId,
delaySeconds: Long,
): Boolean {
val execPath = executablePath ?: return false
val command = SchedulerExecutable.commandLine(taskId) ?: return false

// Remove any previous retry plist
cleanupRetryPlist(taskId)

return if (useNative) {
scheduleRetryNative(taskId, execPath, delaySeconds)
scheduleRetryNative(taskId, command, delaySeconds)
} else {
scheduleRetryShell(taskId, execPath, delaySeconds)
scheduleRetryShell(taskId, command, delaySeconds)
}
}

private fun scheduleRetryNative(
taskId: TaskId,
execPath: String,
command: List<String>,
delaySeconds: Long,
): Boolean {
val retryPath = retryPlistFile(taskId).absolutePath
val programArgs = arrayOf(execPath, SCHEDULER_ARG, taskId.value)
val programArgs = command.toTypedArray()

val error =
MacOSLaunchdSchedulerJni.nativeScheduleRetry(
Expand All @@ -397,17 +388,12 @@ internal object MacOSLaunchdScheduler : PlatformScheduler {
@Suppress("TooGenericExceptionCaught")
private fun scheduleRetryShell(
taskId: TaskId,
execPath: String,
command: List<String>,
delaySeconds: Long,
): Boolean {
val retryFile = retryPlistFile(taskId)

val programArgs =
buildString {
appendLine(" <string>$execPath</string>")
appendLine(" <string>$SCHEDULER_ARG</string>")
appendLine(" <string>${taskId.value}</string>")
}.trimEnd()
val programArgs = programArgsXml(command, indent = " ")

val plist =
buildString {
Expand Down Expand Up @@ -473,9 +459,21 @@ internal object MacOSLaunchdScheduler : PlatformScheduler {
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">"""

/** Renders a command line as the `<string>` entries of a `ProgramArguments` array. */
private fun programArgsXml(
command: List<String>,
indent: String,
): String = command.joinToString("\n") { "$indent<string>${xmlEscape(it)}</string>" }

private fun xmlEscape(s: String): String =
s
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")

private fun buildPlist(
request: TaskRequest,
execPath: String,
command: List<String>,
): String =
buildString {
appendLine(PLIST_HEADER)
Expand All @@ -484,9 +482,7 @@ internal object MacOSLaunchdScheduler : PlatformScheduler {
appendLine(" <string>${label(request.taskId)}</string>")
appendLine(" <key>ProgramArguments</key>")
appendLine(" <array>")
appendLine(" <string>$execPath</string>")
appendLine(" <string>$SCHEDULER_ARG</string>")
appendLine(" <string>${request.taskId.value}</string>")
appendLine(programArgsXml(command, indent = " "))
appendLine(" </array>")

when (request.type) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package dev.nucleusframework.scheduler.internal

import dev.nucleusframework.scheduler.DesktopBootReceiver
import dev.nucleusframework.scheduler.SchedulerConfig
import dev.nucleusframework.scheduler.TaskId

/**
* Resolves the command the OS scheduler should invoke for a task.
*
* Honours the [SchedulerConfig] overrides and falls back to the running executable,
* so all three platform backends agree on what gets registered with the OS.
*/
internal object SchedulerExecutable {
/** The program to invoke, or `null` if it cannot be resolved. */
val path: String?
get() =
SchedulerConfig.executablePath?.takeIf { it.isNotBlank() }
?: ProcessHandle
.current()
.info()
.command()
.orElse(null)

/** Extra arguments placed before the scheduler flag. */
val arguments: List<String>
get() = SchedulerConfig.executableArguments

/** The argument list following the executable: extra args, then the scheduler flag. */
fun argumentsFor(taskId: TaskId): List<String> = arguments + listOf(DesktopBootReceiver.SCHEDULER_ARG, taskId.value)

/** The full command line (executable + [argumentsFor]), or `null` if [path] is unresolved. */
fun commandLine(taskId: TaskId): List<String>? = path?.let { listOf(it) + argumentsFor(taskId) }
}
Loading
Loading