diff --git a/composeApp/src/desktopTest/kotlin/com/nuvio/app/features/plugins/PluginRuntimeDesktopTest.kt b/composeApp/src/desktopTest/kotlin/com/nuvio/app/features/plugins/PluginRuntimeDesktopTest.kt index ecf9739cf..a4c17e42c 100644 --- a/composeApp/src/desktopTest/kotlin/com/nuvio/app/features/plugins/PluginRuntimeDesktopTest.kt +++ b/composeApp/src/desktopTest/kotlin/com/nuvio/app/features/plugins/PluginRuntimeDesktopTest.kt @@ -31,4 +31,38 @@ class PluginRuntimeDesktopTest { assertEquals("1080p", results.single().quality) assertEquals("Desktop Test", results.single().provider) } + + @Test + fun `desktop runtime provides working timers`() = runBlocking { + val results = PluginRuntime.executePlugin( + code = """ + module.exports.getStreams = async function(tmdbId, mediaType) { + // setTimeout resolves and its callback runs + var fired = await new Promise(function(resolve) { + setTimeout(function(value) { resolve(value); }, 50, "fired"); + }); + + // clearTimeout prevents the callback from running + var cancelled = "not-cancelled"; + var id = setTimeout(function() { cancelled = "leaked"; }, 50); + clearTimeout(id); + await new Promise(function(resolve) { setTimeout(resolve, 120); }); + + return [{ + title: fired + " " + cancelled, + url: "https://example.test/timers.mp4", + provider: "Timer Test" + }]; + }; + """.trimIndent(), + tmdbId = "603", + mediaType = "movie", + season = null, + episode = null, + scraperId = "desktop-timer-test", + ) + + assertEquals(1, results.size) + assertEquals("fired not-cancelled", results.single().title) + } } diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt index a0f19002d..db9f6eef6 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt @@ -11,6 +11,7 @@ import com.nuvio.app.features.plugins.runtime.js.JsRuntime import com.dokar.quickjs.binding.function import com.nuvio.app.features.plugins.runtime.network.FetchBridge import com.nuvio.app.features.plugins.runtime.network.UrlBridge +import com.nuvio.app.features.plugins.runtime.timers.TimerBridge import com.nuvio.app.features.plugins.runtime.wasm.WasmBridge import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers @@ -133,6 +134,7 @@ internal object PluginRuntime { val hostRegistry = HostApiRegistry().apply { addModule(HostFunctions(scraperId) { deferred.complete(it) }) addModule(FetchBridge()) + addModule(TimerBridge()) addModule(UrlBridge()) addModule(CryptoBridge()) addModule(WasmBridge()) diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt index 5e2390489..0313b8efc 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt @@ -11,6 +11,7 @@ internal object JsBindings { ${fetchPolyfill()} ${abortControllerPolyfill()} + ${timerPolyfill()} ${base64Polyfill()} ${urlPolyfill()} ${cryptoPolyfill()} @@ -107,6 +108,68 @@ internal object JsBindings { } """.trimIndent() + // Backed by the __native_delay async binding (TimerBridge). Callbacks run as + // QuickJS jobs on the runtime's coroutine scope, so they are processed while + // the host awaits the plugin result and cancelled when the instance closes. + private fun timerPolyfill() = """ + if (typeof setTimeout === 'undefined') { + (function() { + var timerSeq = 1; + var activeTimers = {}; + + function runCallback(fn, args) { + try { + if (typeof fn === 'function') fn.apply(undefined, args); + else if (typeof fn === 'string') (new Function(fn))(); + } catch (e) { + console.error('timer callback error:', e && e.message ? e.message : e); + } + } + + globalThis.setTimeout = function(fn, ms) { + var id = timerSeq++; + var args = Array.prototype.slice.call(arguments, 2); + activeTimers[id] = true; + __native_delay(Number(ms) || 0).then(function() { + if (!activeTimers[id]) return; + delete activeTimers[id]; + runCallback(fn, args); + }); + return id; + }; + + globalThis.clearTimeout = function(id) { + delete activeTimers[id]; + }; + + globalThis.setInterval = function(fn, ms) { + var id = timerSeq++; + var args = Array.prototype.slice.call(arguments, 2); + activeTimers[id] = true; + function tick() { + __native_delay(Number(ms) || 0).then(function() { + if (!activeTimers[id]) return; + runCallback(fn, args); + tick(); + }); + } + tick(); + return id; + }; + + globalThis.clearInterval = function(id) { + delete activeTimers[id]; + }; + + globalThis.setImmediate = function(fn) { + var args = Array.prototype.slice.call(arguments, 1); + return globalThis.setTimeout.apply(undefined, [fn, 0].concat(args)); + }; + globalThis.clearImmediate = globalThis.clearTimeout; + })(); + } + """.trimIndent() + private fun base64Polyfill() = """ if (typeof atob === 'undefined') { globalThis.atob = function(input) { diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/timers/TimerBridge.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/timers/TimerBridge.kt new file mode 100644 index 000000000..12c4de491 --- /dev/null +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/timers/TimerBridge.kt @@ -0,0 +1,22 @@ +package com.nuvio.app.features.plugins.runtime.timers + +import com.dokar.quickjs.QuickJs +import com.dokar.quickjs.binding.asyncFunction +import com.nuvio.app.features.plugins.runtime.host.HostModule +import kotlinx.coroutines.delay + +// Plugins bundled for the mobile app rely on setTimeout/setInterval (mostly for +// fetch timeouts and retry backoff). QuickJS provides no timers by itself, so +// without this bridge those plugins throw ReferenceError and silently return +// zero streams. The JS-side polyfill lives in JsBindings.timerPolyfill(). +private const val MAX_DELAY_MS = 60_000L + +internal class TimerBridge : HostModule { + override fun register(runtime: QuickJs) { + runtime.asyncFunction("__native_delay") { args -> + val ms = (args.getOrNull(0) as? Number)?.toLong() ?: 0L + delay(ms.coerceIn(0L, MAX_DELAY_MS)) + null + } + } +}