My Profile Photo

[Michael Willis@xainey]


more me.txt Developer, Student, and Automation Enthusiast.
Certified in Puncraft and the Wibbly Wobbly.
Believes in DevOps, Alligators, and Aristotle.


Back to the Frontend | A PowerShell-Electron Demo

Img

Abstract

The following article demonstrates using PowerShell and Atom Electron to create and package an application. In the following pages, we will cover creating the frontend and backend of a simple disk utility called Diskr. This demo project was inspired by Stephen Owen’s XAML based GUI demo. If you want to work along or view the source the project can be found on Github at xainey/powershell-electron-demo.



Introduction

I first started using PowerShell to build simple tooling and automation scripts. Since all of our PCs already had PowerShell installed, we could hand over scripts that could run on any system. The problem was getting others to learn how to use PowerShell. The average CLI experience for our target user didn’t go much further than ping or ipconfig.

Here guys, I made a simple script, run it like this:

1
./ConfigureDelorean.ps1 -Month 8 -Day 26 -Year 1985 -Hour 9

JackieChan Average Response

After seeing that our scripts were not getting used, we started wrapping them in Freestyle Jobs in Jenkins to give the user a GUI. It looked like a great idea; not only did users get an easy form, but we also generated an audit each time the job was run. None of our Ops guys wanted to take the time to learn Jenkins.

Okay, we just need to put this on their desktop and give them a GUI wrapper.

A quick search and we found Stephen Owen’s GUI ToolMaking Series. Generating XAML in Visual Studio was easy enough and with some modest effort, we created a few simple apps. However, it started to become tedious managing the layout and styling as the projects grew. When it comes to designing frontend interfaces, I’ve made apps in C# using WPF as well as Java for Desktop and Android. To this day, I still find HTML/CSS to be the absolute easiest way to control layout.

In Back to the Frontend ™, we are going to use Atom Electron to create our desktop application. We will be creating a small app inspired by Stephen Owen’s ToolMaking Series.

Getting Started

As may of you may know, frontend development can be overwhelming. You start out looking to create a project that is ultimately CSS, HTML, and Javascript. Later you find yourself trying to figure out how to use SASS, Gulp, Webpack, Babel, Browserify, NodeJS, React, Flux, Redux, etc. Check out the Developer-Roadmap for a quick bird’s eye view.

In this article, we will stick to using some of the most common and basic tools. Here is a quick preview of the completed app.

Final Product Finished Product: Diskr

Installing Tools

We will need to pull in everything though git and NodeJS (npm). The optional tools are by preference only. If you haven’t written PowerShell in VSCode do yourself a solid and go try it out. Install these tools manually or with Chocolatey.

Doctocat Great Scott! You don’t have Git installed?!? It is 2017!!!1one

Using Chocolatey

For personal use and the lazy alike, you may want to use chocolatey to install the tools.

1
2
3
4
5
6
7
8
# Set your PowerShell execution policy
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Force

# Install Chocolatey
iex ((New-Object System.Net.WebClient).DownloadString('https://chocolatey.org/install.ps1'))

# Install Packages
choco install git visualstudiocode nodejs yarn -y

For an organization, you may want to consider your own internally hosted packages as those packages won’t be subjected to distribution rights like the community repository is (being in the public domain). Rob (@ferventcoder) told me this, trust him, he made Chocolatey.

Scaffolding a New Project

Go to the directory you want to make your project and clone the electron-quick-start repo. I’ve chosen to use this project as our boilerplate since it is incredibly simple and official.

In your PowerShell console clone the project:

1
2
3
cd ~/Github/
git clone https://github.com/electron/electron-quick-start diskr
cd diskr

Remove the existing git folder reinitialise it. There may be a more eloquent approach such as git-clone-init.

1
2
rm .\.git -Force -Recurse
git init

Install the node dependencies and start the app. If you are using yarn instead of npm simply use yarn or yarn install.

1
2
npm install
npm start

FirstRun First Run

Getting Oriented with Electron

Before we start writing any code, let’s take a quick glance at the hotkeys, tools, and files we have to work with.

Initial Menu / Hotkey Items

Command Hotkey
View > Toggle Developer Tools Ctrl-Shift-I
View > Reload Ctrl-R
View > Force Reload Ctrl-Shift-R

DevTools

If you have ever used Chrome DevTools before, rejoice here they are. If you are a complete beginner relax, this tool is very user-friendly. You should start by getting familiar with the Elements and Console Tabs.

  • The Elements tab shows you HTML and CSS.
  • The Console tab shows console logs and errors.

DevTools Chrome DevTools

Both of these tabs can be used to “play” with the HTML, CSS, and JavaScript.

Try it out. Go to the console tab and enter in this ES2015 Expression:

1
[1, 5, 14, 130, 9].filter(val => val > 10)

Files

Open the project in VSCode or your editor of choice. Since my console (ConEmu/PowerShell) is still in the project directory I can type code . to open the project in VSCode. I started doing this with sublime subl . on OS X a few years ago. The time I’ve saved since is huge.

DevTools Electron Files

.gitignore, license.md, readme.md are standard to most git projects.

The main files are:

  • index.html
    • Initial frontend page loaded by main.js
  • main.js
    • Main entry point into our Electron app
  • package.json
    • Manages node dependencies, packaging meta, CLI scripts
  • renderer.js
    • JavaScript for index.html

Importing Frontend Dependencies

HTML/CSS

For our front-end HTML/CSS we are going to use the Bootstrap v4 framework. At this time, v4 is still in alpha so feel free to fall back to v3 if needed.

To pull in bootstrap we have a few options:

  1. Download it manually
  2. Reference it by CDN
  3. Use a package manager such as npm, bower, rubygems, nuget, or composer

Since we already have NodeJS installed we are going to use npm to make it easier to follow along.

Bootstrap from NPM

In your project root run the following command to save bootstrap to our dependencies.

1
npm install bootstrap@4.0.0-alpha.6 -S

Note: In the future, you will want to save these to your dev-dependencies and use something like gulp or webpack to package them.

We can now edit our index.html file and make sure bootstrap is working.

For the index.html we are going to:

  1. Update the <title> (this is also the title bar on the app)
  2. Include a <link> reference to bootstrap.min.css in our node_modules directory
  3. Add a div.container to wrap the body content
  4. Use the alert message component to make sure bootstrap is working

Edit index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>Diskr</title>
    <link rel="stylesheet" href="node_modules\bootstrap\dist\css\bootstrap.min.css">
  </head>
  <body>
    <div class="container">
      <div class="alert alert-success" role="alert">
        <strong>Well done!</strong> You successfully read this important alert message.
      </div>
    </div>
  </body>
  <script>
    require('./renderer.js')
  </script>
</html>

Start the app again with npm start or refresh it with Ctrl-R.

TestingBootstrap An Alert is Born!

Javascript

For our JavaScript requirements we are going to use jQuery. Most people have seen, used, or tasted jQuery. It tastes okay at times, but for larger form intensive projects frameworks like Angular or React start to taste better. Personally, I love VueJS – it’s the cat’s pajamas (this is how my grandfather let me know something is “Hip”).

jQuery from NPM

Again, from your project root, run the following command:

1
npm install jquery -S

Now in renderer.js, we will import jQuery and test to make sure it works.

1
2
3
4
5
// Require jQuery
const $ = require('jquery');

// Test jQuery: ES2015 Arrow Syntax
$(document).ready( () => console.log("Page is loaded!") )

Start the app again with npm start or refresh it with Ctrl-R.

TestingJQuery Console.log() shows our message in DevTools

Setting up PowerShell

After a quick search I found two decent NodeJS projects for PowerShell:

Node-PowerShell From NPM

We are going to be using the one by rannn505. I chose the one with the better documentation and most stars.

1
npm install node-powershell -S

Looking at this nice API, we can use PowerShell with JavaScript Promise based methods.

Running PowerShell in Javascript

That’s right Daddy-O, it’s time to use some PowerShell with JavaScript.

We will now update renderer.js to:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Require Dependencies
const $ = require('jquery');
const powershell = require('node-powershell');

// Testing PowerShell
$(document).ready(() => {

    // Create the PS Instance
    let ps = new powershell({
        executionPolicy: 'Bypass',
        noProfile: true
    })

    // Load the gun
    ps.addCommand("Roads? Where we're going, we don't need roads.")

    // Pull the Trigger
    ps.invoke()
    .then(output => {
        console.log(output)
    })
    .catch(err => {
        console.error(err)
        ps.dispose()
    })

})

Here is a quick breakdown:

  • line: 3 - Require the dependency
  • line: 9 - Create a PS Instance
  • line: 15 - Add a Command to be executed
  • line: 18 - Invoke the Command
  • line: 19, 22 - Handle the response with promises

Start the app npm start or refresh it with Ctrl-R.

PowershellError Crap, we broke it already.

Looks like we need to escape our string a wee bit.

1
ps.addCommand("\"Roads? Where we're going, we don't need roads.\"")

StringEscapeHell Marty, It looks like we are going to string escape hell!

Save and Refresh again.

PowershellResponseGood Now we are cooking with plutonium!

Now instead of just printing a string, let’s inline a PowerShell expression to get system information:

1
ps.addCommand("Get-Process -Name electron")

PowershellSystemCommand Hash Table is passed from PowerShell to JavaScript as a string

Running a PS1 script

Next, we will test running a .ps1 script from node-electron. This is more practical for managing and passing arguments.

In the project root create the file Test-Power.ps1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
param (
    [Parameter(Mandatory = $true)]
    [double] $GigaWatts
)

if ($GigaWatts -ge  1.21) {
    $canWarp = $true
} else {
    $canWarp = $false
}

@{
    GigaWatts = $GigaWatts
    CanWarp = $canWarp
}

In this contrived example, we take some input, do some logic, and output a hashtable.

To run Test-Power.ps1, we use addCommand and provide:

  • The path to the file
  • An array of JavaScript objects for each PowerShell parameter

Update ps.addCommand() in renderer.js to:

1
2
3
4
    // Load the gun
    ps.addCommand("./Test-Power", [
        { GigaWatts: 1.0 }
    ])

Give the app a refresh and look at the DevTools Console.

TestHashTable No Warping Today :[

At this point, you can see how our PowerShell hashtable is just a big string on the JS side – not much we can do with that.

To fix this we will use ConvertTo-Json in PowerShell and supply the -Compress flag to minify it.

Update Test-Power.ps1:

1
2
3
4
@{
    GigaWatts = $GigaWatts
    CanWarp = $canWarp
} | ConvertTo-Json -Compress

In rendered.js use JSON.parse() to convert the JSON into a JavaScript object.

1
2
3
4
.then(output => {
    console.log(output)
    console.log(JSON.parse(output))
})

PowershellToJson PowerShell hashtable -> JSON -> JavaScript Object

Creating the Front-End: Diskr

In index.html we add a small form using the Bootstrap v4 scaffolding. We will also add and inline CSS class .top-buffer to give us some buffer space between the div .rows.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>Diskr</title>
    <link rel="stylesheet" href="node_modules\bootstrap\dist\css\bootstrap.min.css">
    <style>
      .top-buffer {
        margin-top: 1em;
      }
    </style>
  </head>
  <body>
    <div class="container">

    <!-- Form -->
      <div class="row top-buffer">
        <div class="col-lg-6 mx-auto">
        <div class="input-group">
          <input id="computerName" type="text" class="form-control" placeholder="Computer Name">
          <span class="input-group-btn">
            <button id="getDisk" class="btn btn-primary" type="button">Get Disk Info!</button>
          </span>
        </div>
        </div>
      </div>

      <!-- Output -->
      <div class="row justify-content-center">
        <div id="output" class="col-lg-6 mx-auto"></div>
      </div>

    </div>
  </body>
  <script>
    require('./renderer.js')
  </script>
</html>

With the form in place, we want to do a few things:

  1. When the user clicks on the <button> grab the HTML <input> value
  2. Pass the <input> value as arguments to a PowerShell script
  3. Output the results on the page instead of in the DevTools console

Create Get-Drives.ps1 in the project root.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
param (
    [Parameter(Mandatory = $false)]
    [string] $ComputerName = 'localhost'
)

 $drives = [System.IO.DriveInfo]::GetDrives() |
    Where-Object {$_.TotalSize} |
    Select-Object   @{Name='Name';     Expr={$_.Name}},
                    @{Name='Label';    Expr={$_.VolumeLabel}},
                    @{Name='Size(GB)'; Expr={[int32]($_.TotalSize / 1GB)}},
                    @{Name='Free(GB)'; Expr={[int32]($_.AvailableFreeSpace / 1GB)}},
                    @{Name='Free(%)';  Expr={[math]::Round($_.AvailableFreeSpace / $_.TotalSize,2)*100}},
                    @{Name='Format';   Expr={$_.DriveFormat}},
                    @{Name='Type';     Expr={[string]$_.DriveType}},
                    @{Name='Computer'; Expr={$ComputerName}}

$drives | ConvertTo-Json -Compress

Here I started using dotNet classes instead of GWMI since I wrote this on my MacBook Pro.

doc-thinking Since Electron is cross-platform, we can write our PowerShell to work cross OS as well.

Now back in renderer.js, we are going to change our $(document).ready() event to $("#getDisk").click().

Note: #getDisk is the jQuery selector for the ID we placed on our button in the HMTL.

Inside the $("#getDisk").click() event, we get the form input at the top:

1
2
// Get the form input or default to 'localhost'
let computer = $('#computerName').val() || 'localhost'

And change our script to run Get-Drives.ps1:

1
2
3
4
// Load the gun
ps.addCommand("./Get-Drives", [
    { ComputerName: computer }
])

To show the response on the page, we add the following jQuery command to our .then() function.

1
2
3
4
5
.then(output => {
    console.log(output)
    console.log(JSON.parse(output))
    $('#output').html(output) // Show the results
})

Now if we run this, we can see that we are now passing PC-Delorean from our form to PowerShell and showing the output in the HTML.

FormInputGetDisk Just pretend of the console logs uses “PC-Delorean” I forgot to fix the screenshot :)

Outputting the Data to a Table

Printing the JSON directly to the HTML was the first step, but now we want to output it to an HTML <table>. You should be able to find table plugins for just about any JavaScript framework or library.

DataTables from NPM

For this example, we are going to use a framework called DataTables to create a simple table from our JSON reponse.

First, we need to pull them in using npm:

1
2
npm install datatables.net -S
npm install datatables.net-bs4 -S

In the HTML <head> of index.html, reference the DataTables CSS for Bootstrap v4:

1
<link rel="stylesheet" href="node_modules\datatables.net-bs4\css\dataTables.bootstrap4.css">

In renderer.js we want to require them in:

1
2
const dt = require('datatables.net')();
const dtbs = require('datatables.net-bs4')(window, $);

Next, in our .then() promise, we can have the data create a DataTable.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
    .then(output => {
        console.log(output)
        let data = JSON.parse(output)
        console.log(data)

        // generate DataTables columns dynamically
        let columns = [];
        Object.keys(data[0]).forEach( key => columns.push({ title: key, data: key }) )

        // Create DataTable
        $('#output').DataTable({
            data: data,
            columns: columns
        });
    })

DataTable Note: For every column returned from PowerShell we would need to make a columns array (see example).

We use line 7-8 above to avoid manually adding these:

1
2
3
4
5
    var columns [
        {title: "Name",  data: "Name"},
        {title: "Label", data: "Label"},
        // ... etc
    ]

ConvertKeysAuto Auto Create columns from Object.Keys()

Update index.html to support a DataTable:

1
2
3
4
<!-- Output -->
<div class="row justify-content-center top-buffer">
    <table id="output" class="table table-striped table-bordered" cellspacing="0"></table>
</div>

Start or Refresh the app and click Get-DiskInfo!.

DataTablesAll DataTables with default options

Since, we really don’t need to search, paginate, or the info on a small list of drives we can just configure DataTables to omit these options.

In renderer.js update the DataTable:

1
2
3
4
5
6
7
8
$('#output').DataTable({
    data: data,
    columns: columns,
    paging: false,
    searching: false,
    info: false,
    destroy: true  // or retrieve: true
});

Note: Use destroy or retrieve to allow the Table to be recreated on subsequent button clicks.

Give the app a quick refresh:

DataTablesSimple DataTable after options are configured

Using PowerShell Remoting

Now that our app has taken shape, we need to make it work on remote machines on the network. Since we are not using GWMI we need to update our script to use PowerShell remoting by using Invoke-Command.

Update Get-Drives.ps1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
param (
    [Parameter(Mandatory = $false)]
    [string] $ComputerName = 'localhost'
)

$out = Invoke-Command -ComputerName $ComputerName -ScriptBlock {
    [System.IO.DriveInfo]::GetDrives() |
    Where-Object {$_.TotalSize} |
    Select-Object   @{Name='Name';     Expr={$_.Name}},
                    @{Name='Label';    Expr={$_.VolumeLabel}},
                    @{Name='Size(GB)'; Expr={[int32]($_.TotalSize / 1GB)}},
                    @{Name='Free(GB)'; Expr={[int32]($_.AvailableFreeSpace / 1GB)}},
                    @{Name='Free(%)';  Expr={[math]::Round($_.AvailableFreeSpace / $_.TotalSize,2)*100}},
                    @{Name='Format';   Expr={$_.DriveFormat}},
                    @{Name='Type';     Expr={[string]$_.DriveType}}
}

$out | ConvertTo-Json -Compress

Refresh the app and click Get Disk Info!:

PSRemotingAll The Invoke-Command response as a variable adds a few additional fields

Back in Get-Drives.ps1 let’s exclude the flux out of the extra fields.

1
$out | Select-Object * -ExcludeProperty PSComputerName, RunspaceId, PSShowComputerName  ConvertTo-Json -Compress

Much cleaner. I probably don’t need the PC name in the table considering that I just typed in the above box.

Hello, I’m Dory.

PSRemotingAll Updated with filtered $out

Font-End Error Messages

Right now, if any PowerShell error was to occur we are using console.error() to log a message. In a production app, we will want to emit messages on the GUI. To do this we will use the bootstrap-alert component for our warning messages.

At this point our entire call stack should look something like this:

Add the following in index.html between our Form and Output components.

1
2
3
4
5
6
7
<!-- Error -->
<div class="row justify-content-center top-buffer">
    <div class="alert alert-danger" role="alert" style="display: block">
        <strong>Whoops!</strong>
        <div class="message">Flux capacitor is not Fluxing.</div>
    </div>
</div>

Give it a quick refresh.

FluxNotFluxing The message will appear below our form

Now, lets leave the message blank and set block in style="display: block" to none to hide the component.

To show the alert whenever an error occurs, add some jQuery to our .catch() function in renderer.js.

  • Set the div.message inner HTML to the error
  • Show the alert
1
2
3
4
5
6
.catch(err => {
    console.error(err)
    $('.alert-danger .message').html(err)
    $('.alert-danger').show()
    ps.dispose()
})

Now, let’s give it a refresh and try to connect to a PC that doesn’t exist.

ErrorBoxTardis Chameleon circuit appears to still function somewhat

Now, if you query a good computer after the error is shown, the error will not go away when the table is displayed. We will want to clear and hide the alert each time the form is submitted.

At the top of our click() event in renderer.js we will add:

1
2
3
// Clear the Error Messages
$('.alert-danger .message').html()
$('.alert-danger').hide()

Improving Errors

From the TARDIS example above, node-powershell just takes the PowerShell error and all its messy glory and hands it off to JavaScript as a string. Unfortunately, It’s not as friendly as the $_ hashtable you see in a PowerShell catch block. Once you have the error in JavaScript you can’t access the properties individually.

For the time being, I filed an issue: node-powershell-issue-22.

Simple Workaround

As a workaround, we can update Get-Drives.ps1 to form a custom error block.

  1. Invoke-Command can fail in the script block or at the remoting call if it cannot connect or authenticate.
  2. To make sure we actually catch both cases of this we specify ErrorAction = "Stop"
  3. We form our own error object $myError to be converted to JSON.
    • Here I’ve added Type, which is nice if you throw your own custom errors as seen in #22
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
param (
    [Parameter(Mandatory = $false)]
    [string] $ComputerName = 'localhost'
)

$parms = @{
    ComputerName = $ComputerName
    ErrorAction = "Stop"
}

try {
    $out = Invoke-Command @parms {
        [System.IO.DriveInfo]::GetDrives() |
        Where-Object {$_.TotalSize} |
        Select-Object   @{Name='Name';     Expr={$_.Name}},
                        @{Name='Label';    Expr={$_.VolumeLabel}},
                        @{Name='Size(GB)'; Expr={[int32]($_.TotalSize / 1GB)}},
                        @{Name='Free(GB)'; Expr={[int32]($_.AvailableFreeSpace / 1GB)}},
                        @{Name='Free(%)';  Expr={[math]::Round($_.AvailableFreeSpace / $_.TotalSize,2)*100}},
                        @{Name='Format';   Expr={$_.DriveFormat}},
                        @{Name='Type';     Expr={[string]$_.DriveType}}

    } | Select-Object * -ExcludeProperty PSComputerName, RunspaceId, PSShowComputerName
} catch [System.Management.Automation.RuntimeException] {
    $myError = @{
        Message = $_.Exception.Message
        Type = $_.FullyQualifiedErrorID
    }
    $out = @{ Error = $myError }
}

ConvertTo-Json $out -Compress

Next, go to renderer.js in the .then() block and add our custom error code. If the response object contains Error we can now display an error and exit the then() statement early.

1
2
3
4
5
6
// Catch Custom Errors
if (data.Error) {
    $('.alert-danger .message').html(data.Error.Message)
    $('.alert-danger').show()
    return
}

Now we have full control over our cross-language errors.

CustomErrorFrontend PC Not Found

AccessDeniedEnterprise Captain’s Log, Stardate 94881.74. Access was Denied

Authentication

If you noticed the second error message above, we have a computer which we do not have access use with remoting.

  • Invoke-Command uses the security context for the current user running the electron app.

How can we change this context? Let’s take a look at a few of our options.

  1. RunAS: the electron exe can be run as a different user
  2. ADHOC: we can inline Get-Credential into our PowerShell Script
  3. ADHOC/Saved: we can save the credential either in memory, a secure file, or use a module like BetterCredentials.

ADHOC

Test by editing our hashtable in Get-Drives.ps1

1
2
3
4
5
$parms = @{
    ComputerName = $ComputerName
    ErrorAction = "Stop"
    Credential = Get-Credential
}

Refresh the app and click Get Disk Info!:

adhocGetCredential Each time you click on Get Disk Info! the script will prompt for credentials

ADHOC/Saved

For saved credentials, we will explore 2 new concepts: Electron global variables, and passing JSON to PowerShell.

  • Please check with your local security expert before you start saving Domain Admin credentials.
  • Only you can prevent forest fires security breaches.

Passing Data

Create a new file in the project root called Convert-CredToJson.ps1:

1
2
3
4
5
6
7
8
9
param (
    [Parameter(Mandatory = $false)]
    [PSCredential] $Cred = (Get-Credential)
)

@{
    user = $Cred.UserName
    pass = $Cred.Password | ConvertFrom-SecureString
} | ConvertTo-Json -Compress

In index.html add an extra button to our form where the user can specify a different account to use for the Invoke-Command call:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
    <!-- Form -->
      <div class="row top-buffer">
        <div class="col-lg-6 mx-auto">
        <div class="input-group">
          <input id="computerName" type="text" class="form-control" placeholder="Computer Name">
          <span class="input-group-btn">
            <button id="getDisk" class="btn btn-primary" type="button">Get Disk Info!</button>
          </span>
          <span class="input-group-btn">
            <button id="changeUser" class="btn btn-warning" type="button">Change User</button>
          </span>
        </div>
        </div>
      </div>

In main.js under path and url, declare a global variable for cred:

1
2
3
4
5
6
const path = require('path')
const url = require('url')

global.sharedObj = {
  cred: null
};

In renderer.js:

  1. Require in remote (it’s how we are going to get/set variables in main.js)
  2. Add a new click() event for our #changeUser <button>
  3. Call Convert-CredToJson.ps1 to generate our cred
  4. Set the global Variable from the response
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Get Global Variables
let remote = require('electron').remote;

$('#changeUser').click(() => {
    let ps = new powershell({
        executionPolicy: 'Bypass',
        noProfile: true
    })

    ps.addCommand('./Convert-CredToJson.ps1', [])
    ps.invoke()
    .then(output => {
        console.log(output)
        // Set the global Variable
        remote.getGlobal('sharedObj').cred = JSON.parse(output)
        // Read the global variable
        console.log(remote.getGlobal('sharedObj').cred)
    })
    .catch(err => {
        console.dir(err);
        ps.dispose();
    })
})

Refresh the app and click Change User:

customCredential Get Custom Credential

Here is the response JSON which gets saved to our global electron variable cred.

customCredentialJSObject Credential as JS Object

Using the Credential

Now that we have a global credential, we want to pass it to our Get-Drives.ps1 function (if it is not null).

First, we add the input param JsonUser to Get-Drives.ps1.

  • I used JsonUser since the word Cred causes PSScriptAnalzer to complain (I’m too lazy to add a suppress rule).
1
2
[Parameter(Mandatory = $false)]
[String] $JsonUser

Next, after our $parms hashtable we inject our credential into the commands we intend to splat.

If slatting is new to you, be sure to go read up on it. For starters, take a look at PSCookieMonster’s Splat Guide.

This example also uses the ::new() class method instead of New-Object. I’m using PowerShell v5, moving forward, and Boe Prox showed it was faster.

1
2
3
4
5
6
7
8
9
10
11
12
$parms = @{
    ComputerName = $ComputerName
    ErrorAction = "Stop"
}

# Using IsNullOrEmpty instead of advanced function validators in case JS passes a blank/null
# Get-Drives -Computer $PC -JsonUser ''
if ( ! [string]::IsNullOrEmpty($JsonUser) ) {
    $hash = $JsonUser | ConvertFrom-Json
    $hash.pass = $hash.pass | ConvertTo-SecureString
    $parms.Credential = [PSCredential]::new($hash.user, $hash.pass)
}

Now, when we call Get-Drives.ps1 from JavaScript, we will want to serialize and pass along our global cred if it exists.

In renderer.js we can update the following:

1
2
3
4
// Load the gun
ps.addCommand("./Get-Drives", [
    { ComputerName: computer }
])

To:

1
2
3
4
5
6
7
8
9
let commands = [{ ComputerName: computer }]
let cred = remote.getGlobal('sharedObj').cred

// If global cred exists, seralize and push it to commands
if (cred)
    commands.push({ JsonUser: JSON.stringify(cred) })

// Load the gun
ps.addCommand('./Get-Drives', commands)

If we refresh the app and try this out, we will get an error. If you look at the command that is being passed, you will see that the JSON string needs to be wrapped with quotes.

JsonToPsError Parmameter string is not wrapped when node-powershell calls script

This could probably be handled a little better in node-powershell. I’ve added a ticket which helps illustrate the problem (node-powershell-issue-21).

Workaround: String Wrap

To avoid needing to manually wrap or escape every string we pass like:

1
"'" + var + "'"

We will add a prototype to the native string construct.

In the global scope at the top of the renderer.js add:

1
2
3
4
// Helper to wrap a string in quotes
String.prototype.wrap = function () {
    return `'${this}'`;
}

Now we can just use .wrap() when needed.

1
2
3
4
5
6
7
8
9
let commands = [{ ComputerName: computer.wrap() }]
let cred = remote.getGlobal('sharedObj').cred

// If global cred exists, seralize and push it to commands
if (cred)
    commands.push({ JsonUser: JSON.stringify(cred).wrap() })

// Load the gun
ps.addCommand('./Get-Drives', commands)

messageDialog Prototypes make for a smoother ride

Customize Toolbar

We are going to customize the menu bar a bit. Check out the official docs for a full breakdown.

In main.js let’s update our Electron imports using the Object Destructuring syntax. I’ve also included dialog component to demonstrate a click action in our custom menu.

1
2
// const app = electron.app
const {app, Menu, dialog} = electron

In main.js we will make our own function to keep things organized:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
function createMenu() {
  const template = [
    {
        label: 'View',
        submenu: [
          {
            role: 'reload'
          },
          {
            role: 'forcereload'
          },
          {
            role: 'toggledevtools'
          }
        ]
    },
    {
      label: 'Tools',
      submenu: [
        {
          label: 'Check Cred',
            click () {
                let user = (global.sharedObj.cred) ? global.sharedObj.cred.user : "Default"
                dialog.showMessageBox({
                    type: "info",
                    title: "Current Cred",
                    message: `The current user is: ${user}.`
                })
            }
        }
      ]
    }
  ]

  const menu = Menu.buildFromTemplate(template)
  Menu.setApplicationMenu(menu)
}

Add createMenu() to the createWindow() function in main.js.

1
2
3
function createWindow () {
  createMenu()
  //...

To see the changes take effect, close your app and restart it with npm start.

customMenu Custom Menu example

Note: I left the DevTool options and bindings in the custom menu. Feel free to remove them.

messageDialog Check Cred click event opens a dialog

Cleanup and Packaging

Paths

A lot of node projects recommend using absolute paths, be it for OS compatibility or whatnot, don’t get me lying lol. Keep in mind, any of of .ps1 script references could be updated with something like:

1
let scriptPath = require("path").resolve(__dirname, './Get-Drives.ps1')

Package.json

Since we used the electron-quick-start we are still using some of their default config in our package.json. Be sure to visit this file and update it.

Packaging

Once you have completed your app and start searching for packaging instructions you will land on these official pages:

My eyes glazed over when I read the documented process. Luckily Electron mentions two projects to help automate this:

I’m just using electron-packager here since It had more stars… (that’s how instantaneous decision making works).

Install it globally or as a dev-dependency:

1
2
3
4
5
# for use in npm scripts
npm install electron-packager --save-dev

# for use from cli
npm install electron-packager -g

If you installed it globally, check out the help docs with:

1
electron-packager --help

While in the project root, electron-packager will create a package intended for your systems OS and Architecture if you provide no arguments. Running the following command will also name your exe using the project name in package.json

1
electron-packager .

Adding a Custom Icon

Now we can add an icon to the project root. In this example I found a quick NodeJS looking disk-icon.

Since this app is intended for Windows I went ahead and supplied a few more options.

1
electron-packager . --platform=win32 --arch=x64 --icon=icon.ico --overwrite

Running this command from the project root generates the following files:

BuildWin Electron-Packager Build for Windows

To make it easier to exclude builds in source version, we want to change the command to output to a dist folder.

Add the command as a simple npm task in package.json. Then run in from the project root using npm build.

1
2
3
4
"scripts": {
    "start": "electron .",
    "build": "electron-packager . --platform=win32 --arch=x64 --icon=icon.ico --out=dist --overwrite"
}

Add dist to .gitignore

1
2
node_modules
dist

ASAR

If you inspect the dist folder, you will find that your source files are all easily availiable in plain text.

  • Supplying the -asar flag to electron-packager will throw them all in an .asar file instead.
  • The .asar file can still be opened with a text editor and seen in plain text.
  • The .asar file may cause path issues with loading the .ps1 files with node-powershell.
    • I haven’t used .asar much. Feel free to send a PR on proper path workarounds.

Conclusion

Hopefully, this quick demo helps others get starting with Electron and Powershell. This started as a quick proof-of-concept I made after talking to few guys about using PowerShell with NodeJS. With PowerShell steadily becoming a more solid cross platform product, the pairing with Electron could potentially make some very powerful tools that could be distributed on any OS. If you have any thoughts or questions let me know and as always, feel free to file and issue or PR if you find any errors.

messageDialog Outatime…Thanks, for reading

References

  1. Xainey/powershell-electron-demo
  2. Stephen Owen’s GUI ToolMaking Series
  3. Git
  4. VSCode
  5. NodeJS
  6. Yarn
  7. electron-quick-start
  8. git-clone-init
  9. Bootstrap v4
  10. bootstrap-packages
  11. bootstrap-alert
  12. jQuery
  13. rannn505/node-powershell
  14. IonicaBizau/powershell
  15. Promise
  16. node-powershell-api
  17. DataTables
  18. DataTables-Install
  19. DataTables-Bootstrap4
  20. DataTables-JS-Array
  21. node-powershell-issue-21
  22. node-powershell-issue-22
  23. BetterCredentials
  24. @ferventcoder
  25. electron-menu
  26. object destructuring
  27. Electron-dialog
  28. Electron-packaging
  29. Electron-distribution
  30. electron-builder
  31. electron-packager
  32. disk-icon
  33. PSCookieMonster’s Splat Guide
  34. BoeProx
  35. storing-powershell-credentials-in-json
  36. Hodge-PowerShell-Jenkins
  37. Developer-Roadmap
  38. Atom Electron
comments powered by Disqus