430 lines
18 KiB
C#
430 lines
18 KiB
C#
// ─────────────────────────────────────────────────────────────────────────────
|
||
// ManagedPolicyHandler.cs
|
||
//
|
||
// Fetches and applies device-management policies from a remote HTTP endpoint on
|
||
// every app startup.
|
||
//
|
||
// This feature is compiled in under two conditions (either is sufficient):
|
||
// 1. The DEV_ENDPOINT scripting define symbol is set in Player Settings.
|
||
// The endpoint URL is taken from ManagedPolicyEndpointConfig.cs.
|
||
// 2. config.managedPolicyEndpoint is non-empty in config.json at runtime.
|
||
//
|
||
// Policies the handler can apply
|
||
// ───────────────────────────────
|
||
// hiddenApps – package names to hide from the launcher (appended to the
|
||
// managed exclusions file; does NOT modify the user's own
|
||
// excludedpackages.txt).
|
||
// wallpaperUrl – HTTPS URL for a skybox/background image to download.
|
||
// disableSettings – when true the settings gear button is hidden so end-users
|
||
// cannot change launcher configuration.
|
||
// appNames – display name / category overrides (written to
|
||
// appnames_managed.json which AppProcessor picks up
|
||
// automatically alongside other appnames*.json files).
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
using System;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.Threading.Tasks;
|
||
using UnityEngine;
|
||
using UnityEngine.Networking;
|
||
using Newtonsoft.Json;
|
||
using Newtonsoft.Json.Linq;
|
||
|
||
namespace QuestAppLauncher
|
||
{
|
||
/// <summary>
|
||
/// Fetches managed policies from a remote endpoint and applies them locally.
|
||
/// Policies are cached so the last successfully fetched policy is used even
|
||
/// when the device is offline.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// The class is declared <c>partial</c> so that
|
||
/// <c>ManagedPolicyEndpointConfig.cs</c> (compiled only under
|
||
/// <c>DEV_ENDPOINT</c>) can inject the compile-time endpoint URL as a private
|
||
/// constant without touching this file.
|
||
/// </remarks>
|
||
public static partial class ManagedPolicyHandler
|
||
{
|
||
// File names written into the app's persistent data directory
|
||
private const string ManagedExcludedPackagesFile = "excludedpackages_managed.txt";
|
||
private const string ManagedAppNamesFile = "appnames_managed.json";
|
||
private const string ManagedWallpaperFile = "wallpaper_managed.jpg";
|
||
private const string ManagedLogoFile = "branding_logo.png";
|
||
private const string ManagedPolicyCacheFile = "managed_policy_cache.json";
|
||
|
||
// ── Public API ────────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// Branding values extracted from the <c>branding</c> block of the managed
|
||
/// policy. All fields are optional; null/empty means "use the default".
|
||
/// </summary>
|
||
public class BrandingData
|
||
{
|
||
/// <summary>Local path to the downloaded logo PNG, or null if not set.</summary>
|
||
public string LogoPath;
|
||
/// <summary>App title text to show in the launcher header.</summary>
|
||
public string AppTitle;
|
||
/// <summary>Primary UI colour (tabs background, panel headers) as #RRGGBB.</summary>
|
||
public string PrimaryColor;
|
||
/// <summary>Accent UI colour (selected tab indicator, hover border) as #RRGGBB.</summary>
|
||
public string AccentColor;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Branding applied from the most recent policy fetch.
|
||
/// Read by <see cref="BrandingHandler"/> on startup.
|
||
/// </summary>
|
||
public static BrandingData Branding { get; private set; } = new BrandingData();
|
||
|
||
/// <summary>
|
||
/// Returns the path to the managed wallpaper file if it exists, otherwise
|
||
/// <c>null</c>. Used by <see cref="SkyboxHandler"/> to apply a managed
|
||
/// background on startup.
|
||
/// </summary>
|
||
public static string ManagedWallpaperPath
|
||
{
|
||
get
|
||
{
|
||
var path = Path.Combine(Application.persistentDataPath, ManagedWallpaperFile);
|
||
return File.Exists(path) ? path : null;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// When <c>true</c> the settings panel should be hidden from end-users.
|
||
/// Value is derived from the most recently applied policy.
|
||
/// </summary>
|
||
public static bool DisableSettings { get; private set; } = false;
|
||
|
||
/// <summary>
|
||
/// Fetches policies from the configured endpoint (compile-time
|
||
/// <c>EndpointUrl</c> when <c>DEV_ENDPOINT</c> is defined, otherwise
|
||
/// <c>config.managedPolicyEndpoint</c>), writes the resulting local files,
|
||
/// and updates <see cref="DisableSettings"/>.
|
||
///
|
||
/// <para>
|
||
/// Failures are non-fatal: if the network is unavailable the cached policy
|
||
/// from the previous successful fetch is applied instead.
|
||
/// </para>
|
||
/// </summary>
|
||
/// <param name="config">Current launcher configuration.</param>
|
||
public static async Task ApplyPoliciesAsync(Config config)
|
||
{
|
||
string url = ResolveEndpointUrl(config);
|
||
if (string.IsNullOrEmpty(url))
|
||
{
|
||
// Managed policies not configured — apply any cached policy silently
|
||
ApplyCachedPolicy();
|
||
return;
|
||
}
|
||
|
||
Debug.LogFormat("[MDM] Fetching policy from {0}", url);
|
||
|
||
JObject policy = null;
|
||
try
|
||
{
|
||
policy = await FetchPolicyAsync(url);
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
Debug.LogFormat("[MDM] Error fetching policy: {0}", e.Message);
|
||
}
|
||
|
||
if (policy != null)
|
||
{
|
||
// Persist to cache so we can apply it when offline next time
|
||
CachePolicy(policy);
|
||
await ApplyPolicyAsync(policy, config);
|
||
}
|
||
else
|
||
{
|
||
// Network unavailable or error — fall back to cache
|
||
Debug.Log("[MDM] Falling back to cached policy");
|
||
ApplyCachedPolicy();
|
||
}
|
||
}
|
||
|
||
// ── Private helpers ───────────────────────────────────────────────────
|
||
|
||
private static string ResolveEndpointUrl(Config config)
|
||
{
|
||
#if DEV_ENDPOINT
|
||
// Compile-time URL takes precedence over runtime config
|
||
return EndpointUrl;
|
||
#else
|
||
return string.IsNullOrEmpty(config?.managedPolicyEndpoint) ? null : config.managedPolicyEndpoint;
|
||
#endif
|
||
}
|
||
|
||
private static async Task<JObject> FetchPolicyAsync(string url)
|
||
{
|
||
using (var req = new UnityWebRequest(url))
|
||
{
|
||
req.downloadHandler = new DownloadHandlerBuffer();
|
||
await req.SendWebRequest();
|
||
|
||
if (req.result != UnityWebRequest.Result.Success)
|
||
{
|
||
Debug.LogFormat("[MDM] HTTP error fetching policy: {0}", req.error);
|
||
return null;
|
||
}
|
||
|
||
return JObject.Parse(req.downloadHandler.text);
|
||
}
|
||
}
|
||
|
||
private static void CachePolicy(JObject policy)
|
||
{
|
||
try
|
||
{
|
||
var cachePath = Path.Combine(Application.persistentDataPath, ManagedPolicyCacheFile);
|
||
File.WriteAllText(cachePath, policy.ToString(Formatting.Indented));
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
Debug.LogFormat("[MDM] Failed to cache policy: {0}", e.Message);
|
||
}
|
||
}
|
||
|
||
private static void ApplyCachedPolicy()
|
||
{
|
||
var cachePath = Path.Combine(Application.persistentDataPath, ManagedPolicyCacheFile);
|
||
if (!File.Exists(cachePath))
|
||
{
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
var policy = JObject.Parse(File.ReadAllText(cachePath));
|
||
// Cached policy application is synchronous — wallpaper is already downloaded
|
||
ApplyPolicySync(policy);
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
Debug.LogFormat("[MDM] Failed to apply cached policy: {0}", e.Message);
|
||
}
|
||
}
|
||
|
||
private static async Task ApplyPolicyAsync(JObject policy, Config config)
|
||
{
|
||
try
|
||
{
|
||
ApplyHiddenApps(policy);
|
||
ApplyAppNames(policy);
|
||
ApplyDisableSettings(policy);
|
||
await ApplyWallpaperAsync(policy, config);
|
||
await ApplyBrandingAsync(policy);
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
Debug.LogFormat("[MDM] Error applying policy: {0}", e.Message);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Synchronous policy application used when restoring from cache
|
||
/// (wallpaper and logo are already on disk so no download needed).
|
||
/// </summary>
|
||
private static void ApplyPolicySync(JObject policy)
|
||
{
|
||
ApplyHiddenApps(policy);
|
||
ApplyAppNames(policy);
|
||
ApplyDisableSettings(policy);
|
||
ApplyBrandingSync(policy);
|
||
}
|
||
|
||
// ── Policy field handlers ─────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// Writes managed hidden apps to <c>excludedpackages_managed.txt</c>.
|
||
/// <see cref="AppProcessor"/> reads this file in addition to the
|
||
/// user-managed <c>excludedpackages.txt</c>.
|
||
/// </summary>
|
||
private static void ApplyHiddenApps(JObject policy)
|
||
{
|
||
var hiddenApps = policy["hiddenApps"] as JArray;
|
||
if (hiddenApps == null || hiddenApps.Count == 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
var filePath = Path.Combine(Application.persistentDataPath, ManagedExcludedPackagesFile);
|
||
var lines = new List<string> { "# Managed by policy — do not edit" };
|
||
foreach (var token in hiddenApps)
|
||
{
|
||
var pkg = token.Value<string>();
|
||
if (!string.IsNullOrEmpty(pkg))
|
||
{
|
||
lines.Add(pkg);
|
||
}
|
||
}
|
||
File.WriteAllLines(filePath, lines);
|
||
Debug.LogFormat("[MDM] Applied {0} hidden app(s)", hiddenApps.Count);
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
Debug.LogFormat("[MDM] Failed to write managed exclusions: {0}", e.Message);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Writes managed app-name/category overrides to <c>appnames_managed.json</c>.
|
||
/// Because <see cref="AppProcessor"/> reads all <c>appnames*.json</c> files,
|
||
/// this file is picked up automatically without any extra wiring.
|
||
/// </summary>
|
||
private static void ApplyAppNames(JObject policy)
|
||
{
|
||
var appNames = policy["appNames"] as JObject;
|
||
if (appNames == null || !appNames.HasValues)
|
||
{
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
var filePath = Path.Combine(Application.persistentDataPath, ManagedAppNamesFile);
|
||
File.WriteAllText(filePath, appNames.ToString(Formatting.Indented));
|
||
Debug.LogFormat("[MDM] Applied app names for {0} package(s)", appNames.Count);
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
Debug.LogFormat("[MDM] Failed to write managed app names: {0}", e.Message);
|
||
}
|
||
}
|
||
|
||
private static void ApplyDisableSettings(JObject policy)
|
||
{
|
||
var disableSettingsToken = policy["disableSettings"];
|
||
if (disableSettingsToken != null)
|
||
{
|
||
DisableSettings = disableSettingsToken.Value<bool>();
|
||
Debug.LogFormat("[MDM] DisableSettings = {0}", DisableSettings);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Reads the <c>branding</c> block from the policy, downloads the logo if a
|
||
/// URL is provided, then populates <see cref="Branding"/> so
|
||
/// <see cref="BrandingHandler"/> can apply everything to the UI.
|
||
/// </summary>
|
||
private static async Task ApplyBrandingAsync(JObject policy)
|
||
{
|
||
var brandingBlock = policy["branding"] as JObject;
|
||
var data = new BrandingData
|
||
{
|
||
AppTitle = brandingBlock?["appTitle"]?.Value<string>(),
|
||
PrimaryColor = brandingBlock?["primaryColor"]?.Value<string>(),
|
||
AccentColor = brandingBlock?["accentColor"]?.Value<string>(),
|
||
};
|
||
|
||
// Download logo if a URL is supplied
|
||
var logoUrl = brandingBlock?["logoUrl"]?.Value<string>();
|
||
if (!string.IsNullOrEmpty(logoUrl))
|
||
{
|
||
try
|
||
{
|
||
var destPath = Path.Combine(Application.persistentDataPath, ManagedLogoFile);
|
||
using (var req = new UnityWebRequest(logoUrl))
|
||
{
|
||
req.downloadHandler = new DownloadHandlerBuffer();
|
||
await req.SendWebRequest();
|
||
|
||
if (req.result == UnityWebRequest.Result.Success)
|
||
{
|
||
File.WriteAllBytes(destPath, req.downloadHandler.data);
|
||
data.LogoPath = destPath;
|
||
Debug.LogFormat("[MDM] Downloaded branding logo to {0}", destPath);
|
||
}
|
||
else
|
||
{
|
||
Debug.LogFormat("[MDM] Failed to download logo: {0}", req.error);
|
||
// Fall back to cached logo if present
|
||
if (File.Exists(destPath)) data.LogoPath = destPath;
|
||
}
|
||
}
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
Debug.LogFormat("[MDM] Exception downloading logo: {0}", e.Message);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// Use cached logo if no URL this session
|
||
var cachedLogo = Path.Combine(Application.persistentDataPath, ManagedLogoFile);
|
||
if (File.Exists(cachedLogo)) data.LogoPath = cachedLogo;
|
||
}
|
||
|
||
Branding = data;
|
||
Debug.LogFormat("[MDM] Branding applied — title: {0}, primary: {1}, accent: {2}",
|
||
data.AppTitle, data.PrimaryColor, data.AccentColor);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Sync version of branding — used when restoring from cache (logo already on disk).
|
||
/// </summary>
|
||
private static void ApplyBrandingSync(JObject policy)
|
||
{
|
||
var brandingBlock = policy["branding"] as JObject;
|
||
var data = new BrandingData
|
||
{
|
||
AppTitle = brandingBlock?["appTitle"]?.Value<string>(),
|
||
PrimaryColor = brandingBlock?["primaryColor"]?.Value<string>(),
|
||
AccentColor = brandingBlock?["accentColor"]?.Value<string>(),
|
||
};
|
||
|
||
var cachedLogo = Path.Combine(Application.persistentDataPath, ManagedLogoFile);
|
||
if (File.Exists(cachedLogo)) data.LogoPath = cachedLogo;
|
||
|
||
Branding = data;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Downloads the managed wallpaper image if <c>wallpaperUrl</c> is set in
|
||
/// the policy, then updates <see cref="Config.background"/> so the skybox
|
||
/// is applied on the current session (without requiring a scene reload).
|
||
/// </summary>
|
||
private static async Task ApplyWallpaperAsync(JObject policy, Config config)
|
||
{
|
||
var wallpaperUrl = policy["wallpaperUrl"]?.Value<string>();
|
||
if (string.IsNullOrEmpty(wallpaperUrl))
|
||
{
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
var destPath = Path.Combine(Application.persistentDataPath, ManagedWallpaperFile);
|
||
using (var req = new UnityWebRequest(wallpaperUrl))
|
||
{
|
||
req.downloadHandler = new DownloadHandlerBuffer();
|
||
await req.SendWebRequest();
|
||
|
||
if (req.result != UnityWebRequest.Result.Success)
|
||
{
|
||
Debug.LogFormat("[MDM] Failed to download wallpaper: {0}", req.error);
|
||
return;
|
||
}
|
||
|
||
File.WriteAllBytes(destPath, req.downloadHandler.data);
|
||
Debug.LogFormat("[MDM] Downloaded managed wallpaper to {0}", destPath);
|
||
}
|
||
|
||
// Point the config at the managed wallpaper so GridPopulation picks it up
|
||
if (config != null)
|
||
{
|
||
config.background = destPath;
|
||
}
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
Debug.LogFormat("[MDM] Failed to apply wallpaper: {0}", e.Message);
|
||
}
|
||
}
|
||
}
|
||
}
|