@@ -223,6 +223,131 @@ void PluginProcessor::doubleFlushMessageQueue()
223223 unlockAudioThread ();
224224}
225225
226+ #if JUCE_IOS
227+ void PluginProcessor::syncDirectoryFiles (File const & sourceDir, File const & targetDir, Time lastInitTime, bool deleteIfNotExists)
228+ {
229+ if (!sourceDir.exists () || !targetDir.exists ()) {
230+ return ;
231+ }
232+
233+ // Cache frequently used values
234+ const bool hasValidLastInitTime = lastInitTime.toMilliseconds () > 0 ;
235+ const String deleteFlag = deleteIfNotExists ? " true" : " false" ;
236+
237+ // Get all source files once and build a lookup map for efficiency
238+ auto sourceFiles = sourceDir.findChildFiles (juce::File::TypesOfFileToFind::findFiles, true );
239+ std::unordered_set<String> sourceFileSet;
240+ sourceFileSet.reserve (sourceFiles.size ());
241+
242+ // Phase 1: Copy files from source to target (update newer files)
243+ for (const auto & sourceFile : sourceFiles) {
244+ const auto relativePath = sourceFile.getRelativePathFrom (sourceDir);
245+ sourceFileSet.insert (relativePath.toStdString ());
246+
247+ const auto targetFile = targetDir.getChildFile (relativePath);
248+
249+ // Create directory structure if needed
250+ const auto parentDir = targetFile.getParentDirectory ();
251+ if (!parentDir.exists ()) {
252+ parentDir.createDirectory ();
253+ }
254+
255+ // Copy if target doesn't exist or source is newer
256+ if (!targetFile.exists ()) {
257+ sourceFile.copyFileTo (targetFile);
258+ } else {
259+ const auto sourceModTime = sourceFile.getLastModificationTime ();
260+ const auto targetModTime = targetFile.getLastModificationTime ();
261+ if (sourceModTime > targetModTime) {
262+ sourceFile.copyFileTo (targetFile);
263+ }
264+ }
265+ }
266+
267+ // Phase 2: Delete files in target that don't exist in source (only if deleteIfNotExists is true)
268+ if (deleteIfNotExists) {
269+ auto targetFiles = targetDir.findChildFiles (juce::File::TypesOfFileToFind::findFiles, true );
270+
271+ // Pre-cache directory modification times to prevent updates during deletion
272+ HashMap<String, Time> directoryModTimes;
273+ std::unordered_set<String> uniqueDirPaths;
274+
275+ for (const auto & targetFile : targetFiles) {
276+ const auto parentPath = targetFile.getParentDirectory ().getFullPathName ();
277+ if (uniqueDirPaths.find (parentPath.toStdString ()) == uniqueDirPaths.end ()) {
278+ uniqueDirPaths.insert (parentPath.toStdString ());
279+ directoryModTimes.set (parentPath, targetFile.getParentDirectory ().getLastModificationTime ());
280+ }
281+ }
282+
283+ SmallArray<File> directoriesToCleanup;
284+ std::unordered_set<String> cleanupDirPaths; // Prevent duplicates
285+
286+ for (const auto & targetFile : targetFiles) {
287+ const auto relativePath = targetFile.getRelativePathFrom (targetDir);
288+
289+ // Check if file exists in source using our pre-built set
290+ if (sourceFileSet.find (relativePath.toStdString ()) == sourceFileSet.end ()) {
291+ // File doesn't exist in source - candidate for deletion
292+ bool shouldDelete = true ;
293+
294+ if (hasValidLastInitTime) {
295+ const auto parentPath = targetFile.getParentDirectory ().getFullPathName ();
296+ const auto dirModTime = directoryModTimes[parentPath];
297+
298+ if (dirModTime > lastInitTime) {
299+ shouldDelete = false ;
300+ } else {
301+ }
302+ }
303+
304+ if (shouldDelete) {
305+ const auto parentDir = targetFile.getParentDirectory ();
306+ const auto parentPath = parentDir.getFullPathName ();
307+
308+ targetFile.deleteFile ();
309+
310+ // Add parent directory to cleanup list (avoid duplicates)
311+ if (cleanupDirPaths.find (parentPath.toStdString ()) == cleanupDirPaths.end ()) {
312+ cleanupDirPaths.insert (parentPath.toStdString ());
313+ directoriesToCleanup.add (parentDir);
314+ }
315+ }
316+ }
317+ }
318+
319+ // Clean up empty directories (process from deepest to shallowest)
320+ for (const auto & parentDir : directoriesToCleanup) {
321+ auto currentDir = parentDir;
322+ while (currentDir != targetDir) {
323+ // Check if directory is empty (ignoring hidden files)
324+ const auto filesInDir = currentDir.findChildFiles (juce::File::TypesOfFileToFind::findFiles, false );
325+ bool isEmpty = true ;
326+ for (const auto & file : filesInDir) {
327+ if (!file.isHidden ()) {
328+ isEmpty = false ;
329+ break ;
330+ }
331+ }
332+
333+ if (isEmpty) {
334+ const auto parentRelativePath = currentDir.getRelativePathFrom (targetDir);
335+ if (!sourceDir.getChildFile (parentRelativePath).exists ()) {
336+ const auto nextParent = currentDir.getParentDirectory ();
337+ currentDir.deleteRecursively ();
338+ currentDir = nextParent;
339+ } else {
340+ break ; // Directory exists in source, keep it
341+ }
342+ } else {
343+ break ; // Directory is not empty, stop
344+ }
345+ }
346+ }
347+ }
348+ }
349+ #endif
350+
226351bool PluginProcessor::initialiseFilesystem ()
227352{
228353 auto const & homeDir = ProjectInfo::appDataDir;
@@ -303,11 +428,6 @@ bool PluginProcessor::initialiseFilesystem()
303428 if (!dekenDir.exists ()) {
304429 dekenDir.createDirectory ();
305430 }
306- #if !JUCE_IOS
307- if (!patchesDir.exists ()) {
308- patchesDir.createDirectory ();
309- }
310- #endif
311431
312432 auto const testTonePatch = homeDir.getChildFile (" testtone.pd" );
313433 auto const cpuTestPatch = homeDir.getChildFile (" load-meter.pd" );
@@ -346,13 +466,12 @@ bool PluginProcessor::initialiseFilesystem()
346466 auto abstractionsPath = versionDataDir.getChildFile (" Abstractions" ).getFullPathName ().replaceCharacters (" /" , " \\ " );
347467 auto documentationPath = versionDataDir.getChildFile (" Documentation" ).getFullPathName ().replaceCharacters (" /" , " \\ " );
348468 auto extraPath = versionDataDir.getChildFile (" Extra" ).getFullPathName ().replaceCharacters (" /" , " \\ " );
349- auto dekenPath = dekenDir.getFullPathName ();
350- auto patchesPath = patchesDir.getFullPathName ();
351469
352470 createLinkWithRetry (homeDir.getChildFile (" Abstractions" ), versionDataDir.getChildFile (" Abstractions" ));
353471 createLinkWithRetry (homeDir.getChildFile (" Documentation" ), versionDataDir.getChildFile (" Documentation" ));
354472 createLinkWithRetry (homeDir.getChildFile (" Extra" ), versionDataDir.getChildFile (" Extra" ));
355473
474+ // TODO: version transition code, remove this later
356475 auto oldlocation = File::getSpecialLocation (File::SpecialLocationType::userDocumentsDirectory).getChildFile (" plugdata" );
357476 auto backupLocation = File::getSpecialLocation (File::SpecialLocationType::userDocumentsDirectory).getChildFile (" plugdata.old" );
358477 if (oldlocation.isDirectory () && !backupLocation.isDirectory ()) {
@@ -372,13 +491,45 @@ bool PluginProcessor::initialiseFilesystem()
372491 createLinkWithRetry (homeDir.getChildFile (" Extra" ), versionDataDir.getChildFile (" Extra" ));
373492
374493 auto docsPatchesDir = File::getSpecialLocation (File::SpecialLocationType::userDocumentsDirectory).getChildFile (" Patches" );
375- docsPatchesDir. createDirectory ();
376- if (!patchesDir. isSymbolicLink ()) {
494+ if (patchesDir. isSymbolicLink ())
495+ {
377496 patchesDir.deleteRecursively ();
378- } else {
379- patchesDir.deleteFile ();
380497 }
381- docsPatchesDir.createSymbolicLink (patchesDir, true );
498+ if (docsPatchesDir.isSymbolicLink ())
499+ {
500+ docsPatchesDir.deleteRecursively ();
501+ }
502+ patchesDir.createDirectory ();
503+ docsPatchesDir.createDirectory ();
504+
505+ // On iOS, only the standalone can actually access the "patches" folder through the file manager. So we have to do this copying step to make sure it gets synced with our shared "Patches" folder
506+ // Thanks to Reinissance for figuring out the copying logic!
507+ if (ProjectInfo::isStandalone) {
508+ // Read last standalone initialization time
509+ auto timestampFile = homeDir.getChildFile (" .standalone_last_init" );
510+ Time lastInitTime;
511+ if (timestampFile.existsAsFile ()) {
512+ auto timestampString = timestampFile.loadFileAsString ().trim ();
513+ if (timestampString.isNotEmpty ()) {
514+ lastInitTime = Time (timestampString.getLargeIntValue ());
515+ }
516+ }
517+ else {
518+ // If the file doesn't exist, we assume this is the first run
519+ lastInitTime = Time::getCurrentTime ();
520+ // Create the file to store the current time
521+ timestampFile.create ();
522+ }
523+
524+ // Sync files both ways: documents -> shared and shared -> documents
525+ // Pass the last init time to prevent deletion of files created by AUv3 apps after standalone was last initialized
526+ syncDirectoryFiles (patchesDir, docsPatchesDir, lastInitTime, true ); // Copy newer files from shared to documents and delete files that don't exist there in shared
527+ syncDirectoryFiles (docsPatchesDir, patchesDir, lastInitTime); // Copy newer files from documents to shared
528+
529+ // Store current time as the last initialization time for next run...
530+ auto currentTime = Time::getCurrentTime ();
531+ timestampFile.replaceWithText (String (currentTime.toMilliseconds ()));
532+ }
382533#else
383534 createLinkWithRetry (homeDir.getChildFile (" Abstractions" ), versionDataDir.getChildFile (" Abstractions" ));
384535 createLinkWithRetry (homeDir.getChildFile (" Documentation" ), versionDataDir.getChildFile (" Documentation" ));
0 commit comments