using System; using System.IO; using System.Security.Cryptography; using System.Text; using System.Threading.Tasks; using UnityEngine; using UnityEngine.Networking; namespace MyInfoMate.Vr.Net { /// /// Cache disque du contenu — item E4 du lot XR-4. /// /// C'est l'item qui justifie Unity plutôt que WebXR (§10 du plan) : une borne /// d'accueil tourne 8 h par jour sans personne, et le wifi d'un musée tombe. En natif /// on écrit sur le disque, sans quota de navigateur. /// /// La règle est cache d'abord, jamais réseau d'abord : l'app démarre sur ce /// qu'elle a, et se rafraîchit ensuite. Un serveur lent ou absent ne doit pas /// retarder d'une seconde l'affichage d'un contenu déjà téléchargé. /// /// Rien n'est jamais servi à moitié : le JSON est écrit dans un fichier temporaire /// puis déplacé. Une coupure de courant pendant une écriture — le cas nominal pour /// une borne qu'on débranche le soir — laisse l'ancienne version intacte, jamais un /// fichier tronqué qui ne se relit pas. /// public static class ContentCache { static string Root => Path.Combine(Application.persistentDataPath, "content"); // Un seul fichier par configuration, pas un par langue : l'export les porte // toutes (voir ConfigurationExport.FetchAsync). static string ExportPath(string configurationId) => Path.Combine(Root, $"{configurationId}.json"); static string MediaDirectory => Path.Combine(Root, "media"); /// Le contenu en cache, ou null si ce casque n'a jamais rien téléchargé. public static string ReadExport(string configurationId) { var path = ExportPath(configurationId); try { return File.Exists(path) ? File.ReadAllText(path, Encoding.UTF8) : null; } catch (IOException e) { Debug.LogError($"[Cache] Lecture de {path} impossible : {e.Message}"); return null; } } public static DateTime? ExportDate(string configurationId) { var path = ExportPath(configurationId); return File.Exists(path) ? File.GetLastWriteTimeUtc(path) : (DateTime?)null; } public static void WriteExport(string configurationId, string json) { var path = ExportPath(configurationId); var temporary = path + ".tmp"; try { Directory.CreateDirectory(Root); File.WriteAllText(temporary, json, Encoding.UTF8); // File.Move ne remplace pas sur toutes les plateformes ; le couple // delete + move est la seule forme qui marche partout, et la fenêtre // entre les deux est couverte par le .tmp qui reste lisible. if (File.Exists(path)) File.Delete(path); File.Move(temporary, path); } catch (IOException e) { Debug.LogError($"[Cache] Écriture de {path} impossible : {e.Message}"); } } /// /// Le fichier local d'un média déjà téléchargé, ou null. Sans réseau et /// sans attente : c'est ce qui permet à un appelant synchrone — une coroutine, /// typiquement — de jouer un son depuis le disque au lieu de le streamer. /// public static string CachedPath(string url) { if (string.IsNullOrEmpty(url)) return null; var path = LocalPathOf(url); return File.Exists(path) ? path : null; } static string LocalPathOf(string url) => Path.Combine(MediaDirectory, HashOf(url) + ExtensionOf(url)); /// /// Le fichier local d'un média, téléchargé si absent. Le nom vient d'un hachage /// de l'URL : les sources du CMS ne sont pas des noms de fichiers sûrs, et deux /// ressources peuvent porter le même nom d'affichage. /// public static async Task MediaPathAsync(ApiClient client, string url) { if (string.IsNullOrEmpty(url)) return null; var path = LocalPathOf(url); if (File.Exists(path)) return path; using var request = UnityWebRequest.Get(url); var operation = request.SendWebRequest(); while (!operation.isDone) await Task.Yield(); if (request.result != UnityWebRequest.Result.Success) { Debug.LogError($"[Cache] {url} : {request.error}"); return null; } try { Directory.CreateDirectory(MediaDirectory); var temporary = path + ".tmp"; File.WriteAllBytes(temporary, request.downloadHandler.data); if (File.Exists(path)) File.Delete(path); File.Move(temporary, path); return path; } catch (IOException e) { Debug.LogError($"[Cache] Écriture de {path} impossible : {e.Message}"); return null; } } /// Octets occupés par le cache. À afficher un jour dans le manager. public static long SizeBytes() { if (!Directory.Exists(Root)) return 0; long total = 0; foreach (var file in Directory.GetFiles(Root, "*", SearchOption.AllDirectories)) total += new FileInfo(file).Length; return total; } public static void Clear() { try { if (Directory.Exists(Root)) Directory.Delete(Root, true); } catch (IOException e) { Debug.LogError($"[Cache] Purge impossible : {e.Message}"); } } static string HashOf(string value) { using var sha = SHA1.Create(); var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(value)); var hex = new StringBuilder(bytes.Length * 2); foreach (var b in bytes) hex.Append(b.ToString("x2")); return hex.ToString(); } /// /// L'extension compte : Unity choisit son décodeur vidéo et son importeur de /// texture dessus. Une URL avec une query string ne doit pas la faire perdre. /// static string ExtensionOf(string url) { var withoutQuery = url.Split('?')[0]; var extension = Path.GetExtension(withoutQuery); return string.IsNullOrEmpty(extension) || extension.Length > 6 ? "" : extension; } } }