Skip to content

Instantly share code, notes, and snippets.

@blackbone
Created February 19, 2026 23:45
Show Gist options
  • Select an option

  • Save blackbone/533ffe83dc0d94f2ac50acc7b05a9c77 to your computer and use it in GitHub Desktop.

Select an option

Save blackbone/533ffe83dc0d94f2ac50acc7b05a9c77 to your computer and use it in GitHub Desktop.
Remote File Sync
// =====
// MIT © 2026 blackbone
// =====
using System;
using System.Collections.Generic;
using System.IO;
using UnityEditor;
using UnityEngine;
using UnityEngine.Networking;
using UnityEngine.UIElements;
[FilePath("ProjectSettings/RemoteFileSyncSettings.asset", FilePathAttribute.Location.ProjectFolder)]
internal sealed class RemoteFileSyncSettings : ScriptableSingleton<RemoteFileSyncSettings>
{
[SerializeField] private List<Entry> entries = new();
internal List<Entry> Entries => entries;
internal void SaveSettings() => Save(true);
[Serializable]
internal sealed class Entry
{
public string url;
public string relativePath;
[HideInInspector] public string etag;
[HideInInspector] public string lastModified;
[HideInInspector] public string contentLength;
}
}
internal static class RemoteFileSyncRunner
{
private const string ProgressTitle = "Remote File Sync";
[InitializeOnLoadMethod]
private static void RunOnEditorStart()
{
EditorApplication.delayCall += () => RunSync("startup");
}
internal static void RunSync(string reason)
{
var settings = RemoteFileSyncSettings.instance;
var entries = settings.Entries;
if (entries == null || entries.Count == 0) return;
var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
var projectRootWithSeparator = projectRoot.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
? projectRoot
: projectRoot + Path.DirectorySeparatorChar;
var downloadedAny = false;
var total = entries.Count;
try
{
for (var i = 0; i < total; i++)
{
var entry = entries[i];
if (entry == null) continue;
var url = (entry.url ?? string.Empty).Trim();
var relativePath = (entry.relativePath ?? string.Empty).Trim();
if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(relativePath)) continue;
var baseProgress = (float)i / total;
EditorUtility.DisplayProgressBar(
ProgressTitle,
$"HEAD {relativePath} ({i + 1}/{total})",
baseProgress);
var absolutePath = Path.GetFullPath(Path.Combine(projectRoot, relativePath));
if (!absolutePath.StartsWith(projectRootWithSeparator, StringComparison.Ordinal) &&
!StringEquals(absolutePath, projectRoot))
{
Debug.LogWarning("RemoteFileSync: skipped path outside project: " + relativePath);
continue;
}
var headMeta = ReadHeadMetadata(url);
if (!headMeta.ok)
{
Debug.LogWarning("RemoteFileSync HEAD failed: " + url + " " + headMeta.error);
continue;
}
var fileExists = File.Exists(absolutePath);
var hasMismatch = !fileExists;
hasMismatch |= !StringEquals(entry.etag, headMeta.eTag);
hasMismatch |= !StringEquals(entry.lastModified, headMeta.lastModified);
hasMismatch |= !StringEquals(entry.contentLength, headMeta.contentLength);
if (!hasMismatch) continue;
EditorUtility.DisplayProgressBar(
ProgressTitle,
$"GET {relativePath} ({i + 1}/{total})",
baseProgress + 0.5f / total);
var body = DownloadFile(url);
if (!body.ok)
{
Debug.LogWarning("RemoteFileSync GET failed: " + url + " " + body.error);
continue;
}
var directory = Path.GetDirectoryName(absolutePath);
if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory);
File.WriteAllBytes(absolutePath, body.bytes);
entry.etag = FirstNonEmpty(body.eTag, headMeta.eTag);
entry.lastModified = FirstNonEmpty(body.lastModified, headMeta.lastModified);
entry.contentLength = FirstNonEmpty(body.contentLength, headMeta.contentLength);
downloadedAny = true;
Debug.Log("RemoteFileSync updated: " + relativePath + " (reason: " + reason + ")");
}
}
finally
{
EditorUtility.ClearProgressBar();
}
settings.SaveSettings();
if (downloadedAny) AssetDatabase.Refresh();
}
private static bool StringEquals(string left, string right)
{
return string.Equals(left ?? string.Empty, right ?? string.Empty, StringComparison.Ordinal);
}
private static string FirstNonEmpty(string preferred, string fallback)
{
if (!string.IsNullOrEmpty(preferred)) return preferred;
return fallback ?? string.Empty;
}
private static MetadataResult ReadHeadMetadata(string url)
{
using var request = UnityWebRequest.Head(url);
request.timeout = 30;
var operation = request.SendWebRequest();
while (!operation.isDone) { }
if (request.result != UnityWebRequest.Result.Success) return MetadataResult.Fail(request.error ?? "unknown error");
return MetadataResult.Success(
request.GetResponseHeader("ETag"),
request.GetResponseHeader("Last-Modified"),
request.GetResponseHeader("Content-Length"));
}
private static DownloadResult DownloadFile(string url)
{
using var request = UnityWebRequest.Get(url);
request.timeout = 120;
var operation = request.SendWebRequest();
while (!operation.isDone) { }
if (request.result != UnityWebRequest.Result.Success) return DownloadResult.Fail(request.error ?? "unknown error");
return DownloadResult.Success(
request.downloadHandler.data,
request.GetResponseHeader("ETag"),
request.GetResponseHeader("Last-Modified"),
request.GetResponseHeader("Content-Length"));
}
private struct MetadataResult
{
internal readonly bool ok;
internal readonly string error;
internal readonly string eTag;
internal readonly string lastModified;
internal readonly string contentLength;
private MetadataResult(bool ok, string error, string etag, string lastModified, string contentLength)
{
this.ok = ok;
this.error = error;
eTag = etag ?? string.Empty;
this.lastModified = lastModified ?? string.Empty;
this.contentLength = contentLength ?? string.Empty;
}
internal static MetadataResult Success(string etag, string lastModified, string contentLength)
=> new(true, string.Empty, etag, lastModified, contentLength);
internal static MetadataResult Fail(string error)
=> new(false, error ?? "error", string.Empty, string.Empty, string.Empty);
}
private struct DownloadResult
{
internal readonly bool ok;
internal readonly string error;
internal readonly byte[] bytes;
internal readonly string eTag;
internal readonly string lastModified;
internal readonly string contentLength;
private DownloadResult(bool ok, string error, byte[] bytes, string etag, string lastModified, string contentLength)
{
this.ok = ok;
this.error = error;
this.bytes = bytes;
eTag = etag ?? string.Empty;
this.lastModified = lastModified ?? string.Empty;
this.contentLength = contentLength ?? string.Empty;
}
internal static DownloadResult Success(byte[] bytes, string etag, string lastModified, string contentLength)
=> new(true, string.Empty, bytes, etag, lastModified, contentLength);
internal static DownloadResult Fail(string error)
=> new(false, error ?? "error", Array.Empty<byte>(), string.Empty, string.Empty, string.Empty);
}
}
internal sealed class RemoteFileSyncSettingsProvider : SettingsProvider
{
private const string SettingsPath = "Project/Remote File Sync";
private readonly List<DraftEntry> _draftEntries = new();
private bool _isDirty;
private bool _isInitialized;
private RemoteFileSyncSettingsProvider(string path, SettingsScope scope) : base(path, scope) { }
[SettingsProvider]
private static SettingsProvider CreateProvider()
=> new RemoteFileSyncSettingsProvider(SettingsPath, SettingsScope.Project)
{
keywords = new HashSet<string>(new[] { "remote", "sync", "head", "firebase", "tgz" })
};
public override void OnActivate(string searchContext, VisualElement rootElement)
=> LoadDraftFromSettings();
public override void OnDeactivate()
{
if (!_isDirty) return;
var action = EditorUtility.DisplayDialogComplex(
"Unsaved Remote File Sync Settings",
"Save changes before leaving settings?",
"Save",
"Discard",
"Cancel");
switch (action)
{
case 0: SaveDraftAndSync("settings-close"); return;
case 2: SettingsService.OpenProjectSettings(SettingsPath); break;
}
}
public override void OnGUI(string searchContext)
{
if (!_isInitialized) LoadDraftFromSettings();
EditorGUILayout.HelpBox(
"Set URL + local relative path pairs. Sync runs on editor startup, on Save, or on close when Save is confirmed.",
MessageType.Info);
for (var i = 0; i < _draftEntries.Count; i++)
{
var entry = _draftEntries[i];
EditorGUILayout.BeginVertical("box");
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField("Entry " + (i + 1), EditorStyles.boldLabel);
if (GUILayout.Button("Remove", GUILayout.Width(70f)))
{
_draftEntries.RemoveAt(i);
EditorGUILayout.EndHorizontal();
EditorGUILayout.EndVertical();
_isDirty = true;
i--;
continue;
}
EditorGUILayout.EndHorizontal();
var newUrl = EditorGUILayout.DelayedTextField("URL", entry.url ?? string.Empty);
if (!string.Equals(newUrl, entry.url ?? string.Empty, StringComparison.Ordinal))
{
entry.url = newUrl;
_isDirty = true;
}
EditorGUILayout.BeginHorizontal();
var newRelativePath = EditorGUILayout.DelayedTextField("Relative Path", entry.relativePath ?? string.Empty);
if (!string.Equals(newRelativePath, entry.relativePath ?? string.Empty, StringComparison.Ordinal))
{
entry.relativePath = newRelativePath;
_isDirty = true;
}
if (GUILayout.Button("Select...", GUILayout.Width(80f)))
{
var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
var defaultAbsolutePath = GetDefaultAbsolutePath(projectRoot, entry.relativePath);
var selectedPath = EditorUtility.SaveFilePanel(
"Select local target file",
Path.GetDirectoryName(defaultAbsolutePath) ?? projectRoot,
Path.GetFileName(defaultAbsolutePath),
string.Empty);
if (!string.IsNullOrEmpty(selectedPath))
{
if (TryMakeProjectRelativePath(projectRoot, selectedPath, out var relativePath))
{
if (!string.Equals(relativePath, entry.relativePath ?? string.Empty, StringComparison.Ordinal))
{
entry.relativePath = relativePath;
_isDirty = true;
}
}
else
Debug.LogWarning("RemoteFileSync: selected file must be inside project folder.");
}
}
EditorGUILayout.EndHorizontal();
EditorGUILayout.EndVertical();
}
EditorGUILayout.Space(4f);
if (GUILayout.Button("Add Entry"))
{
_draftEntries.Add(new DraftEntry());
_isDirty = true;
}
EditorGUILayout.Space(8f);
EditorGUILayout.BeginHorizontal();
using (new EditorGUI.DisabledScope(!_isDirty))
{
if (GUILayout.Button("Save")) SaveDraftAndSync("settings-save");
if (GUILayout.Button("Revert")) LoadDraftFromSettings();
}
EditorGUILayout.EndHorizontal();
if (GUILayout.Button("Sync Now"))
{
if (_isDirty)
{
SaveDraftAndSync("manual-save");
return;
}
RemoteFileSyncRunner.RunSync("manual");
}
}
private void SaveDraftAndSync(string reason)
{
var settings = RemoteFileSyncSettings.instance;
var entries = settings.Entries;
entries.Clear();
foreach (var item in _draftEntries)
{
entries.Add(new RemoteFileSyncSettings.Entry
{
url = item.url,
relativePath = item.relativePath
});
}
settings.SaveSettings();
_isDirty = false;
_isInitialized = true;
RemoteFileSyncRunner.RunSync(reason);
}
private void LoadDraftFromSettings()
{
_draftEntries.Clear();
var entries = RemoteFileSyncSettings.instance.Entries;
foreach (var item in entries)
{
if (item == null)
{
_draftEntries.Add(new DraftEntry());
continue;
}
_draftEntries.Add(new DraftEntry
{
url = item.url,
relativePath = item.relativePath
});
}
_isDirty = false;
_isInitialized = true;
}
private static string GetDefaultAbsolutePath(string projectRoot, string relativePath)
{
var path = (relativePath ?? string.Empty).Trim();
if (string.IsNullOrEmpty(path)) return Path.Combine(projectRoot, "Packages", "downloaded-package.tgz");
return Path.GetFullPath(Path.Combine(projectRoot, path));
}
private static bool TryMakeProjectRelativePath(string projectRoot, string selectedPath, out string relativePath)
{
var root = Path.GetFullPath(projectRoot);
var rootWithSeparator = root.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
? root
: root + Path.DirectorySeparatorChar;
var fullSelected = Path.GetFullPath(selectedPath);
if (!fullSelected.StartsWith(rootWithSeparator, StringComparison.Ordinal))
{
relativePath = string.Empty;
return false;
}
relativePath = fullSelected.Substring(rootWithSeparator.Length).Replace('\\', '/');
return true;
}
private sealed class DraftEntry
{
internal string relativePath;
internal string url;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment