Thomas Fransolet 0310d28b5e Canal VR Meta Quest : projet Unity, viewer web et documentation
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>
2026-09-16 15:26:07 +02:00

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