Microsoft Dynamics NAV 2016 brings many new features, one of which is a set of PowerShell cmdlets to manage add-ins. These are:
While these cmdlets are certainly useful for installation and deployment, I find them even more useful during development. When I blogged about deploying resource files automatically during development some months ago, I showed how you could use PowerShell during build process in Visual Studio to invoke a codeunit that registers control add-ins. Now, in NAV 2016, these tasks are a lot simpler with these new cmdlets.
Let’s start by understanding the requirement. When I am developing my control add-ins, I want Visual Studio to automatically register the control add-in, create the resource file from relevant scripts, stylesheets, images and manifest, and import the resource file into the control add-in. It saves quite a lot of time during development, because it replaces a number of steps that otherwise have to be done manually.
My old solution has practically everything hardcoded, all over the place: in the post build event in Visual Studio, in the PowerShell script that the post build event calls, and in the codeunit that the PowerShell script calls. Pretty ugly, and requires some manual chores every time I am starting a new project. Not to say that it depends on certain software to be installed on the build machine (namely, 7zip).
So, let’s clean it up, take advantage of the new cmdlets, and do it the right way. The goal is to get rid of all hardcoded variables, and of unnecessary external dependencies.
Each control add-in is identified by two bits of information: name and public key token. Name comes from the ControlAddInExport attribute, and public key token comes from strong name signature.
Instead of hardcoding the public key token, how about reading it from the built dll file? Easy-peasy.
Function GetToken() { Param( [string]$file ) return -join ([Reflection.AssemblyName]::GetAssemblyName($file).GetPublicKeyToken() ` | foreach { $_.ToString("X2").ToLower() }) }
This function receives one parameter representing the assembly, and then it uses reflection to get the public key token out of assembly. Since public key token is returned as a byte array, and we need its hexadecimal representation for NAV, the function converts each byte into hexadecimal value, and joins them together.
That was easy, but how about control add-in name? That’s a tougher nut to crack.
In theory, one class library assembly can contain more than one exported control add-in, so to have a really scalable solution here, I need to detect all exported control add-ins in the assembly.
But how to detect exported control add-ins in the first place. Well, I know of a wrong way, and a correct way. Not to say there are no more ways, you can do the following.
You can look up all C# files in a the project directory (recursively) and then use regular expressions to match any [ControlAddInExport(“…”)] pattern. That would be – you might guess – a wrong way.
The correct way would be to use reflection. You load the assembly, get all types, then inspect custom attributes of each type in search of ControlAddInExportAttribute.
Easier said than done. If you simply load the class library assembly, and try to read attributes of its types, you must first load any dependent assemblies. To detect if ControlAddInExportAttribute is decorating any types, you must load Microsoft.Dynamics.Framework.UI.Extensibility.dll first. But where do you find it? Remember, I don’t want to hardcode anything.
To do that, I’ll have a function that receives path to the C# project file. That’s an XML file that contains the definition of a C# project, including any external references. NAV extensibility framework – if used, and it usually is – will be included. So, the function will load that XML file, look for reference definitions, and then load any referenced assemblies.
Function LoadAssemblies() { Param ( [string]$path ) $navPath = $null $project = [Xml](Get-Content $path) [System.Xml.XmlNamespaceManager] $nsmgr = $project.NameTable $nsmgr.AddNamespace("p", "http://schemas.microsoft.com/developer/msbuild/2003") $nodes = $project.SelectNodes("/p:Project/p:ItemGroup//p:Reference", $nsmgr) foreach ($node in $nodes) { $hint = $node.SelectSingleNode("p:HintPath", $nsmgr) if ($hint -ne $null) { $deppath = [System.IO.Path]::Combine([System.IO.Path]::GetDirectoryName($path), $hint.InnerText) $a = [System.Reflection.Assembly]::LoadFile($deppath) if ($node.Include -eq "Microsoft.Dynamics.Framework.UI.Extensibility") { $navPath = [System.IO.Path]::Combine([System.IO.Path]::GetDirectoryName($deppath), "Add-ins") } } } return $navPath }
So, I start by loading the xml content, and then I search for all instances of Reference under any /Project/ItemGroup. For each of those, I look if they include the HintPath element. If they don’t, it means these are GAC assemblies, and will be resolved automatically by CLR. If there is HintPath, I load the assembly from the specified path. Then, if the Include attribute of the current node equals Microsoft.Dynamics.Framework.UI.Extensibility, I store the path into the $navPath variable. The function will return this later – and I’ll use it to know where exactly the NAV client add-ins folder is. For now, just ignore it.
Anyway, at this point I have all dependency assemblies loaded, and I am ready to do the next step:
Function InstallAllControlAddIns() { Param ( [string]$Dll, [string]$Server, [string]$Project ) $addInsPath = LoadAssemblies -path $Project $path = [System.IO.Path]::GetDirectoryName($Project) $token = GetToken -file $Dll $asm = [System.Reflection.Assembly]::LoadFile($Dll) $types = $asm.GetTypes() | Where { $_.GetCustomAttributes($false).Count -gt 0 } foreach ($type in $types) { $ctrlAddIn = $type.GetCustomAttributes($false) ` | Where { $_.TypeId.ToString() -eq ` "Microsoft.Dynamics.Framework.UI.Extensibility.ControlAddInExportAttribute" } if ($ctrlAddIn -ne $null) { #Do necessary stuff } } }
I call this function by passing the path to the assembly, name of the server instance, and path to the project. Here, I first call the LoadAssemblies function and store the path to NAV add-ins. Then I retrieve the public key token by calling the GetToken function. Then I load the control add-in assembly, retrieve all types from it and look for any types that have custom attributes. Then for each of those types, I detect if it is decorated with the ControlAddInExport attribute. For any types that are, I #Do the necessary stuff. What’s that stuff? Read on.
Necessary stuff is two things: first, I need to create the control add-in record in the database; and second, I need to copy the client dll to the add-ins folder. Let’s first take a look what it takes to create a new control add-in record.
Creating add-in record is a bit more complicated affair than it seems. First, we need to know if the control add-in is a JavaScript or .NET based, and then if it’s JavaScript, we need to create the resource .zip file and import it together with the control.
So, how do we create the resource zip file? In my previous solution, I used 7zip, which was embedded into the project, and then called as an external executable. Pretty bad, but it did the job. Here, I want PowerShell to do all of it, so:
Function ZipFolder() { Param( [string]$Folder ) Add-Type -Assembly "System.IO.Compression.FileSystem" $dest = [System.IO.Path]::GetTempFileName() [System.IO.File]::Delete($dest) [System.IO.Compression.ZipFile]::CreateFromDirectory($Folder, $dest) return $dest }
Not much explanation needed. I pass the folder that I want to zip as parameter, then I create a temporary file, and then zip the folder into that temporary file. Of course, I first needed to delete the temporary file, because otherwise the CreateFromDirectory would fail. Then, I return the full path to the newly created file. It will have the .tmp extension, but NAV couldn’t care less.
Now, I need to do the import. Here’s the code:
Function ImportAddIn() { Param ( [string]$AddInName, [string]$Token, [string]$Path, [string]$Server ) $resFile = $null $folder = [System.IO.Path]::Combine($Path, "Resource", $AddInName) if ([System.IO.Directory]::Exists($folder)) { $resFile = ZipFolder -Folder $folder } Try { if ($resFile -ne $null) { New-NAVAddIn -AddInName $AddInName -PublicKeyToken $Token -ServerInstance $Server -Category JavaScriptControlAddIn -ResourceFile $resFile -ErrorAction Stop } else { New-NAVAddIn -AddInName $AddInName -PublicKeyToken $Token -ServerInstance $Server -Category DotNetControlAddIn -ErrorAction Stop } } Catch [System.Exception] { if ($resFile -ne $null) { Set-NAVAddIn -AddInName $AddInName -PublicKeyToken $Token -ServerInstance $Server -Category JavaScriptControlAddIn -ResourceFile $resFile } else { Set-NAVAddIn -AddInName $AddInName -PublicKeyToken $Token -ServerInstance $Server -Category DotNetControlAddIn } } Finally { if ($resFile -ne $null -and [System.IO.File]::Exists($resFile)) { [System.IO.File]::Delete($resFile) } } }
It receives the add-in name, public key token, path in which there are resource files (if any), and the server instance. Then, if there was a resource folder, it creates a resource zip file from that folder. The only assumption I make in my code is about this resource folder. I assume that it’s always found in my project’s root folder, in the .\Resource\<Add_In_Name> folder.
Then I need to either create a new add-in, or modify an existing one. Unfortunately, there are two cmdlets for this, and they are not too intelligent. New-NAVAddIn creates a new control add-in, and Set-NAVAddIn modifies an existing one. New-NAVAddIn will fail if there is an add in with the same name and public key token, and Set-NAVAddIn will fail if there isn’t one. So, I use the try-catch block – if New fails, Set should work.
However, I must make a decision – if there is a resource file, it’s JavaScript, otherwise it’s DotNet. Finally, whether or not try..catch failed or succeeded, I delete the resource file if there was one. I don’t need that temporary file anymore.
However, before I can actually call any NAV cmdlets, I must import the module. You may think that this is the solution:
Import-Module “C:\Program Files\Microsoft Dynamics NAV\90\Service\Microsoft.Dynamics.Nav.Management.dll”
But it isn’t. Guess why? Because it’s hardcoded, and I said I don’t want anything hardcoded. I want to locate the management assembly programmatically:
Function ImportNavManagementModule() { Param([string]$Server) $svcName = "MicrosoftDynamicsNavServer$" + $Server foreach ($mo in [System.Management.ManagementClass]::new("Win32_Service").GetInstances()) { if ($mo.GetPropertyValue("Name").ToString() -eq $svcName) { $path = $mo.GetPropertyValue("PathName").ToString() $path = $path.Substring(1, $path.IndexOf("$") - 3) $path = [System.IO.Path]::Combine( [System.IO.Path]::GetDirectoryName($path), "Microsoft.Dynamics.Nav.Management.dll") Import-Module -FullyQualifiedName $path return } } }
This function receives the service tier name as parameter, then it loads all Windows services, and finds the one matching the name pattern for the NAV service tiers. From there, I take the PathName property, parse it to locate the NST executable, and in the same path I find the management assembly. Then I import that module.
The next necessary thing is copying the assembly into the client add-ins folder. Again, it’s not a simple thing:
Function CopyDllToNavAddIns() { Param( [string]$AddInsPath, [string]$ControlAddInName, [string]$Dll, [string]$Server ) $path = [System.IO.Path]::Combine($AddInsPath, $ControlAddInName) $p = [System.IO.Directory]::CreateDirectory($path) $allDlls = [System.IO.Path]::GetDirectoryName($Dll) + "\*.dll" Try { Copy-Item $allDlls $path } Catch [System.Exception] { Try { Write-Host "Restarting service tier." Set-NAVServerInstance $Server -Restart Copy-Item $allDlls $path } Catch [System.Exception] { Write-Host "Dll files are still in use. Could they be locked by the Development Environment?" } } }
Here, I receive the add-ins path, control add-in name, assembly path, and server instance as parameters. I need all of them, really.
First, I do not want to only copy the output of the build process in Visual Studio, which is one assembly – I actually want to copy all dependent assemblies. And I don’t want to create mess in my Add-ins folder, so I want to put all dependencies into a subfolder named exactly as my add-in. So, I combine these two paths, make sure that this directory is present, and then I attempt to copy all dlls from my build directory into the target add-in directory.
If this fails, this typically means that the files are locked. At this point, I restart the service tier to kill all client sessions in an attempt to release the file locks. Then I attempt to copy the files again. If it fails again, the most likely reason is that Development Environment is locking them, so I notify the user about this.
So, now that you know what necessary stuff is, this is how I call these two functions:
ImportAddIn ` -AddInName $ctrlAddIn.Name ` -Token $token ` -Path $path ` -Server $Server CopyDllToNavAddIns ` -AddInsPath $addInsPath ` -ControlAddInName $ctrlAddIn.Name ` -Dll $Dll ` -Server $Server
Finally, I call the InstallControlAddIns function like this:
InstallAllControlAddIns -Dll $DllFile -Server $ServerInstance -Project $ProjectPath
These $DllFile, $ServerInstance, and $ProjectPath parameters are my script parameters:
Param( [string]$DllFile, [string]$ServerInstance, [string]$ProjectPath )
Good. The last thing is calling the script from the post-build event:
set nst=DynamicsNAV90 powershell -ExecutionPolicy unrestricted -command "&'$(ProjectDir)Build Tools\ImportResource.ps1' -DllFile '$(TargetPath)' -ServerInstance '%nst%' -ProjectPath '$(ProjectPath)'"
Everything should be pretty clear here, as I am using two built-in Visual Studio macros: $(TargetPath) is the path to the built dll, $(ProjectPath) is the path to the .csproj file, and I am only hardcoding the value of the %nst% batch variable. Well, the name of the service tier should come from somewhere.
In all honesty, I don’t like it being present in the script, I prefer it coming from somewhere else – just like other macros. The problem is, there is no simple way in Visual Studio (without writing extensions, of course) to define a macro. I could have gone for this:
powershell -ExecutionPolicy unrestricted -command "&'$(ProjectDir)ImportResource.ps1' -DllFile '$(TargetPath)' -ServerInstance '$(NavServiceTier)' -ProjectPath '$(ProjectPath)'"
However, to get this $(NavServiceTier) macro to contain the value of DynamicsNAV90, I should unload the project (Solution Explorer > Right click project name > Unload Project), then edit the project file (Solution Explorer > Right click the unloaded project name > Edit project file), and then add a new property group:
<PropertyGroup> <NavServerInstance>DynamicsNAV90</NavServerInstance> </PropertyGroup>
Cleaner way – which is also quite a bit more complicated – is to create a new attribute, e.g. InstallForNavServerInstance, and then decorate the class like this:
[InstallForNavServerInstance("DynamicsNAV90")]
Then access this attribute the same way I accessed the ControlAddInExport attribute. However, this should be repeatable, so I should create a new assembly, preferably turn it into a nuget package, and then reference this assembly from all future control add-ins. Quite a lot of complication, instead of simply building a variable into the post-build event.
If you did everything right, when you build your project in Visual Studio, it will call all this PowerShell hodge-podge and install your control add-in nicely into your local NAV development database. Only thing you need to do before is reference Microsoft.Dynamics.Framework.UI.Extensibility.dll, decorate your class with the ControlAddInExport attribute, and… and nothing, just press F6 to build.
So, at this stage you have two things:
- A PowerShell script that you simply copy into each of your control add-ins projects
- A post-build event that you copy/paste into each of your control add-ins projects post-build event box
Of course, you can create a new project template out of this and teach yourself how to fish for lifetime.
Once, when I find time and motivation, I may create a nice little Visual Studio extension that takes care of this and many more other things relevant for developing control add-ins. Or you can do that. Or – heck, why not? – maybe Microsoft should do that.
Just in case, you can download the complete script here.
Thanks for reading this far, and I hope you find this useful.
Pingback: Activity Log or Activity Lock pattern? | Pardaan.com
Hello,
I wish to thank you so much Vjeko, for this article (and the update) which made my day (and a lot of future ones!) and for the communication tips between the JavaScript addins and C/AL (JSON and strongly typed objects).
You mentioned that you were thinking about a nugget package for the [InstallForNavServerInstance(“DynamicsNAV90”)] attribute.
Dealing with version issues, I would strongly suggest you to add those two attributes at the same time :
[InstallForNavServerInstance]
[DoNotInstallForNavServerInstance(“DynamicsNAV80”)]
This way, you won’t have to add an attribute to each add-in whenever NAV get an update.
(and for the add-ins discontinued, you can reverse the logic and specify what version are supported, like you already do)
Once again, thanks a lot for this great tool and tips!
Thanks for the suggestions. If I ever get around to creating that nuget package, I’ll keep this in mind.