From cea4e559d1fc7048c81c3e55294242ebd6743ffa Mon Sep 17 00:00:00 2001 From: Fluffy Date: Thu, 9 Jul 2026 10:36:03 +0200 Subject: [PATCH] Add setTimeout/setInterval support to the plugin runtime Plugins written for the mobile app commonly call setTimeout for fetch timeouts and retry backoff. The QuickJS runtime has no timers, so those plugins throw ReferenceError inside their own try/catch blocks and silently return zero streams on desktop (see #190). - TimerBridge exposes an async __native_delay binding backed by kotlinx.coroutines.delay, capped at the plugin timeout - JsBindings gains a timer polyfill (setTimeout, clearTimeout, setInterval, clearInterval, setImmediate) built on __native_delay, with JS-side cancellation and exception containment - Desktop test covering timer firing and cancellation Co-Authored-By: Claude Fable 5 --- .../plugins/PluginRuntimeDesktopTest.kt | 34 ++++++++++ .../features/plugins/runtime/PluginRuntime.kt | 2 + .../features/plugins/runtime/js/JsBindings.kt | 63 +++++++++++++++++++ .../plugins/runtime/timers/TimerBridge.kt | 22 +++++++ 4 files changed, 121 insertions(+) create mode 100644 composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/timers/TimerBridge.kt 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 + } + } +}