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

161 lines
6.4 KiB
C#

using System.Text;
using System.Threading.Tasks;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
using UnityEngine;
using UnityEngine.Networking;
namespace MyInfoMate.Vr.Net
{
/// <summary>
/// Le seul endroit du projet qui parle à manager-service.
///
/// Trois règles tenues ici, parce qu'elles se paient cher ailleurs :
/// la clé d'API part en en-tête <c>X-Api-Key</c> et jamais dans l'URL ;
/// un échec produit une <b>phrase lisible</b> comme <see cref="Manifest.ManifestReader"/>,
/// jamais un <c>null</c> silencieux ; et le JSON est en camelCase côté serveur,
/// PascalCase côté C#.
/// </summary>
public class ApiClient
{
public const string ApiKeyHeader = "X-Api-Key";
/// <summary>Une borne sur un wifi de musée : ni instantané, ni infini.</summary>
const int TimeoutSeconds = 20;
static readonly JsonSerializerSettings Settings = new JsonSerializerSettings
{
ContractResolver = new DefaultContractResolver
{
NamingStrategy = new CamelCaseNamingStrategy()
},
MissingMemberHandling = MissingMemberHandling.Ignore,
NullValueHandling = NullValueHandling.Ignore
};
public string BaseUrl { get; }
/// <summary>Null tant que l'appairage n'a pas eu lieu.</summary>
public string ApiKey { get; set; }
public ApiClient(string baseUrl, string apiKey = null)
{
// Une base d'URL avec un slash final produit "//api/..." — accepté par ASP.NET,
// mais pas par tous les proxies devant.
BaseUrl = baseUrl?.TrimEnd('/');
ApiKey = apiKey;
}
public class Result<T>
{
public T Value;
/// <summary>Null si tout va bien ; sinon une phrase destinée au visiteur.</summary>
public string Error;
public bool Ok => Error == null;
}
public Task<Result<T>> GetAsync<T>(string path) =>
SendAsync<T>(UnityWebRequest.Get(BaseUrl + path), path);
public Task<Result<T>> PostAsync<T>(string path, object body) =>
SendBody<T>(path, body, UnityWebRequest.kHttpVerbPOST);
public Task<Result<T>> PutAsync<T>(string path, object body) =>
SendBody<T>(path, body, UnityWebRequest.kHttpVerbPUT);
Task<Result<T>> SendBody<T>(string path, object body, string verb)
{
var json = JsonConvert.SerializeObject(body, Settings);
var request = new UnityWebRequest(BaseUrl + path, verb)
{
uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(json)),
downloadHandler = new DownloadHandlerBuffer()
};
request.SetRequestHeader("Content-Type", "application/json");
return SendAsync<T>(request, path);
}
/// <summary>
/// Le texte brut, pour l'export de configuration : c'est un
/// <c>FileContentResult</c> côté serveur, et on veut pouvoir l'écrire tel quel
/// dans le cache disque avant de le désérialiser.
/// </summary>
public async Task<Result<string>> GetRawAsync(string path)
{
using var request = UnityWebRequest.Get(BaseUrl + path);
var sent = await Send(request, path);
return sent != null
? new Result<string> { Error = sent }
: new Result<string> { Value = request.downloadHandler.text };
}
async Task<Result<T>> SendAsync<T>(UnityWebRequest request, string path)
{
using (request)
{
var sent = await Send(request, path);
if (sent != null) return new Result<T> { Error = sent };
// Un 204 n'a pas de corps — c'est le cas nominal de la télémétrie, pas
// une réponse illisible.
if (string.IsNullOrWhiteSpace(request.downloadHandler?.text))
return new Result<T>();
try
{
return new Result<T>
{
Value = JsonConvert.DeserializeObject<T>(
request.downloadHandler.text, Settings)
};
}
catch (JsonException e)
{
Debug.LogError($"[Api] {path} : réponse illisible — {e.Message}");
return new Result<T> { Error = "Le serveur a répondu quelque chose d'inattendu." };
}
}
}
/// <summary>Null si la requête est passée ; sinon la phrase à afficher.</summary>
async Task<string> Send(UnityWebRequest request, string path)
{
request.timeout = TimeoutSeconds;
if (!string.IsNullOrEmpty(ApiKey)) request.SetRequestHeader(ApiKeyHeader, ApiKey);
UnityWebRequestAsyncOperation operation;
try
{
operation = request.SendWebRequest();
}
catch (System.InvalidOperationException e)
{
// Le cas connu : « Insecure connection not allowed », quand le Player
// interdit le HTTP en clair et que le serveur est en http://. Ça lève
// au lieu de rendre un code, donc sans ce catch l'await remonte
// l'exception et le casque n'affiche rien du tout.
Debug.LogError($"[Api] {path} : requête refusée avant l'envoi — {e.Message}");
return "Le serveur est injoignable. Vérifiez la connexion du casque.";
}
while (!operation.isDone) await Task.Yield();
if (request.result == UnityWebRequest.Result.Success) return null;
Debug.LogError($"[Api] {path} : {(long)request.responseCode} {request.error}");
// Ces trois codes-là ne sont pas des pannes réseau, ce sont des erreurs
// d'appairage — et c'est ce que la personne devant le casque doit lire.
return request.responseCode switch
{
401 => "Ce casque n'est plus autorisé. Refaites l'appairage.",
403 => "Ce casque n'a pas accès à ce contenu.",
404 => "Ce contenu n'existe plus sur le serveur.",
_ => "Le serveur est injoignable. Vérifiez la connexion du casque."
};
}
}
}