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>
161 lines
6.4 KiB
C#
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."
|
|
};
|
|
}
|
|
}
|
|
}
|