[ Web Proxy ]
URL:
Viewing: https://raw.githubusercontent.com/unitycoder/UnityLauncherPro/210/UnityLauncherPro/Tools.cs [Back]  [Original]

using Microsoft.Win32;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.NetworkInformation;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Threading;
using UnityLauncherPro.Data;
using UnityLauncherPro.Helpers;
using UnityLauncherPro.Properties;

namespace UnityLauncherPro
{
    public static class Tools
    {
        const int SW_RESTORE = 9;

        static readonly string initFileDefaultURL = "https://raw.githubusercontent.com/unitycoder/UnityInitializeProject/main/Assets/Editor/InitializeProject.cs";

        [DllImport("user32", CharSet = CharSet.Unicode)]
        static extern IntPtr FindWindow(string cls, string win);
        [DllImport("user32")]
        static extern IntPtr SetForegroundWindow(IntPtr hWnd);
        [DllImport("user32")]
        static extern bool IsIconic(IntPtr hWnd);
        [DllImport("user32")]
        static extern bool OpenIcon(IntPtr hWnd);
        [DllImport("user32")]
        private static extern bool ShowWindow(IntPtr handle, int nCmdShow);

        // reference to already running webgl server processes and ports
        static Dictionary webglServerProcesses = new Dictionary();

        // returns last modified date for file (or null if cannot get it)
        public static DateTime? GetLastModifiedTime(string path)
        {
            if (File.Exists(path) == true || Directory.Exists(path) == true)
            {
                DateTime modification = File.GetLastWriteTime(path);
                return modification;
            }
            else
            {
                return null;
            }
        }

        /// 
        /// parse project version from ProjectSettings/ data
        /// 
        /// project base path
        /// 
        public static string GetProjectVersion(string path)
        {
            string version = null;

            if (File.Exists(Path.Combine(path, "ProjectVersionOverride.txt")))
            {
                version = File.ReadAllText(Path.Combine(path, "ProjectVersionOverride.txt"));
            }
            else if (Directory.Exists(Path.Combine(path, "ProjectSettings")))
            {
                var versionPath = Path.Combine(path, "ProjectSettings", "ProjectVersion.txt");
                if (File.Exists(versionPath) == true) // 5.x and later
                {
                    var data = File.ReadAllLines(versionPath);

                    if (data != null && data.Length > 0)
                    {
                        var dd = data[0];
                        // check string
                        if (dd.Contains("m_EditorVersion"))
                        {
                            var t = dd.Split(new string[] { "m_EditorVersion: " }, StringSplitOptions.None);
                            if (t != null && t.Length > 0)
                            {
                                version = t[1].Trim();
                            }
                            else
                            {
                                throw new InvalidDataException("invalid version data:" + data);
                            }
                        }
                        else
                        {
                            Console.WriteLine("Cannot find m_EditorVersion in '" + versionPath + "'.\n\nFile Content:\n" + string.Join("\n", data).ToString());
                        }
                    }
                    else
                    {
                        Console.WriteLine("Invalid projectversion data found in '" + versionPath + "'.\n\nFile Content:\n" + string.Join("\n", data).ToString());
                    }
                }
                else // maybe its 4.x?
                {
                    versionPath = Path.Combine(path, "ProjectSettings", "ProjectSettings.asset");
                    if (File.Exists(versionPath) == true)
                    {
                        // first try if its ascii format
                        var data = File.ReadAllLines(versionPath);
                        if (data != null && data.Length > 0 && data[0].IndexOf("YAML") > -1) // we have ascii
                        {
                            // check library if available
                            var newVersionPath = Path.Combine(path, "Library", "AnnotationManager");
                            if (File.Exists(newVersionPath) == true)
                            {
                                versionPath = newVersionPath;
                            }
                        }

                        // try to get version data out from binary asset
                        var binData = File.ReadAllBytes(versionPath);
                        if (binData != null && binData.Length > 0)
                        {
                            int dataLen = 7;
                            int startIndex = 20;
                            var bytes = new byte[dataLen];
                            for (int i = 0; i < dataLen; i++)
                            {
                                bytes[i] = binData[startIndex + i];
                            }
                            var vertemp = Encoding.UTF8.GetString(bytes);
                            // probably failed if no dots
                            if (vertemp.IndexOf(".") > -1) version = vertemp;

                        }
                        // if still nothing, TODO probably could find closer version info, if know what features were added to playersettings.assets and checking serializedVersion: .. number
                    }
                }
            }

            return version;
        }

        internal static string ReadProjectName(string projectPath)
        {
            string results = null;
            var versionPath = Path.Combine(projectPath, "ProjectSettings", "ProjectSettings.asset");
            if (File.Exists(versionPath) == true) // 5.x and later
            {
                var data = File.ReadAllLines(versionPath);

                if (data != null && data.Length > 0)
                {
                    for (int i = 0; i < data.Length; i++)
                    {
                        // check row
                        if (data[i].IndexOf("productName: ") > -1)
                        {
                            var t = data[i].Split(new string[] { "productName: " }, StringSplitOptions.None);
                            if (t != null && t.Length > 0)
                            {
                                results = t[1].Trim();
                                break;
                            }
                            else
                            {
                                throw new InvalidDataException("invalid productName data:" + data);
                            }
                        }
                    }
                }
            }
            return results;
        }

        // returns unity version number string from file
        public static string GetFileVersionData(string path)
        {
            FileVersionInfo fvi = FileVersionInfo.GetVersionInfo(path);
            var ver = fvi.ProductName;
            if (string.IsNullOrEmpty(ver) == true)
            {
                ver = fvi.FileDescription;
                if (string.IsNullOrEmpty(ver) == true) return null;
                ver = ver.Replace("Installer", "").Trim();
            }
            var res = ver.Replace("(64-bit)", "").Replace("(32-bit)", "").Replace("Unity", "").Trim();
            return res;
        }

        public static void ExploreFolder(string path)
        {
            if (path != null)
            {
                if (LaunchExplorer(path) == false)
                {
                    //SetStatus("Error> Directory not found: " + folder);
                }
            }
        }

        // this runs before unity editor starts, so the project is not yet in registry (unless it already was there)
        public static void AddProjectToHistory(string projectPath)
        {
            // fix backslashes
            projectPath = projectPath.Replace('\\', '/');

            if (Properties.Settings.Default.projectPaths.Contains(projectPath) == false)
            {
                // TODO do we need to add as first?
                Properties.Settings.Default.projectPaths.Insert(0, projectPath);

                // remove last item, if too many
                if (Properties.Settings.Default.projectPaths.Count > MainWindow.maxProjectCount)
                {
                    Properties.Settings.Default.projectPaths.RemoveAt(Properties.Settings.Default.projectPaths.Count - 1);
                }

                //Console.WriteLine("AddProjectToHistory, count: " + Properties.Settings.Default.projectPaths.Count);

                // TODO no need to save everytime?
                Properties.Settings.Default.Save();

                // TODO need to add into recent grid also? if old items disappear?
            }
        }

        // NOTE holding alt key (when using alt+o) brings up unity project selector
        public static Process LaunchProject(Project proj, DataGrid dataGridRef = null, bool useInitScript = false, bool upgrade = false, bool cloneFromTemplate = false)
        {
            if (proj == null) return null;

            Console.WriteLine("Launching project " + proj?.Title + " at " + proj?.Path);

            if (Directory.Exists(proj.Path) == false) return null;

            // add this project to recent projects in preferences TODO only if enabled +40 projecs
            AddProjectToHistory(proj.Path);

            // check if this project path has unity already running? (from process) 
            // NOTE this check only works if previous unity instance was started while we were running
            if (ProcessHandler.IsRunning(proj.Path))
            {
                Console.WriteLine("Project is already running, lets not launch unity.. because it opens Hub");
                BringProcessToFront(ProcessHandler.Get(proj.Path));
                return null;
            }
            else
            {
                // TODO check lock file?
            }

            // there is no assets path, probably we want to create new project then
            var assetsFolder = Path.Combine(proj.Path, "Assets");
            if (Directory.Exists(assetsFolder) == false)
            {
                // TODO could ask if want to create project..?
                Directory.CreateDirectory(assetsFolder);
            }

            // if its upgrade, we dont want to check current version
            if (upgrade == false)
            {
                // check if project version has changed? (list is not updated, for example pulled new version from git)
                var version = GetProjectVersion(proj.Path);
                if (string.IsNullOrEmpty(version) == false && version != proj.Version)
                {
                    Console.WriteLine("Project version has changed from " + proj.Version + " to " + version);
                    proj.Version = version;
                }
            }

            // check if we have this unity version installed
            var unityExePath = GetUnityExePath(proj.Version);
            if (unityExePath == null)
            {
                // if no editors installed, show message
                if (MainWindow.unityInstallationsSource.Count == 0)
                {
                    MessageBox.Show($"No Unity versions installed. Please run {MainWindow.appName} first to setup root folders.", MainWindow.appName, MessageBoxButton.OK, MessageBoxImage.Warning);
                    return null;
                }

                DisplayUpgradeDialog(proj, null, useInitScript);
                return null;
            }

            // when opening project, check for crashed backup scene first
            var cancelLaunch = CheckCrashBackupScene(proj.Path);
            if (cancelLaunch == true)
            {
                return null;
            }

            Process newProcess = new Process();
            try
            {
                var cmd = "\"" + unityExePath + "\"";
                newProcess.StartInfo.FileName = cmd;

                string unitycommandlineparameters = (cloneFromTemplate ? " -createproject " : " -projectPath ") + "\"" + proj.Path + "\"";

                string customArguments = proj.Arguments;
                if (string.IsNullOrEmpty(customArguments) == false)
                {
                    unitycommandlineparameters += " " + customArguments;
                }

                string projTargetPlatform = proj.TargetPlatform;
                if (string.IsNullOrEmpty(projTargetPlatform) == false)
                {
                    unitycommandlineparameters += " -buildTarget " + projTargetPlatform;
                }

                if (useInitScript == true)
                {
                    unitycommandlineparameters += " -executeMethod UnityLauncherProTools.InitializeProject.Init";
                }

                Console.WriteLine("Start process: " + cmd + " " + unitycommandlineparameters);

                // TODO load custom settings per project
                //string userSettingsFolder = Path.Combine(proj.Path, "UserSettings");
                //string userSettingsPath = Path.Combine(userSettingsFolder, "ULPSettings.txt");
                //if (File.Exists(userSettingsPath))
                //{
                //    var rawSettings = File.ReadAllLines(userSettingsPath);
                //    // needed for env vars.
                //    newProcess.StartInfo.UseShellExecute = false;
                //    foreach (var row in rawSettings)
                //    {
                //        var split = row.Split('=');
                //        if (split.Length == 2)
                //        {
                //            var key = split[0].Trim();
                //            var value = split[1].Trim();
                //            if (string.IsNullOrEmpty(key) == false && string.IsNullOrEmpty(value) == false)
                //            {
                //                //Console.WriteLine("key: " + key + " value: " + value);
                //                //newProcess.StartInfo.EnvironmentVariables[key] = value;
                //                //System.Environment.SetEnvironmentVariable(key, value, EnvironmentVariableTarget.Machine);
                //                var dict = newProcess.StartInfo.EnvironmentVariables;
                //                // print all
                //                foreach (System.Collections.DictionaryEntry de in dict)
                //                {
                //                    Console.WriteLine("  {0} = {1}", de.Key, de.Value);
                //                }
                //                // check if key exists
                //                if (dict.ContainsKey(key) == true)
                //                {
                //                    // modify existing
                //                    //dict[key] = value;
                //                    newProcess.StartInfo.EnvironmentVariables.Remove(key);
                //                    newProcess.StartInfo.EnvironmentVariables.Add(key, value);
                //                }
                //                else
                //                {
                //                    // add new
                //                    dict.Add(key, value);
                //                }
                //                //newProcess.StartInfo.EnvironmentVariables.
                //                //if (newProcess.StartInfo.EnvironmentVariables.ContainsKey(key))
                //                //{
                //                //    Console.WriteLine("exists: "+key);
                //                //    // Test Modify the existing environment variable
                //                //    newProcess.StartInfo.EnvironmentVariables[key] = "...";
                //                // this works, maybe because its not a system variable?
                //                //newProcess.StartInfo.EnvironmentVariables["TESTTEST"] = "...";
                //                //}
                //                //else
                //                //{
                //                //    Console.WriteLine("add new: "+ value);
                //                //    // Optionally, add the environment variable if it does not exist
                //                //    newProcess.StartInfo.EnvironmentVariables.Add(key, value);
                //                //}
                //                Console.WriteLine("custom row: " + row + " key=" + key + " value:" + value);
                //            }
                //        }
                //    }
                //}

                newProcess.StartInfo.Arguments = unitycommandlineparameters;
                newProcess.EnableRaisingEvents = true;
                //newProcess.StartInfo.WindowStyle = ProcessWindowStyle.Hidden; // needed for unity 2023 for some reason? (otherwise console popups briefly), Cannot use this, whole Editor is invisible then
                newProcess.Start();

                if (Properties.Settings.Default.closeAfterProject)
                {
                    Environment.Exit(0);
                }
            }
            catch (Exception e)
            {
                Console.WriteLine(e);
            }

            // NOTE move project as first, since its opened, disabled for now, since its too jumpy..
            //MainWindow wnd = (MainWindow)Application.Current.MainWindow;
            //wnd.MoveRecentGridItem(0);

            ProcessHandler.Add(proj, newProcess);
            return newProcess;
        }

        static bool CheckCrashBackupScene(string projectPath)
        {
            var cancelRunningUnity = false;
            var recoveryFile = Path.Combine(projectPath, "Temp", "__Backupscenes", "0.backup");
            if (File.Exists(recoveryFile))
            {
                if (Settings.Default.showRecoveredScenePopup)
                {
                    var result = MessageBox.Show("Crash recovery scene found, do you want to MOVE it into Assets/_Recovery/-folder?", "UnityLauncherPro - Scene Recovery", MessageBoxButton.YesNo, MessageBoxImage.Question);
                    if (result == MessageBoxResult.Yes)
                    {
                        var restoreFolder = Path.Combine(projectPath, "Assets", "_Recovery");
                        if (Directory.Exists(restoreFolder) == false)
                        {
                            Directory.CreateDirectory(restoreFolder);
                        }

                        if (Directory.Exists(restoreFolder) == true)
                        {
                            Int32 unixTimestamp = (Int32)(DateTime.UtcNow.Subtract(new DateTime(1970, 1, 1))).TotalSeconds;
                            var uniqueFileName = "Recovered_Scene" + unixTimestamp + ".unity";

                            try
                            {
                                File.Move(recoveryFile, Path.Combine(restoreFolder, uniqueFileName));
                                // remove folder, otherwise unity 6000.2 asks for recovery
                                Directory.Delete(Path.Combine(projectPath, "Temp", "__Backupscenes"), true);

                                Console.WriteLine("moved file to " + uniqueFileName);
                            }
                            catch (IOException)
                            {
                                // if move failed, try copy
                                File.Copy(recoveryFile, Path.Combine(restoreFolder, uniqueFileName));
                                Console.WriteLine("copied file");
                            }

                            Console.WriteLine("Recovered crashed scene into: " + restoreFolder);
                        }
                        else
                        {
                            Console.WriteLine("Error: Failed to create restore folder: " + restoreFolder);
                            cancelRunningUnity = true;
                        }
                    }
                    else if (result == MessageBoxResult.Cancel) // dont do restore, but run Unity
                    {
                        cancelRunningUnity = true;
                    }
                }
                else
                {
                    File.Delete(recoveryFile);
                    Directory.Delete(Path.GetDirectoryName(recoveryFile));
                }
            }
            return cancelRunningUnity;
        }

        public static string GetUnityExePath(string version)
        {
            if (string.IsNullOrEmpty(version) == true) return null;
            return MainWindow.unityInstalledVersions.ContainsKey(version) ? MainWindow.unityInstalledVersions[version] : null;
        }


        // opens Explorer to target folder
        public static bool LaunchExplorer(string folder)
        {
            if (folder == null || folder.Length < 1) return false;

            if (Directory.Exists(folder) == true)
            {
                Process.Start(folder);
                return true;
            }
            else // original folder is missing, try to find parent folder that we can go into
            {
                for (int i = folder.Length - 1; i > -1; i--)
                {
                    // TODO path.separator
                    if (folder[i] == '/')
                    {
                        if (Directory.Exists(folder.Substring(0, i)))
                        {
                            Process.Start(folder.Substring(0, i) + "/");
                            break;
                        }
                    }
                }
            }
            return false;
        }

        public static bool LaunchExplorerSelectFile(string fileName)
        {
            if (File.Exists(fileName) == true)
            {

                fileName = Path.GetFullPath(fileName);
                Process.Start("explorer.exe", string.Format("/select,\"{0}\"", fileName));
                return true;
            }
            else // file is missing, try to find parent folder that we can go into
            {
                for (int i = fileName.Length - 1; i > -1; i--)
                {
                    if (fileName[i] == '/')
                    {
                        if (Directory.Exists(fileName.Substring(0, i)))
                        {
                            Process.Start(fileName.Substring(0, i) + "/");
                            break;
                        }
                    }
                }
            }
            return false;
        }

        // run any exe, return process
        public static Process LaunchExe(string path, string param = null, bool captureOutput = false)
        {
            if (string.IsNullOrEmpty(path)) return null;

            // not needed for exe's in PATH
            //if (File.Exists(path) == true)
            {
                Process newProcess = null;
                if (string.IsNullOrEmpty(param) == true)
                {
                    Console.WriteLine("LaunchExe= " + path);
                    newProcess = Process.Start(path);
                }
                else
                {
                    Console.WriteLine("LaunchExe= " + path + " param=" + param);

                    try
                    {
                        newProcess = new Process();
                        newProcess.StartInfo.FileName = "\"" + path + "\"";
                        newProcess.StartInfo.Arguments = param;
                        if (captureOutput)
                        {
                            newProcess.StartInfo.RedirectStandardError = true;
                            newProcess.StartInfo.RedirectStandardOutput = true;
                            newProcess.StartInfo.UseShellExecute = false;
                        }
                        newProcess.EnableRaisingEvents = true; // needed to get Exited event
                        newProcess.Start();
                    }
                    catch (Exception e)
                    {
                        Console.WriteLine(e);
                    }
                }
                return newProcess;
            }
            // Console.WriteLine("Failed to run exe: " + path + " " + param);
            // return null;
        }


        public static string GetUnityReleaseURL(string version)
        {
            if (string.IsNullOrEmpty(version)) return null;

            var cleanVersion = CleanVersionNumber(version);
            string url = $"https://unity.com/releases/editor/whats-new/{cleanVersion}#notes";

            //if (VersionIsArchived(version) == true)
            //{
            //    // remove f#, TODO should remove c# from china version ?
            //    version = Regex.Replace(version, @"f[0-9]{1,2}", "", RegexOptions.IgnoreCase);

            //    string padding = "unity-";
            //    string whatsnew = "whats-new";

            //    if (version.Contains("5.6")) padding = "";
            //    if (version.Contains("2018.2")) whatsnew = "whatsnew";
            //    if (version.Contains("2018.3")) padding = "";
            //    if (version.Contains("2018.1")) whatsnew = "whatsnew";
            //    if (version.Contains("2017.4.")) padding = "";
            //    if (version.Contains("2018.4.")) padding = "";

            //    // later versions seem to follow this
            //    var year = int.Parse(version.Split('.')[0]);
            //    if (year >= 2019) padding = "";

            //    url = "https://unity3d.com/unity/" + whatsnew + "/" + padding + version;
            //}
            //else
            //if (VersionIsPatch(version) == true)
            //{
            //    url = "https://unity3d.com/unity/qa/patch-releases/" + version;
            //}
            //else
            //if (VersionIsBeta(version) == true)
            //{
            //    url = "https://unity3d.com/unity/beta/" + version;
            //}
            //else
            //if (VersionIsAlpha(version) == true)
            //{
            //    url = "https://unity3d.com/unity/alpha/" + version;
            //}
            return url;
        }

        // if version contains *f* its archived version
        public static bool VersionIsArchived(string version)
        {
            return version.Contains("f");
        }

        public static bool VersionIsPatch(string version)
        {
            return version.Contains("p");
        }

        public static bool VersionIsBeta(string version)
        {
            return version.Contains("b");
        }

        public static bool VersionIsAlpha(string version)
        {
            return version.Contains("a");
        }

        public static bool VersionIsChinese(string version)
        {
            return version.Contains("c1");
        }


        //as of 21 May 2021, only final 'f' versions are now available on the alpha release notes for Unity 2018 and newer. 2017 and 5 still have patch 'p' versions as well.
        public static bool HasAlphaReleaseNotes(string version) => VersionIsArchived(version) || VersionIsPatch(version);

        public static string GetAlphaReleaseNotesURL(string fromVersion, string toVersion = null)
            => "https://alpha.release-notes.ds.unity3d.com/search?fromVersion=" + fromVersion + "&toVersion=" + (toVersion != null ? toVersion : fromVersion);

        // open release notes page in browser
        public static bool OpenReleaseNotes(string version)
        {
            bool result = false;
            if (string.IsNullOrEmpty(version)) return false;

            string url = null;
            if (Properties.Settings.Default.useAlphaReleaseNotes && HasAlphaReleaseNotes(version))
            {
                url = GetAlphaReleaseNotesURL(version);
            }
            else
            {
                url = GetUnityReleaseURL(version);
            }

            if (string.IsNullOrEmpty(url)) return false;

            OpenURL(url);
            result = true;
            return result;
        }

        public static bool OpenReleaseNotes_Cumulative(string version)
        {
            bool result = false;
            if (string.IsNullOrEmpty(version)) return false;

            string url = null;
            var comparisonVersion = version;
            //with the alpha release notes, we want a diff between an installed version and the one selected, but the site just shows all the changes inclusive of "fromVersion=vers"
            //so if we find a good installed candidate, we need the version just above it (installed or not) that has release notes page
            var closestInstalledVersion = Tools.FindNearestVersion(version, MainWindow.unityInstalledVersions.Keys.ToList(), true);
            if (closestInstalledVersion != null)
            {
                comparisonVersion = closestInstalledVersion;
                string nextFinalVersionAfterInstalled = closestInstalledVersion;

                //wwe need a loop here, to find the nearest final version. It might be better to warn the user about this before opening the page.
                do
                    nextFinalVersionAfterInstalled = Tools.FindNearestVersion(nextFinalVersionAfterInstalled, MainWindow.updatesAsStrings);
                while (nextFinalVersionAfterInstalled != null && !HasAlphaReleaseNotes(nextFinalVersionAfterInstalled));

                if (nextFinalVersionAfterInstalled != null) comparisonVersion = nextFinalVersionAfterInstalled;

            }
            url = GetAlphaReleaseNotesURL(comparisonVersion, version);

            OpenURL(url);
            result = true;
            return result;
        }

        public static void OpenURL(string url)
        {
            Process.Start(url);
        }

        public static async void DownloadInBrowser(string version, bool preferFullInstaller = false)
        {
            if (version == null) return;
            string exeURL = await GetUnityUpdates.FetchDownloadUrl(version);

            // null from unity api? then try direct download
            // https://beta.unity3d.com/download/330fbefc18b7/UnityDownloadAssistant-6000.1.0a8.exe
            if (exeURL == null)
            {
                // exe url not found, try unofficial list (TODO this is hack to avoid null url, because unofficial list items have been cached to local json, without download url..)
                Console.WriteLine("Fixing null in DownloadInBrowser ,v=" + version);
                var downloadURL = await GetUnityUpdates.CheckUnofficialVersionList(version);
                if (string.IsNullOrEmpty(downloadURL) == false)
                {
                    string unityHash = ParseHashCodeFromURL(downloadURL);
                    exeURL = ParseDownloadURLFromWebpage(version, unityHash, false, true);
                }
                return;
            }

            if (preferFullInstaller == true)
            {
                exeURL = exeURL.Replace("UnityDownloadAssistant-" + version + ".exe", "Windows64EditorInstaller/UnitySetup64-" + version + ".exe");
            }

            Console.WriteLine("DownloadInBrowser exeURL= '" + exeURL + "'");

            if (string.IsNullOrEmpty(exeURL) == false && exeURL.StartsWith("https"))
            {
                //SetStatus("Download installer in browser: " + exeURL);
                Process.Start(exeURL);
            }
            else // not found
            {
                //SetStatus("Error> Cannot find installer executable ... opening website instead");
                const string url = "https://unity3d.com/get-unity/download/archive";
                Process.Start(url + "#installer-not-found-for-version-" + version);
            }
        }

        public static async void DownloadAndInstall(string version)
        {
            string exeURL = await GetUnityUpdates.FetchDownloadUrl(version);

            if (version == null)
            {
                Console.WriteLine("Error> Cannot download and install null version");
                return;
            }

            if (string.IsNullOrEmpty(exeURL) == true)
            {
                // exe url not found, try unofficial list (TODO this is hack to avoid null url, because unofficial list items have been cached to local json, without download url..)
                Console.WriteLine("Fixing null in DownloadInBrowser ,v=" + version);
                var downloadURL = await GetUnityUpdates.CheckUnofficialVersionList(version);
                if (string.IsNullOrEmpty(downloadURL) == false)
                {
                    string unityHash = ParseHashCodeFromURL(downloadURL);
                    exeURL = ParseDownloadURLFromWebpage(version, unityHash, false, true);
                }
            }

            Console.WriteLine("download exeURL= (" + exeURL + ")");

            if (string.IsNullOrEmpty(exeURL) == false && exeURL.StartsWith("https") == true)
            {
                //SetStatus("Download installer in browser: " + exeURL);
                // download url file to temp
                string tempFile = Path.GetTempPath() + "UnityDownloadAssistant-" + version.Replace(".", "_") + ".exe";
                //Console.WriteLine("download tempFile= (" + tempFile + ")");
                if (File.Exists(tempFile) == true) File.Delete(tempFile);

                // TODO make async
                if (await DownloadFileAsync(exeURL, tempFile))
                {
                    // get base version, to use for install path
                    // FIXME check if have any paths?
                    string lastRootFolder = Properties.Settings.Default.rootFolders[Properties.Settings.Default.rootFolders.Count - 1];

                    // check if ends with / or \
                    if (lastRootFolder.EndsWith("/") == false && lastRootFolder.EndsWith("\\") == false) lastRootFolder += "\\";

                    string outputVersionFolder = version.Split('.')[0] + "_" + version.Split('.')[1];
                    string targetPathArgs = " /D=" + lastRootFolder + outputVersionFolder; ;

                    // NOTE cannot enable same modules as the previously installed version, because it requires elevated permissions..
                    // GetPlatformsForUnityVersion..
                    //var previousVersion = Tools.FindNearestVersion(version, MainWindow.unityInstalledVersions.Keys.ToList(), true);
                    //if (previousVersion != null)
                    //{
                    //    string[] previousModules = GetPlatformsForUnityVersion(previousVersion);
                    //    if (previousModules != null && previousModules.Length > 0)
                    //    {
                    //        // find registry Computer\HKEY_LOCAL_MACHINE\SOFTWARE\Unity Technologies\Installer
                    //        // then enable dword 1, for example key: "Select_Android", if my modules has "Android" etc.

                    //        foreach (var module in previousModules)
                    //        {
                    //            Console.WriteLine(previousVersion + " has modules: " + module);
                    //            // 6000.0.66f2 has modules: Android
                    //            // 6000.0.66f2 has modules: iOS
                    //            // 6000.0.66f2 has modules: Win
                    //            // 6000.0.66f2 has modules: Win64
                    //        }
                    //    }
                    //}

                    // if user clicks NO to UAC, this fails (so added try-catch)
                    try
                    {
                        Process process = new Process();
                        process.StartInfo.FileName = tempFile;
                        process.StartInfo.Arguments = targetPathArgs;
                        process.EnableRaisingEvents = true;
                        process.Exited += (sender, e) => InstallationCompleted(tempFile);
                        process.Start();
                    }
                    catch (Win32Exception ex) when (ex.NativeErrorCode == 1223) // ERROR_CANCELLED
                    {
                        // User declined the UAC prompt
                        Console.WriteLine("User cancelled elevation (UAC).");
                        SetStatus("Installation cancelled by user.");
                        InstallationCompleted(tempFile);
                    }
                    catch (Exception ex)
                    {
                        Console.WriteLine("Failed to run exe: " + tempFile + " - " + ex.Message);
                        SetStatus("Failed to run installer: " + ex.Message);
                        InstallationCompleted(tempFile);
                    }

                    // TODO refresh upgrade dialog after installer finished
                }
            }
            else // not found
            {
                //SetStatus("Error> Cannot find installer executable ... opening website instead");
                var url = "https://unity3d.com/get-unity/download/archive";
                Process.Start(url + "#installer-not-found---version-" + version);
                SetStatus("Error> Cannot find installer executable for version " + version);
            }
        } // DownloadAndInstall


        public static async Task DownloadInitScript(string currentInitScriptFullPath, string currentInitScriptLocationOrURL)
        {
            string currentInitScriptFolder = Path.GetDirectoryName(currentInitScriptFullPath);

            string currentInitScriptFile = Path.GetFileName(currentInitScriptFullPath);
            string tempFile = Path.Combine(Path.GetTempPath(), currentInitScriptFile);
            bool isLocalFile = false;

            if (string.IsNullOrEmpty(currentInitScriptLocationOrURL) == true) currentInitScriptLocationOrURL = initFileDefaultURL;

            // check if its URL or local file
            if (currentInitScriptLocationOrURL.ToLower().StartsWith("http") == true)
            {
                // download into temp first
                if (await DownloadFileAsync(currentInitScriptLocationOrURL, tempFile) == false) return;
            }
            else // file is in local folders/drives/projects
            {
                // check if file exists
                if (File.Exists(currentInitScriptLocationOrURL) == false) return;

                tempFile = currentInitScriptLocationOrURL;
                isLocalFile = true;
            }

            // if got file
            if (File.Exists(tempFile) == true)
            {
                // just in case file is locked
                try
                {
                    // small validation to check if its valid editor script
                    var tempContent = File.ReadAllText(tempFile);
                    if (tempContent.IndexOf("public class InitializeProject") > 0 && tempContent.IndexOf("namespace UnityLauncherProTools") > 0 && tempContent.IndexOf("public static void Init()") > 0)
                    {
                        // create scripts folder if missing
                        if (Directory.Exists(currentInitScriptFolder) == false) Directory.CreateDirectory(currentInitScriptFolder);

                        // move old file as backup
                        if (File.Exists(currentInitScriptFullPath))
                        {
                            string oldScriptFullPath = Path.Combine(currentInitScriptFolder, currentInitScriptFile + ".bak");
                            if (File.Exists(oldScriptFullPath)) File.Delete(oldScriptFullPath);
                            File.Move(currentInitScriptFullPath, oldScriptFullPath);
                        }
                        // move new file here (need to delete old to overwrite)
                        if (File.Exists(currentInitScriptFullPath)) File.Delete(currentInitScriptFullPath);

                        // local file copy, not move
                        if (isLocalFile == true)
                        {
                            File.Copy(tempFile, currentInitScriptFullPath);
                        }
                        else
                        {
                            File.Move(tempFile, currentInitScriptFullPath);
                        }
                        SetStatus("Downloaded latest init script.");
                    }
                    else
                    {
                        Console.WriteLine("Invalid c# init file..(missing correct Namespace, Class or Method)");
                        SetStatus("Invalid c# init file..(missing correct Namespace, Class or Method)");
                    }
                }
                catch (Exception e)
                {
                    Console.WriteLine("File exception: " + e.Message);
                    SetStatus("File exception: " + e.Message);
                }
            }
            else
            {
                Console.WriteLine("Failed to download init script from: " + currentInitScriptLocationOrURL);
                SetStatus("Failed to download init script from: " + currentInitScriptLocationOrURL);
            }
        }

        static void InstallationCompleted(string path)
        {
            if (File.Exists(path) == true)
            {
                // delete temp installer file
                File.Delete(path);

                // refresh installed versions list
                mainWindow.Dispatcher.Invoke(() =>
                {
                    mainWindow.UpdateUnityInstallationsList();
                    mainWindow.CallGetUnityUpdates();
                });
            }
        }

        public static string DownloadHTML(string url)
        {
            Console.WriteLine("DownloadHTML: " + url);

            if (string.IsNullOrEmpty(url) == true) return null;
            using (WebClient client = new WebClient())
            {
                try
                {
                    // download page html
                    return client.DownloadString(url);
                }
                catch (WebException e)
                {
                    Console.WriteLine("DownloadHTML: " + e.Message);
                    return null;
                }
            }
        }

        public static string CleanVersionNumber(string version)
        {
            if (string.IsNullOrEmpty(version)) return null;

            var split = version.Split('.');
            float parsedVersion = float.Parse($"{split[0]}.{split[1]}");

            // For 2023.3 and newer pre-release (alpha or beta) versions, do not clean.
            if ((IsAlpha(version) || version.Contains("b")) && parsedVersion >= 2023.3)
            {
                // Do nothing; leave version unchanged.
            }
            else
            {
                // Remove the trailing patch/build indicator.
                version = Regex.Replace(version, @"[fab][0-9]{1,2}", "", RegexOptions.IgnoreCase);
            }
            return version;
        }


        // TODO only hash version is used, cleanup the rest
        public static string ParseDownloadURLFromWebpage(string version, string hash = null, bool preferFullInstaller = false, bool useHash = false)
        {
            string exeURL = "";

            //Console.WriteLine("ParseDownloadURLFromWebpage: " + version + ", hash: " + useHash);

            if (string.IsNullOrEmpty(version)) return null;

            // NOTE no longer uses f# in the end

            string url = null;
            if (useHash == false)
            {
                var cleanVersion = CleanVersionNumber(version);
                // NOTE 2024 June, installs are now located on separate pages, like https://unity.com/releases/editor/whats-new/6000.0.5#installs

                // get correct page url
                //url = "https://unity3d.com/get-unity/download/archive";
                // fix unity server problem, some pages says 404 found if no url params
                url = "https://unity.com/releases/editor/whats-new/" + cleanVersion + "?unitylauncherpro#installs";
                //if (VersionIsPatch(version)) url = "https://unity3d.com/unity/qa/patch-releases";
                if (VersionIsBeta(version)) url = "https://unity.com/releases/editor/beta/" + version;
                if (VersionIsAlpha(version)) url = "https://unity.com/releases/editor/alpha/" + version;
                //url += "?unitylauncherpro";
            }
            else
            {
                // NOTE version here is actually VERSION|HASH
                //string hash = version;
                url = $"https://beta.unity3d.com/download/{hash}/download.html";

                //Console.WriteLine("hashurl: " + url);

                //version = FetchUnityVersionNumberFromHTML(url);
                //Console.WriteLine(url);
                //Console.WriteLine("got "+version);
                if (string.IsNullOrEmpty(version))
                {
                    SetStatus("Failed to get version (" + version + ") number from hash: " + hash);
                    return null;
                }
            }

            //Console.WriteLine("scanning installers from url: " + url);

            //string sourceHTML = DownloadHTML(url);

            //if (string.IsNullOrEmpty(sourceHTML) == true)
            //{
            //    Console.WriteLine("Failed to download html from: " + url);
            //    return null;
            //}

            //// parse changeset hash from html
            //string pattern = $@"href=""unityhub://{version}/([^""]+)""";
            //Regex regex = new Regex(pattern);
            //Match match = regex.Match(sourceHTML);

            //if (match.Success == true)
            //{
            //    string changeSet = match.Groups[1].Value;
            //    Console.WriteLine("changeSet: " + changeSet);
            //}

            exeURL = $"https://beta.unity3d.com/download/{hash}/UnityDownloadAssistant-{version}.exe";
            //string[] lines = sourceHTML.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None);

            // patch version download assistant finder
            //if (useHash == false && VersionIsPatch(version))
            //{
            //    for (int i = 0; i < lines.Length; i++)
            //    {
            //        //if (lines[i].Contains("UnityDownloadAssistant-" + version + ".exe"))
            //        if (lines[i].Contains("UnitySetup64-" + version + ".exe"))
            //        {
            //            int start = lines[i].IndexOf('"') + 1;
            //            int end = lines[i].IndexOf('"', start);
            //            exeURL = lines[i].Substring(start, end - start);
            //            break;
            //        }
            //    }
            //}
            //else if (useHash == false && VersionIsArchived(version))
            //{
            //    // archived version download assistant finder
            //    for (int i = 0; i < lines.Length; i++)
            //    {
            //        // find line where full installer is (from archive page)
            //        if (lines[i].Contains("UnitySetup64-" + version))
            //        {

            //            Console.WriteLine(lines[i]);

            //            // take full exe installer line, to have changeset hash, then replace with download assistant filepath
            //            string line = lines[i];
            //            int start = line.IndexOf('"') + 1;
            //            int end = line.IndexOf('"', start);
            //            exeURL = line.Substring(start, end - start);
            //            exeURL = exeURL.Replace("Windows64EditorInstaller/UnitySetup64-", "UnityDownloadAssistant-");
            //            break;
            //        }
            //    }
            //}
            //else // alpha or beta version download assistant finder
            //{
            //    // regular beta
            //    // 
            //    // https://beta.unity3d.com/download/21aeb48b6ed2/UnityDownloadAssistant.exe

            //    // hidden beta
            //    // 
            //    // https://beta.unity3d.com/download/8008bc0c1b74/UnityDownloadAssistant-6000.0.0b15.exe

            //    // new 10.06.2024, no more downloadassistant.exe in html

            //    // check html lines
            //    for (int i = 0; i < lines.Length; i++)
            //    {
            //        //Console.WriteLine(lines[i]);
            //        //if (lines[i].Contains("UnityDownloadAssistant"))
            //        if (lines[i].Contains("UnityDownloadAssistant"))
            //        {
            //            if (useHash == false)
            //            {
            //                string pattern = @"https://beta\.unity3d\.com/download/[a-zA-Z0-9]+/UnityDownloadAssistant\.exe";
            //                Match match = Regex.Match(lines[i], pattern);
            //                if (match.Success)
            //                {
            //                    exeURL = match.Value;
            //                }
            //                else
            //                {
            //                    Console.WriteLine("No match found for download base url..");
            //                }
            //            }
            //            else // hidden download page
            //            {
            //                string pattern = @"UnityDownloadAssistant(?:-\d+\.\d+\.\d+[bf]\d*)?\.exe";
            //                Match match = Regex.Match(lines[i], pattern);
            //                if (match.Success)
            //                {
            //                    // append base url
            //                    Regex regex = new Regex(@"(https://beta\.unity3d\.com/download/[a-zA-Z0-9]+/)");
            //                    Match match2 = regex.Match(url);

            //                    //Console.WriteLine("source url: " + url);

            //                    if (match2.Success)
            //                    {
            //                        string capturedUrl = match2.Groups[1].Value;
            //                        exeURL = capturedUrl + match.Value;
            //                    }
            //                    else
            //                    {
            //                        Console.WriteLine("No match found for download base url..");
            //                    }
            //                }
            //                break;
            //            }
            //        }
            //    } // for lines
            //} // alpha or beta

            // download full installer instead, TODO probably not needed anymore?
            if (useHash == false && preferFullInstaller == true)
            {
                exeURL = exeURL.Replace("UnityDownloadAssistant-" + version + ".exe", "Windows64EditorInstaller/UnitySetup64-" + version + ".exe");
                // handle alpha/beta
                exeURL = exeURL.Replace("UnityDownloadAssistant.exe", "Windows64EditorInstaller/UnitySetup64-" + version + ".exe");
            }

            // didnt find installer
            if (string.IsNullOrEmpty(exeURL))
            {
                //SetStatus("Cannot find UnityDownloadAssistant.exe for this version.");
                Console.WriteLine("Installer not found from URL: " + url);
            }
            return exeURL;
        }

        private static string FetchUnityVersionNumberFromHTML(string url)
        {
            string sourceHTML = DownloadHTML(url);

            if (string.IsNullOrEmpty(sourceHTML)) return null;

            string pattern = @"\d+\.\d+\.\d+[bf]\d+";
            MatchCollection matches = Regex.Matches(sourceHTML, pattern);
            if (matches.Count > 0)
            {
                foreach (Match match in matches)
                {
                    Console.WriteLine("Extracted number: " + match.Value);
                    return match.Value;
                    //break;
                }
            }
            else
            {
                Console.WriteLine("FetchUnityVersionNumberFromHTML: No match found.");
            }

            return null;

            //if (string.IsNullOrEmpty(sourceHTML) == false)
            //{
            //    // find version number from html
            //    string pattern = @"UnityDownloadAssistant-[0-9]+\.[0-9]+\.[0-9]+[a-z]?\.exe";
            //    Match match = Regex.Match(sourceHTML, pattern);
            //    if (match.Success)
            //    {
            //        version = match.Value.Replace("UnityDownloadAssistant-", "").Replace(".exe", "");
            //    }
            //}
            //return version;
        }

        public static string FindNearestVersion(string currentVersion, List allAvailable, bool checkBelow = false)
        {
            if (allAvailable == null)
                return null;

            string result = null;

            // add current version to list, to sort it with others
            if (!allAvailable.Contains(currentVersion)) allAvailable.Add(currentVersion);

            // sort list
            if (checkBelow)
            {
                allAvailable.Sort((s1, s2) => VersionAsLong(s1).CompareTo(VersionAsLong(s2)));
            }
            else
            {
                allAvailable.Sort((s1, s2) => VersionAsLong(s2).CompareTo(VersionAsLong(s1)));
            }

            // check version above our current version
            int currentIndex = allAvailable.IndexOf(currentVersion);
            // if its index 0, we select that row anyways later
            if (currentIndex > 0 && currentIndex < allAvailable.Count)
            {
                result = allAvailable[currentIndex - 1];
            }

            return result;
        }

        // returns version as integer, for easier sorting between versions: 2019.4.19f1 = 2019041901
        public static long VersionAsLong(string version)
        {
            long result = 0;

            // cleanup 32bit version name, TODO is this needed anymore?
            string cleanVersion = version.Replace("(32-bit)", "");

            // remove a (alpha),b (beta),f (final?),p (path),c (china final)
            cleanVersion = cleanVersion.Replace("a", ".1.");
            cleanVersion = cleanVersion.Replace("b", ".2.");
            cleanVersion = cleanVersion.Replace("c", ".3."); // NOTE this was 'c1'
            cleanVersion = cleanVersion.Replace("f", ".4.");
            cleanVersion = cleanVersion.Replace("p", ".5.");

            // split values
            string[] splitted = cleanVersion.Split('.');
            if (splitted.Length > 1)
            {
                long multiplier = 1;
                for (long i = 0, length = splitted.Length; i < length; i++)
                {
                    long n = int.Parse(splitted[length - 1 - i]);
                    result += n * multiplier;
                    multiplier *= 50;
                }
            }

            return result;
        }

        // https://stackoverflow.com/a/1619103/5452781
        public static KeyValuePair GetEntry(this IDictionary dictionary, TKey key)
        {
            return new KeyValuePair(key, dictionary[key]);
        }

        public static void HandleDataGridScrollKeys(object sender, KeyEventArgs e)
        {
            /*
            DataGrid grid = sender as DataGrid;
            switch (e.Key)
            {
                case Key.Up:
                    if (grid.SelectedIndex > 0)
                    {
                        grid.SelectedIndex--;
                    }
                    // disable wrap around

                    //else
                    //{
                     //   grid.SelectedIndex = grid.Items.Count - 1;
                    //}
            e.Handled = true;
            break;
                case Key.Down:
                    if (grid.SelectedIndex < grid.Items.Count)
            {
                grid.SelectedIndex++;
            }
            //grid.SelectedIndex = ++grid.SelectedIndex % grid.Items.Count;
            e.Handled = true;
            break;
        }
        grid.ScrollIntoView(grid.Items[grid.SelectedIndex]);
        */
        }

        // NOTE this doesnt modify the 2nd line in ProjectVersion.txt
        static void SaveProjectVersion(Project proj)
        {
            var settingsPath = Path.Combine(proj.Path, "ProjectSettings", "ProjectVersion.txt");
            if (File.Exists(settingsPath))
            {
                var versionRows = File.ReadAllLines(settingsPath);
                versionRows[0] = "m_EditorVersion: " + proj.Version;
                File.WriteAllLines(settingsPath, versionRows);
            }
        }

        public static void DisplayUpgradeDialog(Project proj, MainWindow owner, bool useInitScript = false)
        {
            UpgradeWindow modalWindow = new UpgradeWindow(proj.Version, proj.Path, proj.Arguments);
            modalWindow.ShowInTaskbar = owner == null;
            modalWindow.WindowStartupLocation = owner == null ? WindowStartupLocation.CenterScreen : WindowStartupLocation.CenterOwner;
            modalWindow.Topmost = owner == null;
            modalWindow.ShowActivated = true;
            modalWindow.Owner = owner;
            modalWindow.ShowDialog();
            var results = modalWindow.DialogResult.HasValue && modalWindow.DialogResult.Value;

            if (results == true)
            {
                var upgradeToVersion = UpgradeWindow.upgradeVersion;
                if (string.IsNullOrEmpty(upgradeToVersion)) return;

                // get selected version to upgrade for
                Console.WriteLine("Upgrade to " + upgradeToVersion);

                // inject new version for this item
                proj.Version = upgradeToVersion;
                SaveProjectVersion(proj);
                var proc = LaunchProject(proj, dataGridRef: null, useInitScript: false, upgrade: true);

                // TODO update datagrid row for new version
            }
            else
            {
                //Console.WriteLine("results = " + results);
            }
        }

        /// 
        /// install context menu item to registry
        /// 
        /// 
        public static void AddContextMenuRegistry(string contextRegRoot)
        {
            RegistryKey key = Registry.CurrentUser.OpenSubKey(contextRegRoot, true);

            // add folder if missing
            if (key == null)
            {
                key = Registry.CurrentUser.CreateSubKey(@"Software\Classes\Directory\Background\Shell");
            }

            if (key != null)
            {
                var appName = "UnityLauncherPro";
                key.CreateSubKey(appName);

                key = key.OpenSubKey(appName, true);
                key.SetValue("", "Open with " + appName);
                key.SetValue("Icon", "\"" + Process.GetCurrentProcess().MainModule.FileName + "\"");

                key.CreateSubKey("command");
                key = key.OpenSubKey("command", true);
                var executeString = "\"" + Process.GetCurrentProcess().MainModule.FileName + "\"";
                executeString += " -projectPath \"%V\"";
                key.SetValue("", executeString);
            }
            else
            {
                Console.WriteLine("Error> Cannot find registry key: " + contextRegRoot);
            }
        }

        public static void AddContextMenuRegistryAPKInstall(string contextRegRoot)
        {
            // Define the registry key path for .apk file association
            string apkFileTypeRegPath = @"Software\Classes\.apk";

            // Open or create the registry key for .apk files
            RegistryKey apkKey = Registry.CurrentUser.OpenSubKey(apkFileTypeRegPath, true);

            if (apkKey == null)
            {
                apkKey = Registry.CurrentUser.CreateSubKey(apkFileTypeRegPath);
            }

            if (apkKey != null)
            {
                // Create or open the Shell subkey for context menu options
                RegistryKey shellKey = apkKey.CreateSubKey("shell", true);

                if (shellKey != null)
                {
                    var appName = "UnityLauncherPro";
                    // Create a subkey for the app's context menu item
                    RegistryKey appKey = shellKey.CreateSubKey(appName, true);

                    if (appKey != null)
                    {
                        appKey.SetValue("", "Install with " + appName); // Display name in context menu
                        appKey.SetValue("Icon", "\"" + Process.GetCurrentProcess().MainModule.FileName + "\"");
                        appKey.SetValue("Position", "Bottom"); // Set position to adjust order

                        // Create the command subkey to specify the action
                        RegistryKey commandKey = appKey.CreateSubKey("command", true);

                        if (commandKey != null)
                        {
                            // Build the command string to launch with -install argument
                            var executeString = "\"" + Process.GetCurrentProcess().MainModule.FileName + "\" -install \"%1\"";
                            commandKey.SetValue("", executeString);
                        }
                    }
                }
            }
            else
            {
                Console.WriteLine("Error> Cannot create or access registry key for .apk file association: " + apkFileTypeRegPath);
            }
        }


        /// 
        /// uninstall context menu item from registry
        /// 
        /// 
        public static void RemoveContextMenuRegistry(string contextRegRoot)
        {
            RegistryKey key = Registry.CurrentUser.OpenSubKey(contextRegRoot, true);
            if (key != null)
            {
                var appName = "UnityLauncherPro";
                RegistryKey appKey = Registry.CurrentUser.OpenSubKey(contextRegRoot + "\\" + appName, false);
                if (appKey != null)
                {
                    key.DeleteSubKeyTree(appName);
                    //SetStatus("Removed context menu registry items");
                }
                else
                {
                    //SetStatus("Nothing to uninstall..");
                }
            }
            else
            {
                //SetStatus("Error> Cannot find registry key: " + contextRegRoot);
            }
        }

        public static void RemoveContextMenuRegistryAPKInstall(string contextRegRoot)
        {
            // Define the registry key path for .apk file association
            string apkFileTypeRegPath = @"Software\Classes\.apk\shell";

            // Open the registry key for the shell context menu
            RegistryKey shellKey = Registry.CurrentUser.OpenSubKey(apkFileTypeRegPath, true);

            if (shellKey != null)
            {
                var appName = "UnityLauncherPro";

                // Check if the app's context menu key exists
                RegistryKey appKey = shellKey.OpenSubKey(appName, false);
                if (appKey != null)
                {
                    // Delete the app's context menu key
                    shellKey.DeleteSubKeyTree(appName);
                    Console.WriteLine("Removed context menu for .apk files.");
                }
                else
                {
                    Console.WriteLine("No context menu found for .apk files.");
                }
            }
            else
            {
                Console.WriteLine("Error> Cannot find registry key for .apk shell context: " + apkFileTypeRegPath);
            }
        }

        /// 
        /// reads .git/HEAD file from the project to get current branch name
        /// 
        /// 
        /// 
        /// 
        public static string ReadGitBranchInfo(string projectPath, bool searchParentFolders)
        {
            DirectoryInfo directoryInfo = new DirectoryInfo(projectPath);
            while (directoryInfo != null)
            {
                string gitDir = Path.Combine(directoryInfo.FullName, ".git");
                string headFile = Path.Combine(gitDir, "HEAD");

                if (Directory.Exists(gitDir) && File.Exists(headFile))
                {
                    string headContent = File.ReadAllText(headFile).Trim();
                    int pos = headContent.LastIndexOf('/') + 1;
                    return (pos < headContent.Length) ? headContent.Substring(pos) : headContent;
                }

                if (!searchParentFolders)
                {
                    break;
                }
                directoryInfo = directoryInfo.Parent;
            }

            return null;
        }


        public static string ReadPlasticBranchInfo(string projectPath, bool searchParentFolders)
        {
            string branchName = null;
            DirectoryInfo directoryInfo = new DirectoryInfo(projectPath);

            while (directoryInfo != null)
            {
                string plasticSelectorPath = Path.Combine(directoryInfo.FullName, ".plastic", "plastic.selector");
                if (File.Exists(plasticSelectorPath))
                {
                    branchName = ExtractPlasticBranch(plasticSelectorPath);
                    if (!string.IsNullOrEmpty(branchName))
                    {
                        return branchName;
                    }
                }

                if (!searchParentFolders)
                {
                    break;
                }

                directoryInfo = directoryInfo.Parent;
            }

            return branchName;
        }

        private static string ExtractPlasticBranch(string plasticSelectorPath)
        {
            string[] lines = File.ReadAllLines(plasticSelectorPath);
            foreach (string line in lines)
            {
                string trimmedLine = line.Trim();
                if (trimmedLine.StartsWith("br ") || trimmedLine.StartsWith("smartbranch "))
                {
                    // Extract the first quoted string
                    var match = Regex.Match(trimmedLine, "\"([^\"]+)\"");
                    if (match.Success)
                    {
                        string branchName = match.Groups[1].Value;
                        // Remove leading slash if present (e.g., "/main" becomes "main")
                        if (branchName.StartsWith("/"))
                        {
                            branchName = branchName.Substring(1);
                        }
                        return branchName;
                    }
                }
            }
            return null;
        }

        static string GetTargetPlatformRaw(string projectPath)
        {
            string results = null;

            // get buildtarget from .csproj
            // StandaloneWindows64:19
            // get main csproj file
            var csproj = Path.Combine(projectPath, "Assembly-CSharp.csproj");

            // TODO check projname also, if no assembly-.., NOTE already checked above
            // var csproj = Path.Combine(projectPath, projectName + ".csproj");

            if (File.Exists(csproj))
            {
                // Read the file line by line for performance
                using (var reader = new StreamReader(csproj))
                {
                    string line;
                    while ((line = reader.ReadLine()) != null)
                    {
                        const string tagStart = "";
                        const string tagEnd = "";

                        int startIdx = line.IndexOf(tagStart);
                        if (startIdx >= 0)
                        {
                            int endIdx = line.IndexOf(tagEnd, startIdx);
                            if (endIdx > startIdx)
                            {
                                string inner = line.Substring(startIdx + tagStart.Length, endIdx - startIdx - tagStart.Length);
                                int colonIndex = inner.IndexOf(':');
                                if (colonIndex > -1)
                                {
                                    //Console.WriteLine("build target: " + inner.Substring(0, colonIndex));
                                    // 5.6 : win32, win64, osx, linux, linux64, ios, android, web, webstreamed, webgl, xboxone, ps4, psp2, wsaplayer, tizen, samsungtv
                                    // 2017: standalone, Win, Win64, OSXUniversal, Linux, Linux64, LinuxUniversal, iOS, Android, Web, WebStreamed, WebGL, XboxOne, PS4, PSP2, WindowsStoreApps, Switch, WiiU, N3DS, tvOS, PSM
                                    // 2018: standalone, Win, Win64, OSXUniversal, Linux, Linux64, LinuxUniversal, iOS, Android, Web, WebStreamed, WebGL, XboxOne, PS4, WindowsStoreApps, Switch, N3DS, tvOS
                                    // 2019: Standalone, Win, Win64, OSXUniversal, Linux64, iOS, Android, WebGL, XboxOne, PS4, WindowsStoreApps, Switch, tvOS
                                    // 2020: Standalone, Win, Win64, OSXUniversal, Linux64, iOS, Android, WebGL, XboxOne, PS4, WindowsStoreApps, Switch, tvOS
                                    // 2021: Standalone, Win, Win64, OSXUniversal, Linux64, iOS, Android, WebGL, XboxOne, PS4, WindowsStoreApps, Switch, tvOS
                                    results = inner.Substring(0, colonIndex);
                                    //results = (Platform)Enum.Parse(typeof(Platform), inner.Substring(0, colonIndex));
                                    break; // we found it, exit early
                                }
                            }
                        }
                    }
                }
            }
            else
            {
                //Console.WriteLine("Missing csproj, cannot parse target platform: " + projectPath);
            }

            return results;
        }


        public static string GetTargetPlatform(string projectPath)
        {
            var rawPlatformName = GetTargetPlatformRaw(projectPath);

            return GetTargetPlatformFromRaw(rawPlatformName);
        }

        public static string GetTargetPlatformFromRaw(string rawPlatformName)
        {
            if (string.IsNullOrEmpty(rawPlatformName) == false && GetProjects.remapPlatformNames.ContainsKey(rawPlatformName))
            {
                return GetProjects.remapPlatformNames[rawPlatformName];
            }
            else
            {
                if (string.IsNullOrEmpty(rawPlatformName) == false) Console.WriteLine("Missing buildTarget remap name for: " + rawPlatformName);
                return null;
            }
        }

        public static string ReadCustomProjectData(string projectPath, string customFile)
        {
            // ProjectSettings is the old deprecated location (and current location from Hub 3.15.x, but later they will use UserSettings first too)
            string[] directories = { "UserSettings", "ProjectSettings" };

            foreach (var directory in directories)
            {
                string filePath = Path.Combine(projectPath, directory, customFile);
                if (File.Exists(filePath))
                {
                    return string.Join(" ", File.ReadAllLines(filePath));
                }
            }

            return null;
        }

        public static bool SaveCustomProjectData(string projectPath, string customFile, string data)
        {
            // NOTE: now saving into UserSettings folder only (there might be old custom data is in ProjectSettings/ still..)
            var customPath = Path.Combine(projectPath, "UserSettings");
            if (!Directory.Exists(customPath))
            {
                Directory.CreateDirectory(customPath);
            }

            customFile = Path.Combine(customPath, customFile);
            try
            {
                File.WriteAllText(customFile, data);
                return true;
            }
            catch (Exception)
            {
                SetStatus("Failed to save custom project data to: " + customFile);
            }

            return false;
        }

        public static bool HasFocus(DependencyObject obj, Control control, bool checkChildren)
        {
            var oFocused = FocusManager.GetFocusedElement(obj) as DependencyObject;
            if (!checkChildren)
                return oFocused == control;
            while (oFocused != null)
            {
                if (oFocused == control)
                    return true;
                oFocused = VisualTreeHelper.GetParent(oFocused);
            }
            return false;
        }

        public static void SetFocusToGrid(DataGrid targetGrid, int index = -1)
        {
            if (targetGrid.Items.Count < 1) return;

            if (index == -1 && targetGrid.SelectedIndex > -1) index = targetGrid.SelectedIndex;
            if (index == -1) index = 0;

            targetGrid.SelectedIndex = index;

            // Try get the row, if not realized yet, defer
            DataGridRow row = (DataGridRow)targetGrid.ItemContainerGenerator.ContainerFromIndex(index);
            if (row == null)
            {
                // clamp to max items
                if (index >= targetGrid.Items.Count) index = targetGrid.Items.Count - 1;

                targetGrid.ScrollIntoView(targetGrid.Items[index]);
                // Defer the focus once row is generated
                targetGrid.Dispatcher.InvokeAsync(() =>
                {
                    var newRow = (DataGridRow)targetGrid.ItemContainerGenerator.ContainerFromIndex(index);
                    if (newRow != null)
                    {
                        newRow.MoveFocus(new TraversalRequest(FocusNavigationDirection.Up));
                        newRow.Focus();
                        Keyboard.Focus(newRow);
                    }
                }, DispatcherPriority.Background);
            }
            else
            {
                row.MoveFocus(new TraversalRequest(FocusNavigationDirection.Up));
                row.Focus();
                Keyboard.Focus(row);
            }
        }


        public static string BrowseForOutputFolder(string title, string initialDirectory = null)
        {
            // https://stackoverflow.com/a/50261723/5452781
            // Create a "Save As" dialog for selecting a directory (HACK)
            var dialog = new SaveFileDialog();
            if (initialDirectory != null) dialog.InitialDirectory = initialDirectory;
            dialog.Title = title;
            dialog.Filter = "Project Folder|*.Folder"; // Prevents displaying files
            dialog.FileName = "Project"; // Filename will then be "select.this.directory"
            if (dialog.ShowDialog() == true)
            {
                string path = dialog.FileName;
                // Remove fake filename from resulting path
                path = path.Replace("\\Project.Folder", "");
                path = path.Replace("Project.Folder", "");
                // If user has changed the filename, create the new directory
                if (!Directory.Exists(path))
                {
                    Directory.CreateDirectory(path);
                }
                return path;
            }
            return null;
        }

        // TODO too many params..
        public static Project FastCreateProject(string version, string baseFolder, string projectName = null, string templateZipPath = null, string[] platformsForThisUnity = null, string platform = null, bool useInitScript = false, string initScriptPath = null, bool forceDX11 = false)
        {
            // check for base folders in settings tab
            if (string.IsNullOrEmpty(baseFolder) == true)
            {
                SetStatus("Missing baseFolder value");
                return null;
            }

            // check if base folder exists
            if (Directory.Exists(baseFolder) == false)
            {
                // TODO add streaming filter
                SetStatus("Missing baseFolder: " + baseFolder);
                return null;
            }

            // check selected unity version
            if (string.IsNullOrEmpty(version) == true)
            {
                SetStatus("Missing unity version string");
                return null;
            }

            string newPath = null;
            // if we didnt have name yet
            if (string.IsNullOrEmpty(projectName) == true)
            {
                projectName = GetSuggestedProjectName(version, baseFolder);
                // failed getting new path a-z
                if (projectName == null) return null;
            }
            newPath = Path.Combine(baseFolder, projectName);

            // create folder
            CreateEmptyProjectFolder(newPath, version);

            bool cloneFromTemplate = false;
            // unzip or copy template
            if (templateZipPath != null)
            {
                // Console.WriteLine(templateZipPath);

                if (File.Exists(templateZipPath))
                {
                    cloneFromTemplate = true;
                    try
                    {
                        // NOTE no need to extract, unity can handle it with -cloneFromTemplate
                        //TarLib.Tar.ExtractTarGz(templateZipPath, newPath);
                    }
                    catch (Exception ex)
                    {
                        SetStatus("Error extract template file:" + ex.Message);
                    }
                }
                else if (Directory.Exists(templateZipPath))
                {
                    // this is for folder templates, copy files
                    try
                    {
                        CopyDirectory(templateZipPath, newPath);
                    }
                    catch (Exception ex)
                    {
                        SetStatus("Error copying template folder: " + ex.Message);
                    }
                }

            }

            // copy init file into project
            if (useInitScript == true)
            {
                if (File.Exists(initScriptPath) == true)
                {
                    var editorTargetFolder = Path.Combine(baseFolder, projectName, "Assets", "Editor");
                    if (Directory.Exists(editorTargetFolder) == false) Directory.CreateDirectory(editorTargetFolder);
                    var targetScriptFile = Path.Combine(editorTargetFolder, Path.GetFileName(initScriptPath));
                    // TODO overwrite old file, there shouldnt be anything here
                    if (File.Exists(targetScriptFile) == false) File.Copy(initScriptPath, targetScriptFile);
                }
            }

            // launch empty project
            var proj = new Project();
            proj.Title = projectName;
            proj.Path = Path.Combine(baseFolder, newPath).Replace("\\", "/");
            proj.Version = version;
            proj.TargetPlatforms = platformsForThisUnity;
            proj.TargetPlatform = platform;
            proj.Modified = DateTime.Now;
            proj.folderExists = true; // have to set this value, so item is green on list
            proj.Arguments = version.Contains("6000") ? (forceDX11 ? "-force-d3d11" : null) : null; // this gets erased later, since its not saved? would be nice to not add it at all though
            if (cloneFromTemplate == true)
            {
                proj.Arguments += " -cloneFromTemplate \"" + templateZipPath + "\"";
            }
            var proc = LaunchProject(proj, null, useInitScript, false, cloneFromTemplate);
            ProcessHandler.Add(proj, proc);

            return proj;
        } // FastCreateProject


        public static string GetSuggestedProjectName(string version, string baseFolder)
        {
            // check for base folders in settings tab, could use currently selected project folder parent as base?
            if (string.IsNullOrEmpty(baseFolder))
            {
                Console.WriteLine("Missing txtRootFolderForNewProjects");
                return null;
            }

            // find next free folder checking all "unityversion_a-z" characters
            var unityBaseVersion = version.Substring(0, version.LastIndexOf('.'));
            unityBaseVersion = unityBaseVersion.Replace(".", "_");
            for (int i = 97; i < 122; i++)
            {
                var newProject = unityBaseVersion + "_" + ((char)i);
                var path = Path.Combine(baseFolder, newProject);

                if (Directory.Exists(path))
                {
                    //Console.WriteLine("directory exists..trying again");
                }
                else // its available
                {
                    return newProject;
                }
            }
            // couldnt find free letter to use, lets add timestamp then
            return unityBaseVersion + "_" + DateTime.Now.ToString("ddMMyyyy_HHmmss");
        }

        static void CreateEmptyProjectFolder(string path, string version)
        {
            Console.WriteLine("Create new project folder: " + path);
            // TODO add streaming filter
            SetStatus("Creating new project folder: " + path);
            Directory.CreateDirectory(path);

            // create project version file, to avoid wrong version warning
            var settingsPath = Path.Combine(path, "ProjectSettings");
            Directory.CreateDirectory(settingsPath);
            var settingsFile = Path.Combine(settingsPath, "ProjectVersion.txt");
            File.WriteAllText(settingsFile, "m_EditorVersion: " + version);
        }

        public static string GetGlobalEditorLogsFolder()
        {
            // NOTE this is pre 6.5 unity (in 6.5 and later its inside project: MyProj/Logs/Editor.log)
            return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Unity", "Editor");
        }

        public static void OpenEditorLogForProject(Project proj)
        {
            // if version is 6000.5 or higher, then log file is in Proj/Logs, otherwise in Appdata/LocalLow
            if (proj.Version != null && Tools.IsVersionAtLeast(proj.Version, "6000.5") == true)
            {
                var logFolder = Path.Combine(proj.Path, "Logs");
                var logFile = Path.Combine(logFolder, "Editor.log");
                if (File.Exists(logFile) == true)
                {
                    Tools.LaunchExe(logFile);
                }
                else
                {
                    SetStatus("Log file not found: " + logFile, MessageType.Warning);
                }
            }
            else
            {
                var logFolder = Tools.GetGlobalEditorLogsFolder();
                var logFile = Path.Combine(logFolder, "Editor.log");
                if (File.Exists(logFile) == true)
                {
                    Tools.LaunchExe(logFile);
                }
                else
                {
                    SetStatus("Log file not found: " + logFile, MessageType.Warning);
                }
            }
        }

        public static string[] GetPlatformsForUnityVersion(string version)
        {
            // get platforms array for this unity version
            // TODO use dictionary instead of looping versions
            for (int i = 0; i < MainWindow.unityInstallationsSource.Count; i++)
            {
                if (MainWindow.unityInstallationsSource[i].Version == version)
                {
                    return MainWindow.unityInstallationsSource[i].Platforms;
                }
            }
            return null;
        }

        // https://stackoverflow.com/a/675347/5452781
        public static void SetStartupRegistry(bool state)
        {
            RegistryKey rk = Registry.CurrentUser.OpenSubKey("SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run", true);
            if (state == true)
            {
                rk.SetValue(MainWindow.appName, "\"" + Process.GetCurrentProcess().MainModule.FileName + "\"");
            }
            else
            {
                rk.DeleteValue(MainWindow.appName, false);
            }
        }

        public static Dictionary ScanTemplates(string unityInstallPath)
        {
            var items = new Dictionary();

            // add none as default item
            items.Add("None", null);

            // get list of existing packages
            var unityPath = Path.GetDirectoryName(unityInstallPath);
            var templateFolder = Path.Combine(unityPath, "Data/Resources/PackageManager/ProjectTemplates/");
            if (Directory.Exists(templateFolder) == false) return items;

            // get all files in template folder
            var fileEntries = Directory.GetFiles(templateFolder).ToList();

            // process found files
            for (int i = 0; i < fileEntries.Count; i++)
            {
                //Console.WriteLine(fileEntries[i]);
                // check if its tgz
                if (fileEntries[i].ToLower().IndexOf(".tgz") > -1)
                {
                    // cleanup displayed name
                    var name = Path.GetFileName(fileEntries[i]).Replace("com.unity.template.", "").Replace(".tgz", "");
                    items.Add(name, fileEntries[i]);
                }
            }

            // 2018.4 has folders instead of tgz files
            // BUT do this for all versions, in case user has added custom template folders (that contain Assets/ folder)
            var dirEntries = Directory.GetDirectories(templateFolder).ToList();
            for (int i = 0; i < dirEntries.Count; i++)
            {
                //Console.WriteLine(dirEntries[i]);
                // if it has com.unity.template. prefix, then its a template
                // if that directory contains Assets/ folder, then its a template
                if (Directory.Exists(Path.Combine(dirEntries[i], "Assets")) == true)
                {
                    //Console.WriteLine(dirEntries[i]);
                    var name = Path.GetFileName(dirEntries[i]).Replace("com.unity.template.", "");
                    items.Add(name, dirEntries[i]);
                }
            }

            return items;
        }

        // chatgpt
        public static string GetElapsedTime(DateTime datetime)
        {
            TimeSpan ts = DateTime.Now - datetime;

            if (ts.TotalSeconds < 60)
            {
                return ts.TotalSeconds < 2 ? "Right now" : $"{(int)ts.TotalSeconds} seconds ago";
            }
            else if (ts.TotalMinutes < 60)
            {
                return ts.TotalMinutes < 2 ? "1 minute ago" : $"{(int)ts.TotalMinutes} minutes ago";
            }
            else if (ts.TotalHours < 24)
            {
                return ts.TotalHours < 2 ? "1 hour ago" : $"{(int)ts.TotalHours} hours ago";
            }
            else if (ts.TotalDays < 30)
            {
                return ts.TotalDays < 2 ? "1 day ago" : $"{(int)ts.TotalDays} days ago";
            }
            else if (ts.TotalDays < 365)
            {
                if (ts.TotalDays < 60)
                {
                    return "1 month ago";
                }
                else
                {
                    return $"{(int)(ts.TotalDays / 30)} months ago";
                }
            }
            else
            {
                return ts.TotalDays < 730 ? "1 year ago" : $"{(int)(ts.TotalDays / 365)} years ago";
            }
        }

        public static bool ValidateDateFormat(string format)
        {
            try
            {
                String formattedDate = DateTime.Now.ToString(format);
                DateTime.Parse(formattedDate);
                return true;
            }
            catch (Exception)
            {
                //Console.WriteLine("Invalid custom datetime format: " + format);
                return false;
            }
        }

        // https://stackoverflow.com/a/37724335/5452781
        public static void BringProcessToFront(Process process)
        {
            if (process == null) return;
            IntPtr handle = process.MainWindowHandle;
            if (IsIconic(handle))
            {
                ShowWindow(handle, SW_RESTORE);
            }

            SetForegroundWindow(handle);
        }

        public static void DownloadAdditionalModules(string UnityExePath, string unityVersion, string moduleName)
        {
            var editorFolder = Path.GetDirectoryName(UnityExePath);

            string hash = null;

            // get from unity exe (only for 2018.4 and later?)
            var versionInfo = FileVersionInfo.GetVersionInfo(UnityExePath);
            var versionRaw = versionInfo.ProductVersion.Split('_');
            if (versionRaw.Length == 2)
            {
                hash = versionRaw[1];
            }
            else // try other files
            {
                var changeSetFile = Path.Combine(editorFolder, @"Data\PlaybackEngines\windowsstandalonesupport\Source\WindowsPlayer\WindowsPlayer\UnityConfigureRevision.gen.h");
                if (File.Exists(changeSetFile) == true)
                {
                    var allText = File.ReadAllText(changeSetFile);
                    var hashRaw = allText.Split(new string[] { "#define UNITY_VERSION_HASH \"" }, StringSplitOptions.None);
                    if (hashRaw.Length > 1)
                    {
                        hash = hashRaw[1].Replace("\"", "");
                    }
                    else
                    {
                        Console.WriteLine("Unable to parse UNITY_VERSION_HASH from " + changeSetFile);
                    }
                }
                else
                {
                    Console.WriteLine("Changeset hash file not found: " + changeSetFile);
                }
            }

            if (hash == null) return;

            var moduleURL = "https://download.unity3d.com/download_unity/" + hash + "/TargetSupportInstaller/UnitySetup-" + moduleName + "-Support-for-Editor-" + unityVersion + ".exe";
            OpenURL(moduleURL);
        }

        public static void OpenAppdataSpecialFolder(string subfolder)
        {
            var logfolder = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), subfolder);
            if (Directory.Exists(logfolder) == true)
            {
                if (Tools.LaunchExplorer(logfolder) == false)
                {
                    Console.WriteLine("Cannot open folder.." + logfolder);
                }
            }
        }

        // NOTE android only at the moment
        public static void BuildProject(Project proj, Platform platform)
        {
            Console.WriteLine("Building " + proj.Title + " for " + platform);
            SetStatus("Build process started: " + DateTime.Now.ToString("HH:mm:ss"));

            // TODO use theme colors, keep list of multiple builds, if click status button show list of builds, if click for single build (show output folder)
            SetBuildStatus(Colors.Red);

            if (string.IsNullOrEmpty(proj.Path)) return;

            // create builder script template (with template string, that can be replaced with project related paths or names?)
            // copy editor build script to Assets/Editor/ folder (if already exists then what? Use UnityLauncherBuildSomething.cs name, so can overwrite..)
            var editorScriptFolder = Path.Combine(proj.Path, "Assets", "Editor");
            if (Directory.Exists(editorScriptFolder) == false) Directory.CreateDirectory(editorScriptFolder);
            // TODO check if creation failed

            // create output file for editor script
            var editorScriptFile = Path.Combine(editorScriptFolder, "UnityLauncherProBuilder.cs");

            // check build folder and create if missing
            var outputFolder = Path.Combine(proj.Path, "Builds/" + platform + "/");
            outputFolder = outputFolder.Replace('\\', '/'); // fix backslashes
            Console.WriteLine("outputFolder= " + outputFolder);
            if (Directory.Exists(outputFolder) == false) Directory.CreateDirectory(outputFolder);
            // TODO check if creation failed

            // cleanup filename from project name
            var invalidChars = Path.GetInvalidFileNameChars();
            var outputFile = String.Join("_", proj.Title.Split(invalidChars, StringSplitOptions.RemoveEmptyEntries)).TrimEnd('.');
            // replace spaces also, for old time(r)s
            outputFile = outputFile.Replace(' ', '_');
            outputFile = Path.Combine(outputFolder, outputFile + ".apk");
            Console.WriteLine("outputFile= " + outputFile);

            // TODO move to txt resource? and later load from local custom file if exists, and later open window or add settings for build options
            // TODO different unity versions? wont work in older unitys right now
            var builderScript = @"using System.Linq;
using UnityEditor;
using UnityEngine;
public static class UnityLauncherProTools
{
    public static void BuildAndroid()
    {
        EditorUserBuildSettings.buildAppBundle = false;
        EditorUserBuildSettings.androidBuildSystem = AndroidBuildSystem.Gradle;
        PlayerSettings.SetScriptingBackend(BuildTargetGroup.Android, ScriptingImplementation.IL2CPP);
        PlayerSettings.Android.targetArchitectures = AndroidArchitecture.ARM64;
        var settings = new BuildPlayerOptions();
        settings.scenes = GetScenes();
        settings.locationPathName = ""###OUTPUTFILE###"";
        settings.target = BuildTarget.Android;
        settings.options = BuildOptions.None;
        var report = BuildPipeline.BuildPlayer(settings);
    }
    public static void BuildiOS() // Note need to match platform name
    {
        PlayerSettings.iOS.targetDevice = iOSTargetDevice.iPhoneAndiPad;
        var settings = new BuildPlayerOptions();
        settings.scenes = GetScenes();
        settings.locationPathName = ""###OUTPUTFOLDER###"";
        settings.target = BuildTarget.iOS;
        settings.options = BuildOptions.None;
        var report = BuildPipeline.BuildPlayer(settings);
    }
    static string[] GetScenes()
    {
        return EditorBuildSettings.scenes.Where(scene => scene.enabled).Select(scene => scene.path).ToArray();
    }
}";

            // fill in project specific data
            builderScript = builderScript.Replace("###OUTPUTFILE###", outputFile); // android
            builderScript = builderScript.Replace("###OUTPUTFOLDER###", outputFolder); // ios
            Console.WriteLine("builderScript=" + builderScript);

            File.WriteAllText(editorScriptFile, builderScript);
            // TODO check if write failed

            // get selected project unity exe path
            var unityExePath = Tools.GetUnityExePath(proj.Version);
            if (unityExePath == null) return;

            // create commandline string for building and launch it
            //var buildcmd = $"\"{unityExePath}\" -quit -batchmode -nographics -projectPath \"{proj.Path}\" -executeMethod \"Builder.BuildAndroid\" -buildTarget android -logFile -";
            // TODO test without nographics : https://forum.unity.com/threads/batch-build-one-scene-is-black-works-in-normal-file-build.1282823/#post-9456524
            var buildParams = $" -quit -batchmode -nographics -projectPath \"{proj.Path}\" -executeMethod \"UnityLauncherProTools.Build{platform}\" -buildTarget {platform} -logFile \"{outputFolder}/../build.log\"";
            Console.WriteLine("buildcmd= " + buildParams);

            // launch build
            var proc = Tools.LaunchExe(unityExePath, buildParams);

            // wait for process exit then open output folder
            proc.Exited += (o, i) =>
            {
                Console.WriteLine("Build process exited: " + outputFolder);
                Tools.ExploreFolder(outputFolder);
                SetStatus("Build process finished: " + DateTime.Now.ToString("HH:mm:ss"));
                // TODO set color based on results
                SetBuildStatus(Colors.Green);
            };

        }

        public static void BuildProjectCustom(Project proj)
        {
            Console.WriteLine("Building " + proj.Title + " (custom)");
            SetStatus("Build process started: " + DateTime.Now.ToString("HH:mm:ss"));

            // get selected project unity exe path
            var unityExePath = Tools.GetUnityExePath(proj.Version);
            if (unityExePath == null) return;

            // create commandline string for building and launch it
            //var buildcmd = $"\"{unityExePath}\" -quit -batchmode -nographics -projectPath \"{proj.Path}\" -executeMethod \"Builder.BuildAndroid\" -buildTarget android -logFile -";
            // TODO test without nographics : https://forum.unity.com/threads/batch-build-one-scene-is-black-works-in-normal-file-build.1282823/#post-9456524
            var buildParams = $" -quit -batchmode -nographics -projectPath \"{proj.Path}\" -executeMethod \"UnityLauncherProToolsCustom.BuildCustom\"";
            Console.WriteLine("buildcmd= " + buildParams);

            // launch build
            var proc = Tools.LaunchExe(unityExePath, buildParams);

            // wait for process exit then open output folder
            proc.Exited += (o, i) =>
            {
                SetStatus("Build process finished: " + DateTime.Now.ToString("HH:mm:ss"));
                // TODO set color based on results
                SetBuildStatus(Colors.Green);
            };
        }

        // runs unity SimpleWebServer.exe and launches default Browser into project build/ folder'
        public static void LaunchWebGL(Project proj, string relativeFolder)
        {
            var projPath = proj?.Path.Replace('/', '\\');
            if (string.IsNullOrEmpty(projPath) == true) return;

            var buildPath = Path.Combine(projPath, "Builds", relativeFolder);
            if (Directory.Exists(buildPath) == false) return;

            if (MainWindow.unityInstalledVersions.ContainsKey(proj.Version) == false) return;

            // get mono and server exe paths
            var editorPath = Path.GetDirectoryName(MainWindow.unityInstalledVersions[proj.Version]);

            var monoToolsPath = Path.Combine(editorPath, "Data/MonoBleedingEdge/bin");
            if (Directory.Exists(monoToolsPath) == false) return;

            var webglToolsPath = Path.Combine(editorPath, "Data/PlaybackEngines/WebGLSupport/BuildTools");
            if (Directory.Exists(webglToolsPath) == false) return;

            var monoExe = Path.Combine(monoToolsPath, "mono.exe");
            if (File.Exists(monoExe) == false) return;

            var webExe = Path.Combine(webglToolsPath, "SimpleWebServer.exe");
            if (File.Exists(webExe) == false) return;

            int port = MainWindow.webglPort;
            if (port < 50000) port = 50000;
            if (port > 65534) port = 65534;

            // check if this project already has server running and process is not closed
            if (webglServerProcesses.ContainsKey(port) && webglServerProcesses[port].HasExited == false)
            {
                Console.WriteLine("Port found in cache: " + port + " process=" + webglServerProcesses[port]);

                // check if project matches
                if (webglServerProcesses[port].StartInfo.Arguments.IndexOf("\"" + buildPath + "\"") > -1)
                {
                    Console.WriteLine("this project already has webgl server running.. lets open browser url only");
                    // then open browser url only
                    Tools.OpenURL("http://localhost:" + port);
                    return;

                }
                else
                {
                    Console.WriteLine("Port in use, but its different project: " + port);
                    Console.WriteLine(webglServerProcesses[port].StartInfo.Arguments + " == " + "\"" + buildPath + "\"");

                    // then open new port and process
                    // -----------------------------------------------------------
                    // check if port is available https://stackoverflow.com/a/2793289
                    bool isAvailable = true;
                    IPGlobalProperties ipGlobalProperties = IPGlobalProperties.GetIPGlobalProperties();
                    IPEndPoint[] objEndPoints = ipGlobalProperties.GetActiveTcpListeners();

                    // NOTE instead of iterating all ports, just try to open port, if fails, open next one
                    // compare with existing ports, if available
                    for (int i = 0; i < objEndPoints.Length; i++)
                    {
                        if (objEndPoints[i].Port == port)
                        {
                            port++;
                            if (port > 65535)
                            {
                                Console.WriteLine("Failed to find open port..");
                                isAvailable = false;
                                return;
                            }
                        }
                    }

                    Console.WriteLine("Found available port: " + port);

                    if (isAvailable == false)
                    {
                        Console.WriteLine("failed to open port " + port + " (should be open already, or something else is using it?)");
                    }
                    else
                    {
                        // take process id from unity, if have it (then webserver closes automatically when unity is closed)
                        var proc = ProcessHandler.Get(proj.Path);
                        int pid = proc == null ? -1 : proc.Id;
                        string param = null;

                        // parse proj version year as number 2019.4.1f1 -> 2019
                        int year = 0;
                        var versionParts = proj.Version.Split('.');
                        bool parsedYear = int.TryParse(versionParts[0], out year);

                        if (parsedYear && year >= 6000)
                        {
                            param = "\"" + webExe + "\" \"" + buildPath + "\" " + "http://localhost:" + port + "/" + (pid == -1 ? "" : " " + pid);
                        }
                        else // older versions or failed to parse
                        {
                            param = "\"" + webExe + "\" \"" + buildPath + "\" " + port + (pid == -1 ? "" : " " + pid); // server exe path, build folder and port
                        }

                        var webglServerProcess = Tools.LaunchExe(monoExe, param);

                        if (webglServerProcesses.ContainsKey(port))
                        {
                            Console.WriteLine("Error> Should not happen - this port is already in dictionary! port: " + port);
                        }
                        else // keep reference to this process on this port
                        {
                            // TODO how to remove process once its closed? (or unlikely to have many processes in total? can also remove during check, if process already null)
                            webglServerProcesses.Add(port, webglServerProcess);
                            Console.WriteLine("Added port " + port);
                        }

                        Tools.OpenURL("http://localhost:" + port);
                    }
                    // -----------------------------------------------------------

                }
            }
            else
            {
                Console.WriteLine("Port not running in cache or process already closed, remove it from cache: " + port);
                if (webglServerProcesses.ContainsKey(port)) webglServerProcesses.Remove(port);

                // TODO remove duplicate code
                // then open new process
                // -----------------------------------------------------------
                // check if port is available https://stackoverflow.com/a/2793289
                bool isAvailable = true;
                IPGlobalProperties ipGlobalProperties = IPGlobalProperties.GetIPGlobalProperties();
                IPEndPoint[] objEndPoints = ipGlobalProperties.GetActiveTcpListeners();

                // compare with existing ports, if available
                for (int i = 0; i < objEndPoints.Length; i++)
                {
                    if (objEndPoints[i].Port == port)
                    {
                        if (port > 65535)
                        {
                            Console.WriteLine("Failed to find open port..");
                            isAvailable = false;
                            return;
                        }
                        port++;
                    }
                }

                Console.WriteLine("Found available port: " + port);

                if (isAvailable == false)
                {
                    Console.WriteLine("failed to open port " + port + " (should be open already, or something else is using it?)");
                }
                else
                {
                    // take process id from unity, if have it(then webserver closes automatically when unity is closed)
                    var proc = ProcessHandler.Get(proj.Path);
                    int pid = proc == null ? -1 : proc.Id;

                    // parse proj version year as number 2019.4.1f1 -> 2019
                    string param = null;
                    int year = 0;
                    var versionParts = proj.Version.Split('.');
                    bool parsedYear = int.TryParse(versionParts[0], out year);

                    if (parsedYear && year >= 6000)
                    {
                        param = "\"" + webExe + "\" \"" + buildPath + "\" " + "\"http://localhost:" + port + "/\"" + (pid == -1 ? "" : " " + pid);
                    }
                    else // older versions or failed to parse
                    {
                        param = "\"" + webExe + "\" \"" + buildPath + "\" " + port + (pid == -1 ? "" : " " + pid); // server exe path, build folder and port
                    }

                    //var param = "\"" + webExe + "\" \"" + buildPath + "\" " + port + (pid == -1 ? "" : " " + pid); // server exe path, build folder and port

                    var webglServerProcess = Tools.LaunchExe(monoExe, param);

                    if (webglServerProcess == null)
                    {
                        Console.WriteLine("Failed to start exe..");
                    }

                    if (webglServerProcesses.ContainsKey(port))
                    {
                        Console.WriteLine("Error> Should not happen - this port is already in dictionary! port: " + port);
                    }
                    else // keep reference to this process on this port
                    {
                        // TODO how to remove process once its closed? (or unlikely to have many processes in total? can also remove during check, if process already null)
                        webglServerProcesses.Add(port, webglServerProcess);
                        Console.WriteLine("Added port " + port);
                    }

                    Tools.OpenURL("http://localhost:" + port);
                }
                // -----------------------------------------------------------

            }
        } // LaunchWebGL()

        // creates .bat file to launch UnityLauncherPro and then .url link file on desktop, into that .bat file
        public static bool CreateDesktopShortCut(Project proj, string batchFolder)
        {
            string lnkFileName = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Desktop));

            if (string.IsNullOrEmpty(batchFolder)) return false;

            //string batchFolder = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "UnityLauncherPro");
            if (Directory.Exists(batchFolder) == false) Directory.CreateDirectory(batchFolder);
            string batFileName = Path.Combine(batchFolder, proj.Title + ".bat");
            string launcherExe = Process.GetCurrentProcess().MainModule.FileName;
            string args = "-projectPath " + "\"" + proj.Path + "\" " + proj.Arguments;
            string description = "Unity Project: " + proj.Title;

            // create .bat file
            var batLauncherData = "start \"\" \"" + launcherExe + "\"" + " " + args;
            File.WriteAllText(batFileName, batLauncherData);

            // create desktop link file
            using (StreamWriter writer = new StreamWriter(lnkFileName + "\\" + proj.Title + ".url"))
            {
                writer.WriteLine("[InternetShortcut]");
                writer.WriteLine("URL=file:///" + batFileName);
                //writer.WriteLine("ShowCommand=7"); // doesnt work for minimized
                writer.WriteLine("IconIndex=0");
                writer.WriteLine("Arguments=-projectPath " + proj.Path);
                // TODO maybe could take icon from project (but then need to convert into .ico)
                string iconExe = GetUnityExePath(proj.Version);
                if (iconExe == null) iconExe = launcherExe;
                string icon = iconExe.Replace('\\', '/');
                writer.WriteLine("IconFile=" + icon);
            }

            // TODO check for streamwriter and file write success

            return true;
        }


        internal static long GetFolderSizeInBytes(string currentBuildReportProjectPath)
        {
            // FIXME: 0 is not really correct for missing folder..
            if (Directory.Exists(currentBuildReportProjectPath) == false) return 0;

            return DirSize(new DirectoryInfo(currentBuildReportProjectPath));
        }

        // https://stackoverflow.com/a/468131/5452781
        static long DirSize(DirectoryInfo d)
        {
            long size = 0;
            // Add file sizes.
            FileInfo[] fis = d.GetFiles();
            foreach (FileInfo fi in fis)
            {
                size += fi.Length;
            }
            // Add subdirectory sizes.
            DirectoryInfo[] dis = d.GetDirectories();
            foreach (DirectoryInfo di in dis)
            {
                size += DirSize(di);
            }
            return size;
        }

        // Returns the human-readable file size for an arbitrary, 64-bit file size 
        // The default format is "0.### XB", e.g. "4.2 KB" or "1.434 GB"
        internal static string GetBytesReadable(long i)
        {
            // Get absolute value
            long absolute_i = (i < 0 ? -i : i);
            // Determine the suffix and readable value
            string suffix;
            double readable;
            if (absolute_i >= 0x1000000000000000) // Exabyte
            {
                suffix = "EB";
                readable = (i >> 50);
            }
            else if (absolute_i >= 0x4000000000000) // Petabyte
            {
                suffix = "PB";
                readable = (i >> 40);
            }
            else if (absolute_i >= 0x10000000000) // Terabyte
            {
                suffix = "TB";
                readable = (i >> 30);
            }
            else if (absolute_i >= 0x40000000) // Gigabyte
            {
                suffix = "GB";
                readable = (i >> 20);
            }
            else if (absolute_i >= 0x100000) // Megabyte
            {
                suffix = "MB";
                readable = (i >> 10);
            }
            else if (absolute_i >= 0x400) // Kilobyte
            {
                suffix = "KB";
                readable = i;
            }
            else
            {
                return i.ToString("0 B"); // Byte
            }
            // Divide by 1024 to get fractional value
            readable = (readable / 1024);
            // Return formatted number with suffix
            return readable.ToString("0.### ") + suffix;
        }

        public static MainWindow mainWindow;

        // set status bar in main thread
        public static void SetStatus(string text, MessageType messageType = MessageType.Info)
        {
            mainWindow.Dispatcher.Invoke(() => { mainWindow.SetStatus(text, messageType); });
        }

        public static void SetBuildStatus(Color color)
        {
            mainWindow.Dispatcher.Invoke(() => { mainWindow.SetBuildStatus(color); });
        }

        // https://unity3d.com/unity/alpha
        public static bool IsAlpha(string version)
        {
            if (string.IsNullOrEmpty(version)) return false;
            return version.IndexOf("a", 0, StringComparison.CurrentCultureIgnoreCase) > -1;
        }

        // https://unity3d.com/beta/
        public static bool IsBeta(string version)
        {
            if (string.IsNullOrEmpty(version)) return false;
            return version.IndexOf("b", 0, StringComparison.CurrentCultureIgnoreCase) > -1;
        }

        // https://unity3d.com/unity/qa/lts-releases
        public static bool IsLTS(string versionRaw)
        {
            if (string.IsNullOrEmpty(versionRaw)) return false;
            var version = versionRaw.Split('.');
            var versionInt = int.Parse(version[0]);
            var versionMinor = int.Parse(version[1]);
            return (versionInt >= 2017 && versionMinor == 4) || (versionInt > 2019 && versionMinor == 3);
        }

        internal static void UninstallEditor(string path, string version)
        {
            if (string.IsNullOrEmpty(path)) return;
            if (string.IsNullOrEmpty(version)) return;

            // run uninstaller from path
            var installFolder = Path.GetDirectoryName(path);
            var uninstaller = Path.Combine(installFolder, "Uninstall.exe");
            // TODO could be optional setting for non-silent uninstall
            LaunchExe(uninstaller, "/S");
            // remove firewall settings
            var cmd = "netsh advfirewall firewall delete rule name=all program=\"" + path + "\"";
            Console.WriteLine("Cleanup firewall: " + cmd);
            LaunchExe("cmd.exe", "/c " + cmd);

            int year;
            string[] parts = version.Split('.');
            // TODO handle unity 6.x
            if (parts.Length >= 1 && int.TryParse(parts[0], out year) && year  cancellationTokenSource.Cancel());
            progressWindow.Show();
            var result = false;

            // create destination folder, if not exists
            var outputFolderPath = Path.GetDirectoryName(destinationPath);
            if (Directory.Exists(outputFolderPath) == false)
            {
                Directory.CreateDirectory(outputFolderPath);
            }

            try
            {
                using (var client = new HttpClient())
                using (var response = await client.GetAsync(fileUrl, HttpCompletionOption.ResponseHeadersRead, cancellationTokenSource.Token))
                {
                    response.EnsureSuccessStatusCode();

                    var totalBytes = response.Content.Headers.ContentLength ?? 1;
                    var buffer = new byte[8192];
                    var totalRead = 0;

                    using (var contentStream = await response.Content.ReadAsStreamAsync())
                    using (var fileStream = new FileStream(destinationPath, FileMode.Create, FileAccess.Write, FileShare.None, buffer.Length, true))
                    {
                        int bytesRead;
                        while ((bytesRead = await contentStream.ReadAsync(buffer, 0, buffer.Length, cancellationTokenSource.Token)) > 0)
                        {
                            await fileStream.WriteAsync(buffer, 0, bytesRead, cancellationTokenSource.Token);
                            totalRead += bytesRead;
                            progressWindow.UpdateProgress(new DownloadProgress(totalRead, totalBytes));
                        }
                        result = true;
                    }
                }
            }
            catch (TaskCanceledException)
            {
                Console.WriteLine("Download cancelled");
            }
            catch (Exception e)
            {
                Console.WriteLine(e);
            }
            finally
            {
                if (!result)
                {
                    InstallationCompleted(destinationPath);
                }
                progressWindow.Close();
            }
            return result;
        }

        internal static string GetSRP(string projectPath)
        {
            var settingsFile = Path.Combine(projectPath, "ProjectSettings", "GraphicsSettings.asset");
            if (!File.Exists(settingsFile)) return null;

            bool srpSectionFound = false;

            using (var reader = new StreamReader(settingsFile))
            {
                string line;
                while ((line = reader.ReadLine()) != null)
                {
                    if (!srpSectionFound)
                    {
                        if (line.Contains("m_SRPDefaultSettings:") || line.Contains("m_RenderPipelineGlobalSettingsMap:"))
                        {
                            srpSectionFound = true;
                        }
                        continue;
                    }

                    if (line.Contains("UniversalRenderPipeline"))
                    {
                        return "URP";
                    }
                    else if (line.Contains("HDRenderPipeline"))
                    {
                        return "HDRP";
                    }
                }
            }
            return null; // BIRP or unknown
        }

        internal static void InstallAPK(string ApkPath)
        {
            try
            {
                var cmd = "cmd.exe";
                var pars = $"/C adb install -r \"{ApkPath}\""; // Use /C to execute and close the window after finishing

                var processStartInfo = new ProcessStartInfo
                {
                    FileName = cmd,
                    Arguments = pars,
                    RedirectStandardOutput = true, // Capture output to wait for completion
                    RedirectStandardError = true,
                    UseShellExecute = false,
                    CreateNoWindow = false
                };

                string installOutput = null;
                string installError = null;

                using (var installProcess = Process.Start(processStartInfo))
                {
                    installOutput = installProcess.StandardOutput.ReadToEnd();
                    installError = installProcess.StandardError.ReadToEnd();
                    installProcess.WaitForExit();

                    if (installProcess.ExitCode != 0 || !string.IsNullOrEmpty(installError))
                    {
                        SetStatus($"Error installing APK: {installError.Trim()}\n{installOutput.Trim()}");
                        return;
                    }
                }

                // Attempt to extract package name using aapt
                var aaptCmd = $"aapt dump badging \"{ApkPath}\" | findstr package:";
                var aaptProcessStartInfo = new ProcessStartInfo
                {
                    FileName = "cmd.exe",
                    Arguments = $"/C {aaptCmd}",
                    RedirectStandardOutput = true,
                    UseShellExecute = false,
                    CreateNoWindow = true
                };

                string packageName = null;
                using (var process = Process.Start(aaptProcessStartInfo))
                {
                    var output = process.StandardOutput.ReadToEnd();
                    process.WaitForExit();

                    var match = System.Text.RegularExpressions.Regex.Match(output, "package: name='(.*?)'");
                    if (match.Success)
                    {
                        packageName = match.Groups[1].Value;
                    }
                }

                if (!string.IsNullOrEmpty(packageName))
                {
                    // Run the application using adb
                    var runPars = $"/C adb shell monkey -p {packageName} 1";
                    var runProcessStartInfo = new ProcessStartInfo
                    {
                        FileName = cmd,
                        Arguments = runPars,
                        UseShellExecute = true,
                        CreateNoWindow = false,
                        WindowStyle = ProcessWindowStyle.Normal
                    };
                    Process.Start(runProcessStartInfo);

                    SetStatus($"Installed and launched APK with package name: {packageName}");
                }
                else
                {
                    SetStatus("ADB install completed, but failed to extract package name. Application not launched.");
                }
            }
            catch (Win32Exception ex)
            {
                // Handle case where adb or aapt is not found
                SetStatus($"Error: Required tool not found. Ensure adb and aapt are installed and added to PATH. Details: {ex.Message}");
            }
            catch (Exception ex)
            {
                // Handle other unexpected exceptions
                SetStatus($"An unexpected error occurred: {ex.Message}");
            }
        }

        // exclude folders from windows defender
        internal static bool RunExclusionElevated(IEnumerable paths, bool silent = false)
        {
            var escapedPaths = new List();
            foreach (var rawPath in paths)
            {
                var path = rawPath.Trim();
                string safePath = path.Replace("'", "''");
                escapedPaths.Add($"'{safePath}'");
            }

            string joinedPaths = string.Join(", ", escapedPaths);
            string psCommand = $"Add-MpPreference -ExclusionPath {joinedPaths}";

            string fullCommand;

            if (silent)
            {
                // No output, just run the command silently
                fullCommand = psCommand;
            }
            else
            {
                // Show command and keep window open
                var quotedPathsForDisplay = string.Join(", ", escapedPaths.ConvertAll(p => $"'{p.Trim('\'')}'"));
                string displayCommand = $"Add-MpPreference -ExclusionPath {quotedPathsForDisplay}";
                fullCommand = $"Write-Host 'Running: {displayCommand}'; {psCommand}; Write-Host ''; Write-Host 'Done. Press any key to exit...'; pause";
            }

            var startInfo = new ProcessStartInfo
            {
                FileName = "powershell.exe",
                Arguments = silent
                    ? $"-WindowStyle Hidden -Command \"{fullCommand}\""
                    : $"-NoExit -Command \"{fullCommand}\"",
                UseShellExecute = true,
                Verb = "runas", // Requires admin rights
                WindowStyle = silent ? ProcessWindowStyle.Hidden : ProcessWindowStyle.Normal
            };

            try
            {
                Process.Start(startInfo);
            }
            catch (Win32Exception)
            {
                if (!silent)
                {
                    MessageBox.Show("Operation cancelled or failed due to insufficient privileges.", "Cancelled", MessageBoxButton.OK, MessageBoxImage.Warning);
                }
                return false;
            }
            catch (Exception ex)
            {
                MessageBox.Show("Error: " + ex.Message, "Error", MessageBoxButton.OK, MessageBoxImage.Error);
                return false;
            }

            return true;
        }

        /// 
        /// Returns a full file path under either the application's install folder
        /// or, if that isn't writable, under LocalAppData\UnityLauncherPro\.
        /// 
        /// e.g. "Themes", "Scripts"
        /// e.g. "custom.ini", "InitializeProject.cs"
        public static string GetSafeFilePath(string subfolder, string fileName)
        {
            // 1) Preferred: under the app folder
            string preferredDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, subfolder);
            // 2) Fallback: in LocalAppData
            string fallbackDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "UnityLauncherPro", subfolder);

            try
            {
                // Safe even if it already exists
                Directory.CreateDirectory(preferredDir);
                return Path.Combine(preferredDir, fileName);
            }
            catch (UnauthorizedAccessException)
            {
                // no write access under Program Files
            }
            catch (Exception ex)
            {
                // optional: log unexpected errors
                Console.WriteLine($"Warning: couldnt create {preferredDir}: {ex.Message}");
            }

            // Ensure fallback always exists
            Directory.CreateDirectory(fallbackDir);
            return Path.Combine(fallbackDir, fileName);
        }

        // copy directory structure (for template folder)
        public static void CopyDirectory(string sourceDir, string targetDir)
        {
            // Create the target directory if it doesn't exist
            Directory.CreateDirectory(targetDir);

            // Copy all files
            foreach (var file in Directory.GetFiles(sourceDir))
            {
                var destFile = Path.Combine(targetDir, Path.GetFileName(file));
                File.Copy(file, destFile, overwrite: false);
            }

            // Recursively copy subdirectories
            foreach (var directory in Directory.GetDirectories(sourceDir))
            {
                var destDir = Path.Combine(targetDir, Path.GetFileName(directory));
                CopyDirectory(directory, destDir);
            }
        }


        // test fetching

        // Reuse a single HttpClient
        private static readonly HttpClient _http = new HttpClient
        {
            Timeout = TimeSpan.FromSeconds(15)
        };

        /// 
        /// Fire-and-forget entry; returns immediately. Keeps your UI thread free.
        /// 
        internal static void FetchAdditionalInfoForEditors()
        {
            _ = FetchImplAsync(); // don't await -> no UI hang
        }

        private static async Task FetchImplAsync()
        {
            try
            {
                foreach (var version in MainWindow.unityInstalledVersions.Keys)
                {
                    //Console.WriteLine("******** Fetching " + version);

                    var url = $"https://services.api.unity.com/unity/editor/release/v1/releases?order=RELEASE_DATE_DESC&limit=1&version={Uri.EscapeDataString(version)}";

                    string json;
                    try
                    {
                        json = await _http.GetStringAsync(url).ConfigureAwait(false);
                    }
                    catch (Exception ex)
                    {
                        Debug.WriteLine($"[FetchAdditionalInfo] {version}: HTTP error: {ex.Message}");
                        continue;
                    }

                    try
                    {
                        //Console.WriteLine("received: " + json);
                        UnityEditorInfoLabel label = ExtractLabelText(json);
                        if (label != null)
                        {

                            // feed this info back to main window
                            if (string.IsNullOrEmpty(label.labelText) == false)
                            {
                                //Console.WriteLine(label.description);

                                var u = MainWindow.unityInstallationsSource.FirstOrDefault(x => x.Version == version);

                                string infoText = label.labelText + "\n" + label.description; ;

                                u.InfoLabel = infoText;

                                int index = MainWindow.unityInstallationsSource.IndexOf(u);
                                if (index >= 0)
                                {
                                    MainWindow.unityInstallationsSource[index] = u;
                                }

                                // update all projectsSource items with this version too
                                foreach (var p in MainWindow.projectsSource)
                                {
                                    if (p.Version == version)
                                    {
                                        p.InfoLabel = infoText;
                                    }
                                }


                                //SetStatus($"Info for {version}: {label.labelText}");
                                //Console.WriteLine("got infolabel for " + version);
                            }
                        }

                    }
                    catch (Exception ex)
                    {
                        Debug.WriteLine($"[FetchAdditionalInfo] {version}: JSON parse error: {ex.Message}");
                    }

                    // delay
                    await Task.Delay(500).ConfigureAwait(false);

                } // foreach version
            }
            catch (Exception ex)
            {
                // Last-resort catch so fire-and-forget exceptions arent unobserved
                Debug.WriteLine($"[FetchAdditionalInfo] Fatal: {ex}");
            }
        } // FetchImplAsync()

        public class UnityEditorInfoLabel
        {
            public string description; // may contain HTML
            public string labelText;
            public string icon;
            public string color;
        }

        private static UnityEditorInfoLabel ExtractLabelText(string json)
        {
            var label = new UnityEditorInfoLabel();
            if (string.IsNullOrEmpty(json) || json.Contains("\"results\":[]")) return label;

            int resultsIndex = json.IndexOf("\"results\":", StringComparison.Ordinal);
            if (resultsIndex == -1) return label;

            // Use the LAST "label": in the payload (the editor-level one)
            int labelIndex = json.LastIndexOf("\"label\":", StringComparison.Ordinal);
            if (labelIndex == -1 || labelIndex < resultsIndex) return label;

            // labelText
            int ltIndex = json.IndexOf("\"labelText\":", labelIndex, StringComparison.Ordinal);
            if (ltIndex != -1)
            {
                int vStart = json.IndexOf('\"', ltIndex + 12) + 1; // "labelText": => +12
                if (vStart > 0)
                {
                    int vEnd = json.IndexOf('\"', vStart);
                    if (vEnd > vStart) label.labelText = json.Substring(vStart, vEnd - vStart);
                }
            }

            // icon
            int iconIndex = json.IndexOf("\"icon\":", labelIndex, StringComparison.Ordinal);
            if (iconIndex != -1)
            {
                int vStart = json.IndexOf('\"', iconIndex + 7) + 1; // "icon": => +7
                if (vStart > 0)
                {
                    int vEnd = json.IndexOf('\"', vStart);
                    if (vEnd > vStart) label.icon = json.Substring(vStart, vEnd - vStart);
                }
            }

            // color
            int colorIndex = json.IndexOf("\"color\":", labelIndex, StringComparison.Ordinal);
            if (colorIndex != -1)
            {
                int vStart = json.IndexOf('\"', colorIndex + 8) + 1; // "color": => +8
                if (vStart > 0)
                {
                    int vEnd = json.IndexOf('\"', vStart);
                    if (vEnd > vStart) label.color = json.Substring(vStart, vEnd - vStart);
                }
            }

            // description (may contain escaped quotes)
            int descIndex = json.IndexOf("\"description\":", labelIndex, StringComparison.Ordinal);
            if (descIndex != -1)
            {
                int qStart = json.IndexOf('\"', descIndex + 14) + 1; // "description": => +14
                if (qStart > 0)
                {
                    int i = qStart;
                    for (; i < json.Length; i++)
                        if (json[i] == '\"' && json[i - 1] != '\\') break;

                    if (i > qStart)
                    {
                        label.description = json.Substring(qStart, i - qStart).Replace("\\\"", "\"").Replace("\\n", "\n").Replace("\\r", "\r").Replace("\\t", "\t");
                        // strip HTML tags for now
                        label.description = System.Text.RegularExpressions.Regex.Replace(label.description, "", string.Empty);
                    }
                }
            }

            return label;
        }

        public static string ParseHashCodeFromURL(string url)
        {
            // https://beta.unity3d.com/download/330fbefc18b7/download.html#6000.1.0a8 > 330fbefc18b7

            int hashStart = url.IndexOf("download/") + 9;
            int hashEnd = url.IndexOf("/download.html", hashStart);
            return url.Substring(hashStart, hashEnd - hashStart);
        }

        internal static void OpenReleasesApiForVersion(string version)
        {
            string url = $"https://services.api.unity.com/unity/editor/release/v1/releases?limit=1&version={version}";
            LaunchBrowser(url);
        }

        private static void LaunchBrowser(string url)
        {
            try
            {
                Process.Start(new ProcessStartInfo
                {
                    FileName = url,
                    UseShellExecute = true
                });
            }
            catch (Exception ex)
            {
                MessageBox.Show("Failed to open URL: " + ex.Message, "Error", MessageBoxButton.OK, MessageBoxImage.Error);
            }
        }

        internal static bool IsVersionAtLeast(string version, string v)
        {
            if (string.IsNullOrEmpty(version) || string.IsNullOrEmpty(v)) return false;

            try
            {
                var versionParts = version.Split('.');
                var vParts = v.Split('.');

                int versionMajor = 0;
                int vMajor = 0;
                int versionMinor = 0;
                int vMinor = 0;

                if (versionParts.Length > 0)
                {
                    int i = 0;
                    while (i < versionParts[0].Length && char.IsDigit(versionParts[0][i])) i++;
                    int.TryParse(versionParts[0].Substring(0, i), out versionMajor);
                }

                if (vParts.Length > 0)
                {
                    int i = 0;
                    while (i < vParts[0].Length && char.IsDigit(vParts[0][i])) i++;
                    int.TryParse(vParts[0].Substring(0, i), out vMajor);
                }

                if (versionMajor != vMajor)
                {
                    return versionMajor > vMajor;
                }

                if (versionParts.Length > 1)
                {
                    int i = 0;
                    while (i < versionParts[1].Length && char.IsDigit(versionParts[1][i])) i++;
                    int.TryParse(versionParts[1].Substring(0, i), out versionMinor);
                }

                if (vParts.Length > 1)
                {
                    int i = 0;
                    while (i < vParts[1].Length && char.IsDigit(vParts[1][i])) i++;
                    int.TryParse(vParts[1].Substring(0, i), out vMinor);
                }

                return versionMinor >= vMinor;
            }
            catch
            {
                return false;
            }
        }

        public static string GetBaseVersion(string version)
        {
            // e.g. 2020.3.15f1 -> 2020.3
            var parts = version.Split('.');
            if (parts.Length >= 2)
            {
                return parts[0] + "." + parts[1];
            }
            return version;
        }


        public static string SplitTextToRows(string description, int rows)
        {
            if (rows < 2) return description;
            if (string.IsNullOrEmpty(description)) return description;

            int len = description.Length;
            if (len 

Web Proxy Viewer  |  New URL  |  Original Page