You can license an Electron application that runs cross-platform on Windows, Linux and MacOS using the SpAgent runtime to enforce licensing in the application's Node.js back-end service.
Licensing Approach
The SpAgent runtime API can be called directly from Node.js to both activate and query licenses. Typically, calls to the API are used to configure the application UI e.g. to display only licensed features/options in the application menus.
It is also also possible to migrate some of the core Electron application functionality from the Node.js back-end to one or more .NET DLLs and to enforce license checks in these DLLs. As with native Node.js addons, there may be good reasons to implement core application features in .NET DLLs e.g. to leverage features/functionality in existing .NET DLLs, to improve performance of compute intensive features etc.
License checks in the .NET addons (DLLs) can be implemented programmatically via explicit calls to the SpAgent runtime API; alternatively, they can be done declaratively by marking methods to be licensed with appropriate attributes in the DLL source code. Please see the following article for more information on these implementation options Selecting Methods to be Licensed/Protected.
Calling SpAgent from Node.js Using Edge.js
With Edge.js you can run Node.js and .NET code in one process on Windows, MacOS, and Linux and can call .NET functions from Node.js and Node.js functions from .NET. Edge.js takes care of marshaling data between CLR and V8. Edge.js also reconciles threading models of single threaded V8 and multi-threaded CLR. Edge.js ensures correct lifetime of objects on V8 and CLR heaps. The CLR code can be precompiled or specified as C#, F#, Python, or PowerShell source: Edge.js can compile CLR scripts at runtime. See https://github.com/tjanczuk/edge/tree/master#scripting-clr-from-nodejs
Due to differences in the V8 engines used in Node.js and Electron, to integrate .NET core with Electron you will need to use ElectronEdge.js rather than vanilla Edge.js - see the ElectronEdge project on GitHub at electron-edge-js
Support for Electron apps is only included in SpAgent 4.0.2003 onwards. Please ensure that your permutation version is 4.0.2003 or later on the Software Potential service Develop > Manage Permutations page.
Prerequisites
All platforms:
- .NET Core 2.1.300 or later. For OS versions supported by .Net Core see these tables.
- Node.js 8.2.1 or later
- A code editor (e.g. Visual Studio Code or Atom) or IDE (e.g. [Visual Studio 15.7 or later).
Ubuntu
- .Net Core prerequisites
- build-essential package
- Mono (Mono-devel development package)
- libgtk2.0-dev (GTK development package).
Process Steps
The following are the steps to licenss an Electron application using SpAgent runtime:
- Create a Product in Software Potential
- Setup Electron/Edge Environment
- Setup .NET Project Environment
- Implement Licensing in Code
- Configure Nodejs Environment
- Add Licensing User Interfaces
- Build Application
Create a Product in Software Potential
- Navigate to the Software Potential service [Define > Create Products page
- Create a Product
- Add three Features (e.g. `Feature1`, `Feature2` and `Feature3`)
This product definition will be used in a later step when licensing the application. Code snippets in this document, for simplicity, use the product "DemoProduct, Version 1.0" and the features `Feature1`, `Feature2` and `Feature3`.
Setup Electron/Edge Environment
- Setup Electron environment as normal using
npm install electron
- Add ElectronEdge.js using
npm install electronedgejs --save
(The "save" option adds ElectronEdge.js as a dependency in packages.json for subsequent restores operations)
Setup .NET Project Environment
Add .NET project for licensing code
In the \src folder of the Electron application add a .NET project using the .NET CLI:
dotnet new classlib -lang C# -o <ProjectName>
Add NuGet Packages
Add the following NuGet package references to the .csproj:
- SoftwarePotential-Protection-<PermutationShortCode>
- SoftwarePotential-Licensing-<ProductName/Version>
- SoftwarePotential.Configuration.Local.MultiUser-<PermutationShortCode>
- Newtonsoft.Json
- Microsoft.CodeAnalysis
- Microsoft.CSharp
- Microsoft.DotNet.InternalAbstractions
- Microsoft.Extensions.DependencyModel
See Getting Started - Software Potential NuGet Feed or NuGet Package Management Without the Visual Studio Package Manager for more detailed guidance on configuring the Software Potential NuGet feed and adding packages.
Add reference to the electron-edge.js module:
<ItemGroup>
<Reference Include="EdgeJs">
<HintPath>..\..\node_modules\electron-edgejs\lib\bootstrap\bin\Release\netcoreapp1.1\EdgeJs.dll</HintPath>
<Private>true</Private>
</Reference>
</ItemGroup>
Restore the NuGet packages using the dotnet restore
command
To enable the restore of the NuGet packages via the authenticated Software Potential NuGet feed, make sure to save the user credentials for the Software Potential NuGet feed in a NuGet config file - see Getting Started - Software Potential NuGet Feed for more details on how to do this in Visual Studio Code.
Implement Licensing
Wrap the SpAgent Runtime API
Add a licensing.cs file that wraps the necessary license activation and license query methods in the SpAgent API. The following is a snippet from the Electron sample in GitHub:
namespace ImageEditor.Core { public class Licensing { public async Task VerifyStoresInitialized(dynamic input) { SpAgent.Configuration.VerifyStoresInitialized(); return true; } public async Task Activate(dynamic input) { await SpAgent.Product.Activation.OnlineActivateAsync((string)input); return true; } public async Task IsActivationKeyWellFormed(dynamic input) => SpAgent.Product.Activation.IsWellFormedKey((string)input); public async Task GenerateActivationRequest(dynamic input) => SpAgent.Product.Activation.Advanced().CreateManualActivationRequest((string)input, null); public async Task InstallLicenseFile(dynamic input) { var license = File.ReadAllBytes((string)input); SpAgent.Product.Stores.Install(license); return true; } public async Task GetProductName(dynamic input) => SpAgent.Product.ProductName; public async Task GetProductVersion(dynamic input) => SpAgent.Product.ProductVersion; public async Task RetrieveAllLicenses(dynamic input) => from license in SpAgent.Product.Licenses.All() select new License(license.ActivationKey, license.ValidUntil, license.Advanced.AllFeatures().Select(f => f.Key).ToArray()); public async Task DeleteLicenseByActivationKey(dynamic input) { SpAgent.Product.Stores.Delete((string)input); return true; } public async Task GetFeatures(dynamic input) => SpAgent.Product.LocalFeatures.Valid().Except(new HashSet { "Execute" }).ToArray(); class License { public string ActivationKey { get; set; } public DateTime ValidUntil { get; set; } public IEnumerable Features { get; set; } public License(string activationKey, DateTime validUntil, string[] features) { ActivationKey = activationKey; ValidUntil = validUntil; Features = features; } } } }
License Methods in .NET DLL
Extract some of the business logic from the existing Node.js service and implement as asynchronous methods in a separate .NET DLL, and add the appropriate license attributes to the methods in source code.
If you already have a compiled .NET DLL containing the required business functionality (e.g. a third party library as used in our Electron sample):
- Add a Features.cs file that wraps the necessary API calls to the .NET DLL so they can be called asynchronously from Node.js.
- Add licensing attributes to the methods to be licensed in Features.cs
The following is a snippet from such a license wrapper in our Electron sample application on GitHub that
- exposes three methods from an underlying library and
- marks the exposed methods with the required licensing attributes to ensure these methods can only be executed when a valid license exists with the corresponding feature:
namespace ImageEditor.Core { public class Features { public async Task ConvertToGreyscale( dynamic input ) { using ( var stream = new MemoryStream( ToBytes( (string)input.base64String ) ) ) using ( Image image = Image.Load( stream, new JpegDecoder() ) ) { MutateGreyscale( image ); return ToBase64String( image ); } } public async Task Rotate( dynamic input ) { var rotateMode = (bool)input.isClockwise ? RotateMode.Rotate90 : RotateMode.Rotate270; using ( var stream = new MemoryStream( ToBytes( (string)input.base64String ) ) ) using ( Image image = Image.Load( stream, new JpegDecoder() ) ) { MutateRotate( image, rotateMode ); return ToBase64String( image ); } } public async Task Crop( dynamic input ) { var rectangle = new Rectangle( (int)input.offsetX, (int)input.offsetY, (int)input.width, (int)input.height ); using ( var stream = new MemoryStream( ToBytes( (string)input.base64String ) ) ) using ( Image image = Image.Load( stream, new JpegDecoder() ) ) { MutateCrop( image, rectangle, (int)input.originalWidth, (int)input.originalHeight ); return ToBase64String( image ); } } [Demo_10.Features.Feature1] // Requires a license with the feature "Feature 1" included void MutateGreyscale( Image image ) => image.Mutate( x => x.Grayscale() ); [Demo_10.Features.Feature2] // Requires a license with the feature "Feature 2" included void MutateRotate( Image image, RotateMode rotateMode ) => image.Mutate( x => x.Rotate( rotateMode ) ); [Demo_10.Features.Feature3] // Requires a license with the feature "Feature 2" included void MutateCrop( Image image, Rectangle rectangle, int w, int h ) => image.Mutate( x => x.Resize( w, h ).Crop( rectangle ) ); byte[] ToBytes( string base64String ) => Convert.FromBase64String( base64String ); string ToBase64String( Image image ) { using ( var outputStream = new MemoryStream() ) { image.Save( outputStream, new JpegEncoder() ); var bytes = outputStream.ToArray(); return Convert.ToBase64String( bytes ); } } } }
You will then need to call the methods in the .NET DLL from the Electron application's Node.js backend.
Configure Nodejs Environment
Add Build/Package Targets
To publish and package the Electron application from the the command line using npm scripts add the following publish and package targets for each OS platform being targeted to the packages.json file.
You must set the -r argument value in the publish command to the target platform's Runtime Identifier (RID). To ascertain the target platform's .Net Core Runtime Identifier run dotnet --info
on the command line. For example the Windows RID is win10-x64
; for Ubuntu.18.04 then it is ubuntu.18.04-x64
.
The following are the sample targets for the Windows and Ubuntu platforms, taken from our sample Electron sample in GitHub (where the .NET project name is Image.Editor.Core):
"clean": "rimraf src/ImageEditor.Core/bin src/ImageEditor.Core/obj dotNetAssemblies" "publish-win": "npm run clean && dotnet publish ./src -r win10-x64 -c Release --self-contained -o dotNetAssemblies && rimraf src/ImageEditor.Core/bin/Release/netcoreapp2.1/win10-x64/publish/ImageEditor.Core.deps.json" "package-win": "electron-packager . Demo --platform win32 --arch=x64 asar=true --overwrite --icon=style/camera-retro-32-blue.ico --ignore=\"src\\/ImageEditor\\.Core\\/((?!bin).)*$|.vs|ImageEditor.sln|.vscode|Debug|.gitignore|README.md\"" "publish-linux": "npm run clean && dotnet publish ./src -r ubuntu.18.04-x64 -c Release --self-contained -o ../../dotNetAssemblies" "package-linux": "electron-packager . Demo --platform linux --arch=x64 asar=true --overwrite --icon=style/camera-retro-blue.png --ignore=\"src|.vs|ImageEditor.sln|.vscode|.gitignore|README.md\""
In the publish-win target, it is necessary to delete the deps.json file from the published folder using "rimraf src/ ImageEditor.Core/bin/Release/netcoreapp2.1/win10-x64/publish/ImageEditor.Core.deps.json"
Call the licensed .NET wrapper methods
It is recommended to use promises when calling methods in .NET DLLs to ensure correct access to any exceptions thrown via a PromiseFactory pattern.
To use the .NET DLLs you need to configure Nodejs to use the .NET Core CLR and to define the location of the .NET DLLs using:
process.env.EDGE_USE_CORECLR = 1;
process.env.EDGE_APP_ROOT = path.join(__dirname, 'dotNetAssemblies');
Add a PromiseFactory.js file with the following:
const econst path = require('path'); const edgeAppRoot = path.join(__dirname, 'dotNetAssemblies'); process.env.EDGE_USE_CORECLR = 1; process.env.EDGE_APP_ROOT = edgeAppRoot; const edge = require('electron-edge-js');dge = require('electron-edge-js'); const _assembly = new WeakMap(); const _typeName = new WeakMap(); class EdgePromiseFactory { constructor(assembly, typeName) { _assembly.set(this, assembly); _typeName.set(this, typeName); } create(methodName) { const fn = edge.func({ assemblyFile: path.join(edgeAppRoot, _assembly.get(this)), typeName: _typeName.get(this), methodName: methodName }); return arg => new Promise((resolve, reject) => { fn(arg, function (err, result) { if (err) reject(err); else resolve(result); }); }); } } module.exports = EdgePromiseFactory;
Add a license.js file that exposes the activation methods in the SpAgent runtime API:
const EdgePromiseFactory = require('../edgePromiseFactory'); class Activation { constructor() { const edgePromise = new EdgePromiseFactory('ImageEditor.Core.dll', 'ImageEditor.Core.Licensing'); this.activate = edgePromise.create('Activate'); this.isActivationKeyWellFormed = edgePromise.create('IsActivationKeyWellFormed'); this.generateActivationRequest = edgePromise.create('GenerateActivationRequest'); this.installLicenseFile = edgePromise.create('InstallLicenseFile'); } static get licenseRevisionException() { return 'Sp.Agent.Licensing.LicenseRevisionException'; } static get nonmatchingProductIdException() { return 'Sp.Agent.Storage.NonmatchingProductIdException'; } } module.exports = Activation;
Add a product.js file that exposes product/version/feature methods in the SpAgent runtime API:
const EdgePromiseFactory = require('../edgePromiseFactory'); class Product { constructor() { const edgePromise = new EdgePromiseFactory('ImageEditor.Core.dll', 'ImageEditor.Core.Licensing'); this.getName = edgePromise.create('GetProductName'); this.getVersion = edgePromise.create('GetProductVersion'); this.getEdition = edgePromise.create('GetEdition'); this.getFeatures = edgePromise.create('GetFeatures'); } } module.exports = Product;
Add a store.js file that exposes the store configuration methods in the SpAgent runtime API:
const EdgePromiseFactory = require('../edgePromiseFactory'); class Store { constructor() { const edgePromise = new EdgePromiseFactory('ImageEditor.Core.dll', 'ImageEditor.Core.Licensing'); this.verifyStoresInitialized = async () => { try { await edgePromise.create('VerifyStoresInitialized')(); console.log('License store initialization verified.'); } catch (err) { console.log(err); } }; this.retrieveAllLicenses = edgePromise.create('RetrieveAllLicenses'); this.deleteLicenseByActivationKey = edgePromise.create('DeleteLicenseByActivationKey'); } static get notLicensedExceptionName() { return 'Sp.Agent.Execution.NotLicensedException'; } } module.exports = Store;
Add a features.js file that exposes the methods in the application DLL:
const EdgePromiseFactory = require('./edgePromiseFactory'); class ImageEditorCore { constructor() { const edgePromise = new EdgePromiseFactory('ImageEditor.Core.dll', 'ImageEditor.Core.Features'); this.convertToGreyscale = edgePromise.create('ConvertToGreyscale'); this.crop = edgePromise.create('Crop'); this.rotate = edgePromise.create('Rotate'); } } module.exports = ImageEditorCore;
Add Licensing User Interfaces
At a minimum you must add a user interface to activate a license. Optionally, you can also display details of activated licenses to the application user. Your application should support both online and offline activation methods; the latter is required for situations where the machine does not have direct internet access. See Submitting Activation Requests and Manual Activation for more details on activation modes.
First add an Activation.js file that exposes the online/offline activation methods via the Promise Factory, as well as the manual activation exceptions thrown by the licensing runtime. See the following snippet from the GitHub sample:
class Activation { constructor() { const edgePromise = new EdgePromiseFactory('ImageEditor.Core.dll', 'ImageEditor.Core.Licensing'); this.activate = edgePromise.create('Activate'); this.isActivationKeyWellFormed = edgePromise.create('IsActivationKeyWellFormed'); this.generateActivationRequest = edgePromise.create('GenerateActivationRequest'); this.installLicenseFile = edgePromise.create('InstallLicenseFile'); } static get licenseRevisionException() { return 'Sp.Agent.Licensing.LicenseRevisionException'; } static get nonmatchingProductIdException() { return 'Sp.Agent.Storage.NonmatchingProductIdException'; } } module.exports = Activation;
Online Activation Mode
You then need to provide an online activation UI element where the customer can enter an Activation Key and submit an activation request. The following code snippets from the GitHub Electron sample show how to a) validate the Activation Key when entered and b) submit the online activation request.
... async function onOnlineKeyInput() { messages.clear(); closeButton.clear(); const key = onlineKey.value; const isEmpty = string => string.trim().length === 0; if (isEmpty(key)) return; try { onKeyValidated(await activation.isActivationKeyWellFormed(onlineKey.value)); } catch (err) { console.log(err); } } function onKeyValidated(isValid) { if (isValid) activateButton.disabled = false; else { activateButton.disabled = true; messages.updateWithError('Activation key is not in the correct format.'); closeButton.updateWithError(); } } async function onActivateClicked() { spinner.style.display = 'inline-block'; try { await activation.activate(onlineKey.value); messages.updateWithSuccess('Activation Complete!'); closeButton.updateWithSuccess(); ipcRenderer.send('license-update'); spinner.style.display = 'none'; } catch (err) { messages.updateWithError(err['Message']); closeButton.updateWithError(); spinner.style.display = 'none'; } } ...
Offline Activation Mode
For offline activation you must allow the user a) create a manual activation request file and b) install a license file returned by the Software Potential Activation Service. See the following snippet from the GitHub Electron sample:
.......
async function onGenerateClicked() { try { const request = await activation.generateActivationRequest(offlineKey.value); saveRequestFile(offlineKey.value, request); } catch (err) { updateMessagesWithError(err['Message']); } } function saveRequestFile(key, requestString) { dialog.showSaveDialog({ defaultPath: path.join( app.getPath('desktop'), `${key}_${moment().format('YYYY-MM-DD_HH-mm-SSSS')}.txt`), filters: [{ name: 'Text Files', extensions: ['txt'] }] }, filename => { if (filename === undefined) return; fs.writeFile(filename, requestString, err => { if (err) updateMessagesWithError('Could not save Activation Request File. ' + err.message); else updateMessagesWithSuccess(`Activation Request File saved at ${filename}.`); }); }); } function onInstallClicked() { dialog.showOpenDialog({ defaultPath: app.getPath('downloads'), filters: [{ name: 'License Files', extensions: ['bin'] }] }, onFileSelected); } async function onFileSelected(filenames) { if (filenames === undefined) return; try { await activation.installLicenseFile(filenames[0]); updateMessagesWithSuccess('The license has been successfully installed.'); ipcRenderer.send('license-update'); } catch (err) { handleInstallError(err); } }; function handleInstallError(err) { if (err['Name'] === Activation.licenseRevisionException) updateMessagesWithError('There is a newer version of the license already installed.'); else if (err['Name'] === Activation.nonmatchingProductIdException) updateMessagesWithError(err.message); else console.log(err); }
.......
License Details
You may wish to display details of activated licenses to the user; you may even wish to allow a user delete a license. The the following code snippet from GitHub Electron sample retrieves all activated licenses and displays some basic details of each license:
........
async function updateLicenses() { try { const licenses = await store.retrieveAllLicenses(); if (licenses.length == 0) licenseList.innerHTML = noLicenseTemplate; else renderLicenseList(licenses); } catch (err) { console.log(err); } } function renderLicenseList(licenses) { licenses.forEach(license => license['ValidUntil'] = moment(license['ValidUntil']).format('DD/MM/YYYY')); Mustache.parse(licenseListTemplate); // optional, speeds up future uses licenseList.innerHTML = Mustache.render(licenseListTemplate, { licenses: licenses }); var deleteButtonsHtmlCollection = document.getElementsByClassName('deleteLicense'); [].forEach.call(deleteButtonsHtmlCollection, button => button.onclick = onDeleteButtonClicked); } async function onDeleteButtonClicked(e) { try { await store.deleteLicenseByActivationKey(e.target.dataset.activationKey); updateLicenses(); ipcRenderer.send('license-update'); } catch (err) { console.log(err); } } ........
Build Electron Applications
You must build the sample on the platform you wish to target, as it depends on native assemblies built during Node package installation. For example you must build and publish the application on Linux and Windows if you wish to target both of these platforms.
Running either the npm publish script (either publish-win
or publish-linux
) will overwrite platform specific assemblies in relevant`/dotNetAssemblies` location.
If you use the dotnet CLI to build and publish the sample .Net Core project, the `--self-contained` option must be included in the publish command if you wish to deploy to machines that do not have the dotnet framework installed.
Publish/Package for Windows
From the command line in the project root run the following commands:
npm install npm run publish-win npm start
The publish-win script removes `ImageEditor.Core.deps.json` from the published files as it prevents correct composition of Software Potential assemblies on development machines.
To package the application once published, run npm run package-win
. The output will be written to a directory called `-win32-x64`, where is the name provided to the electron packager in the packag target.
Publish/Package for Linux
From the command line in the project root run the following commands:
npm install npm run publish-linux npm start
To package the application once published run npm run package-linux
. The output will be written to a directory called `-linux-x64`, where is the name provided to the electron packager in the packag target.
Comments
0 comments
Article is closed for comments.