diff --git a/Assets/Editor/Tests/TestFileCache.cs b/Assets/Editor/Tests/TestFileCache.cs
new file mode 100644
index 0000000000..ba10bd1e58
--- /dev/null
+++ b/Assets/Editor/Tests/TestFileCache.cs
@@ -0,0 +1,142 @@
+using System.IO;
+using System.Linq;
+using NUnit.Framework;
+
+namespace TiltBrush
+{
+ [TestFixture]
+ internal class TestFileCache
+ {
+ private FileCache m_Cache;
+ private string m_Path;
+ [SetUp]
+ public void Setup()
+ {
+ m_Path = Path.Combine(Path.GetTempPath(), "FileCacheTest");
+ if (File.Exists(m_Path))
+ {
+ File.Delete(m_Path);
+ }
+ if (Directory.Exists(m_Path))
+ {
+ Directory.Delete(m_Path, recursive: true);
+ }
+ m_Cache = new FileCache(m_Path, 1);
+ }
+
+ [TearDown]
+ public void Teardown()
+ {
+ if (Directory.Exists(m_Path))
+ {
+ Directory.Delete(m_Path, recursive: true);
+ }
+ }
+
+ [Test]
+ public void IsDirectoryCreated()
+ {
+ Assert.IsTrue(Directory.Exists(m_Path));
+ }
+
+ [Test]
+ public void IsCacheSizeUpdated()
+ {
+ Assert.That(m_Cache.CacheSize == 0);
+ byte[] bytes = new byte[1000];
+ m_Cache.Write("test", "onethousand", bytes);
+ Assert.That(m_Cache.CacheSize == 1000);
+ }
+
+ [Test]
+ public void IsCacheLimitRespected()
+ {
+ byte[] bytes = new byte[100000];
+ for (int i = 0; i < 11; i++)
+ {
+ m_Cache.Write($"test_{i}", "100kbytes", bytes);
+ }
+ Assert.That(m_Cache.CacheSize == 1000000);
+ var rootDir = new DirectoryInfo(m_Path);
+ Assert.That(rootDir.EnumerateFiles("*", SearchOption.AllDirectories)
+ .Sum(x => x.Length) == 1000000);
+ }
+
+ [Test]
+ public void IsLastCreatedExpunged()
+ {
+ byte[] bytes = new byte[100000];
+ for (int i = 0; i < 11; i++)
+ {
+ m_Cache.Write($"test_{i}", "100kbytes", bytes);
+ }
+ Assert.IsFalse(m_Cache.FilesetExists("test_0"));
+ }
+
+ [Test]
+ public void CanWriteMultipleFilesSetAndFiles()
+ {
+ byte[] bytes = new byte[1000];
+ for (int i = 0; i < 5; i++)
+ {
+ for (int j = 0; j < 5; ++j)
+ {
+ m_Cache.Write($"test_{i}", $"100kbytes_{j}", bytes);
+ }
+ }
+
+ for (int i = 0; i < 5; i++)
+ {
+ Assert.That(m_Cache.FilesetExists($"test_{i}"));
+ for (int j = 0; j < 5; ++j)
+ {
+ Assert.That(m_Cache.FileExists($"test_{i}", $"100kbytes_{j}"));
+ }
+ }
+ }
+
+ [Test]
+ public void ThingsThatDontExistDontExist()
+ {
+ byte[] bytes = new byte[1000];
+ m_Cache.Write("Real", "onethousand", bytes);
+
+ Assert.IsFalse(m_Cache.FilesetExists("Imaginary"));
+ Assert.IsFalse(m_Cache.FileExists("Imaginary", "onethousand"));
+ Assert.IsFalse(m_Cache.FileExists("Real", "twothousand"));
+ }
+
+ [Test]
+ public void FilesCanBeDeleted()
+ {
+ byte[] bytes = new byte[1000];
+ m_Cache.Write("Real", "onethousand", bytes);
+ Assert.That(m_Cache.FileExists("Real", "onethousand"));
+ m_Cache.DeleteFile("Real", "onethousand");
+ Assert.That(!m_Cache.FileExists("Real", "onethousand"));
+ }
+
+ [Test]
+ public void FileHasTheRightContents()
+ {
+ byte[] bytes = Enumerable.Range(0, 127).Select(x => (byte)x).ToArray();
+ m_Cache.Write("test", "data", bytes);
+ byte[] read = m_Cache.Read("test", "data");
+ Assert.That(bytes.SequenceEqual(read));
+ }
+
+ [Test]
+ public void TestStreamRead()
+ {
+ byte[] bytes = Enumerable.Range(0, 127).Select(x => (byte)x).ToArray();
+ m_Cache.Write("test", "data", bytes);
+ using (var stream = m_Cache.ReadStream("test", "data"))
+ {
+ for (int i = 0; i < 127; ++i)
+ {
+ Assert.That(stream.ReadByte() == i);
+ }
+ }
+ }
+ }
+}
diff --git a/Assets/Editor/Tests/TestFileCache.cs.meta b/Assets/Editor/Tests/TestFileCache.cs.meta
new file mode 100644
index 0000000000..77e6065cda
--- /dev/null
+++ b/Assets/Editor/Tests/TestFileCache.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 064578b627c24b20bf936bf824721a81
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Scripts/Sharing/FileCache.cs b/Assets/Scripts/Sharing/FileCache.cs
new file mode 100644
index 0000000000..47e6cf1cbf
--- /dev/null
+++ b/Assets/Scripts/Sharing/FileCache.cs
@@ -0,0 +1,178 @@
+using System;
+using System.IO;
+using System.Linq;
+namespace TiltBrush
+{
+ ///
+ /// The FileCache is a simple cache that can store files in multiple 'filesets'.
+ /// The idea is that a fileset is effectively a uniquely named folder that can store files.
+ ///
+ public class FileCache
+ {
+ ///
+ /// Constructor
+ ///
+ /// The root path of the cache
+ /// The maximum size of the cache in megabytes
+ public FileCache(string path, long maxMegabytes)
+ {
+ if (!Directory.Exists(path))
+ {
+ Directory.CreateDirectory(path);
+ }
+
+ m_Root = new DirectoryInfo(path);
+ if (!m_Root.Exists)
+ {
+ m_Root.Create();
+ }
+
+ m_MaxBytes = maxMegabytes * 1024 * 1024;
+ ReadCacheSize();
+ TrimCacheSize();
+ }
+
+ ///
+ /// Trims folders within the cache directory if the maximum cache size is breached.
+ /// Works on a last-accessed basis.
+ ///
+ public void TrimCacheSize()
+ {
+ foreach (var subdir in m_Root.EnumerateDirectories().OrderBy(x => x.LastWriteTimeUtc))
+ {
+ if (m_CurrentBytes < m_MaxBytes)
+ {
+ m_Root.Refresh();
+ return;
+ }
+ long subdirSize = subdir.EnumerateFiles("*", SearchOption.AllDirectories).Sum(x => x.Length);
+ subdir.Delete(recursive: true);
+ m_CurrentBytes -= subdirSize;
+ }
+ }
+
+ ///
+ /// Determines if a given fileset exists within the cache
+ ///
+ /// The name of the fileset
+ /// Whether it exists
+ public bool FilesetExists(string fileset)
+ {
+ return Directory.Exists(Path.Combine(m_Root.FullName, fileset));
+ }
+
+ ///
+ /// Checks whether a given file exists within a fileset.
+ ///
+ ///
+ ///
+ ///
+ public bool FileExists(string fileset, string filename)
+ {
+ string folder = Path.Combine(m_Root.FullName, fileset);
+ string path = Path.Combine(folder, filename);
+ return File.Exists(path);
+ }
+
+ ///
+ /// Writes a file to a fileset
+ ///
+ /// The name of the fileset
+ /// The file within the fileset
+ /// File bytes
+ public void Write(string fileset, string filename, byte[] data)
+ {
+ string folder = Path.Combine(m_Root.FullName, fileset);
+ string path = Path.Combine(folder, filename);
+ DirectoryInfo subdir = new DirectoryInfo(folder);
+ bool createDir = !subdir.Exists;
+ if (createDir)
+ {
+ Directory.CreateDirectory(folder);
+ }
+ else
+ {
+ subdir.LastWriteTimeUtc = DateTime.UtcNow;
+ }
+ File.WriteAllBytes(path, data);
+ m_CurrentBytes += data.LongLength;
+ if (createDir)
+ {
+ m_Root.Refresh();
+ }
+ TrimCacheSize();
+ }
+
+ ///
+ /// Read all the bytes from a file in a fileset
+ ///
+ /// The fileset
+ /// The file
+ /// All the bytes from the file
+ public byte[] Read(string fileset, string filename)
+ {
+ string folder = Path.Combine(m_Root.FullName, fileset);
+ string path = Path.Combine(folder, filename);
+ return File.ReadAllBytes(path);
+ }
+
+ ///
+ /// Read the bytes from a file in a fileset as a stream
+ ///
+ /// fileset
+ /// filename
+ /// A stream of the bytes in the file
+ public Stream ReadStream(string fileset, string filename)
+ {
+ string folder = Path.Combine(m_Root.FullName, fileset);
+ string path = Path.Combine(folder, filename);
+ return File.OpenRead(path);
+ }
+
+ ///
+ /// Delete a file in a fileset
+ ///
+ /// Fileset
+ /// File
+ public void DeleteFile(string fileset, string filename)
+ {
+ string folder = Path.Combine(m_Root.FullName, fileset);
+ string path = Path.Combine(folder, filename);
+ FileInfo file = new FileInfo(path);
+ m_CurrentBytes -= file.Length;
+ file.Delete();
+ }
+
+ ///
+ /// Delete a fileset
+ ///
+ /// Fileset
+ public void DeleteFileset(string fileset)
+ {
+ string folder = Path.Combine(m_Root.FullName, fileset);
+ DirectoryInfo subdir = new DirectoryInfo(folder);
+ m_CurrentBytes -= subdir.EnumerateFiles("*", SearchOption.AllDirectories).Sum(x => x.Length);
+ Directory.Delete(fileset, recursive: true);
+ m_Root.Refresh();
+ }
+
+ ///
+ /// Deletes the entire cache.
+ ///
+ public void Clear()
+ {
+ m_Root.Delete(recursive: true);
+ }
+
+ public long CacheSize => m_CurrentBytes;
+
+ private DirectoryInfo m_Root;
+ private long m_MaxBytes;
+ private long m_CurrentBytes;
+
+ private void ReadCacheSize()
+ {
+ m_CurrentBytes = m_Root.EnumerateFiles("*", SearchOption.AllDirectories).Sum(x => x.Length);
+ }
+ }
+}
diff --git a/Assets/Scripts/Sharing/FileCache.cs.meta b/Assets/Scripts/Sharing/FileCache.cs.meta
new file mode 100644
index 0000000000..d77ca352f7
--- /dev/null
+++ b/Assets/Scripts/Sharing/FileCache.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: ddd12e6ee0b04c72ad52fe1829c5d225
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant: