fix: split plugin source cache from metadata (NUVIO-MOBILE-1)

This commit is contained in:
tapframe 2026-08-02 17:01:53 +05:30
parent 4f2c2b90d0
commit 3b7d6f4b9e
9 changed files with 526 additions and 121 deletions

View file

@ -6,11 +6,14 @@ import android.content.SharedPreferences
internal object PluginStorage {
private const val preferencesName = "nuvio_plugins"
private const val pluginsStateKey = "plugins_state"
private const val scraperCodeDirectoryName = "nuvio_plugin_scrapers"
private var preferences: SharedPreferences? = null
private var scraperCodeStore: PluginScraperCodeFileStore? = null
fun initialize(context: Context) {
preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
scraperCodeStore = PluginScraperCodeFileStore(context.filesDir.resolve(scraperCodeDirectoryName))
}
fun loadState(profileId: Int): String? =
@ -23,6 +26,19 @@ internal object PluginStorage {
?.apply()
}
fun hasScraperCode(profileId: Int, scraperId: String): Boolean =
scraperCodeStore?.contains(profileId, scraperId) == true
fun loadScraperCode(profileId: Int, scraperId: String): String? =
scraperCodeStore?.load(profileId, scraperId)
fun saveScraperCode(
profileId: Int,
scraperId: String,
code: String,
overwrite: Boolean,
): Boolean = scraperCodeStore?.save(profileId, scraperId, code, overwrite) == true
fun loadScraperSettings(scraperId: String): String? =
preferences?.getString("settings_${scraperId}", null)

View file

@ -0,0 +1,64 @@
package com.nuvio.app.features.plugins
import kotlinx.atomicfu.locks.SynchronizedObject
import kotlinx.atomicfu.locks.synchronized
import java.io.File
internal class PluginScraperCodeFileStore(
private val root: File,
) {
private val lock = SynchronizedObject()
fun contains(profileId: Int, scraperId: String): Boolean =
scraperCodeFile(profileId, scraperId).isFile
fun load(profileId: Int, scraperId: String): String? = synchronized(lock) {
val file = scraperCodeFile(profileId, scraperId).takeIf(File::isFile)
?: return@synchronized null
runCatching { file.readText() }.getOrNull()
}
fun save(
profileId: Int,
scraperId: String,
code: String,
overwrite: Boolean,
): Boolean {
val target = scraperCodeFile(profileId, scraperId)
if (!overwrite && target.isFile) return true
return synchronized(lock) {
if (!overwrite && target.isFile) return@synchronized true
val directory = target.parentFile ?: return@synchronized false
if (!directory.exists() && !directory.mkdirs()) return@synchronized false
val temporary = runCatching {
File.createTempFile("scraper-", ".tmp", directory)
}.getOrNull() ?: return@synchronized false
val backup = directory.resolve("${target.name}.backup")
try {
temporary.bufferedWriter().use { writer -> writer.write(code) }
if (!overwrite && target.isFile) return@synchronized true
if (backup.exists() && !backup.delete()) return@synchronized false
val hadTarget = target.exists()
if (hadTarget && !target.renameTo(backup)) return@synchronized false
if (!temporary.renameTo(target)) {
if (hadTarget) backup.renameTo(target)
return@synchronized false
}
if (backup.exists()) backup.delete()
true
} catch (_: Throwable) {
false
} finally {
if (temporary.exists()) temporary.delete()
if (backup.exists() && !target.exists()) backup.renameTo(target)
}
}
}
private fun scraperCodeFile(profileId: Int, scraperId: String): File {
val fileName = "${pluginDigestHex("SHA256", scraperId)}.js"
return root.resolve(profileId.toString()).resolve(fileName)
}
}

View file

@ -0,0 +1,33 @@
package com.nuvio.app.features.plugins
import java.nio.file.Files
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class PluginScraperCodeFileStoreTest {
@Test
fun scraperCodeRoundTripsWithoutRewritingUnlessRequested() {
val root = Files.createTempDirectory("nuvio-plugin-code").toFile()
try {
val store = PluginScraperCodeFileStore(root)
val scraperId = "https://plugins.example/manifest.json:scraper"
val original = "x".repeat(4 * 1024 * 1024)
val refreshed = "updated scraper"
assertFalse(store.contains(1, scraperId))
assertTrue(store.save(1, scraperId, original, overwrite = false))
assertEquals(original, store.load(1, scraperId))
assertTrue(store.save(1, scraperId, refreshed, overwrite = false))
assertEquals(original, store.load(1, scraperId))
assertTrue(store.save(1, scraperId, refreshed, overwrite = true))
assertEquals(refreshed, store.load(1, scraperId))
assertFalse(store.contains(2, scraperId))
} finally {
root.deleteRecursively()
}
}
}

View file

@ -50,5 +50,6 @@ internal actual object PlatformLocalAccountDataCleaner {
.clear()
.apply()
}
context.filesDir.resolve("nuvio_plugin_scrapers").deleteRecursively()
}
}

View file

@ -134,9 +134,75 @@ internal data class StoredPluginScraper(
val logo: String? = null,
val contentLanguage: List<String> = emptyList(),
val formats: List<String>? = null,
val code: String,
val code: String? = null,
)
internal data class RestoredPluginScraper(
val scraper: PluginScraper,
val requiresMigration: Boolean,
)
internal fun PluginsUiState.toStoredPluginsState(): StoredPluginsState =
StoredPluginsState(
pluginsEnabled = pluginsEnabled,
groupStreamsByRepository = groupStreamsByRepository,
repositories = repositories.map { repository ->
StoredPluginRepository(
manifestUrl = repository.manifestUrl,
name = repository.name,
description = repository.description,
version = repository.version,
scraperCount = repository.scraperCount,
lastUpdated = repository.lastUpdated,
)
},
scrapers = scrapers.map(PluginScraper::toStoredPluginScraper),
)
internal fun PluginScraper.toStoredPluginScraper(): StoredPluginScraper =
StoredPluginScraper(
id = id,
repositoryUrl = repositoryUrl,
name = name,
description = description,
version = version,
filename = filename,
supportedTypes = supportedTypes,
enabled = enabled,
manifestEnabled = manifestEnabled,
hasSettings = hasSettings,
logo = logo,
contentLanguage = contentLanguage,
formats = formats,
code = null,
)
internal fun StoredPluginScraper.restorePluginScraper(
loadCachedCode: (String) -> String?,
): RestoredPluginScraper? {
val cachedCode = loadCachedCode(id)
val resolvedCode = cachedCode ?: code ?: return null
return RestoredPluginScraper(
scraper = PluginScraper(
id = id,
repositoryUrl = repositoryUrl,
name = name,
description = description,
version = version,
filename = filename,
supportedTypes = supportedTypes,
enabled = enabled,
manifestEnabled = manifestEnabled,
hasSettings = hasSettings,
logo = logo,
contentLanguage = contentLanguage,
formats = formats,
code = resolvedCode,
),
requiresMigration = code != null,
)
}
internal fun normalizePluginType(value: String): String =
when (value.lowercase()) {
"series", "show", "other" -> "tv"

View file

@ -0,0 +1,66 @@
package com.nuvio.app.features.plugins
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
class PluginPersistenceTest {
private val json = Json { encodeDefaults = true }
@Test
fun metadataStateDoesNotSerializeScraperSourceCode() {
val sourceCode = "module.exports = " + "x".repeat(4 * 1024 * 1024)
val stored = PluginsUiState(
scrapers = listOf(pluginScraper(sourceCode)),
).toStoredPluginsState()
val encoded = json.encodeToString(stored)
assertNull(stored.scrapers.single().code)
assertTrue(encoded.length < 2_048)
assertFalse(sourceCode in encoded)
}
@Test
fun cachedScraperCodeRestoresOfflineWithoutMigration() {
val sourceCode = "offline scraper source"
val stored = pluginScraper(sourceCode).toStoredPluginScraper()
val restored = stored.restorePluginScraper { scraperId ->
if (scraperId == stored.id) sourceCode else null
}
assertEquals(sourceCode, restored?.scraper?.code)
assertFalse(restored?.requiresMigration ?: true)
}
@Test
fun legacyEmbeddedScraperCodeIsPreservedForMigration() {
val sourceCode = "legacy scraper source"
val stored = pluginScraper(sourceCode)
.toStoredPluginScraper()
.copy(code = sourceCode)
val restored = stored.restorePluginScraper { null }
assertEquals(sourceCode, restored?.scraper?.code)
assertTrue(restored?.requiresMigration == true)
}
private fun pluginScraper(code: String): PluginScraper = PluginScraper(
id = "https://plugins.example/manifest.json:scraper",
repositoryUrl = "https://plugins.example/manifest.json",
name = "Scraper",
description = "",
version = "1.0.0",
filename = "scraper.js",
supportedTypes = listOf("movie", "tv"),
enabled = true,
manifestEnabled = true,
code = code,
)
}

View file

@ -9,6 +9,9 @@ import com.nuvio.app.features.plugins.runtime.PluginRuntime
import io.github.jan.supabase.postgrest.postgrest
import io.github.jan.supabase.postgrest.query.Order
import io.github.jan.supabase.postgrest.rpc
import kotlinx.atomicfu.atomic
import kotlinx.atomicfu.locks.SynchronizedObject
import kotlinx.atomicfu.locks.synchronized
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@ -52,6 +55,18 @@ private data class PluginPushItem(
@SerialName("sort_order") val sortOrder: Int = 0,
)
private data class PluginPersistenceSnapshot(
val profileId: Int,
val generation: Long,
val revision: Long,
val state: PluginsUiState,
)
private data class LoadedPluginState(
val state: PluginsUiState,
val requiresMigration: Boolean,
)
actual object PluginRepository {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val log = Logger.withTag("PluginRepository")
@ -64,6 +79,10 @@ actual object PluginRepository {
private var pulledFromServer = false
private var currentProfileId = 1
private val activeRefreshJobs = mutableMapOf<String, Job>()
private val persistenceGeneration = atomic(0L)
private val persistenceRevision = atomic(0L)
private val persistenceLock = SynchronizedObject()
private val persistedRevisionByProfile = mutableMapOf<Int, Long>()
actual fun initialize() {
val effectiveProfileId = resolveEffectiveProfileId(ProfileRepository.activeProfileId)
@ -89,6 +108,7 @@ actual object PluginRepository {
actual fun clearLocalState() {
cancelActiveRefreshes()
persistenceGeneration.incrementAndGet()
currentProfileId = 1
initialized = false
pulledFromServer = false
@ -358,59 +378,71 @@ actual object PluginRepository {
private suspend fun fetchRepositoryData(
manifestUrl: String,
previousScrapers: Map<String, PluginScraper>,
): Pair<PluginRepositoryItem, List<PluginScraper>> = withContext(Dispatchers.Default) {
val payload = httpGetText(manifestUrl)
val manifest = PluginManifestParser.parse(payload)
val baseUrl = manifestUrl.substringBefore("?").removeSuffix("/manifest.json")
): Pair<PluginRepositoryItem, List<PluginScraper>> {
val storageProfileId = currentProfileId
return withContext(Dispatchers.Default) {
val payload = httpGetText(manifestUrl)
val manifest = PluginManifestParser.parse(payload)
val baseUrl = manifestUrl.substringBefore("?").removeSuffix("/manifest.json")
val scrapers = manifest.scrapers
.filter { scraper -> scraper.isSupportedOnCurrentPlatform() }
.mapNotNull { info ->
val codeUrl = if (info.filename.startsWith("http://") || info.filename.startsWith("https://")) {
info.filename
} else {
"$baseUrl/${info.filename.trimStart('/')}"
}
runCatching {
val code = httpGetText(codeUrl)
val scraperId = "${manifestUrl.lowercase()}:${info.id}"
val previous = previousScrapers[scraperId]
val enabled = when {
!info.enabled -> false
previous != null -> previous.enabled
else -> info.enabled
val scrapers = manifest.scrapers
.filter { scraper -> scraper.isSupportedOnCurrentPlatform() }
.mapNotNull { info ->
val codeUrl = if (info.filename.startsWith("http://") || info.filename.startsWith("https://")) {
info.filename
} else {
"$baseUrl/${info.filename.trimStart('/')}"
}
runCatching {
val code = httpGetText(codeUrl)
val scraperId = "${manifestUrl.lowercase()}:${info.id}"
val cached = PluginStorage.saveScraperCode(
profileId = storageProfileId,
scraperId = scraperId,
code = code,
overwrite = true,
)
if (!cached) {
log.w { "Failed to cache plugin scraper $scraperId" }
}
val previous = previousScrapers[scraperId]
val enabled = when {
!info.enabled -> false
previous != null -> previous.enabled
else -> info.enabled
}
PluginScraper(
id = scraperId,
repositoryUrl = manifestUrl,
name = info.name,
description = info.description.orEmpty(),
version = info.version,
filename = info.filename,
supportedTypes = info.supportedTypes,
enabled = enabled,
manifestEnabled = info.enabled,
hasSettings = info.hasSettings,
logo = info.logo,
contentLanguage = info.contentLanguage ?: emptyList(),
formats = info.formats ?: info.supportedFormats,
code = code,
)
}.getOrNull()
}
PluginScraper(
id = scraperId,
repositoryUrl = manifestUrl,
name = info.name,
description = info.description.orEmpty(),
version = info.version,
filename = info.filename,
supportedTypes = info.supportedTypes,
enabled = enabled,
manifestEnabled = info.enabled,
hasSettings = info.hasSettings,
logo = info.logo,
contentLanguage = info.contentLanguage ?: emptyList(),
formats = info.formats ?: info.supportedFormats,
code = code,
)
}.getOrNull()
}
val repo = PluginRepositoryItem(
manifestUrl = manifestUrl,
name = manifest.name,
description = manifest.description,
version = manifest.version,
scraperCount = scrapers.size,
lastUpdated = currentEpochMillis(),
isRefreshing = false,
errorMessage = null,
)
repo to scrapers
val repo = PluginRepositoryItem(
manifestUrl = manifestUrl,
name = manifest.name,
description = manifest.description,
version = manifest.version,
scraperCount = scrapers.size,
lastUpdated = currentEpochMillis(),
isRefreshing = false,
errorMessage = null,
)
repo to scrapers
}
}
private fun PluginManifestScraper.isSupportedOnCurrentPlatform(): Boolean {
@ -460,39 +492,49 @@ actual object PluginRepository {
}
private fun persist() {
val state = _uiState.value
val payload = StoredPluginsState(
pluginsEnabled = state.pluginsEnabled,
groupStreamsByRepository = state.groupStreamsByRepository,
repositories = state.repositories.map { repo ->
StoredPluginRepository(
manifestUrl = repo.manifestUrl,
name = repo.name,
description = repo.description,
version = repo.version,
scraperCount = repo.scraperCount,
lastUpdated = repo.lastUpdated,
)
},
scrapers = state.scrapers.map { scraper ->
StoredPluginScraper(
id = scraper.id,
repositoryUrl = scraper.repositoryUrl,
name = scraper.name,
description = scraper.description,
version = scraper.version,
filename = scraper.filename,
supportedTypes = scraper.supportedTypes,
enabled = scraper.enabled,
manifestEnabled = scraper.manifestEnabled,
hasSettings = scraper.hasSettings,
logo = scraper.logo,
contentLanguage = scraper.contentLanguage,
formats = scraper.formats,
code = scraper.code,
) },
val snapshot = PluginPersistenceSnapshot(
profileId = currentProfileId,
generation = persistenceGeneration.value,
revision = persistenceRevision.incrementAndGet(),
state = _uiState.value,
)
PluginStorage.saveState(currentProfileId, json.encodeToString(payload))
val requiresCodeWrite = snapshot.state.scrapers.any { scraper ->
!PluginStorage.hasScraperCode(snapshot.profileId, scraper.id)
}
if (requiresCodeWrite) {
scope.launch { persist(snapshot) }
} else {
persist(snapshot)
}
}
private fun persist(snapshot: PluginPersistenceSnapshot) {
if (snapshot.generation != persistenceGeneration.value) return
var cached = true
snapshot.state.scrapers.forEach { scraper ->
val scraperCached = PluginStorage.saveScraperCode(
profileId = snapshot.profileId,
scraperId = scraper.id,
code = scraper.code,
overwrite = false,
)
cached = scraperCached && cached
}
if (!cached || snapshot.generation != persistenceGeneration.value) {
if (!cached) {
log.w { "Failed to persist plugin scraper cache for profile ${snapshot.profileId}" }
}
return
}
synchronized(persistenceLock) {
if (snapshot.generation != persistenceGeneration.value) return@synchronized
val persistedRevision = persistedRevisionByProfile[snapshot.profileId] ?: Long.MIN_VALUE
if (snapshot.revision < persistedRevision) return@synchronized
val payload = snapshot.state.toStoredPluginsState()
PluginStorage.saveState(snapshot.profileId, json.encodeToString(payload))
persistedRevisionByProfile[snapshot.profileId] = snapshot.revision
}
}
private fun loadStoredState(profileId: Int): StoredPluginsState? {
@ -517,49 +559,45 @@ actual object PluginRepository {
}
currentProfileId = profileId
_uiState.value = loadStateAsUiState(profileId)
val loadedState = loadStateAsUiState(profileId)
_uiState.value = loadedState.state
initialized = true
if (loadedState.requiresMigration) persist()
}
private fun loadStateAsUiState(profileId: Int): PluginsUiState {
private fun loadStateAsUiState(profileId: Int): LoadedPluginState {
val stored = loadStoredState(profileId)
return PluginsUiState(
pluginsEnabled = stored?.pluginsEnabled ?: true,
groupStreamsByRepository = stored?.groupStreamsByRepository ?: false,
repositories = stored?.repositories
?.map {
PluginRepositoryItem(
manifestUrl = it.manifestUrl,
name = it.name,
description = it.description,
version = it.version,
scraperCount = it.scraperCount,
lastUpdated = it.lastUpdated,
isRefreshing = false,
errorMessage = null,
)
}
?: emptyList(),
scrapers = stored?.scrapers
?.map {
PluginScraper(
id = it.id,
repositoryUrl = it.repositoryUrl,
name = it.name,
description = it.description,
version = it.version,
filename = it.filename,
supportedTypes = it.supportedTypes,
enabled = it.enabled,
manifestEnabled = it.manifestEnabled,
hasSettings = it.hasSettings,
logo = it.logo,
contentLanguage = it.contentLanguage,
formats = it.formats,
code = it.code,
)
}
?: emptyList(),
var requiresMigration = false
val scrapers = stored?.scrapers
?.mapNotNull { storedScraper ->
storedScraper.restorePluginScraper { scraperId ->
PluginStorage.loadScraperCode(profileId, scraperId)
}?.also { restored ->
requiresMigration = requiresMigration || restored.requiresMigration
}?.scraper
}
?: emptyList()
return LoadedPluginState(
state = PluginsUiState(
pluginsEnabled = stored?.pluginsEnabled ?: true,
groupStreamsByRepository = stored?.groupStreamsByRepository ?: false,
repositories = stored?.repositories
?.map {
PluginRepositoryItem(
manifestUrl = it.manifestUrl,
name = it.name,
description = it.description,
version = it.version,
scraperCount = it.scraperCount,
lastUpdated = it.lastUpdated,
isRefreshing = false,
errorMessage = null,
)
}
?: emptyList(),
scrapers = scrapers,
),
requiresMigration = requiresMigration,
)
}

View file

@ -1,10 +1,30 @@
@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
package com.nuvio.app.features.plugins
import kotlinx.atomicfu.locks.SynchronizedObject
import kotlinx.atomicfu.locks.synchronized
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.convert
import kotlinx.cinterop.usePinned
import platform.Foundation.NSFileManager
import platform.Foundation.NSHomeDirectory
import platform.Foundation.NSUserDefaults
import platform.Foundation.timeIntervalSince1970
import platform.posix.SEEK_END
import platform.posix.fclose
import platform.posix.fopen
import platform.posix.fread
import platform.posix.fseek
import platform.posix.ftell
import platform.posix.fwrite
import platform.posix.rewind
internal object PluginStorage {
private const val pluginsStateKey = "plugins_state"
private const val scraperCodeDirectoryName = "nuvio_plugin_scrapers"
private val scraperCodeLock = SynchronizedObject()
fun loadState(profileId: Int): String? =
NSUserDefaults.standardUserDefaults.stringForKey("${pluginsStateKey}_$profileId")
@ -16,6 +36,53 @@ internal object PluginStorage {
)
}
fun hasScraperCode(profileId: Int, scraperId: String): Boolean =
NSFileManager.defaultManager.fileExistsAtPath(scraperCodePath(profileId, scraperId))
fun loadScraperCode(profileId: Int, scraperId: String): String? = synchronized(scraperCodeLock) {
readUtf8File(scraperCodePath(profileId, scraperId))
}
fun saveScraperCode(
profileId: Int,
scraperId: String,
code: String,
overwrite: Boolean,
): Boolean {
val manager = NSFileManager.defaultManager
val target = scraperCodePath(profileId, scraperId)
if (!overwrite && manager.fileExistsAtPath(target)) return true
return synchronized(scraperCodeLock) {
val directory = scraperCodeDirectory(profileId)
if (!manager.createDirectoryAtPath(directory, true, null, null)) return@synchronized false
if (!overwrite && manager.fileExistsAtPath(target)) return@synchronized true
val temporary = "$target.tmp"
val backup = "$target.backup"
if (!writeUtf8File(temporary, code)) return@synchronized false
try {
if (!overwrite && manager.fileExistsAtPath(target)) return@synchronized true
if (manager.fileExistsAtPath(backup) && !manager.removeItemAtPath(backup, null)) {
return@synchronized false
}
val hadTarget = manager.fileExistsAtPath(target)
if (hadTarget && !manager.moveItemAtPath(target, backup, null)) return@synchronized false
if (!manager.moveItemAtPath(temporary, target, null)) {
if (hadTarget) manager.moveItemAtPath(backup, target, null)
return@synchronized false
}
if (manager.fileExistsAtPath(backup)) manager.removeItemAtPath(backup, null)
true
} finally {
if (manager.fileExistsAtPath(temporary)) manager.removeItemAtPath(temporary, null)
if (manager.fileExistsAtPath(backup) && !manager.fileExistsAtPath(target)) {
manager.moveItemAtPath(backup, target, null)
}
}
}
}
fun loadScraperSettings(scraperId: String): String? =
NSUserDefaults.standardUserDefaults.stringForKey("settings_${scraperId}")
@ -25,6 +92,51 @@ internal object PluginStorage {
forKey = "settings_${scraperId}",
)
}
private fun scraperCodeDirectory(profileId: Int): String =
"${NSHomeDirectory()}/Library/Application Support/$scraperCodeDirectoryName/$profileId"
private fun scraperCodePath(profileId: Int, scraperId: String): String =
"${scraperCodeDirectory(profileId)}/${pluginDigestHex("SHA256", scraperId)}.js"
}
@OptIn(ExperimentalForeignApi::class)
private fun readUtf8File(path: String): String? {
val file = fopen(path, "rb") ?: return null
return try {
if (fseek(file, 0L, SEEK_END) != 0) return null
val size = ftell(file)
if (size < 0L || size > Int.MAX_VALUE.toLong()) return null
rewind(file)
val bytes = ByteArray(size.toInt())
if (bytes.isNotEmpty()) {
val read = bytes.usePinned { pinned ->
fread(pinned.addressOf(0), 1.convert(), bytes.size.convert(), file)
}
if (read.toLong() != bytes.size.toLong()) return null
}
bytes.decodeToString()
} finally {
fclose(file)
}
}
@OptIn(ExperimentalForeignApi::class)
private fun writeUtf8File(path: String, value: String): Boolean {
val bytes = value.encodeToByteArray()
val file = fopen(path, "wb") ?: return false
return try {
if (bytes.isEmpty()) {
true
} else {
val written = bytes.usePinned { pinned ->
fwrite(pinned.addressOf(0), 1.convert(), bytes.size.convert(), file)
}
written.toLong() == bytes.size.toLong()
}
} finally {
fclose(file)
}
}
internal fun currentPluginPlatform(): String = "ios"

View file

@ -1,6 +1,10 @@
@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
package com.nuvio.app.core.storage
import platform.Foundation.NSUserDefaults
import platform.Foundation.NSFileManager
import platform.Foundation.NSHomeDirectory
import com.nuvio.app.features.profiles.MAX_PROFILES
internal actual object PlatformLocalAccountDataCleaner {
@ -90,5 +94,10 @@ internal actual object PlatformLocalAccountDataCleaner {
defaults.removeObjectForKey(keyString)
}
}
val scraperCodePath = "${NSHomeDirectory()}/Library/Application Support/nuvio_plugin_scrapers"
if (NSFileManager.defaultManager.fileExistsAtPath(scraperCodePath)) {
NSFileManager.defaultManager.removeItemAtPath(scraperCodePath, null)
}
}
}