From b91f3f6f5cfa9d42278b47d133d552c11b5d85a2 Mon Sep 17 00:00:00 2001 From: Nicolas Bonet Date: Tue, 8 Sep 2026 10:33:40 -0500 Subject: [PATCH] feat(ios): add option to store databases in Library/Application Support The Documents directory becomes user-visible in the Files app when an app enables file sharing, exposing raw databases and their -wal/-shm journals to accidental sharing, modification, or deletion. Setting RNNitroSQLite_DatabaseLocation to "ApplicationSupport" in Info.plist stores databases in Library/Application Support instead (persistent, backed up, never user-visible). Databases created by older app versions are moved out of Documents when they are opened: the database and its journals are copied as a set before the originals are deleted, and if anything fails the database keeps being opened from Documents and the migration retries on the next open. Fixes #289 Co-Authored-By: Claude Fable 5 --- README.md | 17 +++++ .../cpp/hybridObjects/HybridNitroSQLite.cpp | 71 ++++++++++++++++++- .../cpp/hybridObjects/HybridNitroSQLite.hpp | 5 ++ .../react-native-nitro-sqlite/ios/OnLoad.mm | 29 +++++++- 4 files changed, 118 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 80544f3e..4d1010cc 100644 --- a/README.md +++ b/README.md @@ -329,6 +329,23 @@ nitroSqliteFlags="-DSQLITE_ENABLE_FTS5=1" To put the database in an app group (e.g. for extensions), set `RNNitroSQLite_AppGroup` in your `Info.plist` to the app group ID and add the App Groups capability in Xcode. +## Database location (iOS) + +By default, databases are stored in the app's **Documents** directory. If your app enables file sharing (`UIFileSharingEnabled` + `LSSupportsOpeningDocumentsInPlace`), that directory — including your raw database and its `-wal`/`-shm` journal files — becomes visible to users in the Files app, where they can be shared, modified, or deleted from outside your app. + +To store databases in `Library/Application Support` instead (persistent, backed up, and never user-visible), set `RNNitroSQLite_DatabaseLocation` in your `Info.plist`: + +```xml +RNNitroSQLite_DatabaseLocation +ApplicationSupport +``` + +Supported values are `Documents` (the default) and `ApplicationSupport`. + +Databases created while the app was still using the Documents directory are automatically moved to `Library/Application Support` the first time they are opened after enabling this option, so existing users keep their data. If you later remove the option, databases already moved to `Library/Application Support` are **not** moved back. + +This option has no effect when `RNNitroSQLite_AppGroup` is set, since app group databases live in the shared container. + --- # Exports diff --git a/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.cpp b/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.cpp index 60a1d63f..9a00e2cd 100644 --- a/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.cpp +++ b/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.cpp @@ -6,6 +6,7 @@ #include "macros.hpp" #include "operations.hpp" #include "sqliteExecuteBatch.hpp" +#include #include #include #include @@ -65,8 +66,76 @@ const std::string getDocPath(const std::optional& location) { return tempDocPath; } +// Moves a database (together with its -wal/-shm journal files) out of the directory a previous +// app version stored it in. Committed-but-uncheckpointed writes live in the -wal file and SQLite +// only replays it when it sits next to its database, so the set must never be separated: the +// whole set is copied before any original is deleted, and if anything fails the intact originals +// stay in place (the caller then keeps opening the database there) and the migration retries on +// the next open. +static void migrateDatabase(const std::string& dbName, const std::filesystem::path& fromDirectory, + const std::filesystem::path& toDirectory) { + namespace fs = std::filesystem; + const std::string files[] = {dbName, dbName + "-wal", dbName + "-shm"}; + std::error_code ec; + + if (!fs::exists(fromDirectory / dbName, ec)) { + // Nothing to migrate. A previous run may have been interrupted after copying the set but + // before removing the journal files, so sweep any leftovers out of the old directory. + fs::remove(fromDirectory / (dbName + "-wal"), ec); + fs::remove(fromDirectory / (dbName + "-shm"), ec); + return; + } + + // A database in the old directory means an older app version was writing there, so it is the + // live copy. Remove whatever sits at the destination (e.g. after a downgrade and re-upgrade) + // so a -wal from one database generation is never replayed into a database from another. + for (const auto& file : files) { + fs::remove(toDirectory / file, ec); + } + + fs::create_directories(toDirectory, ec); + for (const auto& file : files) { + if (!fs::exists(fromDirectory / file, ec)) { + continue; + } + + if (!fs::copy_file(fromDirectory / file, toDirectory / file, ec) || ec) { + LOGW("Failed to migrate database file %s: %s", file.c_str(), ec.message().c_str()); + return; + } + } + + // The database file is deleted first, and the journals only once that succeeds: if the + // database cannot be removed, the caller keeps opening it from the old directory, so its -wal + // must stay next to it or committed writes would be lost. An interruption after the first + // delete can only leave journal files behind, which the sweep above removes on the next open. + if (!fs::remove(fromDirectory / dbName, ec) || ec) { + LOGW("Failed to remove migrated database %s from its old location: %s", dbName.c_str(), ec.message().c_str()); + return; + } + fs::remove(fromDirectory / (dbName + "-wal"), ec); + fs::remove(fromDirectory / (dbName + "-shm"), ec); +} + void HybridNitroSQLite::open(const std::string& dbName, const std::optional& location) { - const auto docPath = getDocPath(location); + auto docPath = getDocPath(location); + + if (!migrationDocPath.empty()) { + std::string oldDocPath = migrationDocPath; + if (location) { + oldDocPath = oldDocPath + "/" + *location; + } + + migrateDatabase(dbName, oldDocPath, docPath); + + // If the database could not be moved out of its old directory, keep opening it there rather + // than creating a fresh empty one; the migration retries on the next open. + std::error_code ec; + if (std::filesystem::exists(std::filesystem::path(oldDocPath) / dbName, ec)) { + docPath = oldDocPath; + } + } + sqliteOpenDb(dbName, docPath); } diff --git a/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.hpp b/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.hpp index a5331964..1c0139f0 100644 --- a/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.hpp +++ b/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.hpp @@ -14,6 +14,10 @@ class HybridNitroSQLite : public HybridNitroSQLiteSpec { public: static std::string docPath; + // Directory databases were stored in by previous app versions, when the platform layer has + // relocated docPath (e.g. iOS with RNNitroSQLite_DatabaseLocation set to "ApplicationSupport"). + // When non-empty, each database found there is moved to docPath as it is opened. + static std::string migrationDocPath; public: // Methods @@ -43,5 +47,6 @@ class HybridNitroSQLite : public HybridNitroSQLiteSpec { }; inline std::string HybridNitroSQLite::docPath = ""; +inline std::string HybridNitroSQLite::migrationDocPath = ""; } // namespace margelo::nitro::rnnitrosqlite diff --git a/packages/react-native-nitro-sqlite/ios/OnLoad.mm b/packages/react-native-nitro-sqlite/ios/OnLoad.mm index 6ce72584..b1fbb24b 100644 --- a/packages/react-native-nitro-sqlite/ios/OnLoad.mm +++ b/packages/react-native-nitro-sqlite/ios/OnLoad.mm @@ -30,9 +30,32 @@ + (void)load { documentPath = [storeUrl path]; } else { - // Get iOS app's document directory (to safely store database .sqlite3 file) - NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, true); - documentPath = [paths objectAtIndex:0]; + NSString *databaseLocation = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"RNNitroSQLite_DatabaseLocation"]; + + if ([databaseLocation isEqualToString:@"ApplicationSupport"]) { + // Library/Application Support is persistent, backed up, and never exposed to the user + // via the Files app (unlike the Documents directory, which becomes user-visible when + // the app enables file sharing). + NSArray *paths = NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, true); + documentPath = [paths objectAtIndex:0]; + + NSFileManager *fileManager = [NSFileManager defaultManager]; + if (![fileManager fileExistsAtPath:documentPath]) { + [fileManager createDirectoryAtPath:documentPath withIntermediateDirectories:YES attributes:nil error:nil]; + } + + // Databases created before this option was enabled still live in Documents; each one is + // moved over when it is opened (see HybridNitroSQLite::open). + NSString *documentsDirectory = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, true) objectAtIndex:0]; + HybridNitroSQLite::migrationDocPath = [documentsDirectory UTF8String]; + } else { + if (databaseLocation != nil && ![databaseLocation isEqualToString:@"Documents"]) { + NSLog(@"Invalid RNNitroSQLite_DatabaseLocation value provided (%@). Supported values are \"Documents\" and \"ApplicationSupport\". Falling back to \"Documents\".", databaseLocation); + } + // Get iOS app's document directory (to safely store database .sqlite3 file) + NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, true); + documentPath = [paths objectAtIndex:0]; + } } HybridNitroSQLite::docPath = [documentPath UTF8String];