diff --git a/.cspell-wordlist.txt b/.cspell-wordlist.txt index c75488d219..58d46387fd 100644 --- a/.cspell-wordlist.txt +++ b/.cspell-wordlist.txt @@ -129,6 +129,7 @@ RNFS pogodin kesha antonov +Radtke rfdetr basemodule IMAGENET @@ -347,3 +348,5 @@ unclip phonemizations phonemizes həlˈoʊ +NSURL +backgrounding diff --git a/packages/react-native-executorch/__tests__/README.md b/packages/react-native-executorch/__tests__/README.md index 3fcc6e9f9c..7eb83eeccd 100644 --- a/packages/react-native-executorch/__tests__/README.md +++ b/packages/react-native-executorch/__tests__/README.md @@ -72,7 +72,7 @@ misleading leak error. A test that means to leak calls `allowNativeLeaks()`. | Path | Contents | | --- | --- | | `core/` | `tensor`, `model`, `runtime`, the coded `error` type, and the `schema` spec matcher | -| `fetcher/` | `download` (caching, resume, cancellation, shared requests), telemetry, the Android backend | +| `fetcher/` | `download` (caching, resume, cancellation, shared requests), telemetry, the Android backend, the optional background downloader | | `tasks/` | One suite per task pipeline, plus the shared construction-failure behavior. `remainingTasks.ts` holds the pipelines that only get schema acceptance and disposal | | `hooks/` | `useModel`, `useResourceDownload`, and the task hooks end to end | | `extensions/` | The pure-TypeScript helpers: box/point scaling, seeded generators | diff --git a/packages/react-native-executorch/__tests__/fetcher/backgroundDownloader.test.ts b/packages/react-native-executorch/__tests__/fetcher/backgroundDownloader.test.ts new file mode 100644 index 0000000000..5867e5e824 --- /dev/null +++ b/packages/react-native-executorch/__tests__/fetcher/backgroundDownloader.test.ts @@ -0,0 +1,206 @@ +/** + * The optional background downloader, on both platforms. + * + * `src/fetcher/fetcher.ts` prefers `@kesha-antonov/react-native-background-downloader` + * over the per-platform fallbacks whenever the app has it, so the behavior worth + * pinning here is that the SAME backend is chosen on iOS and Android — the point + * of preferring it is that an app installing it stops getting a different + * download mechanism per platform. + * + * The package is an optional peer dependency and is not installed for these + * suites, so it is mocked virtually. Both the platform and the presence of the + * native module are read at import time (`Platform.OS`, `TurboModuleRegistry`), + * and `loadBackgroundDownloader` memoizes its answer, so each case re-imports + * the module graph — hence the `load()` helper rather than file-level imports. + */ +import type { Route } from '../support/blobUtilMock'; +import type { + BackgroundDownloader, + BackgroundDownloadTask, +} from '../../src/fetcher/backgroundDownloader'; + +const PACKAGE = '@kesha-antonov/react-native-background-downloader'; +const NATIVE_MODULE = 'RNBackgroundDownloader'; + +const URL_A = 'https://huggingface.co/software-mansion/model/resolve/v1/model.pte'; +const HF_COUNTER = 'https://huggingface.co/software-mansion/model/resolve/main/config.json'; + +const BODY = 'model-bytes'; + +type FakeDownloader = BackgroundDownloader & { + /** Every task the fetcher actually started, in order. */ + started: { id: string; url: string; destination: string }[]; +}; + +/** + * A stand-in for the library that completes every transfer immediately. + * + * It writes the body straight to the task's `destination`, which is what the + * real one does on Android; on iOS the file is staged privately and moved there + * at the end. Either way the fetcher only ever sees it at `destination`, so one + * fake covers both. + * @param write Writes a file into the blob-util mock's filesystem. + * @returns The fake downloader, with the tasks it started recorded on it. + */ +function createFakeDownloader(write: (path: string, contents: string) => void): FakeDownloader { + const started: FakeDownloader['started'] = []; + + return { + started, + + createDownloadTask({ id, url, destination }) { + let onBegin: ((params: { expectedBytes: number }) => void) | undefined; + let onProgress: + | ((params: { bytesDownloaded: number; bytesTotal: number }) => void) + | undefined; + let onDone: ((params: { bytesDownloaded: number; bytesTotal: number }) => void) | undefined; + + const task: BackgroundDownloadTask = { + id, + state: 'PENDING', + begin: (handler) => { + onBegin = handler; + return task; + }, + progress: (handler) => { + onProgress = handler; + return task; + }, + done: (handler) => { + onDone = handler; + return task; + }, + error: () => task, + start: () => { + started.push({ id, url, destination }); + const total = BODY.length; + onBegin?.({ expectedBytes: total }); + onProgress?.({ bytesDownloaded: total, bytesTotal: total }); + write(destination, BODY); + onDone?.({ bytesDownloaded: total, bytesTotal: total }); + }, + pause: async () => {}, + resume: async () => {}, + stop: async () => {}, + }; + + return task; + }, + + getExistingDownloadTasks: async () => [], + completeHandler: () => {}, + }; +} + +type Harness = { + download: typeof import('../../src/fetcher/fetcher').download; + downloader: FakeDownloader; + serve: (url: string, route?: Route) => void; + paths: () => string[]; + readText: (path: string) => string | undefined; + countRequests: (method: string, url: string) => number; +}; + +const load = async (os: 'ios' | 'android'): Promise => { + jest.resetModules(); + + // A Proxy rather than a spread: the `react-native` entry point defines its + // exports as lazy getters, and spreading it evaluates every one of them — + // including native modules like `DevMenu` that do not exist under Jest. + jest.doMock('react-native', () => { + const actual = jest.requireActual('react-native'); + return new Proxy(actual, { + get: (target, property) => { + if (property === 'Platform') return { ...target.Platform, OS: os }; + // Having the JS is not having the native module, and the fetcher checks + // for it before using the library — so the fake has to be visible here + // too, under the name both platforms register it as. + if (property === 'TurboModuleRegistry') { + return { + ...target.TurboModuleRegistry, + get: (name: string) => (name === NATIVE_MODULE ? {} : null), + }; + } + return target[property as keyof typeof target]; + }, + }); + }); + + const blobUtil = await import('../support/blobUtilMock'); + const downloader = createFakeDownloader(blobUtil.fakeFs.write); + jest.doMock(PACKAGE, () => downloader, { virtual: true }); + + const { download } = await import('../../src/fetcher/fetcher'); + const { setTelemetryEnabled } = await import('../../src/fetcher/telemetry'); + setTelemetryEnabled(false); + + // The freshly loaded fetcher talks to the freshly loaded mock, so the global + // `fetch` installed by the shared setup file has to be pointed at it. + globalThis.fetch = blobUtil.fakeFetch as unknown as typeof globalThis.fetch; + blobUtil.fakeNet.serve(HF_COUNTER); + + return { + download, + downloader, + serve: blobUtil.fakeNet.serve, + paths: blobUtil.fakeFs.paths, + readText: blobUtil.fakeFs.readText, + countRequests: blobUtil.fakeNet.countRequests, + }; +}; + +afterEach(() => { + jest.dontMock('react-native'); + jest.dontMock(PACKAGE); + jest.resetModules(); +}); + +describe.each(['ios', 'android'] as const)( + 'download on %s with the background downloader', + (os) => { + it('routes the transfer through it instead of the platform fallback', async () => { + const harness = await load(os); + harness.serve(URL_A, { body: BODY }); + + const path = await harness.download(URL_A); + + expect(harness.downloader.started).toHaveLength(1); + expect(harness.downloader.started[0]!.url).toBe(URL_A); + // Neither fallback ran: both of them fetch through blob-util. + expect(harness.countRequests('GET', URL_A)).toBe(0); + expect(harness.readText(path)).toBe(BODY); + }); + + it('stages through a partial file and leaves none behind', async () => { + const harness = await load(os); + harness.serve(URL_A, { body: BODY }); + + const path = await harness.download(URL_A); + + expect(harness.downloader.started[0]!.destination).toBe(`${path}.partial`); + expect(harness.paths().filter((p) => p.endsWith('.partial'))).toEqual([]); + }); + + it('serves a second call from the cache', async () => { + const harness = await load(os); + harness.serve(URL_A, { body: BODY }); + + const first = await harness.download(URL_A); + const second = await harness.download(URL_A); + + expect(second).toBe(first); + expect(harness.downloader.started).toHaveLength(1); + }); + } +); + +// Installing the package must not move the cache: the directory is chosen per +// platform and a model already downloaded under the fallback has to stay a hit. +it('keeps caching under the app-private external directory on Android', async () => { + const harness = await load('android'); + harness.serve(URL_A, { body: BODY }); + + const path = await harness.download(URL_A); + + expect(path.startsWith('/fake/sdcard/react-native-executorch/')).toBe(true); +}); diff --git a/packages/react-native-executorch/package.json b/packages/react-native-executorch/package.json index 9f42f1b735..1f7af98e13 100644 --- a/packages/react-native-executorch/package.json +++ b/packages/react-native-executorch/package.json @@ -118,11 +118,17 @@ "@huggingface/jinja": "^0.5.9" }, "peerDependencies": { + "@kesha-antonov/react-native-background-downloader": ">=4.4.0", "react": "*", "react-native": "*", "react-native-blob-util": "^0.24.0", "react-native-worklets": "^0.10.0" }, + "peerDependenciesMeta": { + "@kesha-antonov/react-native-background-downloader": { + "optional": true + } + }, "devDependencies": { "@babel/core": "^7.25.1", "@react-native/babel-preset": "0.83.6", diff --git a/packages/react-native-executorch/src/fetcher/backgroundDownloader.ts b/packages/react-native-executorch/src/fetcher/backgroundDownloader.ts new file mode 100644 index 0000000000..6b0dd127a3 --- /dev/null +++ b/packages/react-native-executorch/src/fetcher/backgroundDownloader.ts @@ -0,0 +1,100 @@ +import { NativeModules, TurboModuleRegistry } from 'react-native'; + +// Optional integration with `@kesha-antonov/react-native-background-downloader`. +// +// An in-process transfer on iOS dies with the app: measured on device, suspending +// it tore the connection down ONE SECOND later, 43 MB into a 314 MB file, and only +// a background `NSURLSession` keeps going. That session is native work, and a +// download helper is not the part of this library that should be growing native +// code for it — there are React Native packages that do nothing else. +// +// So the fetcher uses one WHEN THE APP HAPPENS TO HAVE IT INSTALLED. It is an +// optional peer dependency: apps that install it get transfers that survive +// backgrounding (and an app kill), apps that don't keep the platform's own +// backend and pull in nothing. +// +// It backs BOTH platforms, so an app that installs it gets one download +// mechanism behaving the same way everywhere rather than a per-platform one the +// caller cannot see. The library covers both natively — a background +// `NSURLSession` on iOS, a foreground service on Android — and exposes the same +// task API for each, which is why the backend below needs no platform split. + +// The slice of the library's API the fetcher uses, declared structurally so this +// file typechecks with the dependency absent. +export interface BackgroundDownloadTask { + id: string; + state: 'PENDING' | 'DOWNLOADING' | 'PAUSED' | 'DONE' | 'FAILED' | 'STOPPED'; + begin(handler: (params: { expectedBytes: number }) => void): BackgroundDownloadTask; + progress( + handler: (params: { bytesDownloaded: number; bytesTotal: number }) => void + ): BackgroundDownloadTask; + done( + handler: (params: { bytesDownloaded: number; bytesTotal: number }) => void + ): BackgroundDownloadTask; + error(handler: (params: { error: string; errorCode: number }) => void): BackgroundDownloadTask; + start(): void; + // Keeps the bytes fetched so far as resume data, and resolves once that data + // has been written — not merely once the transfer has been asked to stop. + pause(): Promise; + resume(): Promise; + // Cancels and discards, unlike `pause`. + stop(): Promise; +} + +export interface BackgroundDownloader { + createDownloadTask(options: { + id: string; + url: string; + destination: string; + }): BackgroundDownloadTask; + // Tasks the session is still holding, including ones started by a previous + // launch of the app and ones paused with resume data. + getExistingDownloadTasks(): Promise; + // Releases the OS's background-session completion handler for a finished task. + // Resolves through the native module, so it can also reject. + completeHandler(id: string): void | Promise; +} + +// `null` once resolution has been attempted and come up empty; `undefined` while +// it has not been attempted at all. +let cached: BackgroundDownloader | null | undefined; + +// The library, or null when the app has not installed it (or installed the JS +// without linking the native side, as in Expo Go). Resolved once and remembered. +export function loadBackgroundDownloader(): BackgroundDownloader | null { + if (cached !== undefined) return cached; + cached = null; + + try { + // A `require` inside a try/catch is Metro's own escape hatch for optional + // dependencies (`resolver.allowOptionalDependencies`, which React Native's + // Metro config turns on): a module that isn't installed is left unresolved + // instead of failing the bundle, and the throw lands right here. The name + // has to stay a literal — Metro collects dependencies statically and rejects + // a `require` of anything it cannot read off the call itself. + const required = require('@kesha-antonov/react-native-background-downloader'); + const candidate = (required?.default ?? required) as Partial; + + // Having the JS is not the same as having the native module: without the + // native build step (or in Expo Go) every call would throw, so fall back to + // the platform's own backend instead. The module name is the same on both + // platforms. + const isLinked = + TurboModuleRegistry.get('RNBackgroundDownloader') != null || + NativeModules.RNBackgroundDownloader != null; + + // Older majors expose a different surface (`download`, and a `pause` that + // drops the fetched bytes). Treat anything but the shape used below as + // absent rather than half-supporting it. + const hasApi = + typeof candidate?.createDownloadTask === 'function' && + typeof candidate?.getExistingDownloadTasks === 'function' && + typeof candidate?.completeHandler === 'function'; + + if (isLinked && hasApi) cached = candidate as BackgroundDownloader; + } catch { + // Not installed — the fetcher stays on its per-platform fallback. + } + + return cached; +} diff --git a/packages/react-native-executorch/src/fetcher/fetcher.ts b/packages/react-native-executorch/src/fetcher/fetcher.ts index 98da7fdb09..8219e88095 100644 --- a/packages/react-native-executorch/src/fetcher/fetcher.ts +++ b/packages/react-native-executorch/src/fetcher/fetcher.ts @@ -2,6 +2,11 @@ import { Platform } from 'react-native'; import RNBlobUtil from 'react-native-blob-util'; import * as telemetry from './telemetry'; +import { + loadBackgroundDownloader, + type BackgroundDownloader, + type BackgroundDownloadTask, +} from './backgroundDownloader'; import { RnExecuTorchError } from '../core/error'; const IS_ANDROID = Platform.OS === 'android'; @@ -25,8 +30,10 @@ export interface DownloadOptions { /** Called with overall progress in `[0, 1]` as bytes arrive. */ onProgress?: (progress: number) => void; /** - * Aborts the download. On iOS the bytes fetched so far are kept on disk so a - * later {@link download} of the same source resumes instead of restarting. + * Aborts the download. The bytes fetched so far are kept so a later + * {@link download} of the same source resumes instead of restarting, except on + * Android without the optional background downloader, where the system + * DownloadManager discards a cancelled transfer. */ signal?: AbortSignal; /** @@ -185,6 +192,13 @@ async function downloadUrl(url: string, cb: DownloadUrlCallbacks): Promise {}); + // An interrupted attempt also leaves state behind, and every bit of it is + // something a later download would CONTINUE from: the staged `.partial` and, + // with the background downloader in play, a paused task holding resume data. + // Clear it, or "download it again" quietly resumes the very attempt the + // caller is trying to replace. The DownloadManager backend needs nothing + // here — it unlinks its own staging file before every transfer. + await discardPartialDownload(dest); } else if (await RNBlobUtil.fs.exists(dest)) { // Cache hit — nothing to download. const size = await fileSize(dest); @@ -228,9 +242,25 @@ async function startDownload(url: string, dest: string, entry: InFlightDownload) }, }; - const path = IS_ANDROID - ? await downloadUrlViaAndroidDownloadManager(url, dest, cb) - : await downloadUrlViaIosStream(url, dest, cb); + // The optional background downloader wins on BOTH platforms when the app has + // it, so a caller that installs it gets one transfer mechanism that behaves + // the same everywhere instead of a per-platform one it cannot see. + // + // Without it each platform falls back to the best it can do on its own, and + // only iOS loses background transfers by doing so — blob-util's in-process + // reader is broken on Android (RonRadtke/react-native-blob-util#475: it stops + // after 8 KB), so the system DownloadManager is not a preference there but the + // only backend that works at all. + const backgroundDownloader = loadBackgroundDownloader(); + + let path: string; + if (backgroundDownloader) { + path = await downloadUrlViaBackgroundSession(backgroundDownloader, url, dest, cb); + } else if (IS_ANDROID) { + path = await downloadUrlViaAndroidDownloadManager(url, dest, cb); + } else { + path = await downloadUrlViaIosStream(url, dest, cb); + } // Neither backend is guaranteed to emit a last sample at 100%: blob-util // throttles progress events, and DownloadManager is polled, so the final @@ -264,10 +294,12 @@ function joinDownload(entry: InFlightDownload, cb: DownloadUrlCallbacks): Promis }); } -// Android backend: the system DownloadManager streams to app-private external -// storage. Unlike blob-util's in-process reader it handles files larger than -// 2 GB, keeps downloading while the app is in the background or killed, and -// resumes across transient network drops on its own — so no manual Range logic. +// Android fallback used when that optional dependency is absent: the system +// DownloadManager streams to app-private external storage. blob-util's +// in-process reader cannot stand in for it — upstream #475 makes that path stop +// after 8 KB — and DownloadManager also handles files larger than 2 GB, keeps +// downloading while the app is in the background or killed, and resumes across +// transient network drops on its own, so no manual Range logic. async function downloadUrlViaAndroidDownloadManager( url: string, dest: string, @@ -328,7 +360,170 @@ async function downloadUrlViaAndroidDownloadManager( return dest; } -// iOS backend: blob-util streams via the iOS URL session straight to disk. +// One background task per destination file, under an id that stays the same +// across app launches: that is what lets a later call adopt a transfer this +// process never started. +function backgroundTaskIdFor(dest: string): string { + return dest.split('/').pop()!; +} + +// The task the session is still holding for `id`, when it is one worth +// continuing — it may be running, paused with resume data, or already finished. +// A failed or stopped leftover is cleared instead, so a fresh task can take the +// id rather than colliding with a corpse. +async function adoptableBackgroundTask( + downloader: BackgroundDownloader, + id: string +): Promise { + const tasks = await downloader.getExistingDownloadTasks().catch(() => []); + const task = tasks.find((candidate) => candidate.id === id); + if (!task) return undefined; + if (task.state === 'DOWNLOADING' || task.state === 'PAUSED' || task.state === 'DONE') { + return task; + } + await task.stop().catch(() => {}); + return undefined; +} + +// Clears what an interrupted attempt leaves behind, so the next download of this +// file starts from zero instead of continuing it. Backs `forceDownload`. +async function discardPartialDownload(dest: string): Promise { + const downloader = loadBackgroundDownloader(); + if (downloader) { + const id = backgroundTaskIdFor(dest); + const tasks = await downloader.getExistingDownloadTasks().catch(() => []); + // `stop`, not `pause`: the point is to throw the fetched bytes away. + await Promise.all( + tasks.filter((task) => task.id === id).map((task) => task.stop().catch(() => {})) + ); + } + await RNBlobUtil.fs.unlink(`${dest}.partial`).catch(() => {}); + await RNBlobUtil.fs.unlink(`${dest}.chunk`).catch(() => {}); +} + +// The backend used on either platform when the app installs the optional +// background downloader (see ./backgroundDownloader). The transfer keeps going +// while the app is in the background, and the library persists its task state, +// so it survives the app being killed too. +// +// Resume does NOT go through the HTTP Range request the iOS in-process backend +// uses. iOS only offers background transfers as DOWNLOAD tasks, which stage into +// their own private file and hand it over whole at the end, so there is no +// partially written file to append to: an interrupted transfer continues from +// the resume data a PAUSED task holds. The Android side writes to `part` as the +// body arrives and resumes from its own byte offset, which this code never has +// to know about — either way, adopting the task is what continues the transfer. +async function downloadUrlViaBackgroundSession( + downloader: BackgroundDownloader, + url: string, + dest: string, + cb: DownloadUrlCallbacks +): Promise { + const part = `${dest}.partial`; + const id = backgroundTaskIdFor(dest); + const expected = await expectedBytesFor(url, cb.expectedBytes); + + // A transfer that finished while the app was not running was moved here by the + // session, with no caller left to promote it. Finish that job rather than + // fetching the whole file again. + if (expected > 0 && (await fileSize(part)) === expected) { + await RNBlobUtil.fs.mv(part, dest); + cb.onBytes?.(expected, expected); + return dest; + } + + if (cb.signal?.aborted) throw abortError(); + + const adopted = await adoptableBackgroundTask(downloader, id); + const task = adopted ?? downloader.createDownloadTask({ id, url, destination: part }); + + await new Promise((resolve, reject) => { + let settled = false; + const settle = (finish: () => void) => { + if (settled) return; + settled = true; + cb.signal?.removeEventListener('abort', onAbort); + finish(); + }; + + // Hands the OS's background-session completion handler back. iOS asks for it + // once per finished transfer and keeps waiting until it gets it. + const release = () => { + try { + // Nothing to release when the handler was never armed for this launch, + // and that is reported either way round, so ignore both. + Promise.resolve(downloader.completeHandler(id)).catch(() => {}); + } catch { + // Ignored, as above. + } + }; + + const onAbort = () => { + // A pause keeps the fetched bytes as resume data, where `stop` would throw + // them away, and it settles only once that data has been written: a + // download started right after an abort would otherwise look for resume + // data that isn't there yet and start over from zero. + const rejectAborted = () => settle(() => reject(abortError())); + task.pause().then(rejectAborted, rejectAborted); + }; + cb.signal?.addEventListener('abort', onAbort); + + task + .begin(({ expectedBytes }) => { + // The length the transfer itself reports, before any of the body has + // landed — hence 0 received. + if (expectedBytes > 0) cb.onBytes?.(0, expectedBytes); + }) + .progress(({ bytesDownloaded, bytesTotal }) => { + // A resumed task counts from the resume point up, so these are already + // absolute. A total of 0 means the length isn't known yet. + cb.onBytes?.(bytesDownloaded, bytesTotal > 0 ? bytesTotal : 0); + }) + .done(() => { + release(); + settle(resolve); + }) + .error(({ error }) => { + release(); + settle(() => + reject( + cb.signal?.aborted + ? abortError() + : RnExecuTorchError('DOWNLOAD_FAILED', `Download of ${url} failed: ${error}`) + ) + ); + }); + + if (!adopted) { + task.start(); + } else if (adopted.state === 'PAUSED') { + task.resume().catch((e) => settle(() => reject(e))); + } else if (adopted.state === 'DONE') { + // It finished with nobody listening, so no `done` event is coming: the + // file is already staged at `part`. + release(); + settle(resolve); + } + }); + + // The session reports success once it has written A file, not once it has + // written the RIGHT one: a truncated body still completes. Checking here is + // what keeps a short file from being renamed into the cache, where the + // existence-only hit check would serve it forever and the truncated .pte would + // only fail much later, at load. + const assembled = await fileSize(part); + if (expected > 0 && assembled !== expected) { + throw incompleteError(url, assembled, expected); + } + + await RNBlobUtil.fs.mv(part, dest); + return dest; +} + +// iOS fallback used when that optional dependency is absent: blob-util streams +// via the iOS URL session straight to disk. It does NOT survive the app being +// suspended — iOS tears the connection down about a second later — so an +// interrupted transfer is picked up by the next `download` call instead. // Interrupted downloads resume from a `.partial` file via an HTTP Range request. // `canResume` is set to `false` on an internal retry to avoid recursing forever // if partial-file assembly ever fails. @@ -583,11 +778,20 @@ function substituteRemoteSources(node: T, resolved: ReadonlyMap=4.4.0`) to have transfers keep running while the app is in the background, + * and survive it being killed. The fetcher uses it automatically on both platforms + * when it is present, so the behavior is the same on each; nothing else changes. + * + * Without it the fetcher falls back to what each platform can do on its own: the + * system DownloadManager on Android, which still continues in the background, + * and on iOS a streaming request that stops when the app is suspended and is + * resumed by the next `download` call. * @category Utils / Functions * @typeParam T The shape of the value being resolved. * @param source A URL, a local path, or any nested object/array holding them. diff --git a/yarn.lock b/yarn.lock index 1b707d43ac..62676160b8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13287,10 +13287,14 @@ __metadata: test-renderer: "npm:^1.2.0" typescript: "npm:~5.9.2" peerDependencies: + "@kesha-antonov/react-native-background-downloader": ">=4.4.0" react: "*" react-native: "*" react-native-blob-util: ^0.24.0 react-native-worklets: ^0.10.0 + peerDependenciesMeta: + "@kesha-antonov/react-native-background-downloader": + optional: true languageName: unknown linkType: soft