Created
February 19, 2026 23:45
-
-
Save blackbone/533ffe83dc0d94f2ac50acc7b05a9c77 to your computer and use it in GitHub Desktop.
Remote File Sync
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ===== | |
| // 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