@@ -53,6 +53,18 @@ namespace QuestAppLauncher
|
||||
// File name of excluded package names
|
||||
const string ExcludedPackagesFile = "excludedpackages.txt";
|
||||
|
||||
// File name of managed excluded package names (written by ManagedPolicyHandler)
|
||||
const string ManagedExcludedPackagesFile = "excludedpackages_managed.txt";
|
||||
|
||||
// Cached persistent data path — must be set from the main thread before any background use
|
||||
private static string _persistentDataPath;
|
||||
|
||||
/// <summary>
|
||||
/// Call this from the main thread (e.g. Start/Awake) before any background Task.Run.
|
||||
/// Unity 2023+ throws if Application.persistentDataPath is read off the main thread.
|
||||
/// </summary>
|
||||
public static void CachePersistentDataPath(string path) => _persistentDataPath = path;
|
||||
|
||||
// Icon pack extraction dir
|
||||
const string IconPackExtractionDir = "cache";
|
||||
|
||||
@@ -61,14 +73,13 @@ namespace QuestAppLauncher
|
||||
|
||||
// Built-in tab names
|
||||
public const string Tab_Quest = "Quest";
|
||||
public const string Tab_Go = "Go/Gear";
|
||||
public const string Tab_2D = "2D";
|
||||
public const string Tab_All = "All";
|
||||
|
||||
// LastUsage lookback days
|
||||
const int LastUsedLookbackDays = 30;
|
||||
|
||||
public static readonly string[] Auto_Tabs = { Tab_Quest, Tab_Go, Tab_2D };
|
||||
public static readonly string[] Auto_Tabs = { Tab_Quest, Tab_2D };
|
||||
|
||||
/// <summary>
|
||||
/// Entry point for app processing: Applies app name overrides (from appnames.txt/json) and app icons (from individual jpgs or icon packs).
|
||||
@@ -79,7 +90,7 @@ namespace QuestAppLauncher
|
||||
/// <returns>Dictionary of processed apps</returns>
|
||||
public static Dictionary<string, ProcessedApp> ProcessApps(Config config, bool isRenameMode = false)
|
||||
{
|
||||
var persistentDataPath = UnityEngine.Application.persistentDataPath;
|
||||
var persistentDataPath = _persistentDataPath;
|
||||
Debug.Log("Persistent data path: " + persistentDataPath);
|
||||
|
||||
// Dictionary to hold package name -> app index, app name
|
||||
@@ -89,6 +100,9 @@ namespace QuestAppLauncher
|
||||
using (AndroidJavaClass unity = new AndroidJavaClass("com.unity3d.player.UnityPlayer"))
|
||||
using (AndroidJavaObject currentActivity = unity.GetStatic<AndroidJavaObject>("currentActivity"))
|
||||
{
|
||||
// Log activity class to verify AppInfo is the running activity
|
||||
Debug.Log("currentActivity class: " + currentActivity.Call<AndroidJavaObject>("getClass").Call<string>("getName"));
|
||||
|
||||
// Get # of installed apps
|
||||
int numApps = currentActivity.Call<int>("getSize");
|
||||
Debug.Log("# installed apps: " + numApps);
|
||||
@@ -123,6 +137,21 @@ namespace QuestAppLauncher
|
||||
}
|
||||
}
|
||||
|
||||
// Also load managed exclusions (written by ManagedPolicyHandler)
|
||||
var managedExcludedPackagesFilePath = Path.Combine(persistentDataPath, ManagedExcludedPackagesFile);
|
||||
if (!isRenameMode && File.Exists(managedExcludedPackagesFilePath))
|
||||
{
|
||||
Debug.Log("Found managed exclusions file: " + managedExcludedPackagesFilePath);
|
||||
string[] managedExcludedPackages = File.ReadAllLines(managedExcludedPackagesFilePath);
|
||||
foreach (string excludedPackage in managedExcludedPackages)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(excludedPackage) && !excludedPackage.StartsWith("#"))
|
||||
{
|
||||
excludedPackageNames.Add(excludedPackage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get installed package and app names
|
||||
for (int i = 0; i < numApps; i++)
|
||||
{
|
||||
@@ -150,13 +179,9 @@ namespace QuestAppLauncher
|
||||
|
||||
tabName = Tab_2D;
|
||||
}
|
||||
else if (currentActivity.Call<bool>("isQuestApp", i))
|
||||
{
|
||||
tabName = Tab_Quest;
|
||||
}
|
||||
else
|
||||
{
|
||||
tabName = Tab_Go;
|
||||
tabName = Tab_Quest;
|
||||
}
|
||||
|
||||
apps.Add(packageName, new ProcessedApp { PackageName = packageName, Index = i,
|
||||
@@ -206,7 +231,7 @@ namespace QuestAppLauncher
|
||||
|
||||
private static void ProcessAppNameOverrideJsonFile(bool isRenameMode, Dictionary<string, ProcessedApp> apps, string appNameOverrideFilePath)
|
||||
{
|
||||
if (isRenameMode && appNameOverrideFilePath.Equals(Path.Combine(UnityEngine.Application.persistentDataPath, RenameJsonFileName),
|
||||
if (isRenameMode && appNameOverrideFilePath.Equals(Path.Combine(_persistentDataPath, RenameJsonFileName),
|
||||
StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
// In rename mode, so skip the rename json file itself
|
||||
@@ -453,7 +478,7 @@ namespace QuestAppLauncher
|
||||
var dirs = Directory.GetDirectories(extractionDirPath).OrderBy(f => f);
|
||||
foreach (var dir in dirs)
|
||||
{
|
||||
if (isRenameMode && dir.StartsWith(Path.Combine(UnityEngine.Application.persistentDataPath, RenameIconPackFileName),
|
||||
if (isRenameMode && dir.StartsWith(Path.Combine(_persistentDataPath, RenameIconPackFileName),
|
||||
StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
// In rename mode, so skip the extracted rename icon pack itself
|
||||
@@ -633,7 +658,7 @@ namespace QuestAppLauncher
|
||||
/// <param name="packageName"></param>
|
||||
static public void AddAppToExcludedFile(string packageName)
|
||||
{
|
||||
var persistentDataPath = UnityEngine.Application.persistentDataPath;
|
||||
var persistentDataPath = _persistentDataPath;
|
||||
var excludedPackageNamesFilePath = Path.Combine(persistentDataPath, ExcludedPackagesFile);
|
||||
|
||||
using (StreamWriter writer = File.AppendText(excludedPackageNamesFilePath))
|
||||
@@ -649,7 +674,7 @@ namespace QuestAppLauncher
|
||||
/// <returns>true if file exists</returns>
|
||||
static public bool DeleteExcludedAppsFile()
|
||||
{
|
||||
var persistentDataPath = UnityEngine.Application.persistentDataPath;
|
||||
var persistentDataPath = _persistentDataPath;
|
||||
var excludedPackageNamesFilePath = Path.Combine(persistentDataPath, ExcludedPackagesFile);
|
||||
|
||||
if (File.Exists(excludedPackageNamesFilePath))
|
||||
@@ -668,14 +693,14 @@ namespace QuestAppLauncher
|
||||
static public bool DeleteRenameFiles()
|
||||
{
|
||||
var ret = false;
|
||||
var renameJsonFilePath = Path.Combine(UnityEngine.Application.persistentDataPath, RenameJsonFileName);
|
||||
var renameJsonFilePath = Path.Combine(_persistentDataPath, RenameJsonFileName);
|
||||
if (File.Exists(renameJsonFilePath))
|
||||
{
|
||||
File.Delete(renameJsonFilePath);
|
||||
ret = true;
|
||||
}
|
||||
|
||||
var renameIconPackFilePath = Path.Combine(UnityEngine.Application.persistentDataPath, RenameIconPackFileName);
|
||||
var renameIconPackFilePath = Path.Combine(_persistentDataPath, RenameIconPackFileName);
|
||||
if (File.Exists(renameIconPackFilePath))
|
||||
{
|
||||
File.Delete(renameIconPackFilePath);
|
||||
|
||||
@@ -29,7 +29,7 @@ namespace QuestAppLauncher
|
||||
const string TempDownloadFileExtention = ".tmp_download";
|
||||
|
||||
// GitHub API url
|
||||
const string GithubApiUrl = @"http://api.github.com/repos/";
|
||||
const string GithubApiUrl = @"https://api.github.com/repos/";
|
||||
|
||||
// Rate limit in minutes
|
||||
const int RateLimitInMins = 5;
|
||||
@@ -37,6 +37,15 @@ namespace QuestAppLauncher
|
||||
// Used for mutual exclusion when loading assets
|
||||
static SemaphoreSlim semaphoreSlim = new SemaphoreSlim(1, 1);
|
||||
|
||||
// Cached persistent data path — must be set from the main thread before any background use
|
||||
private static string _persistentDataPath;
|
||||
|
||||
/// <summary>
|
||||
/// Call this from the main thread (e.g. Start/Awake) before any background Task.Run.
|
||||
/// Unity 2023+ throws if Application.persistentDataPath is read off the main thread.
|
||||
/// </summary>
|
||||
public static void CachePersistentDataPath(string path) => _persistentDataPath = path;
|
||||
|
||||
/// <summary>
|
||||
/// Class that tracks each file downloaded.
|
||||
/// Used to determine whether an asset has been updated since last download.
|
||||
@@ -77,37 +86,35 @@ namespace QuestAppLauncher
|
||||
/// <param name="config">Current config</param>
|
||||
/// <param name="downloadProgress">Download progress interface - used to indicate download progress</param>
|
||||
/// <returns></returns>
|
||||
public static async Task DownloadAssetsAsync(Config config, IDownloadProgress downloadProgress = null, bool forceCheck = false)
|
||||
public static void DownloadAssetsAsync(Config config, IDownloadProgress downloadProgress = null, bool forceCheck = false)
|
||||
{
|
||||
// Start background thread
|
||||
Task.Run(async () =>
|
||||
{
|
||||
// Mutual exclusion while loading assets
|
||||
await AssetsDownloader.semaphoreSlim.WaitAsync();
|
||||
_ = DownloadAssetsInternalAsync(config, downloadProgress, forceCheck);
|
||||
}
|
||||
|
||||
// Attach / detatch JNI. Required for any calls into JNI from background threads.
|
||||
AndroidJNI.AttachCurrentThread();
|
||||
private static async Task DownloadAssetsInternalAsync(Config config, IDownloadProgress downloadProgress, bool forceCheck)
|
||||
{
|
||||
// Mutual exclusion while loading assets
|
||||
await AssetsDownloader.semaphoreSlim.WaitAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Download assets from repos.
|
||||
AssetsDownloader assetsDownloader = new AssetsDownloader();
|
||||
return await assetsDownloader.DownloadFromReposAsync(config, downloadProgress, forceCheck);
|
||||
}
|
||||
finally
|
||||
{
|
||||
AssetsDownloader.semaphoreSlim.Release();
|
||||
AndroidJNI.DetachCurrentThread();
|
||||
}
|
||||
}).ContinueWith((downloadedAssets) =>
|
||||
try
|
||||
{
|
||||
if (downloadedAssets.Result)
|
||||
// UnityWebRequest must be created and used on the main thread.
|
||||
// Running as a plain async method (no Task.Run) keeps us on the Unity main-thread
|
||||
// synchronization context where web requests are allowed.
|
||||
AssetsDownloader assetsDownloader = new AssetsDownloader();
|
||||
bool downloaded = await assetsDownloader.DownloadFromReposAsync(config, downloadProgress, forceCheck);
|
||||
|
||||
if (downloaded)
|
||||
{
|
||||
// We downloaded new assets, so re-load the scene
|
||||
Debug.Log("Downloaded new assets. Re-populating panel");
|
||||
SceneManager.LoadSceneAsync(SceneManager.GetActiveScene().name);
|
||||
_ = SceneManager.LoadSceneAsync(SceneManager.GetActiveScene().name);
|
||||
}
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
AssetsDownloader.semaphoreSlim.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -180,30 +187,32 @@ namespace QuestAppLauncher
|
||||
private async Task<Dictionary<string, AssetInfo>> DownloadAssetsMetadata(
|
||||
Config config, AssetsManifest manifest, IDownloadProgress downloadProgress = null, bool forceCheck = false)
|
||||
{
|
||||
// Get asset info from repos
|
||||
// Gather asset metadata from every configured repo.
|
||||
// Duplicate URIs are skipped via the seenUris set.
|
||||
var assetsInfo = new Dictionary<string, AssetInfo>(StringComparer.OrdinalIgnoreCase);
|
||||
var seenUris = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var reposLoaded = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Get the set of repo URIs (removing any duplicates)
|
||||
var configRepos = new HashSet<string>();
|
||||
foreach (var item in config.downloadRepos)
|
||||
{
|
||||
if (null == item.type || !string.Equals(item.type, Config.DownloadRepo_Type_GitHub, StringComparison.OrdinalIgnoreCase))
|
||||
if (null == item.type || null == item.repoUri || !seenUris.Add(item.repoUri))
|
||||
{
|
||||
// For now, we only support GitHub repos
|
||||
continue;
|
||||
}
|
||||
|
||||
configRepos.Add(item.repoUri);
|
||||
}
|
||||
bool repoLoaded = false;
|
||||
if (string.Equals(item.type, Config.DownloadRepo_Type_GitHub, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
repoLoaded = await GetAssetsInfoFromGithubRepoAsync(item.repoUri, assetsInfo, downloadProgress);
|
||||
}
|
||||
else if (string.Equals(item.type, Config.DownloadRepo_Type_Http, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
repoLoaded = await GetAssetsInfoFromHttpEndpointAsync(item.repoUri, assetsInfo, downloadProgress);
|
||||
}
|
||||
|
||||
var reposLoaded = new HashSet<string>();
|
||||
foreach (var repoUri in configRepos)
|
||||
{
|
||||
// Get assets from the GitHub repo
|
||||
var repoLoaded = await GetAssetsInfoFromGithubRepoAsync(repoUri, assetsInfo, downloadProgress);
|
||||
if (repoLoaded)
|
||||
{
|
||||
reposLoaded.Add(repoUri);
|
||||
reposLoaded.Add(item.repoUri);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,7 +313,7 @@ namespace QuestAppLauncher
|
||||
{
|
||||
req.downloadHandler = new DownloadHandlerBuffer();
|
||||
await req.SendWebRequest();
|
||||
if (req.isNetworkError || req.isHttpError)
|
||||
if (req.result != UnityWebRequest.Result.Success)
|
||||
{
|
||||
// Error reading asset metadata, so return.
|
||||
Debug.LogFormat("Error reading asset info: {0}", req.error);
|
||||
@@ -368,6 +377,84 @@ namespace QuestAppLauncher
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the asset manifest from a plain HTTP endpoint and populates
|
||||
/// <paramref name="assetsInfo"/> with the downloadable files.
|
||||
///
|
||||
/// <para>Expected manifest JSON:</para>
|
||||
/// <code>
|
||||
/// {
|
||||
/// "files": [
|
||||
/// { "name": "iconpack_v1.zip", "url": "https://…/iconpack_v1.zip", "updatedAt": "2024-01-01T00:00:00Z" },
|
||||
/// { "name": "appnames.json", "url": "https://…/appnames.json", "updatedAt": "2024-01-01T00:00:00Z" }
|
||||
/// ]
|
||||
/// }
|
||||
/// </code>
|
||||
/// Only files whose names match the icon-pack or app-names naming conventions are accepted.
|
||||
/// </summary>
|
||||
private async Task<bool> GetAssetsInfoFromHttpEndpointAsync(string manifestUrl,
|
||||
Dictionary<string, AssetInfo> assetsInfo, IDownloadProgress downloadProgress = null)
|
||||
{
|
||||
Debug.LogFormat("Reading HTTP asset manifest from {0}", manifestUrl);
|
||||
|
||||
try
|
||||
{
|
||||
using (var req = new UnityWebRequest(manifestUrl))
|
||||
{
|
||||
req.downloadHandler = new DownloadHandlerBuffer();
|
||||
await req.SendWebRequest();
|
||||
|
||||
if (req.result != UnityWebRequest.Result.Success)
|
||||
{
|
||||
Debug.LogFormat("Error reading HTTP manifest: {0}", req.error);
|
||||
if (null != downloadProgress)
|
||||
{
|
||||
downloadProgress.OnError(string.Format("Error reading manifest: {0} ({1})", req.error, manifestUrl));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
var jObject = JObject.Parse(req.downloadHandler.text);
|
||||
var files = jObject["files"];
|
||||
if (null == files)
|
||||
{
|
||||
Debug.LogFormat("HTTP manifest at {0} has no 'files' array", manifestUrl);
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
var name = file["name"]?.Value<string>();
|
||||
var url = file["url"]?.Value<string>();
|
||||
var updatedAt = file["updatedAt"]?.Value<string>() ?? "";
|
||||
|
||||
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(url))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Accept the same file naming conventions as the GitHub path
|
||||
if ((name.StartsWith("iconpack") && name.EndsWith(".zip")) ||
|
||||
(name.StartsWith("appnames") && (name.EndsWith(".txt") || name.EndsWith(".json"))))
|
||||
{
|
||||
assetsInfo[name] = new AssetInfo { RepoUri = manifestUrl, Url = url, UpdatedAt = updatedAt };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogFormat("Exception reading HTTP manifest: {0}", e.Message);
|
||||
if (null != downloadProgress)
|
||||
{
|
||||
downloadProgress.OnError(string.Format("Error reading manifest: {0} ({1})", e.Message, manifestUrl));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Download a single asset (file) from repo
|
||||
/// </summary>
|
||||
@@ -387,7 +474,13 @@ namespace QuestAppLauncher
|
||||
// Request asset url
|
||||
using (var req = new UnityWebRequest(assetInfo.Url))
|
||||
{
|
||||
req.SetRequestHeader("Accept", "application/octet-stream");
|
||||
// GitHub API requires Accept: application/octet-stream to return the raw
|
||||
// binary asset rather than a JSON metadata wrapper.
|
||||
// Plain HTTP endpoints don't need this header and some servers may reject it.
|
||||
if (!string.IsNullOrEmpty(assetInfo.TagName))
|
||||
{
|
||||
req.SetRequestHeader("Accept", "application/octet-stream");
|
||||
}
|
||||
if (null != downloadProgress)
|
||||
{
|
||||
downloadProgress.OnDownloadStart(name);
|
||||
@@ -397,7 +490,7 @@ namespace QuestAppLauncher
|
||||
req.downloadHandler = downloadHandler;
|
||||
await req.SendWebRequest();
|
||||
|
||||
if (req.isNetworkError || req.isHttpError)
|
||||
if (req.result != UnityWebRequest.Result.Success)
|
||||
{
|
||||
// Error reading asset metadata, so return.
|
||||
Debug.LogFormat("Error downloading asset: {0}", req.error);
|
||||
@@ -494,7 +587,7 @@ namespace QuestAppLauncher
|
||||
|
||||
static public string GetOrCreateDownloadPath()
|
||||
{
|
||||
string path = Path.Combine(UnityEngine.Application.persistentDataPath, DownloadCacheFolder);
|
||||
string path = Path.Combine(_persistentDataPath, DownloadCacheFolder);
|
||||
Directory.CreateDirectory(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
using System.IO;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using UnityEngine.SceneManagement;
|
||||
|
||||
namespace QuestAppLauncher
|
||||
{
|
||||
/// <summary>
|
||||
/// Applies managed branding from <see cref="ManagedPolicyHandler.Branding"/>
|
||||
/// to the launcher UI at startup and whenever new tabs are created.
|
||||
///
|
||||
/// No Unity Editor wiring required — all GameObjects are located by their
|
||||
/// scene names at runtime.
|
||||
///
|
||||
/// <para>
|
||||
/// Default colours (original art):
|
||||
/// Primary <c>#3E6DA9</c> · Accent <c>#00AEEF</c>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class BrandingHandler : MonoBehaviour
|
||||
{
|
||||
// Known GameObject / child names from the scene
|
||||
const string TopTabContentName = "Top_Tab_Content";
|
||||
const string LeftTabContentName = "Left_Tab_Content";
|
||||
const string RightTabContentName = "Right_Tab_Content";
|
||||
const string LogoObjectName = "Branding_Logo"; // created at runtime if absent
|
||||
const string TitleObjectName = "Branding_Title"; // created at runtime if absent
|
||||
const string CanvasName = "Canvas";
|
||||
|
||||
// Original art colours — used when no policy is active
|
||||
const string DefaultPrimary = "#3E6DA9";
|
||||
const string DefaultAccent = "#00AEEF";
|
||||
|
||||
/// <summary>Current primary colour (tab backgrounds).</summary>
|
||||
public static Color PrimaryColor { get; private set; }
|
||||
/// <summary>Current accent colour (selected-tab indicator).</summary>
|
||||
public static Color AccentColor { get; private set; }
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Automatically creates a persistent BrandingHandler GameObject on startup.
|
||||
/// No scene setup or Unity Editor wiring required.
|
||||
/// </summary>
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
|
||||
private static void AutoInstall()
|
||||
{
|
||||
if (FindFirstObjectByType<BrandingHandler>() != null) return;
|
||||
var go = new GameObject("BrandingHandler");
|
||||
DontDestroyOnLoad(go);
|
||||
go.AddComponent<BrandingHandler>();
|
||||
}
|
||||
|
||||
void Start()
|
||||
{
|
||||
var branding = ManagedPolicyHandler.Branding;
|
||||
|
||||
PrimaryColor = ParseColor(branding?.PrimaryColor, DefaultPrimary);
|
||||
AccentColor = ParseColor(branding?.AccentColor, DefaultAccent);
|
||||
|
||||
ApplyLogo(branding?.LogoPath);
|
||||
ApplyTitle(branding?.AppTitle);
|
||||
|
||||
// Recolour tabs already in the scene (tabs are also re-coloured by
|
||||
// GridPopulation after it creates them, covering the dynamic case)
|
||||
ApplyToTabParent(GameObject.Find(TopTabContentName));
|
||||
ApplyToTabParent(GameObject.Find(LeftTabContentName));
|
||||
ApplyToTabParent(GameObject.Find(RightTabContentName));
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Recolours every tab child of <paramref name="tabParent"/>.
|
||||
/// Called by <see cref="GridPopulation"/> after it populates each tab container.
|
||||
/// </summary>
|
||||
public void ApplyToTabParent(GameObject tabParent)
|
||||
{
|
||||
if (tabParent == null) return;
|
||||
foreach (Transform tab in tabParent.transform)
|
||||
ApplyToTab(tab.gameObject);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies branding colours to a single tab (Tab.prefab structure:
|
||||
/// Toggle root with <c>Background</c> and <c>Checkmark</c> children).
|
||||
/// </summary>
|
||||
public void ApplyToTab(GameObject tab)
|
||||
{
|
||||
if (tab == null) return;
|
||||
|
||||
var bg = tab.transform.Find("Background");
|
||||
if (bg != null)
|
||||
{
|
||||
var img = bg.GetComponent<Image>();
|
||||
if (img != null) img.color = PrimaryColor;
|
||||
}
|
||||
|
||||
var check = tab.transform.Find("Checkmark");
|
||||
if (check != null)
|
||||
{
|
||||
var img = check.GetComponent<Image>();
|
||||
if (img != null) img.color = AccentColor;
|
||||
}
|
||||
|
||||
var toggle = tab.GetComponent<Toggle>();
|
||||
if (toggle != null)
|
||||
{
|
||||
var cb = toggle.colors;
|
||||
cb.normalColor = PrimaryColor;
|
||||
cb.highlightedColor = Brighten(PrimaryColor, 0.15f);
|
||||
cb.pressedColor = Darken(PrimaryColor, 0.15f);
|
||||
cb.selectedColor = AccentColor;
|
||||
toggle.colors = cb;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Logo & title ──────────────────────────────────────────────────────
|
||||
|
||||
private void ApplyLogo(string logoPath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(logoPath) || !File.Exists(logoPath)) return;
|
||||
|
||||
var bytes = File.ReadAllBytes(logoPath);
|
||||
var tex = new Texture2D(2, 2, TextureFormat.RGBA32, false);
|
||||
tex.filterMode = FilterMode.Bilinear;
|
||||
if (!tex.LoadImage(bytes)) return;
|
||||
|
||||
var logoObj = GetOrCreateUIImage(LogoObjectName, new Vector2(400, 100), new Vector2(0, 60));
|
||||
var img = logoObj.GetComponent<Image>();
|
||||
img.sprite = Sprite.Create(tex, new Rect(0, 0, tex.width, tex.height), new Vector2(0.5f, 0.5f));
|
||||
img.color = Color.white;
|
||||
img.preserveAspect = true;
|
||||
logoObj.SetActive(true);
|
||||
}
|
||||
|
||||
private void ApplyTitle(string title)
|
||||
{
|
||||
if (string.IsNullOrEmpty(title)) return;
|
||||
|
||||
// Re-use an existing Branding_Title object or create one
|
||||
var existing = GameObject.Find(TitleObjectName);
|
||||
TextMeshProUGUI tmp;
|
||||
|
||||
if (existing == null)
|
||||
{
|
||||
var canvas = GameObject.Find(CanvasName);
|
||||
if (canvas == null) return;
|
||||
|
||||
existing = new GameObject(TitleObjectName);
|
||||
existing.transform.SetParent(canvas.transform, false);
|
||||
|
||||
var rt = existing.AddComponent<RectTransform>();
|
||||
rt.anchorMin = new Vector2(0.5f, 1f);
|
||||
rt.anchorMax = new Vector2(0.5f, 1f);
|
||||
rt.pivot = new Vector2(0.5f, 1f);
|
||||
rt.anchoredPosition = new Vector2(0, -10);
|
||||
rt.sizeDelta = new Vector2(800, 80);
|
||||
|
||||
tmp = existing.AddComponent<TextMeshProUGUI>();
|
||||
tmp.fontSize = 48;
|
||||
tmp.fontStyle = FontStyles.Bold;
|
||||
tmp.alignment = TextAlignmentOptions.Center;
|
||||
tmp.color = Color.white;
|
||||
}
|
||||
else
|
||||
{
|
||||
tmp = existing.GetComponent<TextMeshProUGUI>();
|
||||
if (tmp == null) tmp = existing.AddComponent<TextMeshProUGUI>();
|
||||
}
|
||||
|
||||
tmp.text = title;
|
||||
existing.SetActive(true);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Finds an existing Image GameObject by name, or creates one anchored to the
|
||||
/// top-centre of the Canvas.
|
||||
/// </summary>
|
||||
private GameObject GetOrCreateUIImage(string objName, Vector2 size, Vector2 anchoredPos)
|
||||
{
|
||||
var existing = GameObject.Find(objName);
|
||||
if (existing != null) return existing;
|
||||
|
||||
var canvas = GameObject.Find(CanvasName);
|
||||
if (canvas == null) return new GameObject(objName); // fallback
|
||||
|
||||
var obj = new GameObject(objName);
|
||||
obj.transform.SetParent(canvas.transform, false);
|
||||
|
||||
var rt = obj.AddComponent<RectTransform>();
|
||||
rt.anchorMin = new Vector2(0.5f, 1f);
|
||||
rt.anchorMax = new Vector2(0.5f, 1f);
|
||||
rt.pivot = new Vector2(0.5f, 1f);
|
||||
rt.anchoredPosition = anchoredPos;
|
||||
rt.sizeDelta = size;
|
||||
|
||||
obj.AddComponent<Image>();
|
||||
return obj;
|
||||
}
|
||||
|
||||
// ── Colour utilities ──────────────────────────────────────────────────
|
||||
|
||||
private static Color ParseColor(string hex, string fallback)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(hex) && TryParseHex(hex, out var c)) return c;
|
||||
if (!string.IsNullOrEmpty(fallback) && TryParseHex(fallback, out var f)) return f;
|
||||
return Color.white;
|
||||
}
|
||||
|
||||
private static bool TryParseHex(string hex, out Color color)
|
||||
{
|
||||
color = Color.white;
|
||||
hex = hex?.TrimStart('#');
|
||||
if (hex == null || (hex.Length != 6 && hex.Length != 8)) return false;
|
||||
try
|
||||
{
|
||||
byte r = System.Convert.ToByte(hex.Substring(0, 2), 16);
|
||||
byte g = System.Convert.ToByte(hex.Substring(2, 2), 16);
|
||||
byte b = System.Convert.ToByte(hex.Substring(4, 2), 16);
|
||||
byte a = hex.Length == 8 ? System.Convert.ToByte(hex.Substring(6, 2), 16) : (byte)255;
|
||||
color = new Color32(r, g, b, a);
|
||||
return true;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
private static Color Brighten(Color c, float v) =>
|
||||
new Color(Mathf.Clamp01(c.r + v), Mathf.Clamp01(c.g + v), Mathf.Clamp01(c.b + v), c.a);
|
||||
|
||||
private static Color Darken(Color c, float v) =>
|
||||
new Color(Mathf.Clamp01(c.r - v), Mathf.Clamp01(c.g - v), Mathf.Clamp01(c.b - v), c.a);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f52c5e25b45a8ed49b559af88d2eb527
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -26,6 +26,14 @@ namespace QuestAppLauncher
|
||||
|
||||
// Download repos
|
||||
public const string DownloadRepo_Type_GitHub = "github";
|
||||
|
||||
/// <summary>
|
||||
/// Asset source backed by a plain HTTP endpoint that hosts a manifest JSON.
|
||||
/// <c>repoUri</c> should be the full URL to the manifest file.
|
||||
/// Manifest format: { "files": [ { "name": "iconpack_v1.zip", "url": "...", "updatedAt": "..." } ] }
|
||||
/// </summary>
|
||||
public const string DownloadRepo_Type_Http = "http";
|
||||
|
||||
public const string DownloadRepo_Default = @"hooverhigh/QuestAppLauncher_Assets/releases/latest";
|
||||
|
||||
// Background
|
||||
@@ -72,9 +80,16 @@ namespace QuestAppLauncher
|
||||
// Background image path
|
||||
public string background = Background_Default;
|
||||
|
||||
// Github download repos
|
||||
// Download repos
|
||||
public List<DownloadRepo> downloadRepos = new List<DownloadRepo>();
|
||||
|
||||
/// <summary>
|
||||
/// Optional URL for the managed-policy endpoint.
|
||||
/// When non-empty, policies are fetched from this URL on every startup.
|
||||
/// Can also be set at compile time via the DEV_ENDPOINT scripting define.
|
||||
/// </summary>
|
||||
public string managedPolicyEndpoint = "";
|
||||
|
||||
public Config(bool initDefaults = false)
|
||||
{
|
||||
if (initDefaults)
|
||||
|
||||
@@ -62,6 +62,9 @@ namespace QuestAppLauncher
|
||||
// Download status indicator
|
||||
public DownloadStatusIndicator downloadStatusIndicator;
|
||||
|
||||
// Settings button — hidden when managed policy sets disableSettings = true
|
||||
public GameObject openSettingsButton;
|
||||
|
||||
// App info prefab (a cell in the grid content)
|
||||
public GameObject prefabCell;
|
||||
|
||||
@@ -78,9 +81,26 @@ namespace QuestAppLauncher
|
||||
// Set high texture resolution scale to minimize aliasing
|
||||
XRSettings.eyeTextureResolutionScale = 2.0f;
|
||||
|
||||
// Cache Application.persistentDataPath on the main thread.
|
||||
// Unity 2023+ throws if this is read from a background Task.Run thread.
|
||||
var persistentDataPath = UnityEngine.Application.persistentDataPath;
|
||||
AppProcessor.CachePersistentDataPath(persistentDataPath);
|
||||
AssetsDownloader.CachePersistentDataPath(persistentDataPath);
|
||||
|
||||
// Initialize the core platform
|
||||
Core.AsyncInitialize();
|
||||
|
||||
// Apply managed policies before populating so hidden apps / names are
|
||||
// in place when AppProcessor.ProcessApps() runs.
|
||||
var config = ConfigPersistence.LoadConfig();
|
||||
await ManagedPolicyHandler.ApplyPoliciesAsync(config);
|
||||
|
||||
// Hide the settings button if the policy disables it
|
||||
if (ManagedPolicyHandler.DisableSettings && this.openSettingsButton != null)
|
||||
{
|
||||
this.openSettingsButton.SetActive(false);
|
||||
}
|
||||
|
||||
// Populate the grid
|
||||
await PopulateAsync();
|
||||
}
|
||||
@@ -94,9 +114,9 @@ namespace QuestAppLauncher
|
||||
/// Populates rename grid
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public async Task PopulateRenameAsync()
|
||||
public Task PopulateRenameAsync()
|
||||
{
|
||||
PopulateAsync(true);
|
||||
return PopulateAsync(true);
|
||||
}
|
||||
|
||||
#region Private Functions
|
||||
@@ -109,10 +129,18 @@ namespace QuestAppLauncher
|
||||
// Load configuration
|
||||
var config = ConfigPersistence.LoadConfig();
|
||||
|
||||
// A managed policy wallpaper (downloaded by ManagedPolicyHandler) always
|
||||
// takes precedence over the user's background setting.
|
||||
var managedWallpaper = ManagedPolicyHandler.ManagedWallpaperPath;
|
||||
if (!string.IsNullOrEmpty(managedWallpaper))
|
||||
{
|
||||
config.background = managedWallpaper;
|
||||
}
|
||||
|
||||
// Set skybox
|
||||
if (!isRenameMode)
|
||||
{
|
||||
this.skyboxHandler.SetSkybox(config.background);
|
||||
await this.skyboxHandler.SetSkybox(config.background);
|
||||
}
|
||||
|
||||
// Process apps in background
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ManagedPolicyEndpointConfig.cs
|
||||
//
|
||||
// Edit the URL constant below before building with the DEV_ENDPOINT scripting
|
||||
// define symbol enabled. This file is intentionally separate from
|
||||
// ManagedPolicyHandler.cs so you only need to change one simple value.
|
||||
//
|
||||
// To enable managed-policy support:
|
||||
// Unity → Project Settings → Player → Other Settings → Scripting Define Symbols
|
||||
// Add "DEV_ENDPOINT" (no quotes) and rebuild.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#if DEV_ENDPOINT
|
||||
namespace QuestAppLauncher
|
||||
{
|
||||
public static partial class ManagedPolicyHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// The HTTP(S) URL that is contacted on startup to retrieve managed policies.
|
||||
/// Change this value before building and then add the DEV_ENDPOINT scripting
|
||||
/// define symbol to your Unity Player Settings.
|
||||
///
|
||||
/// <para>Expected JSON response from the endpoint:</para>
|
||||
/// <code>
|
||||
/// {
|
||||
/// "version": 1,
|
||||
/// "hiddenApps": ["com.example.app1", "com.example.app2"],
|
||||
/// "wallpaperUrl": "https://example.com/wallpaper.jpg",
|
||||
/// "disableSettings": false,
|
||||
/// "appNames": {
|
||||
/// "com.example.app1": { "name": "Friendly Name", "category": "Education" }
|
||||
/// }
|
||||
/// }
|
||||
/// </code>
|
||||
/// All fields except <c>version</c> are optional.
|
||||
/// </summary>
|
||||
private const string EndpointUrl = "https://your-mdm-endpoint.example.com/policies";
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9a955b9b54944b648b3974e0bdb6afa5
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,429 @@
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8eb96e6845f1ec948b67d88c85c00209
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -37,7 +37,7 @@ namespace QuestAppLauncher
|
||||
// Parent OVRRawRayCaster
|
||||
private OVRRawRaycaster parentRawRaycaster = null;
|
||||
|
||||
void Start()
|
||||
protected override void Start()
|
||||
{
|
||||
this.cellHeight = this.transform.GetComponentInChildren<GridLayoutGroup>().cellSize.y;
|
||||
this.boxCollider = GetComponent<BoxCollider>();
|
||||
@@ -138,7 +138,7 @@ namespace QuestAppLauncher
|
||||
this.verticalNormalizedPosition = Mathf.Clamp01(this.verticalNormalizedPosition + verticalIncrement);
|
||||
}
|
||||
|
||||
void OnEnable()
|
||||
protected override void OnEnable()
|
||||
{
|
||||
// When this scroll view is enabled, make sure we resize the box collider appropriately.
|
||||
ResizeBoxCollider();
|
||||
|
||||
@@ -81,7 +81,7 @@ namespace QuestAppLauncher
|
||||
if (null != entry)
|
||||
{
|
||||
// Set the skybox
|
||||
SetSkybox(entry.path);
|
||||
await SetSkybox(entry.path);
|
||||
this.skyviewListContainer.SetActive(false);
|
||||
|
||||
// Callback if registered
|
||||
|
||||
Reference in New Issue
Block a user