From 70f4e4ca2436074ba93ea0927165769bec415bbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AE=89=E5=AE=B9?= Date: Sun, 16 Aug 2026 14:56:15 +0800 Subject: [PATCH 1/2] feat(scheduler): allow a custom command for scheduled tasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every backend resolved the program to wake up from ProcessHandle.current().info().command(), so apps that bootstrap through a custom launcher had the OS start the JVM/native binary instead of the launcher, with no way to override it. SchedulerConfig now exposes executablePath and executableArguments, mirroring AutoLaunchConfig. The scheduled invocation becomes ` --nucleus-scheduler-run `; leaving both unset keeps the previous behavior. The new internal SchedulerExecutable resolves the command for all three backends and centralizes the scheduler flag on DesktopBootReceiver, replacing the three duplicated executablePath getters and two copies of the constant. Wrapper-script content generation is split from file writing so it can be unit-tested, and while threading the arguments through: Linux script values are single-quote escaped instead of naively wrapped in double quotes, Windows arguments containing spaces are quoted, and macOS plist values are XML-escaped. Closes #498 Co-Authored-By: Claude Opus 5 --- scheduler/api/scheduler.api | 8 ++ .../scheduler/DesktopTaskScheduler.kt | 3 + .../scheduler/SchedulerConfig.kt | 41 ++++++ .../internal/LinuxSystemdScheduler.kt | 11 +- .../internal/MacOSLaunchdScheduler.kt | 62 ++++---- .../scheduler/internal/SchedulerExecutable.kt | 33 +++++ .../scheduler/internal/TaskWrapperScript.kt | 136 ++++++++++++------ .../internal/WindowsTaskScheduler.kt | 20 ++- 8 files changed, 217 insertions(+), 97 deletions(-) create mode 100644 scheduler/src/main/kotlin/dev/nucleusframework/scheduler/SchedulerConfig.kt create mode 100644 scheduler/src/main/kotlin/dev/nucleusframework/scheduler/internal/SchedulerExecutable.kt 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) From 416d13c0621375534d2bd2f636df7ebe23485a2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AE=89=E5=AE=B9?= Date: Sun, 16 Aug 2026 14:56:15 +0800 Subject: [PATCH 2/2] test(scheduler): cover the custom scheduler command SchedulerExecutableTest pins the resolution order (config override, blank override ignored, ProcessHandle fallback) and the argument ordering. TaskWrapperScriptTest asserts the generated Linux and Windows wrappers invoke the configured launcher with its extra arguments ahead of the scheduler flag, escape quotes and spaces, and still self-destruct when the executable is gone. Co-Authored-By: Claude Opus 5 --- .../internal/SchedulerExecutableTest.kt | 62 ++++++++++++ .../internal/TaskWrapperScriptTest.kt | 94 +++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 scheduler/src/test/kotlin/dev/nucleusframework/scheduler/internal/SchedulerExecutableTest.kt create mode 100644 scheduler/src/test/kotlin/dev/nucleusframework/scheduler/internal/TaskWrapperScriptTest.kt 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) + } +}