Files
QuestAppLauncher/Assets/Scripts/ManagedPolicyHandler.cs
oxmc7769 cb8c3eb5df
Some checks failed
Build / Build launcher (push) Has been cancelled
Major update
2026-03-18 09:14:25 -07:00

430 lines
18 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ─────────────────────────────────────────────────────────────────────────────
// 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);
}
}
}
}