Premier commit du cinquième front. Trois parties : - `unity/MyInfoMateVR/` : le projet Unity (6000.0.83f1, URP, Meta XR SDK 205), un APK unique pour tous les clients. Menu flottant à sélection au regard, appairage, chargement de scène GLB, POI, cache de contenu, télémétrie. - `unity-overlay/` : les mêmes scripts à recopier sur un projet Unity neuf, avec les pièges rencontrés consignés dans son README. - `viewer/` : viewer et éditeur de scène web autonome (Vite, TypeScript, three.js), partagé avec les autres fronts. - `docs/` : état des lieux, setup Unity, décisions d'architecture et plan d'exécution en 9 étapes. La scène est décrite par un `scene.json` poussé par `adb push` : l'app le préfère à celui embarqué dans l'APK. Les binaires (GLB, textures de l'échantillon Sponza, DLL Meta XR) passent par Git LFS dès ce premier commit — les y faire entrer après coup demanderait de réécrire l'historique. Les artefacts régénérés par l'éditeur et par CMake (`Library/`, `.utmp/`, Burst debug) sont ignorés. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
181 lines
6.7 KiB
C#
181 lines
6.7 KiB
C#
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
|
|
{
|
|
/// <summary>
|
|
/// Cache disque du contenu — item <b>E4</b> du lot XR-4.
|
|
///
|
|
/// <b>C'est l'item qui justifie Unity plutôt que WebXR</b> (§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 <b>cache d'abord</b>, 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.
|
|
/// </summary>
|
|
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");
|
|
|
|
/// <summary>Le contenu en cache, ou null si ce casque n'a jamais rien téléchargé.</summary>
|
|
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}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Le fichier local d'un média <b>déjà téléchargé</b>, 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.
|
|
/// </summary>
|
|
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));
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public static async Task<string> 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;
|
|
}
|
|
}
|
|
|
|
/// <summary>Octets occupés par le cache. À afficher un jour dans le manager.</summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
static string ExtensionOf(string url)
|
|
{
|
|
var withoutQuery = url.Split('?')[0];
|
|
var extension = Path.GetExtension(withoutQuery);
|
|
return string.IsNullOrEmpty(extension) || extension.Length > 6 ? "" : extension;
|
|
}
|
|
}
|
|
}
|