diff --git a/scheduler/api/scheduler.api b/scheduler/api/scheduler.api index 764da07d0..6d55a8638 100644 --- a/scheduler/api/scheduler.api +++ b/scheduler/api/scheduler.api @@ -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 (Ljava/lang/String;Ldev/nucleusframework/scheduler/TaskData;IILkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun (Ljava/lang/String;Ldev/nucleusframework/scheduler/TaskData;ILkotlin/jvm/internal/DefaultConstructorMarker;)V diff --git a/scheduler/src/main/kotlin/dev/nucleusframework/scheduler/DesktopTaskScheduler.kt b/scheduler/src/main/kotlin/dev/nucleusframework/scheduler/DesktopTaskScheduler.kt index c3f71ed86..6b1258d79 100644 --- a/scheduler/src/main/kotlin/dev/nucleusframework/scheduler/DesktopTaskScheduler.kt +++ b/scheduler/src/main/kotlin/dev/nucleusframework/scheduler/DesktopTaskScheduler.kt @@ -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 { diff --git a/scheduler/src/main/kotlin/dev/nucleusframework/scheduler/SchedulerConfig.kt b/scheduler/src/main/kotlin/dev/nucleusframework/scheduler/SchedulerConfig.kt new file mode 100644 index 000000000..26a1487d7 --- /dev/null +++ b/scheduler/src/main/kotlin/dev/nucleusframework/scheduler/SchedulerConfig.kt @@ -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 + * ` --nucleus-scheduler-run `. + * + * 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 ` flag, for launchers + * that need flags of their own. Empty by default. + */ + @JvmStatic + public var executableArguments: List = emptyList() +} diff --git a/scheduler/src/main/kotlin/dev/nucleusframework/scheduler/internal/LinuxSystemdScheduler.kt b/scheduler/src/main/kotlin/dev/nucleusframework/scheduler/internal/LinuxSystemdScheduler.kt index c3299e967..0469bd2b7 100644 --- a/scheduler/src/main/kotlin/dev/nucleusframework/scheduler/internal/LinuxSystemdScheduler.kt +++ b/scheduler/src/main/kotlin/dev/nucleusframework/scheduler/internal/LinuxSystemdScheduler.kt @@ -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}" @@ -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 @@ -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, diff --git a/scheduler/src/main/kotlin/dev/nucleusframework/scheduler/internal/MacOSLaunchdScheduler.kt b/scheduler/src/main/kotlin/dev/nucleusframework/scheduler/internal/MacOSLaunchdScheduler.kt index b551ea99a..cb10d87c2 100644 --- a/scheduler/src/main/kotlin/dev/nucleusframework/scheduler/internal/MacOSLaunchdScheduler.kt +++ b/scheduler/src/main/kotlin/dev/nucleusframework/scheduler/internal/MacOSLaunchdScheduler.kt @@ -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 @@ -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}" @@ -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 } @@ -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, ): 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 @@ -261,13 +252,13 @@ internal object MacOSLaunchdScheduler : PlatformScheduler { private fun enqueueShell( request: TaskRequest, - execPath: String, + command: List, ): 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 @@ -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, delaySeconds: Long, ): Boolean { val retryPath = retryPlistFile(taskId).absolutePath - val programArgs = arrayOf(execPath, SCHEDULER_ARG, taskId.value) + val programArgs = command.toTypedArray() val error = MacOSLaunchdSchedulerJni.nativeScheduleRetry( @@ -397,17 +388,12 @@ internal object MacOSLaunchdScheduler : PlatformScheduler { @Suppress("TooGenericExceptionCaught") private fun scheduleRetryShell( taskId: TaskId, - execPath: String, + command: List, delaySeconds: Long, ): Boolean { val retryFile = retryPlistFile(taskId) - val programArgs = - buildString { - appendLine(" $execPath") - appendLine(" $SCHEDULER_ARG") - appendLine(" ${taskId.value}") - }.trimEnd() + val programArgs = programArgsXml(command, indent = " ") val plist = buildString { @@ -473,9 +459,21 @@ internal object MacOSLaunchdScheduler : PlatformScheduler { """ + /** Renders a command line as the `` entries of a `ProgramArguments` array. */ + private fun programArgsXml( + command: List, + indent: String, + ): String = command.joinToString("\n") { "$indent${xmlEscape(it)}" } + + private fun xmlEscape(s: String): String = + s + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + private fun buildPlist( request: TaskRequest, - execPath: String, + command: List, ): String = buildString { appendLine(PLIST_HEADER) @@ -484,9 +482,7 @@ internal object MacOSLaunchdScheduler : PlatformScheduler { appendLine(" ${label(request.taskId)}") appendLine(" ProgramArguments") appendLine(" ") - appendLine(" $execPath") - appendLine(" $SCHEDULER_ARG") - appendLine(" ${request.taskId.value}") + appendLine(programArgsXml(command, indent = " ")) appendLine(" ") when (request.type) { diff --git a/scheduler/src/main/kotlin/dev/nucleusframework/scheduler/internal/SchedulerExecutable.kt b/scheduler/src/main/kotlin/dev/nucleusframework/scheduler/internal/SchedulerExecutable.kt new file mode 100644 index 000000000..4c079d84d --- /dev/null +++ b/scheduler/src/main/kotlin/dev/nucleusframework/scheduler/internal/SchedulerExecutable.kt @@ -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 + get() = SchedulerConfig.executableArguments + + /** The argument list following the executable: extra args, then the scheduler flag. */ + fun argumentsFor(taskId: TaskId): List = arguments + listOf(DesktopBootReceiver.SCHEDULER_ARG, taskId.value) + + /** The full command line (executable + [argumentsFor]), or `null` if [path] is unresolved. */ + fun commandLine(taskId: TaskId): List? = path?.let { listOf(it) + argumentsFor(taskId) } +} diff --git a/scheduler/src/main/kotlin/dev/nucleusframework/scheduler/internal/TaskWrapperScript.kt b/scheduler/src/main/kotlin/dev/nucleusframework/scheduler/internal/TaskWrapperScript.kt index dbd19b0f2..49aa9d8aa 100644 --- a/scheduler/src/main/kotlin/dev/nucleusframework/scheduler/internal/TaskWrapperScript.kt +++ b/scheduler/src/main/kotlin/dev/nucleusframework/scheduler/internal/TaskWrapperScript.kt @@ -1,6 +1,7 @@ package dev.nucleusframework.scheduler.internal import dev.nucleusframework.core.runtime.Platform +import dev.nucleusframework.scheduler.DesktopBootReceiver import dev.nucleusframework.scheduler.TaskId import java.io.File @@ -17,6 +18,8 @@ import java.io.File * explicit uninstall hooks. */ internal object TaskWrapperScript { + private const val SCHEDULER_ARG = DesktopBootReceiver.SCHEDULER_ARG + private fun scriptsDir(appId: String): File { val baseDir = when (Platform.Current) { @@ -73,40 +76,57 @@ internal object TaskWrapperScript { appId: String, taskId: TaskId, execPath: String, + execArgs: List, taskFolder: String, metadataDir: String, ): File { val file = scriptFile(appId, taskId) file.parentFile.mkdirs() + file.writeText(buildWindowsScript(taskId, execPath, execArgs, taskFolder, metadataDir)) + return file + } + /** Builds the VBScript content written by [generateWindowsScript]. */ + internal fun buildWindowsScript( + taskId: TaskId, + execPath: String, + execArgs: List, + taskFolder: String, + metadataDir: String, + ): String { val metadataFile = "$metadataDir\\${taskId.value}.properties" - val content = + // shell.Run expects a single command string; the exe path is always quoted so + // spaces are safe, and any argument containing a space gets quoted too. + val commandLine = buildString { - appendLine("Set fso = CreateObject(\"Scripting.FileSystemObject\")") - appendLine("If Not fso.FileExists(${vbsQuote(execPath)}) Then") - appendLine(" On Error Resume Next") - appendLine(" Set svc = CreateObject(\"Schedule.Service\")") - appendLine(" svc.Connect") - appendLine(" Set folder = svc.GetFolder(${vbsQuote(taskFolder)})") - appendLine(" folder.DeleteTask ${vbsQuote(taskId.value)}, 0") - appendLine(" folder.DeleteTask ${vbsQuote("${taskId.value}-retry")}, 0") - appendLine(" On Error GoTo 0") - appendLine( - " If fso.FileExists(${vbsQuote(metadataFile)}) Then fso.DeleteFile ${vbsQuote(metadataFile)}", - ) - appendLine(" fso.DeleteFile WScript.ScriptFullName") - appendLine(" WScript.Quit 0") - appendLine("End If") - appendLine("Set shell = CreateObject(\"WScript.Shell\")") - // shell.Run expects a command string; inner quotes wrap the exe path for spaces. - // VBS string: "..." with doubled quotes inside → literal quotes in the value. - appendLine( - "shell.Run \"\"\"${vbsEscape(execPath)}\"\" --nucleus-scheduler-run ${taskId.value}\", 0, True", - ) + append('"').append(execPath).append('"') + for (arg in execArgs + SCHEDULER_ARG + taskId.value) { + append(' ') + if (arg.contains(' ')) append('"').append(arg).append('"') else append(arg) + } } - file.writeText(content) - return file + + return buildString { + appendLine("Set fso = CreateObject(\"Scripting.FileSystemObject\")") + appendLine("If Not fso.FileExists(${vbsQuote(execPath)}) Then") + appendLine(" On Error Resume Next") + appendLine(" Set svc = CreateObject(\"Schedule.Service\")") + appendLine(" svc.Connect") + appendLine(" Set folder = svc.GetFolder(${vbsQuote(taskFolder)})") + appendLine(" folder.DeleteTask ${vbsQuote(taskId.value)}, 0") + appendLine(" folder.DeleteTask ${vbsQuote("${taskId.value}-retry")}, 0") + appendLine(" On Error GoTo 0") + appendLine( + " If fso.FileExists(${vbsQuote(metadataFile)}) Then fso.DeleteFile ${vbsQuote(metadataFile)}", + ) + appendLine(" fso.DeleteFile WScript.ScriptFullName") + appendLine(" WScript.Quit 0") + appendLine("End If") + appendLine("Set shell = CreateObject(\"WScript.Shell\")") + // VBS string: "..." with doubled quotes inside → literal quotes in the value. + appendLine("shell.Run ${vbsQuote(commandLine)}, 0, True") + } } /** Wraps a value in VBS double quotes, doubling any inner quotes. */ @@ -117,10 +137,12 @@ internal object TaskWrapperScript { // -- Linux bash wrapper --------------------------------------------------- + @Suppress("LongParameterList") fun generateLinuxScript( appId: String, taskId: TaskId, execPath: String, + execArgs: List, timerUnit: String, serviceUnit: String, serviceFilePath: String, @@ -129,27 +151,55 @@ internal object TaskWrapperScript { ): File { val file = scriptFile(appId, taskId) file.parentFile.mkdirs() - - val content = - buildString { - appendLine("#!/bin/bash") - appendLine("EXEC=${quote(execPath)}") - appendLine("if [ ! -x \"${'$'}EXEC\" ]; then") - appendLine(" systemctl --user disable --now ${quote(timerUnit)} 2>/dev/null") - appendLine(" systemctl --user disable ${quote(serviceUnit)} 2>/dev/null") - appendLine(" rm -f ${quote(timerFilePath)}") - appendLine(" rm -f ${quote(serviceFilePath)}") - appendLine(" systemctl --user daemon-reload 2>/dev/null") - appendLine(" rm -f ${quote(metadataDir + "/" + taskId.value + ".properties")}") - appendLine(" rm -f ${quote(file.absolutePath)}") - appendLine(" exit 0") - appendLine("fi") - appendLine("\"${'$'}EXEC\" --nucleus-scheduler-run ${taskId.value}") - } - file.writeText(content) + file.writeText( + buildLinuxScript( + taskId = taskId, + execPath = execPath, + execArgs = execArgs, + timerUnit = timerUnit, + serviceUnit = serviceUnit, + serviceFilePath = serviceFilePath, + timerFilePath = timerFilePath, + metadataDir = metadataDir, + scriptPath = file.absolutePath, + ), + ) file.setExecutable(true) return file } - private fun quote(s: String): String = "\"$s\"" + /** Builds the bash script content written by [generateLinuxScript]. */ + @Suppress("LongParameterList") + internal fun buildLinuxScript( + taskId: TaskId, + execPath: String, + execArgs: List, + timerUnit: String, + serviceUnit: String, + serviceFilePath: String, + timerFilePath: String, + metadataDir: String, + scriptPath: String, + ): String { + val args = + (execArgs + SCHEDULER_ARG + taskId.value).joinToString(" ") { shellQuote(it) } + return buildString { + appendLine("#!/bin/bash") + appendLine("EXEC=${shellQuote(execPath)}") + appendLine("if [ ! -x \"${'$'}EXEC\" ]; then") + appendLine(" systemctl --user disable --now ${shellQuote(timerUnit)} 2>/dev/null") + appendLine(" systemctl --user disable ${shellQuote(serviceUnit)} 2>/dev/null") + appendLine(" rm -f ${shellQuote(timerFilePath)}") + appendLine(" rm -f ${shellQuote(serviceFilePath)}") + appendLine(" systemctl --user daemon-reload 2>/dev/null") + appendLine(" rm -f ${shellQuote("$metadataDir/${taskId.value}.properties")}") + appendLine(" rm -f ${shellQuote(scriptPath)}") + appendLine(" exit 0") + appendLine("fi") + appendLine("\"${'$'}EXEC\" $args") + } + } + + /** Wraps a value in single quotes, escaping any embedded single quote. */ + private fun shellQuote(s: String): String = "'" + s.replace("'", "'\\''") + "'" } diff --git a/scheduler/src/main/kotlin/dev/nucleusframework/scheduler/internal/WindowsTaskScheduler.kt b/scheduler/src/main/kotlin/dev/nucleusframework/scheduler/internal/WindowsTaskScheduler.kt index b009a227f..b71e8febe 100644 --- a/scheduler/src/main/kotlin/dev/nucleusframework/scheduler/internal/WindowsTaskScheduler.kt +++ b/scheduler/src/main/kotlin/dev/nucleusframework/scheduler/internal/WindowsTaskScheduler.kt @@ -25,7 +25,6 @@ import java.util.logging.Logger @Suppress("TooManyFunctions") internal object WindowsTaskScheduler : PlatformScheduler { private val logger = Logger.getLogger(WindowsTaskScheduler::class.java.name) - private const val SCHEDULER_ARG = "--nucleus-scheduler-run" private const val TASK_FOLDER = "Nucleus" private const val HOURLY_INTERVAL_MINUTES = 60 private const val HOURLY_DURATION_MINUTES = 60 @@ -35,21 +34,17 @@ internal object WindowsTaskScheduler : PlatformScheduler { private val appId: String get() = NucleusApp.appId - private val executablePath: String? - get() = - ProcessHandle - .current() - .info() - .command() - .orElse(null) - // -- Task naming ---------------------------------------------------------- private fun folderPath(): String = "\\$TASK_FOLDER\\$appId" private fun retryTaskName(taskId: TaskId): String = "${taskId.value}-retry" - private fun arguments(taskId: TaskId): String = "$SCHEDULER_ARG ${taskId.value}" + /** Argument string for a direct executable invocation (retry fallback path). */ + private fun arguments(taskId: TaskId): String = + SchedulerExecutable.argumentsFor(taskId).joinToString(" ") { + if (it.contains(' ')) "\"$it\"" else it + } // -- WScript invocation --------------------------------------------------- @@ -76,7 +71,7 @@ internal object WindowsTaskScheduler : PlatformScheduler { } } - val execPath = executablePath + val execPath = SchedulerExecutable.path if (execPath == null) { logger.warning("Cannot resolve executable path — task '${request.taskId}' not scheduled") return false @@ -92,6 +87,7 @@ internal object WindowsTaskScheduler : PlatformScheduler { appId = appId, taskId = request.taskId, execPath = execPath, + execArgs = SchedulerExecutable.arguments, taskFolder = folderPath(), metadataDir = metadataDir, ) @@ -170,7 +166,7 @@ internal object WindowsTaskScheduler : PlatformScheduler { delaySeconds: Long, ): Boolean { if (!isAvailable) return false - val execPath = executablePath ?: return false + val execPath = SchedulerExecutable.path ?: return false val startTime = LocalDateTime.now().plusSeconds(delaySeconds) val startBoundary = startTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) diff --git a/scheduler/src/test/kotlin/dev/nucleusframework/scheduler/internal/SchedulerExecutableTest.kt b/scheduler/src/test/kotlin/dev/nucleusframework/scheduler/internal/SchedulerExecutableTest.kt new file mode 100644 index 000000000..1b0319896 --- /dev/null +++ b/scheduler/src/test/kotlin/dev/nucleusframework/scheduler/internal/SchedulerExecutableTest.kt @@ -0,0 +1,62 @@ +package dev.nucleusframework.scheduler.internal + +import dev.nucleusframework.scheduler.SchedulerConfig +import dev.nucleusframework.scheduler.TaskId +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class SchedulerExecutableTest { + @AfterTest + fun reset() { + SchedulerConfig.executablePath = null + SchedulerConfig.executableArguments = emptyList() + } + + private fun runningExecutable(): String? = + ProcessHandle + .current() + .info() + .command() + .orElse(null) + + @Test + fun `defaults to the running executable`() { + assertEquals(runningExecutable(), SchedulerExecutable.path) + } + + @Test + fun `custom executable path wins`() { + SchedulerConfig.executablePath = "/opt/myapp/launcher" + + assertEquals("/opt/myapp/launcher", SchedulerExecutable.path) + } + + @Test + fun `blank executable path falls back to the running executable`() { + SchedulerConfig.executablePath = " " + + assertEquals(runningExecutable(), SchedulerExecutable.path) + } + + @Test + fun `extra arguments precede the scheduler flag`() { + SchedulerConfig.executableArguments = listOf("--background", "--quiet") + + assertEquals( + listOf("--background", "--quiet", "--nucleus-scheduler-run", "sync"), + SchedulerExecutable.argumentsFor(TaskId("sync")), + ) + } + + @Test + fun `command line starts with the executable`() { + SchedulerConfig.executablePath = "/opt/myapp/launcher" + SchedulerConfig.executableArguments = listOf("--background") + + assertEquals( + listOf("/opt/myapp/launcher", "--background", "--nucleus-scheduler-run", "sync"), + SchedulerExecutable.commandLine(TaskId("sync")), + ) + } +} diff --git a/scheduler/src/test/kotlin/dev/nucleusframework/scheduler/internal/TaskWrapperScriptTest.kt b/scheduler/src/test/kotlin/dev/nucleusframework/scheduler/internal/TaskWrapperScriptTest.kt new file mode 100644 index 000000000..5b0e9eb48 --- /dev/null +++ b/scheduler/src/test/kotlin/dev/nucleusframework/scheduler/internal/TaskWrapperScriptTest.kt @@ -0,0 +1,94 @@ +package dev.nucleusframework.scheduler.internal + +import dev.nucleusframework.scheduler.TaskId +import kotlin.test.Test +import kotlin.test.assertTrue + +class TaskWrapperScriptTest { + private val taskId = TaskId("sync") + + private fun linuxScript( + execPath: String = "/opt/myapp/launcher", + execArgs: List = emptyList(), + ) = TaskWrapperScript.buildLinuxScript( + taskId = taskId, + execPath = execPath, + execArgs = execArgs, + timerUnit = "nucleus-app-sync.timer", + serviceUnit = "nucleus-app-sync.service", + serviceFilePath = "/home/u/.config/systemd/user/nucleus-app-sync.service", + timerFilePath = "/home/u/.config/systemd/user/nucleus-app-sync.timer", + metadataDir = "/home/u/.local/share/nucleus/scheduler/app", + scriptPath = "/home/u/.local/share/nucleus/scheduler/app/scripts/sync.sh", + ) + + private fun windowsScript( + execPath: String = "C:\\Program Files\\MyApp\\launcher.exe", + execArgs: List = emptyList(), + ) = TaskWrapperScript.buildWindowsScript( + taskId = taskId, + execPath = execPath, + execArgs = execArgs, + taskFolder = "\\Nucleus\\app", + metadataDir = "C:\\Users\\u\\AppData\\Local\\nucleus\\scheduler\\app", + ) + + @Test + fun `linux script invokes the custom executable`() { + val script = linuxScript() + + assertTrue(script.contains("EXEC='/opt/myapp/launcher'"), script) + assertTrue(script.contains("\"\$EXEC\" '--nucleus-scheduler-run' 'sync'"), script) + } + + @Test + fun `linux script places extra args before the scheduler flag`() { + val script = linuxScript(execArgs = listOf("--background", "--quiet")) + + assertTrue( + script.contains("\"\$EXEC\" '--background' '--quiet' '--nucleus-scheduler-run' 'sync'"), + script, + ) + } + + @Test + fun `linux script escapes single quotes in arguments`() { + val script = linuxScript(execArgs = listOf("--name=it's")) + + assertTrue(script.contains("""'--name=it'\''s'"""), script) + } + + @Test + fun `windows script quotes the executable and appends the scheduler flag`() { + val script = windowsScript() + + assertTrue( + script.contains( + "shell.Run \"\"\"C:\\Program Files\\MyApp\\launcher.exe\"\" " + + "--nucleus-scheduler-run sync\", 0, True", + ), + script, + ) + } + + @Test + fun `windows script places extra args before the scheduler flag`() { + val script = windowsScript(execArgs = listOf("--background", "--log dir")) + + assertTrue( + script.contains("--background \"\"--log dir\"\" --nucleus-scheduler-run sync"), + script, + ) + } + + @Test + fun `windows script still self-destructs when the executable is gone`() { + val script = windowsScript(execArgs = listOf("--background")) + + assertTrue( + script.contains("If Not fso.FileExists(\"C:\\Program Files\\MyApp\\launcher.exe\") Then"), + script, + ) + assertTrue(script.contains("fso.DeleteFile WScript.ScriptFullName"), script) + } +}