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");
}
}
}