With every deployment, upgrade and migration there are often challenges for how the user profile data should be handled and moved across. I would like to introduce you to a cheeky PowerShell script with an optional User Interface I created specifically for user profile migrations, but can also be used to backup and restore user profile settings, as well as being extended well beyond the current use cases.
Background
In early 2021 I was working towards upgrading multiple highly important 24×7 Citrix platforms from Windows 8.1 VDI hosts to Windows 10 2019 LTSC (1809) hosts, and Windows Server 2012 R2 RDS hosts to Windows Server 2016. We couldn’t go to 2019 at the time as we were still using CVAD 7.15 LTSR. The older Operating Systems had run to the end of their life and we were starting to have support issues with things like Nvidia drivers and the A40 GPU support, etc. It was time to move on. So I had to find a smooth way forward moving to the new Operating Systems that wouldn’t interrupt the users.
We use Citrix User Profile Manager (UPM), with the path set to “\\fileserver\profiles$\Profiles\%username%.%userdomain%\!CTX_PROFILEVER!”. Makes sense, right?
For Windows 8.1/2012R2, !CTX_PROFILEVER! resolves to v4, and was working perfectly for many years. I wanted to migrate all the user profile data from a v4 structure to a new v6 structure to support the new Operating Systems in a way that was seamless to the users, but without losing their settings, creating support issues, and damaging the reputation of our service.
At the time Citrix only had an “Automatic migration of existing application profiles” setting for UPM. HOWEVER, this feature requires you to specify the short name of the OS by including the !CTX_OSNAME! variable in the user store path. As we don’t use it, it was not going to work, even with the !CTX_PROFILEVER! variable. This was disappointing. Perhaps they implemented that mechanism for a large/important customer, where the Product Manager and/or Developer assigned to the task didn’t put a great deal of thought into the use cases to allow for a more generic and flexible mechanism using other variables. Not great development if you ask me! From what I understood when chatting to others, it was also not a “user friendly” process either, so I didn’t even bother raising this with Citrix as I knew that getting this addressed with some urgency to provide the outcome I was after would not be a valuable use of my time.
There is so much important data in user profiles. It would have been unacceptable to have to ask the users to reconfigure all their apps again. So I needed to find a way to get this done.
I posed this question to the World of EUC Slack forum and Ryan Gallier suggested using VMware UEM (User Environment Manager), now known as DEM (Dynamic Environment Manager). I was already familiar with this product and the underlying FlexEngine. As I work for a company that are licensed to use it, it was a no brainer for me to jump in and work on a solution. Thanks Ryan 🙂
I started using the awesome Login Consultants Flex Profile Kit 5.0 when it was released way back in 2005. In 2008 this was spun off into a subsidiary called Immidio so they could put focus into these tools and start monetising them. In November 2008 Immidio released Flex Profiles Express Edition 6.0.1, which was the last free version and worked on Windows Server 2003 and 2008R1. Then in 2009 they released Flex Profiles Advanced Edition 6.1, which was the start of the licensed versions. VMware acquired Immidio in February 2015 and rebranded it to User Environment Manager (UEM), although it’s still Immidio under the hood.
As of version 9.9, User Environment Manager (UEM) was renamed to Dynamic Environment Manager (DEM). 9.11 was the last release of the version 9 stream before VMware jumped to version 10 stream, starting with the release of 2006 (10.0) where DEM is available in both Standard and Enterprise editions.
- DEM Standard Edition assists VMware Horizon Standard Edition and VMware Horizon Advanced Edition customers with user profile management.
- DEM Enterprise Edition is the full-featured version of VMware Dynamic Environment Manager.
As version 2111 (10.4) was released at the time I was completing this process, this is the version I chose to implement. As all I needed was the profile management (FlexEngine.exe) functionality, I used the Standard Edition.
I then found a bug with the release of version 10 stream, where it would skip “legacy path-based” export or import if you ran the FlexEngine directly referencing the config share instead of an individual ini file. I call it a bug because there was no documentation on this, nor was there any help on the support forum. As it turns out VMware changed the way the FlexEngine worked, but didn’t actually document this until many months later. DEM settings do not apply and FlexDebug only logs ” [DEBUG] Skipping legacy path-based import” (90079). Thanks VMware!
However, once I worked out the path forward, it actually made more sense for me to process individual command lines for each ini file. Then I can have total control of how I setup and process the ini file structure for exports and imports.
I love the use of the ini and zip files for the FlexEngine process. Whilst it may not always be the most efficient way to manage a profile in modern times, it’s such a cool way to keep a backup of the important settings allowing you to recreate a user profile at any time without the loss of critical app settings. In the end I realised that it’s also a cool way to manage a migration of profile data. So I’m glad I went down this path. Thanks again Ryan 🙂
Whilst I initially got this all up and running using a PowerShell logon and logoff script, it needed to be far more user friendly, empowering users to manage this themselves, and giving Service Desk some basic instructions to also assist when users had issues. I was also mindful that it really needed to be in place and exporting for a couple of months before the migration takes place so it picks up the data for infrequent logons, or those on leave. If you don’t have this in place early enough, then to mitigate the risks, you need to have a way for users to easily migrate their own settings across. So the process I created also needed a UI, allowing me to publish both an Export and Import process for the users.
I also found that some processes, especially the importing of mapped drives, was not as smooth as I would have expected with FlexEngine. So I needed to write some C# wrappers around this that are called as functions in the PowerShell script. This meant that the settings applied immediately after the import and users didn’t need to logoff and back on again to see their mapped drives, etc. The extra functions are as follows:
- An EnvironmentRefresh function that sends a WM_SETTINGCHANGE message to all the open windows, so they know about any environment variables that have changed.
- A DesktopRefresh function that notifies the Windows Shell to refresh when a file type or association has changed.
- A RemapUnavailableNetworkDrives function that remaps any unavailable network drives that have a red X against them once the mapped drives have been imported.
- A RestartProcessUsingRestartManager function that uses the Restart Manager API to restart Explorer once the mapped drives have been imported. The Restart Manager API is a very cool way to restart Explorer in a user friendly fashion.
Once I became more comfortable with the new version of DEM, I found that we don’t actually need to install the full DEM Agent. We can just copy 3 files into place and register the DLL, and then it will work as we need it.
At the end of the day the FlexEngine.exe and FlexEgine.dll contain all the smarts needed for this process, and of course you also need the license file. So you don’t actually need to install anything. You can just copy these 3 files and it works! However, I do deploy it correctly, but just stop and disable the VMware DEM Service (ImmidioFlexProfiles) service, as it’s not required for this process and I did not want it interfering with Citrix User Profile Manager (UPM). I was just interested to see what the minimum footprint was to meet my requirements.
It’s important to note that there was a change to the DEM licensing from 2203 (10.5) where you are no longer prompted to select a license file during installation. The Management Console provides an option to configure a license and save it in the configuration share. I have not tested this change to confirm if my process still works beyond version 2111 (10.4). I don’t believe there will be an issue as long as the VMware license file is accessible by FlexEngine.
Setting up the profile share:
The UPM path I typically use is “\\fileserver\profiles$\Profiles\%username%.%userdomain%\!CTX_PROFILEVER!”. The following settings are based on this.
Create a FlexProfiles folder at the root of your share. This is referred to as the UEMConfigShare and is where the ini files go. ie. \\fileserver\profiles$\FlexProfiles
This is all you need to do.
INI Files and their Folder Structure
If you’re not familiar with the process to manually create the files, I suggest using the VMware Dynamic Environment Manager (DEM) Management Console. It’s an optional component of the install. Point it to the “\\perfs1\profiles$\FlexProfiles” share. This is the share where the DEM configuration will be stored. This is also known as the RootPath. It creates a General folder under there by default, which is where the ini files will be located and aligns to the UEMConfigShare xml element in our process.
You can create subfolders to separate your ini files as needed. I personally like to create a Common, RDSH and VDI folder structure, as I like to store the ini files based on Server (RDSH) or Workstation (VDI) Session Hosts, hence the SplitRDSHandVDI and IncludeCommonIniFiles xml element setting per location. But you don’t have to do this and can have them all under the General folder. I just wanted more control for the migration of Operating Systems by Delivery Groups, etc.
Once you become more confident, you can create the ini files manually without the assistance of the Management Console. They are simple to create once you get the hang of all the section headings and variables.
It’s important to note that if you create them yourself, they should be saved as UTF-8 with BOM format.
Deploying the script:
There are 5 files in total, as the script comes with 4 supporting files:
- ProfileMigration.ps1 – The main script.
- ProfileMigration.xml – Configurable global settings and config data per site.
- ProfileMigration-Launcher.cmd – Optional and used for published applications.
- File Export.ico – Optional and used for published applications.
- File Import.ico – Optional and used for published applications.
Here is the full Profile Migration (298 downloads) in the in the form of a zip file.
Use an installation script to copy them to the same location as the FlexEngine, which is the “C:\Program Files\Immidio\Flex Profiles” folder by default as per the following screen shot:
How the script works?
This script derives its config data from the accompanying XML file. There are a couple of Global settings. And then each location has its own section.
Example of the XML:
<configuration> <Global> <FlexEnginePath>%ProgramFiles%\Immidio\Flex Profiles\FlexEngine.exe</FlexEnginePath> <DateTimeFormat>dd/MM/yyyy HH:mm:ss</DateTimeFormat> </Global> <Location Name="PER"> <Description>Perth</Description> <UEMConfigShare>\\perfs1\profiles$\FlexProfiles\general</UEMConfigShare> <UEMProfileArchives>\\perfs1\profiles$\Profile\%username%.%userdomain%\Migration</UEMProfileArchives> <UEMProfileLogs>\\perfs1\profiles$\Profile\%username%.%userdomain%\MigrationLogs</UEMProfileLogs> <RecurseIniFiles>TRUE</RecurseIniFiles> <SplitRDSHandVDI>TRUE</SplitRDSHandVDI> <IncludeCommonIniFiles>TRUE</IncludeCommonIniFiles> <EnableDebugLogging>TRUE</EnableDebugLogging> <ExportFromOS>6.3.9600</ExportFromOS> <OnceOffExport>FALSE</OnceOffExport> <ExportUntil>30/11/2023 17:00:00</ExportUntil> <ImportToOS>10.0.17763,10.0.19043,10.0.14393,10.0.17763</ImportToOS> <OnceOffImport>TRUE</OnceOffImport> <ImportUntil>30/11/2023 17:00:00</ImportUntil> <PlaceFlagFilesInUserProfile>TRUE</PlaceFlagFilesInUserProfile> </Location> <Location Name="SYD"> <Description>Sydney</Description> <UEMConfigShare>\\sydfs1\profiles$\FlexProfiles\general</UEMConfigShare> <UEMProfileArchives>\\sydfs1\profiles$\Profile\%username%.%userdomain%\Migration</UEMProfileArchives> <UEMProfileLogs>\\sydfs1\profiles$\Profile\%username%.%userdomain%\MigrationLogs</UEMProfileLogs> <RecurseIniFiles>TRUE</RecurseIniFiles> <SplitRDSHandVDI>TRUE</SplitRDSHandVDI> <IncludeCommonIniFiles>TRUE</IncludeCommonIniFiles> <EnableDebugLogging>TRUE</EnableDebugLogging> <ExportFromOS>6.3.9600</ExportFromOS> <OnceOffExport>FALSE</OnceOffExport> <ExportUntil>30/11/2023 17:00:00</ExportUntil> <ImportToOS>10.0.17763,10.0.19043,10.0.14393,10.0.17763</ImportToOS> <OnceOffImport>TRUE</OnceOffImport> <ImportUntil>30/11/2023 17:00:00</ImportUntil> <PlaceFlagFilesInUserProfile>TRUE</PlaceFlagFilesInUserProfile> </Location> </configuration>
Under the Global section…
- FlexEnginePath: The path to the FlexEngine.exe. The default value should be the correct location, unless you’ve installed DEM to a different folder.
- DateTimeFormat: The date and time format used by the ExportUntil and ImportUntil elements of each Location section. These values are used to “expire” the process after a certain time.
Under each Location section…
- Name: The first x letters of the computer name of the Session Hosts that help you differentiate the location so that the correct configuration data is used. This can easily be changed in the code to suit any naming standard.
- Description: A description of that location.
- UEMConfigShare: The central share where the ini files will be located.
- UEMProfileArchives: The per-user location where the data will be exported to and imported from.
- UEMProfileLogs: The per-user location where the FlexEngine.exe output log will be created.
- RecurseIniFiles: Set to True to recurse all subfolders from the root of the UEMConfigShare location. This allows you to neatly manage the ini files.
- SplitRDSHandVDI: Set to True to separate the RDSH and VDI ini files for the export/import process.
- IncludeCommonIniFiles: Set to True to include the Common ini files for the export/import process. Only valid when SplitRDSHandVDI is also set to True.
- EnableDebugLogging : Set to True to enable FlexEngine.exe debug logging. I recommend setting it to true at least until you become confident with its actions.
- ExportFromOS: Set the full OS version numbers (Major.Minor.Build.Revision) that you want to export from. This field supports a comma separated list. Note that the Revision field is the UBR (Update Build Revision) number, which is not mandatory. It just allows you to specify the exact OS.
- OnceOffExport: I recommend leaving this set to False so it continually exports at logoff. Set to True to complete a once off export for each ini file. This will create a flag file will be created under the UEMProfileArchives location. The flag file will need to be deleted if you would like this ini file to be processed again. Refer to the PlaceFlagFilesInUserProfile value to change the location of the flag files.
- ExportUntil: Set an end date when the exports should end. A OnceOffExport overrides this.
- ImportToOS: Set the full OS version numbers (Major.Minor.Build.Revision) that you want to import to. This field supports a comma separated list. Note that the Revision number is not mandatory.
- OnceOffImport: Set to True to complete a once off import for each ini file. Note that a flag file will be created under the UEMProfileArchives location. The flag file will need to be deleted if you would like this ini file to be processed again.
- ImportUntil: Set an end date when the imports should end. A OnceOffImport overrides this.
- PlaceFlagFilesInUserProfile: Set to true to create the Export and Import flag files will in the user profile under the “%APPDATA%\Immidio\Flex Profiles” folder. Set to false to create the Export and Import flag files in the same location as the archives.
- If using flag files, why are we placing them on the file system instead of the registry? Whilst the registry may seem more efficient, it would be more difficult for Service Desk and various other Support Teams to be able to clear them if needed.
The script accepts 7 different parameters:
- Import = Will instruct the script to process an import, which it will do as long as the settings in the XML file evaluate that way.
- Export = Will instruct the script to process an export, which it will do as long as the settings in the XML file evaluate that way.
- ProcessAllIniFiles = Will process all ini files overriding the SplitRDSHandVDI setting in the XML file.
- ShowUI = Will show/display the UI so that it can be used as a Desktop or Published Application.
- HideConsole = Will hide the PowerShell Console and keep the execution of the script neat.
- IgnoreFlag = Will ignore the flags created by the OnceOffImport and OnceOffExport process.
- DriveMappingsOnly = Will only process the ini files where the name starts with “DriveMappings”
Note that Import and Export cannot be used together in the same command line.
Here is the full Profile Migration (298 downloads) in the in the form of a zip file.
<# This script will enumerate all INI files in a specific location and run them against VMware's Dynamic Environment Manager (DEM) FlexEngine.exe to either export or import user profile settings (application data). This script has been tested against version 10.4 of VMware DEM, but should work with any version. It's recommeneded to add "FlexEngine.exe" to the "LogoffCheckSysModules" registry value to prevent a session from staying active should the executable hang/fail. This script derives its config data from the accompanying XML file. Each location has its own section. The location name is compared against the first x letters of the computername of the Session Host, which is used to retrieve its configuration data. This can easily be changed in the code to suit any naming standard. Example of the XML: <?xml version="1.0" encoding="utf-8"?> <!-- <configuration> <Global> <FlexEnginePath>%ProgramFiles%\Immidio\Flex Profiles\FlexEngine.exe</FlexEnginePath> <DateTimeFormat>dd/MM/yyyy HH:mm:ss</DateTimeFormat> </Global> <Location Name="PER"> <Description>Perth</Description> <UEMConfigShare>\\fileserver\share$\FlexProfiles\general</UEMConfigShare> <UEMProfileArchives>\\fileserver\share$\Profile\%username%.%userdomain%\Migration</UEMProfileArchives> <UEMProfileLogs>\\fileserver\share$\Profile\%username%.%userdomain%\MigrationLogs</UEMProfileLogs> <RecurseIniFiles>TRUE</RecurseIniFiles> <SplitRDSHandVDI>TRUE</SplitRDSHandVDI> <IncludeCommonIniFiles>TRUE</IncludeCommonIniFiles> <EnableDebugLogging>TRUE</EnableDebugLogging> <ExportFromOS>6.3.9600</ExportFromOS> <OnceOffExport>FALSE</OnceOffExport> <ExportUntil>30/06/2022 17:00:00</ExportUntil> <ImportToOS>10.0.19043.1165,10.0.17763.107</ImportToOS> <OnceOffImport>TRUE</OnceOffImport> <ImportUntil>30/06/2022 17:00:00</ImportUntil> <PlaceFlagFilesInUserProfile>TRUE</PlaceFlagFilesInUserProfile> </Location> </configuration> Where: Name = The first x letters of the computer name of the Session Hosts that help you differentiate the location so that the correct configuration data is used. Description = A description of that location. UEMConfigShare = The share where the ini files will be located. UEMProfileArchives = The per-user location where the data will be exported to and imported from. UEMProfileLogs = The per-user location where the FlexEngine.exe output log will be. RecurseIniFiles = Set to True to recurse all subfolders from the root of the UEMConfigShare location. This allows you to neatly manage the ini files. SplitRDSHandVDI = Set to True to separate the RDSH and VDI ini files for the export/import process. IncludeCommonIniFiles = Set to True to include the Common ini files for the export/import process. Only valid when SplitRDSHandVDI is also set to True. EnableDebugLogging = Set to True to enable FlexEngine.exe debug logging. ExportFromOS = Set the full OS version numbers (Major.Minor.Build.Revision) that you want to export from. This field supports a comma separated list. Note that the Revision number is not mandatory. OnceOffExport = Set to True to complete a once off export for each ini file. Note that a flag file will be created under the UEMProfileArchives location. The flag file will need to be deleted if you would like this ini file to be processed again. ExportUntil = Set an end date when the exports should end. A OnceOffExport overrides this. ImportToOS = Set the full OS version numbers (Major.Minor.Build.Revision) that you want to import to. This field supports a comma separated list. Note that the Revision number is not mandatory. OnceOffImport = Set to True to complete a once off import for each ini file. Note that a flag file will be created under the UEMProfileArchives location. The flag file will need to be deleted if you would like this ini file to be processed again. ImportUntil = Set an end date when the imports should end. A OnceOffImport overrides this. PlaceFlagFilesInUserProfile = Set to true to create the Export and Import flag files will in the user profile under the "%APPDATA%\Immidio\Flex Profiles" folder. Set to false to create the Export and Import flag files in the same location as the archives. If using flag files, why are we placing them on the file system instead of the registry? Whilst the registry may seem more efficient, it would be more difficult for Service Desk and various other Support Teams to be able to clear them if needed. Also note the Global elements: FlexEnginePath = The full path to the FlexEngine.exe. DateTimeFormat = The Date and Time format to use to convert the ExportUntil and ImportUntil XML elements. Syntax example: .\ProfileMigration.ps1 -Export -ProcessAllIniFiles -ShowUI -HideConsole Where: Import = Will instruct the script to process an import if the settings in the XML file evaluate that way. Export = Will instruct the script to process an export if the settings in the XML file evaluate that way. ProcessAllIniFiles = Will process all ini files overriding the SplitRDSHandVDI setting in the XML file. ShowUI = Will show the UI so that it can be used as a Desktop or Published Application. HideConsole = Will hide the PowerShell Console and keep the execution of the script neat. IgnoreFlag = Will ignore the flags created by the OnceOffImport and OnceOffExport process. DriveMappingsOnly = Will only process the ini files where the name starts with "DriveMappings" Note that Import and Export cannot be used together at the same time. Use Group Policy to add PowerShell Logoff and Logon Scripts: 1) Logoff Script - Script Name: C:\Program Files\Immidio\Flex Profiles\ProfileMigration.ps1 - Script Parameters: -Export 2) Logon Script - Script Name: C:\Program Files\Immidio\Flex Profiles\ProfileMigration.ps1 - Script Parameters: -Import Published App notes: 1) As well as running the script at logoff and logoff, I also found it useful to publish two applications. This allows us to help users that may of been on leave during the migration, of cases where we may need to instruct users to re-export and/or re-import their profile data. 2) Unfortunately you cannot publish the script directly as a Published App because the path to the executable file and command line arguments combined are greater than 203 characters. So you must use a batch script to publish it instead. Published Apps: 1) Name: User Profile Data Export - Path to the executable file: C:\Program Files\Immidio\Flex Profiles\ProfileMigration-Launcher.cmd - Command line argument: Export 2) Name: User Profile Data Import - Path to the executable file: C:\Program Files\Immidio\Flex Profiles\ProfileMigration-Launcher.cmd - Command line argument: Import Script name: ProfileMigration.ps1 Release 2.3 Written by Jeremy Saunders (jeremy@jhouseconsulting.com) 2nd February 2022 Modified by Jeremy Saunders (jeremy@jhouseconsulting.com) 4th September 2023 #> #------------------------------------------------------------- [cmdletbinding()] param ( [switch]$Import, [switch]$Export, [switch]$ProcessAllIniFiles, [switch]$ShowUI, [switch]$IgnoreFlag, [switch]$DriveMappingsOnly, [switch]$HideConsole ) # Set Powershell Compatibility Mode Set-StrictMode -Version 2.0 # Enable verbose, warning and error mode $VerbosePreference = 'Continue' $WarningPreference = 'Continue' $ErrorPreference = 'Continue' #------------------------------------------------------------- $StartDTM = (Get-Date) # Set the working folder to the users TEMP folder $LogFolder = [System.IO.Path]::GetTempPath() # Get the script path $ScriptPath = {Split-Path $MyInvocation.ScriptName} $ScriptPath = $(&$ScriptPath) # Get the script name $ScriptName = [System.IO.Path]::GetFilenameWithoutExtension($MyInvocation.MyCommand.Path.ToString()) # Start the transcript try { Start-Transcript "$LogFolder$ScriptName.log" } catch { Write-Verbose "$(Get-Date -format "dd/MM/yyyy HH:mm:ss"): This host does not support transcription" } #------------------------------------ # Set the name and path for the XMLFile $XMLFilePath = "$ScriptPath\$ScriptName.xml" #------------------------------------ If ($Import -AND $Export) { write-warning "$(Get-Date): You cannot set both Import and Export to be true together." -verbose Exit } # Hide the PowerShell console window without hiding the other child windows that it spawns # - http://powershell.cz/2013/04/04/hide-and-show-console-window-from-gui/ $Code = @" [DllImport("Kernel32.dll")] public static extern IntPtr GetConsoleWindow(); [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, Int32 nCmdShow); "@ # Create new types as per the definition above. Add-Type -Namespace Console -Name Window -MemberDefinition $code -PassThru | out-null Function Show-Console { $consolePtr = [Console.Window]::GetConsoleWindow() #5 show [Console.Window]::ShowWindow($consolePtr, 5) } Function Hide-Console { $consolePtr = [Console.Window]::GetConsoleWindow() #0 hide [Console.Window]::ShowWindow($consolePtr, 0) } If ($HideConsole) { Hide-Console | out-null } #------------------------------------ # This section is for the functions Function EnvironmentRefresh { # Send a WM_SETTINGCHANGE broadcast message with "Environment" as the parameter to all open windows, indicating a change in the # environment variables. It is used to reload/refresh the environment variables in all running processes without the need for a # system restart. # This is used in scenarios where a change in user and system environment variables needs to be propagated immediately to all # running applications and services. # Notes: # - Applications and services that have a top-level window will be notified of a change in the environment variables. # - A restart of some applications and services may still be necessary for the changes to take effect, as some processes only # read environment variables at startup. # - Not all applications will respond to the WM_SETTINGCHANGE message, especially those that do not have a top-level window or # those that simply choose to ignore the message. In those cases, a system restart or individual application/service restart # might still be necessary. param ( [switch]$EnableDebug ) $HWND_BROADCAST = [IntPtr] 0xffff; $WM_SETTINGCHANGE = 0x1a; $result = [UIntPtr]::Zero if (-not ("Win32.NativeMethods" -as [Type])) { # import sendmessagetimeout from win32 Add-Type -Namespace Win32 -Name NativeMethods -MemberDefinition @" [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Auto)] public static extern IntPtr SendMessageTimeout( IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out UIntPtr lpdwResult); "@ } # notify all windows of environment block change If ($EnableDebug) { write-verbose "Sending a WM_SETTINGCHANGE message to all the open windows..." -verbose } [void][Win32.Nativemethods]::SendMessageTimeout($HWND_BROADCAST, $WM_SETTINGCHANGE, [UIntPtr]::Zero, "Environment", 2, 5000, [ref] $result) If ($EnableDebug) { if ($result -eq 0) { write-warning "- Failed to reload environment variables." -verbose } else { write-verbose "- Environment variables have been reloaded." -verbose } } } Function DesktopRefresh{ # Call the SHChangeNotify method to notify the Windows Shell to refresh or rebuild the desktop. Here we are specifically telling # the system that a SHCNE_ASSOCCHANGED event has occurred. This event signals that a file type or association has changed. This # is useful in situations where a change has been made to the file associations and you need the system to immediately recognize # this change without having to log off or reboot. $source = @" using System; using System.Collections.Generic; using System.Text; using System.Runtime.InteropServices; namespace FileEncryptProject.Algorithm { public class DesktopRefurbish { [DllImport("shell32.dll")] public static extern void SHChangeNotify(HChangeNotifyEventID wEventId, HChangeNotifyFlags uFlags, IntPtr dwItem1, IntPtr dwItem2); public static void DeskRef() { SHChangeNotify(HChangeNotifyEventID.SHCNE_ASSOCCHANGED, HChangeNotifyFlags.SHCNF_IDLIST, IntPtr.Zero, IntPtr.Zero); } } #region public enum HChangeNotifyFlags [Flags] public enum HChangeNotifyFlags { SHCNF_DWORD = 0x0003, SHCNF_IDLIST = 0x0000, SHCNF_PATHA = 0x0001, SHCNF_PATHW = 0x0005, SHCNF_PRINTERA = 0x0002, SHCNF_PRINTERW = 0x0006, SHCNF_FLUSH = 0x1000, SHCNF_FLUSHNOWAIT = 0x2000 } #endregion//enum HChangeNotifyFlags #region enum HChangeNotifyEventID [Flags] public enum HChangeNotifyEventID { SHCNE_ALLEVENTS = 0x7FFFFFFF, SHCNE_ASSOCCHANGED = 0x08000000, SHCNE_ATTRIBUTES = 0x00000800, SHCNE_CREATE = 0x00000002, SHCNE_DELETE = 0x00000004, SHCNE_DRIVEADD = 0x00000100, SHCNE_DRIVEADDGUI = 0x00010000, SHCNE_DRIVEREMOVED = 0x00000080, SHCNE_EXTENDED_EVENT = 0x04000000, SHCNE_FREESPACE = 0x00040000, SHCNE_MEDIAINSERTED = 0x00000020, SHCNE_MEDIAREMOVED = 0x00000040, SHCNE_MKDIR = 0x00000008, SHCNE_NETSHARE = 0x00000200, SHCNE_NETUNSHARE = 0x00000400, SHCNE_RENAMEFOLDER = 0x00020000, SHCNE_RENAMEITEM = 0x00000001, SHCNE_RMDIR = 0x00000010, SHCNE_SERVERDISCONNECT = 0x00004000, SHCNE_UPDATEDIR = 0x00001000, SHCNE_UPDATEIMAGE = 0x00008000, } #endregion } "@ Add-Type -TypeDefinition $source [FileEncryptProject.Algorithm.DesktopRefurbish]::DeskRef() } Function RemapUnavailableNetworkDrives { # Remap any unavailable network drives that have a red X against them # Reference: https://docs.microsoft.com/en-us/troubleshoot/windows-client/networking/mapped-network-drive-fail-reconnect # Can re-write this with "net use" easily enough if needed $i=3 while($True){ $error.clear() $MappedDrives = Get-SmbMapping | where -property Status -Value Unavailable -EQ | select LocalPath,RemotePath If (($MappedDrives | Measure-Object).Count -gt 0) { Write-verbose "$(Get-Date): Remapping Unavailable Network Drives" -verbose foreach( $MappedDrive in $MappedDrives) { try { Remove-SmbMapping -LocalPath $MappedDrive.LocalPath -UpdateProfile -Force -ErrorAction SilentlyContinue Write-verbose "$(Get-Date): - Mapping $($MappedDrive.LocalPath) to $($MappedDrive.RemotePath)" -verbose New-SmbMapping -LocalPath $MappedDrive.LocalPath -RemotePath $MappedDrive.RemotePath -Persistent $True } catch { Write-warning "$(Get-Date): - There was an error mapping $($MappedDrive.LocalPath) to $($MappedDrive.RemotePath)" -verbose } } } $i = $i - 1 if($error.Count -eq 0 -Or $i -eq 0) {break} Start-Sleep -Seconds 1 } $FinalMappedDrives = Get-SmbMapping | where -property Status -Value OK -EQ | select LocalPath,RemotePath If (($FinalMappedDrives | Measure-Object).Count -gt 0) { Write-verbose "$(Get-Date): Mapped Network Drives" -verbose foreach($FinalMappedDrive in $FinalMappedDrives) { Write-verbose "$(Get-Date): - $($FinalMappedDrive.LocalPath) = $($FinalMappedDrive.RemotePath)" -verbose } } } Function RestartProcessUsingRestartManager { # This PowerShell function is derived from C# code written by Vanco Pavlevski and published in his article titled "Restart Explorer # Programmatically with C#" (https://devindeep.com/restart-explorer-programmatically-with-c/) # It uses the Restart Manager API to restart a process. The code has been modified and optimised to work on a multi-session hosts # so it will only restart the process in the current user session. # The benefit of restarting Explorer this way is that Explorer knows how to restart itself, and it will restore all existing open # windows after the restart. Therefore it's user friendly. This is also a really nice way of restarting the Explorer shell without # requiring the user to log off and back on again for specific changes. param ( [string]$Name ) Add-Type -TypeDefinition @" using System; using System.Collections.Generic; using System.Diagnostics; using Com = System.Runtime.InteropServices.ComTypes; using System.Runtime.InteropServices; public static class RestartManager { [StructLayout(LayoutKind.Sequential)] struct RM_UNIQUE_PROCESS { public int dwProcessId; public Com.FILETIME ProcessStartTime; } [Flags] enum RM_SHUTDOWN_TYPE : uint { RmForceShutdown = 0x1, RmShutdownOnlyRegistered = 0x10 } delegate void RM_WRITE_STATUS_CALLBACK(UInt32 nPercentComplete); [DllImport("rstrtmgr.dll", CharSet = CharSet.Auto)] static extern int RmStartSession(out IntPtr pSessionHandle, int dwSessionFlags, string strSessionKey); [DllImport("rstrtmgr.dll")] static extern int RmEndSession(IntPtr pSessionHandle); [DllImport("rstrtmgr.dll", CharSet = CharSet.Auto)] static extern int RmRegisterResources(IntPtr pSessionHandle, UInt32 nFiles, string[] rgsFilenames, UInt32 nApplications, RM_UNIQUE_PROCESS[] rgApplications, UInt32 nServices, string[] rgsServiceNames); [DllImport("rstrtmgr.dll")] static extern int RmShutdown(IntPtr pSessionHandle, RM_SHUTDOWN_TYPE lActionFlags, RM_WRITE_STATUS_CALLBACK fnStatus); [DllImport("rstrtmgr.dll")] static extern int RmRestart(IntPtr pSessionHandle, int dwRestartFlags, RM_WRITE_STATUS_CALLBACK fnStatus); [DllImport("kernel32.dll")] static extern bool GetProcessTimes(IntPtr hProcess, out Com.FILETIME lpCreationTime, out Com.FILETIME lpExitTime, out Com.FILETIME lpKernelTime, out Com.FILETIME lpUserTime); public static void RestartProcessInCurrentSession(string process) { if (!string.IsNullOrEmpty(process)) { lock (typeof(RestartManager)) { int waitTime = 2000; IntPtr handle; string key = Guid.NewGuid().ToString(); int sessionID = Process.GetCurrentProcess().SessionId; int res = RmStartSession(out handle, 0, key); if (res == 0) { Console.WriteLine("Restart Manager session created with ID " + key); Console.WriteLine("Checking for " + process + " processes running under Session ID " + sessionID.ToString()); List<RM_UNIQUE_PROCESS> lst = new List<RM_UNIQUE_PROCESS>(); foreach (Process p in Process.GetProcessesByName(process)) { if (p.SessionId == sessionID) { RM_UNIQUE_PROCESS rp = new RM_UNIQUE_PROCESS(); rp.dwProcessId = p.Id; Com.FILETIME creationTime, exitTime, kernelTime, userTime; GetProcessTimes(p.Handle, out creationTime, out exitTime, out kernelTime, out userTime); rp.ProcessStartTime = creationTime; lst.Add(rp); } } RM_UNIQUE_PROCESS[] processes = lst.ToArray(); if (processes.Length > 0) { Console.WriteLine(processes.Length + " unique " + process + " process found."); res = RmRegisterResources( handle, 0, null, (uint)processes.Length, processes, 0, null ); if (res == 0) { Console.WriteLine("Successfully registered resources."); Console.WriteLine("Stopping the " + process + " process..."); res = RmShutdown(handle, RM_SHUTDOWN_TYPE.RmForceShutdown, null); if (res == 0) { Console.WriteLine("The " + process + " process stopped successfully."); System.Threading.Thread.Sleep(waitTime); Console.WriteLine("Restarting the " + process + " process..."); res = RmRestart(handle, 0, null); if (res == 0) { Console.WriteLine("The " + process + " process restarted successfully."); } } } } else { Console.WriteLine("There are no " + process + " processes running."); } res = RmEndSession(handle); if (res == 0) { Console.WriteLine("Restart Manager session ended."); } } } } else { Console.WriteLine("The process name was not specified."); } } } "@ $Result = [RestartManager]::RestartProcessInCurrentSession($Name) } Function Call-Main { # This is for the main program param ( [switch]$Import, [switch]$Export, [switch]$ProcessAllIniFiles, [switch]$ShowUI, [switch]$IgnoreFlag, [switch]$DriveMappingsOnly ) $StatusMessage = "" If (Test-Path -path $XMLFilePath) { # Create the XML Object and open the XML file $XmlDocument = Get-Content -path $XMLFilePath # Uncomment the following lines for debugging purposes only #$XmlDocument.configuration.Global #$XmlDocument.configuration.Global | Format-Table -Autosize foreach ($Config in $XmlDocument.configuration.Global) { $FlexEnginePath = $Config.FlexEnginePath $FlexEnginePath = $FlexEnginePath.Replace("%ProgramFiles(x86)%",${env:ProgramFiles(x86)}) $FlexEnginePath = $FlexEnginePath.Replace("%ProgramFiles%",${env:ProgramFiles}) $FlexEnginePath = $FlexEnginePath.Replace("%ProgramData%",${env:ProgramData}) $FlexEnginePath = $FlexEnginePath.Replace("%SystemDrive%",${env:SystemDrive}) $FlexEnginePath = $FlexEnginePath.Replace("%SystemRoot%",${env:SystemRoot}) $DateTimeFormat = $Config.DateTimeFormat } Write-verbose "$(Get-Date): Global variables" -verbose Write-verbose "$(Get-Date): - FlexEnginePath: $FlexEnginePath" -verbose Write-verbose "$(Get-Date): - DateTimeFormat: $DateTimeFormat" -verbose # Uncomment the following lines for debugging purposes only #$XmlDocument.configuration.Location #$XmlDocument.configuration.Location | Format-Table -Autosize $MappedDrives = $False $ConfigFound = $False foreach ($Config in $XmlDocument.configuration.Location) { If ($env:COMPUTERNAME -Like "$($Config.Name)*") { $ConfigFound = $True $Description = $Config.Description $UEMConfigShare = $Config.UEMConfigShare $UEMProfileArchives = $Config.UEMProfileArchives $UEMProfileArchives = $UEMProfileArchives.Replace("%username%",${env:username}) $UEMProfileArchives = $UEMProfileArchives.Replace("%userdomain%",${env:userdomain}) $UEMProfileLogs = $Config.UEMProfileLogs $UEMProfileLogs = $UEMProfileLogs.Replace("%username%",${env:username}) $UEMProfileLogs = $UEMProfileLogs.Replace("%userdomain%",${env:userdomain}) $RecurseIniFiles = [System.Convert]::ToBoolean($Config.RecurseIniFiles) $SplitRDSHandVDI = [System.Convert]::ToBoolean($Config.SplitRDSHandVDI) $IncludeCommonIniFiles = [System.Convert]::ToBoolean($Config.IncludeCommonIniFiles) $EnableDebugLogging = [System.Convert]::ToBoolean($Config.EnableDebugLogging) $ExportFromOS = $Config.ExportFromOS $OnceOffExport = [System.Convert]::ToBoolean($Config.OnceOffExport) If ([String]::IsNullOrEmpty($Config.ExportUntil)) { $ExportUntil = [Datetime]::ParseExact(((Get-Date).AddMinutes(10) | Get-Date -Format $DateTimeFormat), $DateTimeFormat, $null) } Else { Try { $ExportUntil = [Datetime]::ParseExact($Config.ExportUntil, $DateTimeFormat, $null) } Catch { $ExportUntil = [Datetime]::ParseExact(((Get-Date).AddMinutes(10) | Get-Date -Format $DateTimeFormat), $DateTimeFormat, $null) } } $ImportToOS = $Config.ImportToOS $OnceOffImport = [System.Convert]::ToBoolean($Config.OnceOffImport) If ([String]::IsNullOrEmpty($Config.ImportUntil)) { $ImportUntil = [Datetime]::ParseExact(((Get-Date).AddMinutes(10) | Get-Date -Format $DateTimeFormat), $DateTimeFormat, $null) } Else { Try { $ImportUntil = [Datetime]::ParseExact($Config.ImportUntil, $DateTimeFormat, $null) } Catch { $ImportUntil = [Datetime]::ParseExact(((Get-Date).AddMinutes(10) | Get-Date -Format $DateTimeFormat), $DateTimeFormat, $null) } } $PlaceFlagFilesInUserProfile = [System.Convert]::ToBoolean($Config.PlaceFlagFilesInUserProfile) $StatusMessage = "Found the settings for $Description" If ($ShowUI) { $status1TextBox.AppendText("$StatusMessage `r`n") $status1TextBox.ScrollToCaret() } Write-verbose "$(Get-Date): $StatusMessage" -verbose Write-verbose "$(Get-Date): - UEMConfigShare: $UEMConfigShare" -verbose Write-verbose "$(Get-Date): - UEMProfileArchives: $UEMProfileArchives" -verbose Write-verbose "$(Get-Date): - UEMProfileLogs: $UEMProfileLogs" -verbose Write-verbose "$(Get-Date): - RecurseIniFiles: $RecurseIniFiles" -verbose Write-verbose "$(Get-Date): - SplitRDSHandVDI: $SplitRDSHandVDI" -verbose Write-verbose "$(Get-Date): - IncludeCommonIniFiles: $IncludeCommonIniFiles" -verbose Write-verbose "$(Get-Date): - EnableDebugLogging: $EnableDebugLogging" -verbose Write-verbose "$(Get-Date): - ExportFromOS: $ExportFromOS" -verbose Write-verbose "$(Get-Date): - OnceOffExport: $OnceOffExport" -verbose Write-verbose "$(Get-Date): - ExportUntil: $ExportUntil" -verbose Write-verbose "$(Get-Date): - ImportToOS: $ImportToOS" -verbose Write-verbose "$(Get-Date): - OnceOffImport: $OnceOffImport" -verbose Write-verbose "$(Get-Date): - ImportUntil: $ImportUntil" -verbose Write-verbose "$(Get-Date): - PlaceFlagFilesInUserProfile: $PlaceFlagFilesInUserProfile" -verbose } } If ($ConfigFound) { If (Test-Path -path "$FlexEnginePath") { Write-verbose "$(Get-Date): Script parameters:" -verbose Write-verbose "$(Get-Date): - Import: $Import" -verbose Write-verbose "$(Get-Date): - Export: $Export" -verbose Write-verbose "$(Get-Date): - ProcessAllIniFiles: $ProcessAllIniFiles" -verbose Write-verbose "$(Get-Date): - ShowUI: $ShowUI" -verbose Write-verbose "$(Get-Date): - IgnoreFlag: $IgnoreFlag" -verbose Write-verbose "$(Get-Date): - DriveMappingsOnly: $DriveMappingsOnly" -verbose Write-verbose "$(Get-Date): Action to be taken:" -verbose # Get the Operating System Major, Minor, Build and Revision Version Numbers $OSVersion = [System.Environment]::OSVersion.Version [int]$OSMajorVer = $OSVersion.Major [int]$OSMinorVer = $OSVersion.Minor [int]$OSBuildVer = $OSVersion.Build # Get the UBR (Update Build Revision) from the registry so that the full build number # is the same as the output of of the ver command line. [int]$OSRevisionVer = 0 $Path = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion" $ValueExists = $False $ErrorActionPreference = "stop" try { If ((Get-ItemProperty -Path "$Path" | Select-Object -ExpandProperty "UBR") -ne $null) { $ValueExists = $True } } catch { # } $ErrorActionPreference = "Continue" If ($ValueExists) { [int]$OSRevisionVer = (Get-ItemProperty -Path "$Path" -Name UBR).UBR } # Get Operating System information so we can split based on ProductType. $OperatingSystem = Get-WmiObject Win32_OperatingSystem | Select-Object ProductType Write-verbose "$(Get-Date): - RecurseIniFiles: $RecurseIniFiles" -verbose If ($ProcessAllIniFiles -eq $False) { Write-verbose "$(Get-Date): - SplitRDSHandVDI: $SplitRDSHandVDI" -verbose Write-verbose "$(Get-Date): - IncludeCommonIniFiles: $IncludeCommonIniFiles" -verbose } Else { $SplitRDSHandVDI = $False $IncludeCommonIniFiles = $False Write-verbose "$(Get-Date): - ProcessAllIniFiles: $ProcessAllIniFiles" -verbose } Write-verbose "$(Get-Date): - EnableDebugLogging: $EnableDebugLogging" -verbose $MachineType = "" If ($OperatingSystem.ProductType -eq 1) { $MachineType = "VDI" } If ($OperatingSystem.ProductType -eq 3) { $MachineType = "RDSH" } Write-verbose "$(Get-Date): - MachineType: $MachineType" -verbose $ProcessExport = $False ForEach ($ExportOS in $ExportFromOS.Split(",")) { [int]$Count = ($ExportOS.Split(".") | Measure-Object).Count If ($Count -eq 3) { If ($OSMajorVer -eq [int]$ExportOS.Split(".")[0] -AND $OSMinorVer -eq [int]$ExportOS.Split(".")[1] -AND $OSBuildVer -eq [int]$ExportOS.Split(".")[2]) { $ProcessExport = $True Break } } If ($Count -eq 4) { If ($OSMajorVer -eq [int]$ExportOS.Split(".")[0] -AND $OSMinorVer -eq [int]$ExportOS.Split(".")[1] -AND $OSBuildVer -eq [int]$ExportOS.Split(".")[2] -AND $OSRevisionVer -eq [int]$ExportOS.Split(".")[3]) { $ProcessExport = $True Break } } } If ($ProcessExport -AND $Export) { Write-verbose "$(Get-Date): - Exporting" -verbose Write-verbose "$(Get-Date): Assessing the Export process:" -verbose If ((Get-Date) -le $ExportUntil) { Write-verbose "$(Get-Date): - Date range is valid" -verbose } Else { $StatusMessage = "Date range has expired. The Export will not run again unless the date range is extended." If ($ShowUI) { $status1TextBox.AppendText("$StatusMessage `r`n") $status1TextBox.ScrollToCaret() } Write-verbose "$(Get-Date): - $StatusMessage" -verbose $ProcessExport = $False } Write-verbose "$(Get-Date): - Exporting: $ProcessExport" -verbose } $ProcessImport = $False ForEach ($ImportOS in $ImportToOS.Split(",")) { [int]$Count = ($ImportOS.Split(".") | Measure-Object).Count If ($Count -eq 3) { If ($OSMajorVer -eq [int]$ImportOS.Split(".")[0] -AND $OSMinorVer -eq [int]$ImportOS.Split(".")[1] -AND $OSBuildVer -eq [int]$ImportOS.Split(".")[2]) { $ProcessImport = $True Break } } If ($Count -eq 4) { If ($OSMajorVer -eq [int]$ImportOS.Split(".")[0] -AND $OSMinorVer -eq [int]$ImportOS.Split(".")[1] -AND $OSBuildVer -eq [int]$ImportOS.Split(".")[2] -AND $OSRevisionVer -eq [int]$ImportOS.Split(".")[3]) { $ProcessImport = $True Break } } } If ($ProcessImport -AND $Import) { Write-verbose "$(Get-Date): - Importing" -verbose Write-verbose "$(Get-Date): Assessing the Import process:" -verbose If ((Get-Date) -le $ImportUntil) { Write-verbose "$(Get-Date): - Date range is valid" -verbose } Else { $StatusMessage = "Date range has expired. The Import will not run again unless the date range is extended." If ($ShowUI) { $status1TextBox.AppendText("$StatusMessage `r`n") $status1TextBox.ScrollToCaret() } Write-verbose "$(Get-Date): - $StatusMessage" -verbose $ProcessImport = $False } Write-verbose "$(Get-Date): - Importing: $ProcessImport" -verbose } $OutputLog = "$UEMProfileLogs\FlexEngine.log" If ($DriveMappingsOnly -eq $False) { If ($SplitRDSHandVDI) { $IniFiles = Get-ChildItem "$UEMConfigShare\$MachineType" -recurse:$RecurseIniFiles -ErrorAction SilentlyContinue If ($IncludeCommonIniFiles) { $IniFiles += Get-ChildItem "$UEMConfigShare\Common" -recurse:$RecurseIniFiles -ErrorAction SilentlyContinue } $OutputLog = "$UEMProfileLogs\FlexEngine" + $MachineType + ".log" } Else { $IniFiles = Get-ChildItem "$UEMConfigShare" -recurse:$RecurseIniFiles -ErrorAction SilentlyContinue } } Else { $IniFiles = Get-ChildItem "$UEMConfigShare" -recurse:$True -ErrorAction SilentlyContinue | where {$_.Name -like "MappedDrives*"} } If (($ProcessExport -AND $Export) -OR ($ProcessImport -AND $Import)) { If (($IniFiles | Measure-Object).Count -gt 0) { $IniFiles | ForEach-Object { If ($_.Extension -eq ".INI") { $FullPathofINI = $_.FullName $DirectoryNameofINI = $_.DirectoryName If ($UEMConfigShare.Length -eq $DirectoryNameofINI.Length) { $FullPathofZIP = $UEMProfileArchives + "\" + ($_.Name.Replace('.INI','.zip')) } Else { $FullPathofZIP = $UEMProfileArchives + $DirectoryNameofINI.Substring($UEMConfigShare.Length, $DirectoryNameofINI.Length - $UEMConfigShare.Length) + "\" + ($_.Name.Replace('.INI','.zip')) } If ($PlaceFlagFilesInUserProfile) { $RunOnceImportFlag = "${env:AppData}\Immidio\Flex Profiles" + "\" + ($_.Name.Replace('.INI','RunOnceImportFlag')) $RunOnceExportFlag = "${env:AppData}\Immidio\Flex Profiles" + "\" + ($_.Name.Replace('.INI','RunOnceExportFlag')) } Else { $RunOnceImportFlag = $UEMProfileArchives + "\" + ($_.Name.Replace('.INI','RunOnceImportFlag')) $RunOnceExportFlag = $UEMProfileArchives + "\" + ($_.Name.Replace('.INI','RunOnceExportFlag')) } $ProcessThisFile = $True $StatusMessage = "Processing $($_.Basename) settings..." If ($ShowUI) { $status1TextBox.AppendText("$StatusMessage `r`n") $status1TextBox.ScrollToCaret() } Write-verbose "$(Get-Date): Processing `"$FullPathofINI`"" -verbose If ($IgnoreFlag) { $OnceOffImport = $False $OnceOffExport = $False } If ($ProcessImport -AND $OnceOffImport) { If (!(Test-Path -Path "$RunOnceImportFlag")) { Write-verbose "$(Get-Date): - The run once import flag does not exist. It will be processed." -verbose } Else { $StatusMessage = "- The import has previously been completed. It will not run again." If ($ShowUI) { $status1TextBox.AppendText("$StatusMessage `r`n") $status1TextBox.ScrollToCaret() } Write-verbose "$(Get-Date): $StatusMessage " -verbose $ProcessThisFile = $False } } If ($ProcessExport -AND $OnceOffExport) { If (!(Test-Path -Path "$RunOnceExportFlag")) { Write-verbose "$(Get-Date): - The run once export flag does not exist. It will be processed." -verbose } Else { $StatusMessage = "- The export has previously been completed. It will not run again." If ($ShowUI) { $status1TextBox.AppendText("$StatusMessage `r`n") $status1TextBox.ScrollToCaret() } Write-verbose "$(Get-Date): $StatusMessage " -verbose $ProcessThisFile = $False } } If ($ProcessImport -AND $Import) { If (!(Test-Path -Path "$FullPathofZIP")) { $StatusMessage = "- A corresponding archive (zip) does not exist, so the import will not run." If ($ShowUI) { $status1TextBox.AppendText("$StatusMessage `r`n") $status1TextBox.ScrollToCaret() } Write-verbose "$(Get-Date): $StatusMessage " -verbose $ProcessThisFile = $False } } If ($ProcessThisFile) { If ($_.Name -Like "MappedDrives*" ) { $MappedDrives = $True } $CmdLineArgs = @() # Although the -i parameter is not applicable for file-based imports, it can still be left in the # arguments passed and will be ignored. We use it in the code above for the import process to ensure # there is a matching archive (zip) for each ini file. $CmdLineArgs += "-i" $CmdLineArgs += "`"$FullPathofINI`"" If ($ProcessImport) { $CmdLineArgs += "-r" } If ($ProcessExport) { $CmdLineArgs += "-s" } $CmdLineArgs += "`"$FullPathofZIP`"" # The -b parameter is not used for imports If ($ProcessExport) { $CmdLineArgs += "-b-" } $CmdLineArgs += "-R" $CmdLineArgs += "-f" $CmdLineArgs += "`"$OutputLog`"" If ($EnableDebugLogging) { $CmdLineArgs += "-l" $CmdLineArgs += "DEBUG" } $Success = $False $pinfo = New-Object System.Diagnostics.ProcessStartInfo $pinfo.FileName = $FlexEnginePath $pinfo.UseShellExecute = $false $pinfo.RedirectStandardOutput = $true $pinfo.RedirectStandardError = $true $pinfo.Arguments = $CmdLineArgs $p = New-Object System.Diagnostics.Process $p.StartInfo = $pinfo Try { $p.Start() | Out-Null $p.WaitForExit() $output = $p.StandardOutput.ReadToEnd() $output += $p.StandardError.ReadToEnd() #$output If ($p.ExitCode -eq 0) { $Success = $True Write-verbose "$(Get-Date): Successfully executed" -Verbose Write-verbose "$(Get-Date): Exit code: $($p.ExitCode)" -verbose } Else { Write-warning "$(Get-Date): Failed to execute" -Verbose Write-warning "$(Get-Date): Exit code: $($p.ExitCode)" -verbose } } Catch { write-warning "$(Get-Date): Failed to execute" -Verbose } If ($Success) { If ($ProcessImport -AND $OnceOffImport) { Write-verbose "$(Get-Date): Creating the Import Flag so that it doesn't import again" -verbose $ParentFolder = (Split-Path -path $RunOnceImportFlag -parent) If (!(Test-Path -Path $ParentFolder)) { New-Item $ParentFolder -itemtype directory | out-null } Out-File -FilePath "$RunOnceImportFlag" } If ($ProcessExport -AND $OnceOffExport) { Write-verbose "$(Get-Date): Creating the Export Flag so that it doesn't export again" -verbose $ParentFolder = (Split-Path -path $RunOnceExportFlag -parent) If (!(Test-Path -Path $ParentFolder)) { New-Item $ParentFolder -itemtype directory | out-null } Out-File -FilePath "$RunOnceExportFlag" } } $p.Dispose() } Else { Write-verbose "$(Get-Date): - No action will be taken" -verbose } } } } Else { Write-verbose "$(Get-Date): No ini files were found" -verbose } } Else { If ($Import) { $StatusMessage = "A valid Import profile was not found for this Operating System version." If ($ShowUI) { $status1TextBox.AppendText("$StatusMessage `r`n") $status1TextBox.ScrollToCaret() } write-verbose "$(Get-Date): $StatusMessage" -verbose } If ($Export) { $StatusMessage = "A valid Export profile was not found for this Operating System version." If ($ShowUI) { $status1TextBox.AppendText("$StatusMessage `r`n") $status1TextBox.ScrollToCaret() } write-verbose "$(Get-Date): $StatusMessage" -verbose } If ($Import -eq $False -AND $Export -eq $False) { $StatusMessage = "No Import or Export action will be taken." If ($ShowUI) { $status1TextBox.AppendText("$StatusMessage `r`n") $status1TextBox.ScrollToCaret() } write-verbose "$(Get-Date): $StatusMessage" -verbose } } If ($MappedDrives -AND $ProcessImport -AND $Import) { $StatusMessage = "Refreshing mapped drives..." If ($ShowUI) { # On the odd occasion we may get an error here saying that the "Method invocation failed because # [System.String] does not contain a method named 'Length'". So I've wrapped this in a try/catch. Try { $TextLength = $status1TextBox.Text.Length $status1TextBox.AppendText("$StatusMessage `r`n") $status1TextBox.ScrollToCaret() # Sleep to allow the UI message to update before Explorer.exe is terminated. do { Start-sleep -s 1 } until ($status1TextBox.Text.Length -eq $TextLength + $StatusMessage.Length()) } catch { # } } write-verbose "$(Get-Date): $StatusMessage" -verbose RemapUnavailableNetworkDrives RestartProcessUsingRestartManager -Name:"explorer" } If ($ProcessImport -AND $Import -AND $DriveMappingsOnly -eq $False) { $StatusMessage = "Refreshing environment variables and file associations..." If ($ShowUI) { $status1TextBox.AppendText("$StatusMessage `r`n") $status1TextBox.ScrollToCaret() } write-verbose "$(Get-Date): $StatusMessage" -verbose EnvironmentRefresh DesktopRefresh } $StatusMessage = "Finished!" If ($ShowUI) { $status1TextBox.AppendText("$StatusMessage `r`n") $status1TextBox.ScrollToCaret() } write-verbose "$(Get-Date): $StatusMessage" -verbose } Else { Write-warning "$(Get-Date): `"$FlexEnginePath`" does not exist" -verbose } } Else { write-warning "$(Get-Date): No valid config found" -verbose } } Else { write-warning "$(Get-Date): The XML file was not found" -verbose } } Function Call-MainForm { # This is for the user form param ( [switch]$Import, [switch]$Export, [switch]$ProcessAllIniFiles, [switch]$ShowUI, [switch]$IgnoreFlag, [switch]$DriveMappingsOnly ) $codeDisableX = @" using System.Windows.Forms; namespace MyForm { public class FormWithoutX : Form { protected override CreateParams CreateParams { get { CreateParams cp = base.CreateParams; cp.ClassStyle = cp.ClassStyle | 0x200; return cp; } } } } "@ Add-Type -AssemblyName System.Windows.Forms Add-Type -TypeDefinition $codeDisableX -ReferencedAssemblies System.Windows.Forms Add-Type -AssemblyName System.Drawing Add-Type -AssemblyName PresentationCore,PresentationFramework $IconFile = "$ScriptPath\File Export.ico" If ($Import) { $IconFile = "$ScriptPath\File Import.ico" } $form = [MyForm.FormWithoutX]::new() #$form = New-Object System.Windows.Forms.Form $form.Text = 'User Profile Migration Tool' $form.ClientSize = New-Object System.Drawing.Size(350,350) $form.MaximizeBox = $False $form.MinimizeBox = $False $form.MinimumSize = New-Object System.Drawing.Size(350,440) $form.MaximumSize = New-Object System.Drawing.Size(350,440) $form.FormBorderStyle = "FixedDialog" $form.SizeGripStyle = "Hide" If (Test-Path "$IconFile") { $form.Icon = [System.Drawing.Icon]::ExtractAssociatedIcon("$IconFile") } $form.WindowState = 'Normal' # Start the form from the top left eighth position of the primary screen. This ensures # it starts consistently in the same location and out of the way of any popups. $LocationWidth = $(([System.Windows.Forms.Screen]::PrimaryScreen.WorkingArea.Width /8)) $LocationHeight = $(([System.Windows.Forms.Screen]::PrimaryScreen.WorkingArea.Height /8)) $form.Location = New-Object System.Drawing.Point($LocationWidth,$LocationHeight) $form.StartPosition = 'Manual' $labelheader = New-Object 'System.Windows.Forms.Label' $labelheader.Font = 'Microsoft Sans Serif, 14.25pt' $labelheader.ForeColor = 'White' $labelheader.Location = New-Object System.Drawing.Point(47, 18) $labelheader.Name = 'labelheader' $labelheader.Size = New-Object System.Drawing.Size(350, 23) $labelheader.TabIndex = 1 $labelheader.Text = 'User Profile Migration Tool' $labelheader.TextAlign = 'MiddleLeft' $panelheader = New-Object 'System.Windows.Forms.Panel' $panelheader.Controls.Add($labelheader) $panelheader.BackColor = '0, 114, 198' $panelheader.Location = New-Object System.Drawing.Point(0, 0) $panelheader.Name = 'header' $panelheader.Size = New-Object System.Drawing.Size(350, 67) $panelheader.TabIndex = 8 $form.Controls.Add($panelheader) $closeButton = New-Object System.Windows.Forms.Button $closeButton.Location = New-Object System.Drawing.Point(250,210) $closeButton.Size = New-Object System.Drawing.Size(75,23) $closeButton.Text = 'Close' $closeButton.DialogResult = [System.Windows.Forms.DialogResult]::OK $form.CancelButton = $closeButton $form.Controls.Add($closeButton) $status1label = New-Object System.Windows.Forms.Label $status1label.Font = 'Microsoft Sans Serif, 9pt' $status1label.Location = New-Object System.Drawing.Point(10,215) $status1label.Name = 'status1label' $status1label.Size = New-Object System.Drawing.Size(100,23) $status1label.UseMnemonic = $false $status1label.Text = "Output message:" $form.Controls.Add($status1label) $status1TextBox = New-Object System.Windows.Forms.RichTextBox $status1TextBox.Location = New-Object System.Drawing.Point(10,240) $status1TextBox.Size = New-Object System.Drawing.Size(315, 150) $status1TextBox.Multiline = $true $status1TextBox.WordWrap = $true $status1TextBox.ScrollBars = [System.Windows.Forms.ScrollBars]::'Vertical' $status1TextBox.Enabled = $true $status1TextBox.ReadOnly = $true $oldFont = $status1label.Font $newFont = New-Object System.Drawing.Font($oldFont.FontFamily, $oldFont.Size, [System.Drawing.FontStyle]::Bold) $status1TextBox.Font = $newFont $form.Controls.Add($status1TextBox) #------------------------------------------------------------- $action1Button = New-Object System.Windows.Forms.Button $action1Button.Location = New-Object System.Drawing.Point(90,80) $action1Button.Size = New-Object System.Drawing.Size(150,23) If ($Export) { $buttonText1 = "Export my settings" $GroupBox1 = New-Object System.Windows.Forms.GroupBox $GroupBox1.Location = '40,110' $GroupBox1.size = '250,90' $GroupBox1.text = "Export options:" $RadioButton1 = New-Object System.Windows.Forms.RadioButton $RadioButton1.Location = '10,20' $RadioButton1.size = '180,20' $RadioButton1.Checked = $true $RadioButton1.Text = "Default" $RadioButton2 = New-Object System.Windows.Forms.RadioButton $RadioButton2.Location = '10,40' $RadioButton2.size = '230,20' $RadioButton2.Checked = $false $RadioButton2.Text = "Force the re-export of ALL settings" # Add all the GroupBox controls on one line $GroupBox1.Controls.AddRange(@($Radiobutton1,$RadioButton2)) $form.Controls.Add($GroupBox1) } If ($Import) { $buttonText1 = "Import my settings" $GroupBox1 = New-Object System.Windows.Forms.GroupBox $GroupBox1.Location = '40,110' $GroupBox1.size = '250,90' $GroupBox1.text = "Import options:" $RadioButton1 = New-Object System.Windows.Forms.RadioButton $RadioButton1.Location = '10,20' $RadioButton1.size = '180,20' $RadioButton1.Checked = $true $RadioButton1.Text = "Default" $RadioButton2 = New-Object System.Windows.Forms.RadioButton $RadioButton2.Location = '10,40' $RadioButton2.size = '230,20' $RadioButton2.Checked = $false $RadioButton2.Text = "Force the re-import of ALL settings" $RadioButton3 = New-Object System.Windows.Forms.RadioButton $RadioButton3.Location = '10,60' $RadioButton3.size = '230,20' $RadioButton3.Checked = $false $RadioButton3.Text = "Force the import of Mapped Drives ONLY" # Add all the GroupBox controls on one line $GroupBox1.Controls.AddRange(@($Radiobutton1,$RadioButton2,$RadioButton3)) $form.Controls.Add($GroupBox1) } $action1Button.Text = $buttonText1 $action1Button.Add_Click({ $ButtonType = [System.Windows.MessageBoxButton]::OK $MessageIcon = [System.Windows.MessageBoxImage]::Information If ($Export) { If ($RadioButton1.Checked -AND (!($RadioButton2.Checked))) { $ProcessAllIniFiles = $True $IgnoreFlag = $False $DriveMappingsOnly = $False } If ($RadioButton2.Checked -AND (!($RadioButton1.Checked))) { $ProcessAllIniFiles = $True $IgnoreFlag = $True $DriveMappingsOnly = $False } } If ($Import) { If ($RadioButton1.Checked -AND (!($RadioButton2.Checked)) -AND (!($RadioButton3.Checked))) { $ProcessAllIniFiles = $True $IgnoreFlag = $False $DriveMappingsOnly = $False } If ($RadioButton2.Checked -AND (!($RadioButton1.Checked)) -AND (!($RadioButton3.Checked))) { $ProcessAllIniFiles = $True $IgnoreFlag = $True $DriveMappingsOnly = $False } If ($RadioButton3.Checked -AND (!($RadioButton1.Checked)) -AND (!($RadioButton2.Checked))) { $ProcessAllIniFiles = $False $IgnoreFlag = $True $DriveMappingsOnly = $True } } Call-Main -Import:$Import -Export:$Export -ProcessAllIniFiles:$ProcessAllIniFiles -ShowUI:$ShowUI -DriveMappingsOnly:$DriveMappingsOnly -IgnoreFlag:$IgnoreFlag }) $form.AcceptButton = $action1Button $form.Controls.Add($action1Button) #------------------------------------------------------------- $form.Add_Resize({ #$userTxtBox.Size = [System.Drawing.Size]::new(($this.Width-120),60) #$passwordTxtBox.Size = [System.Drawing.Size]::new(($this.Width-120),60) }) $form.Topmost = $true $form.Add_Shown({$action1Button.Select()}) $form.ShowDialog() | out-null #This starts the GUI } #------------------------------------ # This starts the functions depending on whether or not the UI is required. If ($ShowUI -eq $False) { Call-Main -Import:$Import -Export:$Export -ProcessAllIniFiles:$ProcessAllIniFiles -ShowUI:$ShowUI } Else { Call-MainForm -Import:$Import -Export:$Export -ProcessAllIniFiles:$ProcessAllIniFiles -ShowUI:$ShowUI } #------------------------------------ $EndDTM = (Get-Date) Write-verbose "Elapsed Time: $(($EndDTM-$StartDTM).TotalSeconds) Seconds" -Verbose Write-Verbose "Elapsed Time: $(($EndDTM-$StartDTM).TotalMinutes) Minutes" -Verbose try { Stop-Transcript } catch { Write-Verbose "$(Get-Date): This host does not support transcription" }
Using Logon and Logoff Scripts
Use Group Policy to add PowerShell Logoff (for export) and Logon (for import) Scripts:
- Logoff Script
- Script Name: C:\Program Files\Immidio\Flex Profiles\ProfileMigration.ps1
- Script Parameters: -Export
- Logon Script
- Script Name: C:\Program Files\Immidio\Flex Profiles\ProfileMigration.ps1
- Script Parameters: -Import
Remember that the logoff script should be in place 2 to 3 months before your migration kicks off to give you the best opportunity to capture the logons from most, if not all, of the users.
Using the optional User Interface
As well as running the script at logoff and logon, I also found it useful to publish two applications. It’s the same script, just using different command line arguments to expose the UI. This allows us to help users that may of been on leave during the migration, or cases where we may need to instruct users to re-export and/or re-import their profile data as something didn’t work as expected.
Unfortunately you cannot publish the script directly as a Citrix Published Application because the path to the executable file and command line arguments combined are greater than 203 characters. So you must use a batch script to publish it instead, which is the purpose of the included batch file.
- Published App Name: User Profile Data Export
- Path to the executable file: C:\Program Files\Immidio\Flex Profiles\ProfileMigration-Launcher.cmd
- Command line argument: Export
- Use the “File Export.ico” file as the icon.
- IMPORTANT: This should be published from a Delivery Group with an Operation System that you will be exporting from, as it exports from the old profile.
- Published App Name: User Profile Data Import
- Path to the executable file: C:\Program Files\Immidio\Flex Profiles\ProfileMigration-Launcher.cmd
- Command line argument: Import
- Use the “File Import.ico” file as the icon.
- IMPORTANT: This should be published from a Delivery Group with an Operation System that you will be importing to, as it imports into the new profile.
Users should see these two apps…
In a Citrix world it is recommended to add “FlexEngine.exe” to the “LogoffCheckSysModules” registry value to prevent a session from staying active should the executable hang/fail.
Of course if your publishing a full desktop session, you can create these as Desktop and/or Start Menu shortcuts. They don’t need to be Published Applications.
The User Profile Structure
There are a couple of per-user folders that are automatically created by the FlexEngine process. These are set in the ProfileMogration.xml file:
- The UEMProfileArchives (export) folder is automatically created by FlexEngine. I place this under the root of the user profile. To avoid confusion, I set it up to go to a “Migration” folder: \\fileserver\profiles$\Profiles\%username%.%userdomain%\Migration
- The UEMProfileLogs folder is automatically created by the FlexEngine. I place this under the root of the user profile. To avoid confusion, I set it up to go to a “MigrationLogs” folder: \\fileserver\profiles$\Profiles\%username%.%userdomain%\MigrationLogs
- You can change any of the paths to suite your needs. This is just how I set it up. It makes it simple for Service Desk and the Operational Teams to follow.
The Export Process:
- This is what the root of a users profile may look like during the export process. Note here that there is a v4 profile folder, where our end state after the migration is a v6 profile folder:
- As per the ini file data, the settings are exported and compressed into zip files. Note how the zip files go into the same Common, VDI and RDSH folder structure as the ini files themselves.
- The FlexEngine log is created by the VMware process. I chose to use debug level information to assist with any troubleshooting:
The Import Process:
- These are the flag files created per zip once imported if the OnceOffImport setting is set to True, These zip files will be skipped when the import process next runs. This prevents settings from being overwritten once imported unless the flag file(s) are deleted or the “Force the re-import of ALL settings” option is used from the UI.
- Just like the export process, the FlexEngine log will contain the debug level information for the import process.
- And then of course UPM, in this case, will create the v6 profile where all the imported settings will be.
Logging
Logging is extensive and written to two places, plus the output message box of the UI if it’s used.
The FlexEngine logs are written to the location defined by the UEMProfileLogs xml element. This is a per-user location. So I place this under the root of the user profile. To avoid confusion, I set it up to go to a “MigrationLogs” folder, but you can name it as you please: \\fileserver\profiles$\Profiles\%username%.%userdomain%\MigrationLogs
The following screen shot is an example of the debug level export information logged by FlexEngine. Note that I have truncated this log file.
The following screen shot is an example of the debug level import information logged by FlexEngine.
The PowerShell script output is written to the a file called “ProfileMigration.log” under the users %TEMP% folder.
The following screen shot is an example of the export information logged by the script.
The following screen shot is an example of the import information logged by the script.
If using the UI, the “Output message” text box is for the most part the same as what is written to the “ProfileMigration.log” under their %TEMP% folder.
The following screen shot is an example of the export information logged to the output message text box of the UI.
The following screen shot is an example of the import information logged to the output message text box of the UI.
Final words
If VMware are upset from what I’ve done here, or you are not licensed to use DEM, recreating the functions needed to remove the FlexEngine from the process in either PowerShell or C# wouldn’t be too difficult to achieve.
At the end of the day this is a thorough and well thought out process that is extremely flexible for any environment, and very simple to extend for use cases that I may not have considered.
I hope you find this useful. Enjoy!