diff --git a/CS/TokenCacheHelperEx.cs b/CS/TokenCacheHelperEx.cs
new file mode 100644
index 0000000..4dc2837
--- /dev/null
+++ b/CS/TokenCacheHelperEx.cs
@@ -0,0 +1,57 @@
+// Updated original code from
+// Added support for custom file location
+// https://docs.microsoft.com/en-us/azure/active-directory/develop/msal-net-token-cache-serialization#simple-token-cache-serialization-msal-only
+
+using System;
+using System.IO;
+using System.Security.Cryptography;
+using Microsoft.Identity.Client;
+
+public static class TokenCacheHelperEx
+{
+ public static void EnableSerialization(ITokenCache tokenCache, String fileName = @"%LOCALAPPDATA%\GraphPowerShellManager\MSALToken.bin")
+ {
+ tokenCache.SetBeforeAccess(BeforeAccessNotification);
+ tokenCache.SetAfterAccess(AfterAccessNotification);
+
+ CacheFilePath = Environment.ExpandEnvironmentVariables(fileName);
+ }
+
+ ///
+ /// Path to the token cache
+ ///
+
+ public static string CacheFilePath { get; private set;}
+
+ private static readonly object FileLock = new object();
+
+ private static void BeforeAccessNotification(TokenCacheNotificationArgs args)
+ {
+ lock (FileLock)
+ {
+ args.TokenCache.DeserializeMsalV3(File.Exists(CacheFilePath)
+ ? ProtectedData.Unprotect(File.ReadAllBytes(CacheFilePath),
+ null,
+ DataProtectionScope.CurrentUser)
+ : null);
+ }
+ }
+
+ private static void AfterAccessNotification(TokenCacheNotificationArgs args)
+ {
+ // if the access operation resulted in a cache update
+ if (args.HasStateChanged)
+ {
+ lock (FileLock)
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(CacheFilePath));
+ // reflect changes in the persistent store
+ File.WriteAllBytes(CacheFilePath,
+ ProtectedData.Protect(args.TokenCache.SerializeMsalV3(),
+ null,
+ DataProtectionScope.CurrentUser)
+ );
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/CloudAPIPowerShellManagement.psd1 b/CloudAPIPowerShellManagement.psd1
new file mode 100644
index 0000000..3ca7eb2
Binary files /dev/null and b/CloudAPIPowerShellManagement.psd1 differ
diff --git a/CloudAPIPowerShellManagement.psm1 b/CloudAPIPowerShellManagement.psm1
new file mode 100644
index 0000000..fe812a9
--- /dev/null
+++ b/CloudAPIPowerShellManagement.psm1
@@ -0,0 +1,108 @@
+#region Console functions
+
+# https://stackoverflow.com/questions/40617800/opening-powershell-script-and-hide-command-prompt-but-not-the-gui
+Add-Type -Name Window -Namespace Console -MemberDefinition '
+[DllImport("Kernel32.dll")]
+public static extern IntPtr GetConsoleWindow();
+
+[DllImport("user32.dll")]
+public static extern bool ShowWindow(IntPtr hWnd, Int32 nCmdShow);
+
+[DllImport("kernel32.dll", SetLastError = true)]
+public static extern bool SetConsoleIcon(IntPtr hIcon);
+
+[DllImport("user32.dll")]
+public static extern int SendMessage(int hWnd, uint wMsg, uint wParam, IntPtr lParam);
+'
+
+function Show-Console
+{
+ $consolePtr = [Console.Window]::GetConsoleWindow()
+
+ # Hide = 0,
+ # ShowNormal = 1,
+ # ShowMinimized = 2,
+ # ShowMaximized = 3,
+ # Maximize = 3,
+ # ShowNormalNoActivate = 4,
+ # Show = 5,
+ # Minimize = 6,
+ # ShowMinNoActivate = 7,
+ # ShowNoActivate = 8,
+ # Restore = 9,
+ # ShowDefault = 10,
+ # ForceMinimized = 11
+
+ [Console.Window]::ShowWindow($consolePtr, 4)
+}
+
+function Hide-Console
+{
+ $consolePtr = [Console.Window]::GetConsoleWindow()
+ #0 hide
+ [Console.Window]::ShowWindow($consolePtr, 0) | Out-Null
+}
+
+#endregion
+
+# Unblock all files
+# Not 100% OK but avoid issues with loading blocked files
+function Unblock-AllFiles
+{
+ param($folder)
+
+ (Get-ChildItem $folder -force | Where-Object {! $_.PSIsContainer}) | Unblock-File
+
+ foreach($subFolder in (Get-ChildItem $folder -force | Where-Object {$_.PSIsContainer}))
+ {
+ Unblock-AllFiles $subFolder.FullName
+ }
+}
+
+function Initialize-CloudAPIManagement
+{
+ [CmdletBinding(SupportsShouldProcess=$True)]
+ param(
+ [string]
+ $View = "",
+ [switch]
+ $ShowConsoleWindow
+ )
+
+ $global:wpfNS = "xmlns='http://schemas.microsoft.com/winfx/2006/xaml/presentation' xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'"
+
+ [void] [System.Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms")
+ Add-Type -AssemblyName PresentationFramework
+
+ try
+ {
+ [xml]$xaml = Get-Content ([IO.Path]::GetDirectoryName($PSCommandPath) + "\Xaml\SplashScreen.xaml")
+ $global:SplashScreen = ([Windows.Markup.XamlReader]::Load((New-Object System.Xml.XmlNodeReader $xaml)))
+ $global:txtSplashTitle = $global:SplashScreen.FindName("txtSplashTitle")
+ $global:txtSplashText = $global:SplashScreen.FindName("txtSplashText")
+
+ $global:txtSplashTitle.Text = ("Initializing Cloud API PowerShell Management")
+
+ $global:SplashScreen.Show() | Out-Null
+ [System.Windows.Forms.Application]::DoEvents()
+ }
+ catch
+ {
+
+ }
+
+ if($ShowConsoleWindow -ne $true)
+ {
+ Hide-Console
+ }
+
+ $global:txtSplashText.Text = "Unblock files"
+ [System.Windows.Forms.Application]::DoEvents()
+ Unblock-AllFiles $PSScriptRoot
+
+ $global:txtSplashText.Text = "Load core module"
+ [System.Windows.Forms.Application]::DoEvents()
+ Import-Module ($PSScriptRoot + "\Core.psm1") -Force -Global
+
+ Start-CoreApp $View
+}
diff --git a/Core.psm1 b/Core.psm1
new file mode 100644
index 0000000..fd2a093
--- /dev/null
+++ b/Core.psm1
@@ -0,0 +1,1435 @@
+<#
+.SYNOPSIS
+Core UI and Settings fatures for the CloudAPIPowerShellManager solution
+
+.DESCRIPTION
+This module handles the WPF UI
+
+.NOTES
+ Version: 3.0.0
+ Author: Mikael Karlsson
+#>
+
+function Get-ModuleVersion
+{
+ '3.0.0'
+}
+
+function Start-CoreApp
+{
+ param($View)
+
+ if(-not $global:defaultGlobalVariables)
+ {
+ $global:defaultGlobalVariables = Get-Variable -Scope Global
+ }
+
+ $global:useDefaultFolderDialog = $false
+ $global:WindowsAPICodePackLoaded = $false
+
+ $global:loadedModules = @()
+ $global:viewObjects = @()
+
+ $global:AppRootFolder = $PSScriptRoot
+
+ # Load all modules in the Modules folder
+ $global:modulesPath = [IO.Path]::GetDirectoryName($PSCommandPath) + "\Extensions"
+
+ #Import-Module ($PSScriptRoot + "\Core.psm1") -Force -Global
+
+ Add-DefaultSettings
+
+ Write-Log "#####################################################################################"
+ Write-Log "Application started"
+ Write-Log "#####################################################################################"
+
+ if(Test-Path $global:modulesPath)
+ {
+ Import-AllModules
+ }
+ else
+ {
+ Write-Warning "Extensions folder $($global:modulesPath) not found. Aborting..." 3
+ exit 1
+ }
+
+ $global:Debug = Get-SettingValue "Debug"
+ $global:currentViewObject = $null
+ $global:FirstTimeRunning = ((Get-Setting "" "FirstTimeRunning" "true") -eq "true")
+ $global:MainAppStarted = $false
+
+ $global:txtSplashText.Text = "Initialize views"
+ [System.Windows.Forms.Application]::DoEvents()
+
+ Invoke-ModuleFunction "Invoke-InitializeModule"
+
+ #This will load the main window
+ $global:txtSplashText.Text = "Load main window"
+ [System.Windows.Forms.Application]::DoEvents()
+ Get-MainWindow
+
+ if($global:window)
+ {
+ $global:txtSplashText.Text = "Open default view"
+ [System.Windows.Forms.Application]::DoEvents()
+
+ Show-View $View
+
+ Invoke-ModuleFunction "Invoke-ShowMainWindow"
+
+ $global:txtSplashText.Text = "Open main window"
+ [System.Windows.Forms.Application]::DoEvents()
+ $global:window.ShowDialog() | Out-Null
+ }
+}
+
+function Import-AllModules
+{
+ foreach($file in (Get-Item -path "$($global:modulesPath)\*.psm1"))
+ {
+ $fileName = [IO.Path]::GetFileName($file)
+ if($skipModules -contains $fileName) { Write-Warning "Module $fileName excluded"; continue; }
+
+ $global:txtSplashText.Text = "Import module $fileName"
+ [System.Windows.Forms.Application]::DoEvents()
+
+ $module = Import-Module $file -PassThru -Force -Global -ErrorAction SilentlyContinue
+ if($module)
+ {
+ $global:loadedModules += $module
+ Write-Host "Module $($module.Name) loaded successfully"
+ }
+ else
+ {
+ Write-Warning "Failed to load module $file"
+ }
+ }
+}
+
+#region Log functions
+function Write-Log
+{
+ param($Text, $type = 1)
+
+ if($script:logFailed -eq $true) { return }
+
+ if(-not $global:logFile) { $global:logFile = Get-SettingValue "LogFile" ([IO.Path]::Combine($global:AppRootFolder,"CloudAPIPowerShellManagement.log")) }
+
+ if(-not $global:logFileMaxSize) { [Int64]$global:logFileMaxSize = Get-SettingValue "LogFileSize" 1024; $global:logFileMaxSize = $global:logFileMaxSize * 1kb }
+
+ $fi = [IO.FileInfo]$global:logFile
+
+ if($fi.Length -gt $global:logFileMaxSize)
+ {
+ # Larger than max size. Rename current to .bak
+ # Delete current .bak if it exists
+ $bakFile = ($fi.DirectoryName + "\" + $fi.BaseName + ".lo_")
+ if([IO.File]::Exists($bakFile))
+ {
+ try
+ {
+ [IO.File]::Delete($bakFile)
+ }
+ catch { }
+ }
+ try
+ {
+ $fi.MoveTo($bakFile)
+ }
+ catch { }
+ }
+
+ try
+ {
+ $logPath = [IO.Path]::GetDirectoryName($global:logFile)
+ if(-not (Test-Path $logPath)) { mkdir -Path $logPath -Force -ErrorAction SilentlyContinue | Out-Null }
+ }
+ catch
+ {
+ $script:logFailed = $true
+ return
+ }
+
+ $date = Get-Date
+
+ if($global:PSCommandPath)
+ {
+ $fileObj = [System.IO.FileInfo]$global:PSCommandPath
+ }
+ else
+ {
+ $fileObj = [System.IO.FileInfo]$PSCommandPath
+ }
+
+ $timeStr = "$($date.ToString(""HH"")):$($date.ToString(""mm"")):$($date.ToString(""ss"")).000+000"
+ $dateStr = "$($date.ToString(""MM""))-$($date.ToString(""dd""))-$($date.ToString(""yyyy""))"
+ $logOut = "