Major update
Build / Build launcher (push) Has been cancelled

This commit is contained in:
2026-03-18 09:14:25 -07:00
parent 4583f70e32
commit cb8c3eb5df
861 changed files with 13249 additions and 88437 deletions
+39 -14
View File
@@ -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);
+134 -41
View File
@@ -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;
}
+237
View File
@@ -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);
}
}
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f52c5e25b45a8ed49b559af88d2eb527
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+16 -1
View File
@@ -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)
+31 -3
View File
@@ -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:
+429
View File
@@ -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:
+2 -2
View File
@@ -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();
+1 -1
View File
@@ -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