// ───────────────────────────────────────────────────────────────────────────── // 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); } } } }