using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Text; using Newtonsoft.Json; using Uigenx.Protocol; using UnityEditor; using UnityEngine; using Debug = UnityEngine.Debug; namespace Uigenx.Editor { /// /// Applies a `sync.bundle` to the Unity project: writes the files, keeps their GUIDs stable, /// applies texture import settings, and reports what landed. It only ever deletes paths it /// imported itself, recorded in . /// public static class UgxBundleImporter { /// Paths this importer owns. Uncommitted: it is a cache, not a source of truth. public const string ImportLedgerPath = "Library/UgxImported.json"; /// Streamed file cache cap. A bundle that exceeds it is rejected rather than paged. public const int MaxStreamedFiles = 64; static readonly Dictionary StreamedChunks = new Dictionary(StringComparer.Ordinal); /// Feeds one `sync.file` chunk. The bundle picks it up by sha256. public static void AddChunk(UgxSyncFileChunk chunk) { if (chunk == null || string.IsNullOrEmpty(chunk.Sha256)) return; if (!StreamedChunks.TryGetValue(chunk.Sha256, out var builder)) { if (StreamedChunks.Count >= MaxStreamedFiles) { Debug.LogWarning("[ugx] too many streamed files in flight, dropping " + chunk.Sha256); return; } builder = new StringBuilder(); StreamedChunks[chunk.Sha256] = builder; } builder.Append(chunk.Chunk); } public static void ClearChunks() => StreamedChunks.Clear(); public static UgxSyncResult Import(UgxSyncBundle bundle, string assetsUiFolder) { if (bundle == null) throw new ArgumentNullException(nameof(bundle)); var stopwatch = Stopwatch.StartNew(); var result = new UgxSyncResult { BundleId = bundle.BundleId }; var root = string.IsNullOrEmpty(assetsUiFolder) ? UgxProjectSettings.Load().AssetsUiFolder : assetsUiFolder; var ledger = LoadLedger(); var textureTargets = new List>(); AssetDatabase.StartAssetEditing(); try { EnsureFolder(root); foreach (var file in bundle.Files) { if (string.IsNullOrEmpty(file.Path)) { result.Warnings.Add("a bundle file has no path, skipped"); continue; } var assetPath = CombineAssetPath(root, file.Path); if (!IsInside(root, assetPath)) { result.Warnings.Add(file.Path + ": escapes the UI folder, skipped"); continue; } if (!TryResolveContent(file, out var bytes, out var problem)) { result.Warnings.Add(file.Path + ": " + problem); continue; } EnsureFolder(Path.GetDirectoryName(assetPath).Replace('\\', '/')); WriteIfChanged(assetPath, bytes); var guid = UgxGuidPolicy.EnsureMeta(assetPath, file.Guid); result.Imported.Add(new UgxImportedFile { Path = assetPath, Guid = guid }); ledger.Add(assetPath); } foreach (var deletion in bundle.Deletions) { var assetPath = CombineAssetPath(root, deletion); if (!ledger.Contains(assetPath)) { result.Warnings.Add(deletion + ": not imported by UI Creator, left alone"); continue; } DeleteFile(assetPath); ledger.Remove(assetPath); } foreach (var asset in bundle.Assets) { var assetPath = CombineAssetPath(root, asset.Path); if (!File.Exists(assetPath)) { result.Warnings.Add(asset.Path + ": no such file, import settings skipped"); continue; } textureTargets.Add(new KeyValuePair(assetPath, asset.Importer)); } } finally { AssetDatabase.StopAssetEditing(); } AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); // Import settings need the asset to exist in the database, so this runs after Refresh. foreach (var target in textureTargets) { if (ApplyTextureSettings(target.Key, target.Value, out var problem)) continue; result.Warnings.Add(target.Key + ": " + problem); } // The font file has to be imported before Unity can render an atlas from it. if (bundle.Manifest != null) { var fonts = UgxFontAssetBuilder.Build(bundle.Manifest.Fonts, root); result.Warnings.AddRange(fonts.Warnings); foreach (var created in fonts.Created) { result.Imported.Add(new UgxImportedFile { Path = created, Guid = AssetDatabase.AssetPathToGUID(created), }); ledger.Add(created); } } SaveLedger(ledger); StreamedChunks.Clear(); result.DurationMs = stopwatch.Elapsed.TotalMilliseconds; return result; } static bool TryResolveContent(UgxBundleFile file, out byte[] bytes, out string problem) { bytes = null; problem = null; var base64 = file.Content; if (string.IsNullOrEmpty(base64) && !string.IsNullOrEmpty(file.Sha256) && StreamedChunks.TryGetValue(file.Sha256, out var streamed)) base64 = streamed.ToString(); if (string.IsNullOrEmpty(base64)) { problem = "no content and no streamed chunks for sha256 " + file.Sha256; return false; } try { bytes = Convert.FromBase64String(base64); return true; } catch (FormatException e) { problem = "content is not valid base64: " + e.Message; return false; } } /// Skips the write when the bytes match, so unchanged files keep their import timestamp. static void WriteIfChanged(string assetPath, byte[] bytes) { if (File.Exists(assetPath)) { var existing = File.ReadAllBytes(assetPath); if (existing.Length == bytes.Length) { bool same = true; for (int i = 0; i < bytes.Length; i++) { if (existing[i] == bytes[i]) continue; same = false; break; } if (same) return; } AssetDatabase.MakeEditable(assetPath); } File.WriteAllBytes(assetPath, bytes); } static bool ApplyTextureSettings(string assetPath, UgxTextureImportSettings settings, out string problem) { problem = null; if (settings == null) return true; if (!(AssetImporter.GetAtPath(assetPath) is TextureImporter importer)) { problem = "not a texture, import settings skipped"; return false; } importer.textureType = settings.TextureType == UgxTextureType.Sprite ? TextureImporterType.Sprite : TextureImporterType.Default; importer.spriteImportMode = settings.SpriteMode == UgxSpriteMode.Multiple ? SpriteImportMode.Multiple : SpriteImportMode.Single; importer.spritePixelsPerUnit = settings.PixelsPerUnit; importer.filterMode = ToFilterMode(settings.FilterMode); importer.mipmapEnabled = settings.Mipmaps; importer.alphaIsTransparency = settings.AlphaIsTransparency; importer.maxTextureSize = settings.MaxSize; if (settings.SpriteBorder != null && settings.SpriteBorder.Length == 4) { var b = settings.SpriteBorder; importer.spriteBorder = new Vector4(b[0], b[1], b[2], b[3]); } // spriteMeshType lives on TextureImporterSettings, not on the importer itself. var textureSettings = new TextureImporterSettings(); importer.ReadTextureSettings(textureSettings); textureSettings.spriteMeshType = settings.MeshType == UgxMeshType.Tight ? SpriteMeshType.Tight : SpriteMeshType.FullRect; importer.SetTextureSettings(textureSettings); EditorUtility.SetDirty(importer); importer.SaveAndReimport(); return true; } static FilterMode ToFilterMode(UgxFilterMode mode) { switch (mode) { case UgxFilterMode.Point: return FilterMode.Point; case UgxFilterMode.Trilinear: return FilterMode.Trilinear; default: return FilterMode.Bilinear; } } public static string CombineAssetPath(string root, string relative) { var clean = (relative ?? string.Empty).Replace('\\', '/').TrimStart('/'); return root.TrimEnd('/') + "/" + clean; } static bool IsInside(string root, string assetPath) { var normalizedRoot = Path.GetFullPath(root).Replace('\\', '/').TrimEnd('/') + "/"; var normalized = Path.GetFullPath(assetPath).Replace('\\', '/'); return normalized.StartsWith(normalizedRoot, StringComparison.Ordinal); } static void EnsureFolder(string folder) { if (string.IsNullOrEmpty(folder) || Directory.Exists(folder)) return; var parts = folder.Replace('\\', '/').Split('/'); var current = parts[0]; for (int i = 1; i < parts.Length; i++) { current += "/" + parts[i]; if (Directory.Exists(current)) continue; Directory.CreateDirectory(current); UgxGuidPolicy.EnsureFolderMeta(current); } } static void DeleteFile(string assetPath) { if (File.Exists(assetPath)) File.Delete(assetPath); var meta = UgxGuidPolicy.MetaPathFor(assetPath); if (File.Exists(meta)) File.Delete(meta); } public static HashSet LoadLedger() { if (!File.Exists(ImportLedgerPath)) return new HashSet(StringComparer.Ordinal); try { var paths = JsonConvert.DeserializeObject>(File.ReadAllText(ImportLedgerPath)); return paths == null ? new HashSet(StringComparer.Ordinal) : new HashSet(paths, StringComparer.Ordinal); } catch (Exception e) { Debug.LogWarning("[ugx] " + ImportLedgerPath + " is unreadable, starting a new ledger: " + e.Message); return new HashSet(StringComparer.Ordinal); } } static void SaveLedger(HashSet ledger) { var sorted = new List(ledger); sorted.Sort(StringComparer.Ordinal); Directory.CreateDirectory(Path.GetDirectoryName(ImportLedgerPath) ?? "Library"); File.WriteAllText(ImportLedgerPath, JsonConvert.SerializeObject(sorted, Formatting.Indented) + "\n"); } } }