// ─────────────────────────────────────────────────────────────────────────────
// 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
{
///
/// 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.
///
///
/// The class is declared partial so that
/// ManagedPolicyEndpointConfig.cs (compiled only under
/// DEV_ENDPOINT) can inject the compile-time endpoint URL as a private
/// constant without touching this file.
///
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 ────────────────────────────────────────────────────────
///
/// Branding values extracted from the branding block of the managed
/// policy. All fields are optional; null/empty means "use the default".
///
public class BrandingData
{
/// Local path to the downloaded logo PNG, or null if not set.
public string LogoPath;
/// App title text to show in the launcher header.
public string AppTitle;
/// Primary UI colour (tabs background, panel headers) as #RRGGBB.
public string PrimaryColor;
/// Accent UI colour (selected tab indicator, hover border) as #RRGGBB.
public string AccentColor;
}
///
/// Branding applied from the most recent policy fetch.
/// Read by on startup.
///
public static BrandingData Branding { get; private set; } = new BrandingData();
///
/// Returns the path to the managed wallpaper file if it exists, otherwise
/// null. Used by to apply a managed
/// background on startup.
///
public static string ManagedWallpaperPath
{
get
{
var path = Path.Combine(Application.persistentDataPath, ManagedWallpaperFile);
return File.Exists(path) ? path : null;
}
}
///
/// When true the settings panel should be hidden from end-users.
/// Value is derived from the most recently applied policy.
///
public static bool DisableSettings { get; private set; } = false;
///
/// Fetches policies from the configured endpoint (compile-time
/// EndpointUrl when DEV_ENDPOINT is defined, otherwise
/// config.managedPolicyEndpoint), writes the resulting local files,
/// and updates .
///
///
/// Failures are non-fatal: if the network is unavailable the cached policy
/// from the previous successful fetch is applied instead.
///
///
/// Current launcher configuration.
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 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);
}
}
///
/// Synchronous policy application used when restoring from cache
/// (wallpaper and logo are already on disk so no download needed).
///
private static void ApplyPolicySync(JObject policy)
{
ApplyHiddenApps(policy);
ApplyAppNames(policy);
ApplyDisableSettings(policy);
ApplyBrandingSync(policy);
}
// ── Policy field handlers ─────────────────────────────────────────────
///
/// Writes managed hidden apps to excludedpackages_managed.txt.
/// reads this file in addition to the
/// user-managed excludedpackages.txt.
///
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 { "# Managed by policy — do not edit" };
foreach (var token in hiddenApps)
{
var pkg = token.Value();
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);
}
}
///
/// Writes managed app-name/category overrides to appnames_managed.json.
/// Because reads all appnames*.json files,
/// this file is picked up automatically without any extra wiring.
///
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();
Debug.LogFormat("[MDM] DisableSettings = {0}", DisableSettings);
}
}
///
/// Reads the branding block from the policy, downloads the logo if a
/// URL is provided, then populates so
/// can apply everything to the UI.
///
private static async Task ApplyBrandingAsync(JObject policy)
{
var brandingBlock = policy["branding"] as JObject;
var data = new BrandingData
{
AppTitle = brandingBlock?["appTitle"]?.Value(),
PrimaryColor = brandingBlock?["primaryColor"]?.Value(),
AccentColor = brandingBlock?["accentColor"]?.Value(),
};
// Download logo if a URL is supplied
var logoUrl = brandingBlock?["logoUrl"]?.Value();
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);
}
///
/// Sync version of branding — used when restoring from cache (logo already on disk).
///
private static void ApplyBrandingSync(JObject policy)
{
var brandingBlock = policy["branding"] as JObject;
var data = new BrandingData
{
AppTitle = brandingBlock?["appTitle"]?.Value(),
PrimaryColor = brandingBlock?["primaryColor"]?.Value(),
AccentColor = brandingBlock?["accentColor"]?.Value(),
};
var cachedLogo = Path.Combine(Application.persistentDataPath, ManagedLogoFile);
if (File.Exists(cachedLogo)) data.LogoPath = cachedLogo;
Branding = data;
}
///
/// Downloads the managed wallpaper image if wallpaperUrl is set in
/// the policy, then updates so the skybox
/// is applied on the current session (without requiring a scene reload).
///
private static async Task ApplyWallpaperAsync(JObject policy, Config config)
{
var wallpaperUrl = policy["wallpaperUrl"]?.Value();
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);
}
}
}
}