Friday, May 05, 2023

A Simple Introduction to CLAP in the Rust Programming Language

"CLAP" stands for Command-Line Argument Parser.

In order to get started using clap, you need to understand three things:

1 - How to Run a Simple Rust Program

Suppose you want to create a program called "fun". You should have a working installation of Rust and Cargo (the web is full of explanations of how to install this). In your file directory where you want to create the "fun" project:

$ cargo new fun

$ cargo run

This should print "Hello, World!", because every cargo-created project has this simple capability as a starting point.

2 - How to Work with a struct

This is a little bit more advanced. Here's the "src/main.rs" program from our "cargo new fun" command above:

src/main.rs
fn main()
	println!("Hello, world!");
}

A structure is simply a variable of a customized type, which often holds other variables. You should be familiar in a general way with variables, and know that they come in various kinds, or types: there are String variables and there are i32 variables and there are usize variables, etc. A struct simply allows you to create your own customized variable type, and fill it with a collection of other variables of various types.

Say you have a variable called "last" which holds a person's last name, and a variable called "age" which holds that person's age in years. The first variable might be a type of String (which holds a string of text), and the second might be a type of i8 (which holds an integer in the range of -128 to +127; a u8, which holds a range of 0 to 255, might be a better option).

Think of these two variables as two different types of fruit. We can carry them around in our hands if we like, or we can create a shopping bag to put them into. This shopping bag is analogous to a struct, except that a struct is well-defined as to what it can hold, whereas a shopping bag will hold just about anything pretty much, willy-nilly.

So a better way to think of it is as a custom-tool case, with exact-fitting compartments for the tools.

Example:

// This is the "master design" from which all toolboxes will be built.
struct toolbox_template {
  owner: String, // It will belong to a specific person,
  hammer: String, // and will hold a hammer,
  screwdriver: i8, // and a screwdriver of a certain size.
}

Note that this is just the definition of the "template" for a toolbox; it doesn't actually create a toolbox. Let's put this template into our "main.rs" file, along with the creation of two toolboxes based on this template, and then we'll print out a couple of messages about those tools. (We no longer need the "Hello, World!" println, so we'll delete it as per the Strikethru marking.)

src/main.rs
struct ToolboxTemplate {
  owner: String,
  hammer: String,
  screwdriver: i8,
}

fn main() {
  println!("Hello, world!");

  // Let's build a custom toolbox for Him.
  let his_box: ToolboxTemplate = ToolboxTemplate {
    owner: "Joe".to_string(),
    hammer: "sledge".to_string(),
    screwdriver: 8,
  };
  
  // And one for Her.
  let her_box: ToolboxTemplate = ToolboxTemplate {
    owner: "Jane".to_string(),
    hammer: "pink-handled".to_string(),
    screwdriver: 4
  };

  // Print the details of his toolbox.
  println!("{} has a {} hammer and a Number {} screwdriver.",
	his_box.owner, his_box.hammer, his_box.screwdriver
  );

  // And of hers.
  println!("{} has a {} hammer and a Number {} screwdriver.",
    her_box.owner, her_box.hammer, her_box.screwdriver
  );
} // end of main()

So you can see that a struct is just a custom-built variable, a "carrying case", that holds various other variables. The "struct" part defines the type, and then you have to create variables of that type which actually hold the desired data.

A Basic clap Setup

So, you can write a simple Rust program, and you kindda understand a struct. Good. Now lets add clap into the mix.

We first have to tell Cargo (the Rust "compiler" (sort of, but not really)) about Clap. This is done by adding some information to the "Cargo.toml" file. Before doing this, my Cargo.toml file for the "fun" program looks like this:

$ cat Cargo.toml 
[package]
name = "fun"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
$

If you have a recent version of Clap/Cargo, you can add Clap to your "fun" project with this command:

$ cargo add clap --features derive

That'll generate some churn, after which your "Cargo.toml" file will look more like this (the new stuff is in hilite):

$ cat Cargo.toml 
[package]
name = "fun"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
clap = { version = "4.2.7", features = ["derive"] }

Now Cargo knows about clap. Your program does not, but Cargo does. When you compile your program again, Cargo will include all the clap stuff. Try it. Your program will run just as it did before, except for all the extra compiling of Clap.

We want to be able to specify our hammer and screwdriver on the command-line. The command will look something like this:

$ code run -- --name=Bob --hammer=peen --screwdriver=9

Add the hi-lighted code below, and delete the code that is in Strikethru. The "use" line simply tells our program the path to find parse-related stuff in the Clap crate. The "derive" line magically makes our struct definition able to associate its components with information coming from the Command-Line Argument Parser (clap).

src/main.rs
use clap::Parser;

#[derive(Parser)]
struct ToolboxTemplate {
  owner: String,
  hammer: String,
  screwdriver: i8,
}

fn main() {

  // Let's build a custom toolbox for Him.
  let his_box: ToolboxTemplate = ToolboxTemplate {
    owner: "Joe".to_string(),
    hammer: "sledge".to_string(),
    screwdriver: 8,
  };
  
  // And one for Her.
  let her_box: ToolboxTemplate = ToolboxTemplate {
    owner: "Jane".to_string(),
    hammer: "pink-handled".to_string(),
    screwdriver: 4
  };
  
  println!("{} has a {} hammer and a Number {} screwdriver.",
	his_box.owner, his_box.hammer, his_box.screwdriver
  );
  
  // Print the details of his toolbox.
  println!("{} has a {} hammer and a Number {} screwdriver.",
	his_box.owner, his_box.hammer, his_box.screwdriver
  );

  
  // Let's build a toolbox based on command-line arguments.
  let toolbox: ToolboxTemplate = ToolboxTemplate::parse();

  // And of hers.
  // And then print out the details of that toolbox.
  println!("{} has a {} hammer and a Number {} screwdriver.",
    her_box.owner, her_box.hammer, her_box.screwdriver
    toolbox.owner, toolbox.hammer, toolbox.screwdriver
  );
} // end of main()

If you try to run this with arguments, like so:

$ cargo run -- --name=Bob hammer=peen screwdriver=9

you'll get a message about an unexpected argument, and a tip that tells you to do what you're already doing, along with a "usage" blurb.

However, if you run your program this way:

$ cargo run -- --Bob peen 9

it works!

And that's all that's needed for a very basic clap setup. It's reading your command-line arguments according to the position they're given. If you change the order, like this:

$ cargo run -- --9 Bob peen

you'll break your program. Like this, though:

$ cargo run -- --peen Bob 9

and you'll just get unwanted results.

So we want to be able to name our arguments. That's done like this:

src/main.rs
use clap::Parser;

#[derive(Parser)]
struct ToolboxTemplate {
  #[arg(long)]
  owner: String,
  #[arg(long)]
  hammer: String,
  #[arg(long)]
  screwdriver: i8,
};
...

Try running that with $ cargo run -- --name=Bob hammer=peen screwdriver=9.

Hmm, a different error message. And again, it doesn't really make sense. Ah, but now I see it. We defined an argument named "owner", but we're typing in an argument named "name". Let's try this:

$ cargo run -- --ownername=Bob hammer=peen screwdriver=9.

Yay! That works!

But what if we really want to type in "name" instead of "owner", but don't want to change the variable name? Easy. Just do this:

src/main.rs
use clap::Parser;

#[derive(Parser)]
struct ToolboxTemplate {
  #[arg(long="name")]
  owner: String,
  #[arg(long)]
  hammer: String,
  #[arg(long)]
  screwdriver: i8,
};
...

What if we want to just use "s" for "screwdriver"?

src/main.rs
use clap::Parser;

#[derive(Parser)]
struct ToolboxTemplate {
  #[arg(long="name")]
  owner: String,
  #[arg(long)]
  hammer: String,
  #[arg(longshort)]
  screwdriver: i8,
};
...

Then our command-line would like look: cargo run -- --name=Bob --hammer=peen -s=9 Notice that single-letter arguments are introduced with a single-hyphen (e.g. -s=9), rather than a double-hyphen (--s=9).

What if we want to just use "d" for "screwDriver"?

src/main.rs
use clap::Parser;

#[derive(Parser)]
struct ToolboxTemplate {
  #[arg(long="name")]
  owner: String,
  #[arg(long)]
  hammer: String,
  #[arg(short='d')]
  screwdriver: i8,
};
...

Notice that the definition uses single-quotes around "d" rather than double-quotes.

What if we want to allow either a short form or a long form?

src/main.rs
use clap::Parser;

#[derive(Parser)]
struct ToolboxTemplate {
  #[arg(short,long="name")]
  owner: String,
  
  #[arg(short,long)]
  hammer: String,
    
  #[arg(short='d',long)]
  screwdriver: i8,
};
...

What if we want to make the name optional, with a default?

src/main.rs
use clap::Parser;

#[derive(Parser)]
struct ToolboxTemplate {
  #[arg(short,long="name",default_value_t=String::from("Bubba"))]
  owner: String,
 
  #[arg(short,long)]
  hammer: String,

  #[arg(short,long)]
  screwdriver: i8,
};
...

Note that "Bubba".to_string() won't work in this case, so we had to use an alternative method of converting a string literal to a String-type. Don't worry for now about understanding that; just know that usually it doesn't matter which conversion method you use, and that if one method doesn't work, try another.

This short tutorial won't answer all your questions, but it should get you started. Have fun, Rustacean!

OCR On Your Smart Phone

Did you know that your phone can possibly do Optical Character Recognition (OCR)?

Mine, a Samsung Galaxy S22 Ultra, can.

  • I took a picture of an informative display hanging on the wall, that had two columns of side-by-side text.
The image hanging on the wall.
The image
  • I then used the camera's photo editing software (the pen icon at the bottom of the image below) to crop the image to just one of the text columns, and saved (upper-right corner of the cropped image below) that image.
The image in my phone's Gallery.
Cropping the image to just one of the columns.
  • Then at the bottom right corner of that same editing window is a little yellow "T" in a broken-outline box (see the Gallery image above). When I clicked on that "T", it OCR'd the text and highlighted it.
  • I was then able to single-press on the text, which popped up a menu allowing me to "Select All", which popped up another menu allowing me to "Copy".
  • I could then go to an editor of some sort, and "Paste" the text into the editor.
  • I then went back to my image, and edited it again, and "Revert"ed it back to the original.
  • I then repeated the process for the second column.

In just a minute or two, I had the full text of the two columns of the informative display in an editor. With a clean original image with clean-looking text, the accuracy is very high.

Finished text.

The first unit of the hospital was erected in September 1924, at a cost of $150,000. West Texas Baptist Sanitarium had five stories, 72 rooms and admitted more than 800 patients during the first year.

When it opened, West Texas Baptist Sanitarium touted: hot and cold running water in each room; excellent nursing services; three modern elevators; three well-equipped operating rooms; capable physicians and surgeons; and an obstetrical department.

Labor and delivery services were quickly utilized. The first baby was born at Hendrick less than one month after the doors opened. Pauline Marie Turnidge, daughter of Mr. and Mrs. W.A. Turnidge, was born on October 17, 1924.

The vision of a hospital for the Texas Midwest was well under way as the vision of Reverend Millard Jenkins became a reality. The motto for West Texas Baptist Sanitarium was that it opened its doors to everyone, "no matter what your belief or creed."

Friday, February 03, 2023

Using Clap to Parse Rust Program Arguments

 

The native Rust argument-parsing capability is pretty limited, so we are now turning to the third-party crate, "Clap" (which stands for Command-Line Argument Parser). To do that, let's create a new project:
$ cd ~/projects/RUST
$ cargo new parse_clap
$ cd parse_clap

You should pretty much know what the "src/main.rs" file looks like in a new Cargo-created Rust project. Before we begin working with that file, we need to let Cargo know we're going to use the "Clap" crate. Since this is a new project, the "Cargo.toml" file has no dependencies listed. We'll need to add a dependency to the "Cargo.toml" file for Clap.

Clap is found at "crates.io" (a Rust-maintained web site for all things Rustacean). If you web-browse to that site, you can search for "clap", and you'll find (at least near the time of this writing) both a version 3 and a version 4. We want the most recent version, which at the time of this writing is 4.0.29. If you'll click on it to get more details, you'll see in the right-hand column a line that needs to be added to your "Cargo.toml" file, specifically to the "[dependencies]" section, in order to tell Cargo how to use the Clap crate. Up until very recent versions of Cargo, this had to be added to the file manually, but with more recent versions of Cargo, you can just run:

$ cargo add clap@4.0.29

If you don't want a specific version, but rather prefer the newest one available, you can instead run:

$ cargo add clap

Be aware, this may take a few minutes. If you do this, and then examine your "Cargo.toml" file again, you'll discover that the needed line has automatically been added to that file.

But, unfortunately, this instruction does not tell you that you need to add more to that command, in order to get all that we need. You can learn this by clicking on the "docs.rs" link at the "crates.io" website. The command you really need (you can run it even if you ran the previous command) is:

$ cargo add clap --features derive

The second run goes much faster than the first, because most of the work has already been done in our first attempt to add this to "Cargo.toml".

Now look again at your "Cargo.toml" file; if it looks pretty much like below, we should be good to go.

[package]
name = "parse_clap"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
clap = { version = "4.0.29", features = ["derive"] }

Now we're ready to edit your "src/main.rs" file to be identical to the following:

use clap::Parser;

#[derive(Parser)]

struct ArgsType {
  /// Are you happy or sad?
  #[arg(short, long)]
  mood: String,
}

fn main() {
  let switches: ArgsType = ArgsType::parse();

  if switches.mood == "happy"
    { println!("Whoo-hoo! I am {}! {}! {}! {}!", switches.mood, switches.mood, switches.mood, switches.mood) };

  if switches.mood == "sad"
    { println!("Boo-hoo! I am {}!", switches.mood) };
  
  println!("Hello, world!");
} // end of main()

Now compile and run this with the indicated program arguments, like so:

cargo run -- --mood happy

Try providing different switches, in different order, in different numbers. Try the "-V" option, as well as the "-v" non-option. Try the "-h" option.

You can see that this Clap crate is already pretty useful, in that it provides some help screens when the arguments are not what are expected. It doesn't handle every wrong argument (as the program is currently written), but you can see that there's some potential here.

Let's try to understand this program, and then explore a bit of that potential.

The first line, "use clap::Parser;" preps the system for the other two "Parse"-related statements. Just know it's needed.

The next line tells the system that the parser will be deriving its arguments from the struct we build next. Clap can be configured using the Builder Application Programming Interface (API) or the Derive API (or a mix, as I understand it). As a general rule, unless you need to get deeper under the hood of Clap, you'll probably want to use the Derive API. The FAQ at https://docs.rs/clap/4.1.4/clap/_faq/index.html#when-should-i-use-the-builder-vs-derive-apis says this:

When should I use the builder vs derive APIs?

Our default answer is to use the Derive API:

  • Easier to read, write, and modify
  • Easier to keep the argument declaration and reading of argument in sync
  • Easier to reuse, e.g. clap-verbosity-flag

The Builder API is a lower-level API that someone might want to use for

So in other words, do it the way we're doing here, with a struct, not the way other tutorials might show you, without a struct. At least until you want/need to dive deeper.

The "struct" section provides a defining template for a new type of variable. This section does not declare an actual variable (we'll declare that later), but only a new type of variable. This new type of variable is based on a struct format. (A struct is a custom-made variable that holds other variables.)

Any variables declared to be of this new type are defined by this "struct' section, which defines what arguments are allowed to be given as the program's command-line arguments, and what the internal variable names are that will hold those arguments for use in the program. The Clap Derive API uses this struct type of structure to define and build this new type of variable. We could call this new type of variable anything we wanted, like "progInputs" or "options" or "OptionsType", etc. We're calling it "ArgsType". Currently this new type of variable defines one internal variable, named "mood", which is designated to hold String data.

The section that defines this inner "mood" variable is introduced by a line with three forward slashes (///). Whereas two slashes are the beginning of a "comment", which is ignored by the compiler but helps the programmer to keep notes about the code, a three-slash line functions as both a comment and a documentation line, which can be used by the compiler and by Clap to create help text. If you run cargo run -- --help, you can see that text, "Are you happy or sad?", in the output.

The line beginning with a hash mark tells Clap how to handle this program argument: whether it can be entered as a long form (--mood), or as a short form (-m), or must it be required, or should it have a default value, etc.

We can add additional internal variables (and therefore additional program input possibilities) by adding more "#[arg..." sections to the struct design. For example, in addition to the user's mood, perhaps we'd like to know the person's name and age:

struct ArgsType {
  /// Are you happy or sad?
  #[arg(short, long)]
  mood: String,
    
  /// What is your name?
  #[arg(short, long)]
  name: String,
  
  /// What is your age?
  #[arg(short, long, default_value_t = 16)]
  age: u8,
} 

Notice that the "age" variable has a default value (which is a bit silly, but this is just an example). Because of this, Clap won't require the user to enter that option, but it will the other two. You can force it to be required like this:

#[arg(short, long, default_value_t = 3), required(true)]

but that kind of defeats the purpose of having a default.

Although technically an age entered on the command line in a command such as cargo run -- --name Kent --age 35 starts out as a "String" (everything entered on the command line starts out as a "String"), by the time it gets to our "age" variable, Clap will have converted it from a "String" to a "u8" (which is an unsigned (i.e., positive) integer in the range of 0 to 255).

Note again that we have not yet declared a variable of this new type; we have only defined a new type of variable. We actually declare a variable in the main() function. Note also that since the struct is defined outside of the main() function, this definition of a new type of variable is "visible" (or "is in scope") to all parts of the program within this "main.rs" file. If we should create a new function later on in this same file, say, a function called "part_two()", that function will be able to access this "ArgsType" definition; had we put this definition within the main() function, it would only be visible to the main() function itself, but not to the "part_two()" function.

Now let's look at the main() function. The let switches: ArgsType = ArgsType::parse(); line actually defines our variable. The name of the variable is "switches", and the type of the variable is, not String and not i32 and not u8 or etc, but "ArgsType", the type we just invented. If this line seems complicated to you, take out the ": ArgsType", to make the line be just let switches = ArgsType::parse(); which may be less daunting to look at and therefore less daunting to understand. It's basically just calling a "function" named "parse" that is "located" in the "ArgsType" struct we just built (not exactly, but close enough), and assigning the results of that "function" to the variable "switches".

The variable "switches" now holds three variables within it (assuming three options are given as program inputs), which we can access as "switches.mood", "switches.name", and "switches.age". Here are some mods to our program, including a boolean flag to specify if the user is human or not, which defaults to "no":

use clap::Parser;

#[derive(Parser)]

struct ArgsType {
  /// Are you happy or sad?
  #[arg(short, long)]
  mood: String,
  
  /// What is your name?
  #[arg(short, long, value_name = "What yo momma called you...")]
  name: String,
  
  /// What is your age?
  #[arg(short, long, default_value_t = 16)]
  age: u8,

  /// Are you a human?
  #[arg(short = 'H', long, default_value_t = false)]  // 'h' would have conflicted with "help".
  human: bool,
}

fn main() {
  let switches: ArgsType = ArgsType::parse();

  if switches.human {
    println!("Hi, {}! You seem very {} to be {} years old, but that's understandable, since you are a human.",
      switches.name,
      switches.mood,
      switches.age
    );
  } else {
    println!("Hi, {}! You seem very {} to be {} years old, but that's understandable, since you are not a human.",
      switches.name,
      switches.mood,
      switches.age
    );
}

  if switches.mood == "happy"
    { println!("Whoo-hoo! I am {}! {}! {}! {}!", switches.mood, switches.mood, switches.mood, switches.mood) };

  if switches.mood == "sad"
    { println!("Boo-hoo! I am {}!", switches.mood) };
  
} // end of main()

Running this program results in:

$ cargo run -- --mood happy --name Kent --age 253
Compiling parse_clap v0.1.0 (/home/westk/projects/RUST/parse_clap)
Finished dev [unoptimized + debuginfo] target(s) in 0.56s
Running `target/debug/parse_clap --mood happy --name Kent --age 253`
Hi, Kent! You seem very happy to be 253 years old.
Whoo-hoo! I am happy! happy! happy! happy!
$
$ cargo run -- --name=Kent --age 253 -m happy --human -h
    Finished dev [unoptimized + debuginfo] target(s) in 0.02s
     Running `target/debug/parse_clap --name=Kent --age 253 -m happy --human -h`
Usage: parse_clap [OPTIONS] --mood <MOOD> --name <What yo momma called you...>

Options:
  -m, --mood <MOOD>                         Are you happy or sad?
  -n, --name <What yo momma called you...>  What is your name?
  -a, --age <AGE>                           What is your age? [default: 16]
  -H, --human                               Are you a human?
  -h, --help                                Print help information
  -V, --version                             Print version information
$

Note also that various formats can be used for entering the arguments:

--name Kent
-nKent
-n=Kent
--name=Kent

But --nameKent won't work.

And that's pretty much it. We've got our feet wet with parsing arguments in Rust using the Clap crate.

Monday, January 09, 2023

A Man's Most Important Relationships

The most important relationship a man has is with his God.

But the relationship he should focus most on is that with his wife.

God doesn't need a man's attention. A wife does.

Men, your wife likely has two fundamental needs that you need to meet:

  • the need to feel secure (finances seem to you like a huge part of this, but there are bigger security issues for her)
  • the need to feel valued (and listening to her and considering her viewpoint is a huge part of this)

Focus on these things. Make her feel valued; make her feel safe.

 

Originally published at:
https://kentwest.blogspot.com/2023/01/a-mans-most-important-relationships.html

Monday, October 31, 2022

Notes About PowerShell: Adding a TreeView to the GUI - Part 3

In Notes About PowerShell: Adding a TreeView to the GUI - Part 2 of this series, we had a complete set of four PowerShell scripts that together creates a GUI window that displays a Windows Forms TreeView, allowing the user to select a family member from a small family tree. In this post, we're going to replace that family tree with the computer's file system.

Let's make one quick modification to tinker.ps1 file so that our results are graphically displayed in addition to textually in the console. Add in the bolded code below:

   ...
   
If ($Win.DialogResult -eq "OK") {
    Write-Host("The OK button was pressed. The data retrieved are:")
    Write-Host("`t            Node: `"$($Node.Text)`".")
    Write-Host("`tPath to the Node: `"$($NodePath.Text)`".")
    [System.Windows.Forms.MessageBox]::Show("Results:`n`nNODE:`n `"$($Node.Text)`"`n`nPATH TO NODE:`n `"$($NodePath.Text)`"")

   ...

Give 'er a test spin.

Okay, on to putting the filesystem into a treeview. First, we'll need to have the drive letters of Windows. For experimentation/learning purposes, at a PowerShell prompt (not in your script), enter the following command, and you'll see results similar to the following:

PS C:\Users\acutech> Get-PSDrive

Name           Used (GB)     Free (GB) Provider      Root                                CurrentLocation
----           ---------     --------- --------      ----                                ---------------
Alias                                  Alias                                                                                                                                                                                     
C                  24.30         55.00 FileSystem    C:\                                 Users\acutech
Cert                                   Certificate   \
D                                      FileSystem    D:\                                                                                                                                                                         
Env                                    Environment                                                                                                                                                                               
Function                               Function                                                                                                                                                                                 
HKCU                                   Registry      HKEY_CURRENT_USER                                                                                                                                                           
HKLM                                   Registry      HKEY_LOCAL_MACHINE                                                                                                                                                         
Variable                               Variable                                                                                                                                                                                 
WSMan                                  WSMan                                                                                                                                                                                    


PS C:\Users\acutech> 

We're only interested in the filesystem drive letters, not the registry keys or certificates or etc. So let's put some limitations on the command:

PS C:\Users\acutech> Get-PSDrive -PSProvider FileSystem

Name           Used (GB)     Free (GB) Provider      Root                                CurrentLocation
----           ---------     --------- --------      ----                                ---------------
C                  24.30         55.00 FileSystem    C:\                                 Users\acutech
D                                      FileSystem    D:\


PS C:\Users\acutech>

Better. But all we really want is the drive letter itself:

PS C:\Users\acutech> (Get-PSDrive -PSProvider FileSystem).Root
C:\
D:\

PS C:\Users\acutech> (Get-PSDrive -PSProvider FileSystem).Name
C
D

Great! Both of these commands give us an array containing the filesystem drives. Let's load them up into the treeview.

Let's start with a reminder of our tinker_add_nodes.ps1 file:

$TreeView.Nodes.Add("John")
$TreeView.Nodes[0].Nodes.Add("Mary")
$TreeView.Nodes[0].Nodes.Add("Fred")
$TreeView.Nodes[0].Nodes[1].Nodes.Add("Delbert")
$TreeView.Nodes[0].Nodes.Add("Alvin")
$TreeView.Nodes.Add("William")
$TreeView.Nodes[1].Nodes.Add("Estelle")
$TreeView.Nodes[1].Nodes.Add("Angus")
$TreeView.Nodes[1].Nodes.Add("Eugene")
$TreeView.Nodes[1].Nodes.Add("Marvin")
$TreeView.SelectedNode = $TreeView.Nodes[0].Nodes[1].Nodes[0]
$Win.ActiveControl = $TreeView
$Node.Text = $TreeView.SelectedNode.Text

Delete all of that code; we're done with it. And replace it with this code:

$Drives = (Get-PSDrive -PSProvider FileSystem).Root        # Get the drive letters in an array named $Drives
foreach ($_ in $Drives) {                                  # For each drive letter in the array,
    $TreeView.Nodes.Add($_)                                #   add the drive letter to the treeview.
}

Open up .tinker.ps1 if you haven't already done so, and run it. You should get something like this:

This Document is Under Construction

Friday, October 28, 2022

Notes About PowerShell: Adding a TreeView to the GUI - Part 2

See Part 1 here.

If you just want to see the code, without reading how we get there, the code listings are at the bottom of this page.

In Part 1, we built a basic Windows form with a blank treeview. Now we're going to populate the treeview. Probably the three most popular "databases" that are looked at in PowerShell in a tree form are the file system, the Windows Registry, and Active Directory domains. I plan to look at each of these three sources via a treeview, but let's start with something simpler, a simple family tree.

As you remember from Part 1, most of our GUI-window construction takes place in an external file, "tinker_GUI.ps1", which is dot-sourced from our primary script file, "tinker.ps1". That primary script file currently looks like this:

# Offload GUI-building into a different file to decrease clutter here.
. "$PSScriptRoot\tinker_GUI.ps1"

# The computer is now looping forever waiting for user input.
# Once the input is the OK button or the Window's X (or
# keyboard equivalent), the window closes and the code below runs.
If ($Win.DialogResult -eq "OK") {
    Write-Host("The OK button was pressed. The name in the box is `"$($NameBox.Text)`".")
} elseif ($Win.DialogResult -eq "Cancel") {
    Write-Host("The X was pressed. I`'m not going to tell you the name that is in the box.")
} # end of If

If you've followed along with Part 1, you should be able to run the above script, and see a window with an empty treeview, a box for entering your name, and an OK button.

Since a treeview looks at data that is in a tree-like form, we're probably less interested in the user's name and more interested in the leaf-node endpoint of interest in our tree, and the path it takes to get to that leaf-node. So let's edit the second, external script file, so that it replaces the "Name" box with a "Node" box. Let's also take out the pre-fill of that box, and the activation/selection/focus of that box.

Use your PowerShell ISE to open the "tinker_GUI.ps1" script file, and make the following modifications, deleting the strike-out text and adding in the bolded text. You'll find it easier to change "NameBox" to "Node" by using the PowerShell ISE's Edit / Replace in Script... feature to replace all instances of "NameBox" with "Node".

     ...
     
$Win.AcceptButton = $buttonOK                            # Click = "Close form; I accept it as it now is."

# A textbox.The node.
$NameBoxNode = New-Object "System.Windows.Forms.Textbox"
$NameBoxNode.Size = "175,25"
$NameBoxNode.Location = "290,40"
$NameBox.Text = "Babushka"                               # Pre-fill the box.
$Win.Controls.Add($NameBoxNode)
$Win.ActiveControl = $NameBox                            # Once form shows, select this box.
  # Put a label with the box.
$Label_NameBoxNode = New-Object 'System.Windows.Forms.Label'
$Label_NameBoxNode.Text = "Enter your nameSelected Node:"
$Label_NameBoxNode.Size = "150, 25"
$Label_NameBoxNode.Location = '290, 20'
$Win.Controls.Add($Label_NameBoxNode)

   ...

Try running your script, to make sure it all works as expected.

We're also interested in the path to that node, so let's add a "Path to Node" box (by adding the bolded text in the indicated place).

     ...
$Label_Node.Location = '290, 20'
$Win.Controls.Add($Label_Node)

# Path to the node.
$NodePath = New-Object "System.Windows.Forms.Textbox"
$NodePath.Size = "175,25"
$NodePath.Location = "290,120"
$Win.Controls.Add($NodePath)
  # Put a label with the box.
$Label_NodePath = New-Object 'System.Windows.Forms.Label'
$Label_NodePath.Text = "Path to Node:"
$Label_NodePath.Size = "150, 25"
$Label_NodePath.Location = '290, 100'
$Win.Controls.Add($Label_NodePath)

# The TreeView object.
$TreeView = New-Object System.Windows.Forms.TreeView

   ...

Now, for our family tree.

Imagine two brothers, John and William. John has three kids, Mary, Fred, and Alvin, and Fred has one, Delbert. William has four, Estelle, Angus, Eugene, and Marvin. Let's make a tree that views these relationships, and put that tree into the treeview object on our GUI form.

Ideally, since adding things to the treeview is conceptually different than actually building the window and the node and path and treeview box forms, we'd put the coding instructions to add things to the treeview somewhere else besides in this file. However, once the form is shown on-screen (with the $Win.ShowDialog(), no code below that line is executed until that window form is closed.

So, our options are to embed the code in the midst of all this other code, or to put the code to an external file and then import it as a dot-sourced file into a location just above the $Win.ShowDialog() line, or to put the code into a function and call that function just prior to the $Win.ShowDialog() line.

Just for sake of example, let's add a couple of nodes, the two patriarch brothers, with the code embedded in the tinker_GUI.ps1 file:

   ...
   
$Win.Controls.Add($Label_TreeView)                       # Add the label to the form.

$TreeView.Nodes.Add("John")
$TreeView.nodes.Add("William")

# Display the form
$Win.ShowDialog()

   ...

Run the script, and you should start seeing the treeview come together.

Now, instead of embedding this code in the tinker_GUI.ps1 file, let's move that code out of it, and into a new file we can name tinker_add_nodes.ps1. So tinker_GUI.ps1 becomes:

   ...
   
$Win.Controls.Add($Label_TreeView)                       # Add the label to the form.

$TreeView.Nodes.Add("John")
$TreeView.nodes.Add("William")

. "$PSScriptRoot\tinker_add_nodes.ps1"                   # Import file with code to populate tree.

# Display the form
$Win.ShowDialog()

   ...

and tinker_add_nodes.ps1 becomes:

$TreeView.Nodes.Add("John")
$TreeView.nodes.Add("William")

Running tinker.ps1 should still produce the output you expect.

Now let's add the kids and grandkid.

$TreeView.Nodes.Add("John")
$TreeView.Nodes[0].Nodes.Add("Mary")
$TreeView.Nodes[0].Nodes.Add("Fred")
$TreeView.Nodes[0].Nodes[1].Nodes.Add("Delbert")
$TreeView.Nodes[0].Nodes.Add("Alvin")
$TreeView.Nodes.Add("William")
$TreeView.Nodes[1].Nodes.Add("Estelle")
$TreeView.Nodes[1].Nodes.Add("Angus")
$TreeView.Nodes[1].Nodes.Add("Eugene")
$TreeView.Nodes[1].Nodes.Add("Marvin")

As you can see, the .Nodes values are arrays, that attach behind each other like the railroad cars of a train. The first level array, $Win.Nodes has two elements: .Nodes[0] contains the name "John". So by adding "Mary" and "Fred" as new element nodes to $Win.Node[0], "Mary" becomes $Win.Nodes[0].Nodes[0] and shows up in the treeview as "John's" daughter, as does "Fred" as $Win.Nodes[0].Nodes[1]. Don't worry too much if this doesn't yet make sense to you; it should start making more sense the farther we go and the more you work with it.

To help visualize this, we can add this code:

  ...
$TreeView.Nodes[1].Nodes.Add("Eugene")
$TreeView.Nodes[1].Nodes.Add("Marvin")
$TreeView.SelectedNode = $TreeView.Nodes[0].Nodes[1].Nodes[0]
  ...

You'll see that the tree has been expanded out to Delbert, without you having to expand anything using your mouse. If you press TAB a couple of times, until the focus lands on the treeview box, you'll see "Delbert" is highlighted.

We can even move the TAB focus to the treeview programatically:

  ...
$TreeView.Nodes[1].Nodes.Add("Eugene")
$TreeView.Nodes[1].Nodes.Add("Marvin")
$TreeView.SelectedNode = $TreeView.Nodes[0].Nodes[1].Nodes[0]
$Win.ActiveControl = $TreeView
  ...

You may recall we used the "$Win.ActiveControl" setting earlier, in the definitions for the "Node" box, but then deleted that. If it had not been deleted, this second instance would simpy overwrite the effects of the first instance, because it comes later in the code.

But speaking of the "Node" box, this'd be a great time to fill it with the selected node:

...
$TreeView.SelectedNode = $TreeView.Nodes[0].Nodes[1].Nodes[0]
$Win.ActiveControl = $TreeView
$Node.Text = $TreeView.SelectedNode.Text

You might think that since this is a property of $Node, it should go with all the other property settings in the section of code where we first defined $Node. That's great thinking. Unfortunately, since PowerShell is an interpreted language rather than a compiled language, PowerShell doesn't know about the $TreeView.SelectedNode.Text until after it reads this section of code, long after it has passed that section of code. So this assignment needs to go here instead of there.

This would also be a good time to report the selected node after the OK button is pressed. Remember, this code is in the main file, tinker.ps1.

  ...
# The computer is now looping forever waiting for user input.
# Once the input is the OK button or the Window's X (or
# keyboard equivalent), the window closes and the code below runs.
If ($Win.DialogResult -eq "OK") {
    Write-Host("The OK button was pressed. The namenode in the box is `"$($NameBoxNode.Text)`".")
} elseif If ($Win.DialogResult -eq "Cancel") {
    Write-Host("The X was pressed. I`'m not going to tell you the namenode that is in the box.")
} # end of If
  

If you click around in the treeview, say, by clicking on "Mary", you'll see the focus follows your click. But nothing else happens.

In order to perform some action when we click in the treeview, we'll need more code. In the definition for the treeview, we'll add this line:

  ...
$TreeView.Size = "240,400"
$treeview.add_NodeMouseClick({Write-Host("Ouch")})
$Win.Controls.Add($TreeView)
  ...

All this does is print "Ouch" to the PowerShell console. (You could print it to a pop-up message box with a slightly more-complicated statement - [System.Windows.Forms.MessageBox]::Show("Ouch"). (The brackets are kind of a short-hand way of creating an object on-the-fly, without the whole "$MessageBox = New-Object..." variable declaration thingy, but since this is a run-time "declaration", the dot, ".", becomes "::" (among other differences).))

Each time you click anywhere in the treeview area, you'll get an "Ouch".

However, what if we want to do more than a single command on a mouse-click?

We could continue adding code in this embedded manner, like so:

$treeview.add_NodeMouseClick(
  {
    Write-Out("Ouch")})
    Write-Host("You just clicked away from $($TreeView.SelectedNode).")  # Need to fix: click on the same already-highlighted name, and this message will be inaccurate.
    Write-Host("Stop it! That hurts!")
  }
)

Or we could use a variable here that functions like a function by standing in for a whole section of code, or use an actual function call. To keep the various code pieces separate as much as we can, and keep one section of code uncluttered by another portion of code, let's put the variable/function in another file, say, tinker_node_selected.ps1.

$TreeViewMouseClickEvent = {
    Write-Host("Ouch")
    Write-Host("You just clicked away from $($TreeView.SelectedNode).")  # Need to fix: click on the same already-highlighted name, and this message will be inaccurate.
    Write-Host("Stop it! That hurts!")
}

And we'll have to make two changes to tinker_GUI.ps1:

# Initialize the PowerShell GUI
Add-Type -AssemblyName System.Windows.Forms

. "$PSScriptRoot\tinker_node_selected.ps1"
 ...
$treeview.add_NodeMouseClick({Write-Host("Ouch")})
$treeview.add_NodeMouseClick($TreeViewMouseClickEvent)

Or we can create a function, and call that function:

function TreeViewMouseClickEvent {
    Write-Host("Ouch")
    Write-Host("You just clicked away from $($TreeView.SelectedNode).")  # Need to fix: click on the same already-highlighted name, and this message will be inaccurate.
    Write-Host("Stop it! That hurts!")
} # end of TreeViewMouseClickEvent function
and
$treeview.add_NodeMouseClick({TreeViewMouseClickEvent})

Notice that with the embedded and function methods, curly braces are needed, but not with the variable method. Notice also that the function method is not preceded by the $ sign, whereas the variable method is. Lastly, note that the variable declaration includes an = sign, whereas the function declaration does not.

For a fuller look at these three methods, see here. We'll continue on using the function method.

If you watch the console as you click around in the treeview, you'll notice that the name that appears in the console is the name you leave, rather than the name on which you click. That's not the behavior we want. The reason this happens is because the PowerShell scripting engine runs the function/variable/embedded code before changing the treeview's selected node. We don't want to process the name we just left, but rather the name we just clicked on. So we'll have to change our function, to use arguments that the Add_NodeMouseClick feature automatically provides to us:

   ...
  
Write-Host("You just clicked away from $($TreeView.SelectedNode).")  # Need to fix: click on the same already-highlighted name, and this message will be inaccurate.
Write-Host("You just clicked on $($_.Node.Text).")

   ...

The $_ represents the un-named variable given to our function which holds the arguments from the Add_NodeMouseClick event.

Those console messages aren't very valuable to us, though. Let's instead use this function to change out the text of the two text boxes on our form.

function TreeViewMouseClickEvent {
    Write-Host("Ouch")
    Write-Host("You just clicked on $($_.Node.Text).")
    Write-Host("Stop it! That hurts!")
# Fill in the "Node" and Path: fields on the form, based on the node just selected.
    $Node.Text = $_.Node.Text
    $NodePath.Text = $_.Node.FullPath
} # end of TreeViewMouseClickEvent function

This almost works like we want, except you'll notice that if you click on a plus or minus in the treeview, the "Node:" and "Path:" fields change, even if we haven't actually selected a different node. Thinking about it, that makes sense; the Mouse-click event is raised when we click the mouse in the treeview area. We just need to use a different event. Easy. We'll also change the name of our function, to make it more accurate as to what is going on.

In tinker_node_selected.ps1:

     ...

function TreeViewMouseClickEvent {
function TreeNodeSelected {
   ...   
} # end of TreeViewMouseClickEvent function
} # end of TreeNodeSelected function

And in tinker_GUI.ps1:

   ...
  
$treeview.add_NodeMouseClick({TreeViewMouseClickEvent})
$treeview.add_AfterSelect({TreeNodeSelected})
  
  ...

Now we have a working form with a working treeview that allows us to read the selected node and its path. Let's change our output to include both of these bits of data. In tinker.ps1:

   ...
   
If ($Win.DialogResult -eq "OK") {
    Write-Host("The OK button was pressed. The node in the box is `"$($Node.Text)`".")

If ($Win.DialogResult -eq "OK") {
    Write-Host("The OK button was pressed. The data retrieved are:")
    Write-Host("`t            Node: `"$($Node.Text)`".")
    Write-Host("`tPath to the Node: `"$($NodePath.Text)`".")
    
    ...

Just for clarity, here are the four files we are using, in their entirety:

tinker.ps1:

# Offload GUI-building into a different file to decrease clutter here.
. "$PSScriptRoot\tinker_GUI.ps1"

# The computer is now looping forever waiting for user input.
# Once the input is the OK button or the Window's X (or
# keyboard equivalent), the window closes and the code below runs.
If ($Win.DialogResult -eq "OK") {
    Write-Host("The OK button was pressed. The data retrieved are:")
    Write-Host("`t            Node: `"$($Node.Text)`".")
    Write-Host("`tPath to the Node: `"$($NodePath.Text)`".")
} elseif ($Win.DialogResult -eq "Cancel") {
    Write-Host("The X was pressed. I`'m not going to tell you the node that is in the box.")
} # end of If

tinker_GUI.ps1:

# Initialize the PowerShell GUI
Add-Type -AssemblyName System.Windows.Forms

. "$PSScriptRoot\tinker_node_selected.ps1"

# Create a new window form.
$Win = New-Object System.Windows.Forms.Form
$Win.ClientSize = '490,460'                              # Size of window frame.
$Win.text = "My Parent Window"                           # Title of the Win frame.
$Win.BackColor = "#ffffff"                               # Background color of the Win frame.

# Create an OK button.
$buttonOK = New-Object 'System.Windows.Forms.Button'     # Create the button.
$buttonOK.Text = "OK"                                    # Puts text on the button.
$buttonOK.Location = '350, 240'
$buttonOK.Size = '60, 25'
$buttonOK.Anchor = 'Bottom, Right'                       # Keep button in relative bottom-right of Win.
$buttonOK.DialogResult = "OK"                            # "None|OK|Cancel|Abort|Retry|Ignore|Yes|No".
$Win.Controls.Add($buttonOK)                             # Add the button to the form.
$Win.AcceptButton = $buttonOK                            # Click = "Close form; I accept it as it now is."

# The node.
$Node = New-Object "System.Windows.Forms.Textbox"
$Node.Size = "175,25"
$Node.Location = "290,40"
$Win.Controls.Add($Node)
  # Put a label with the box.
$Label_Node = New-Object 'System.Windows.Forms.Label'
$Label_Node.Text = "Selected Node:"
$Label_Node.Size = "150, 25"
$Label_Node.Location = '290, 20'
$Win.Controls.Add($Label_Node)

# Path to the node.
$NodePath = New-Object "System.Windows.Forms.Textbox"
$NodePath.Size = "175,25"
$NodePath.Location = "290,120"
$Win.Controls.Add($NodePath)
  # Put a label with the box.
$Label_NodePath = New-Object 'System.Windows.Forms.Label'
$Label_NodePath.Text = "Path to Node:"
$Label_NodePath.Size = "150, 25"
$Label_NodePath.Location = '290, 100'
$Win.Controls.Add($Label_NodePath)

# The TreeView object.
$TreeView = New-Object System.Windows.Forms.TreeView
$TreeView.Location = "10,40"
$TreeView.Size = "250,400"
$treeview.add_AfterSelect({TreeNodeSelected})
$Win.Controls.Add($TreeView)                             # Add the tree view to the main window form.
  # Put a label with the object.
$Label_TreeView = New-Object System.Windows.Forms.Label
$Label_TreeView.Location = "10,20"
$Label_TreeView.Size = "150,25"
$Label_TreeView.Text = "My Tree Object:"
$Win.Controls.Add($Label_TreeView)                       # Add the label to the form.

. "$PSScriptRoot\tinker_add_nodes.ps1"                   # Import file with code to populate tree.

# Display the form.
$Win.ShowDialog() 

tinker_add_nodes.ps1:

$TreeView.Nodes.Add("John")
$TreeView.Nodes[0].Nodes.Add("Mary")
$TreeView.Nodes[0].Nodes.Add("Fred")
$TreeView.Nodes[0].Nodes[1].Nodes.Add("Delbert")
$TreeView.Nodes[0].Nodes.Add("Alvin")
$TreeView.Nodes.Add("William")
$TreeView.Nodes[1].Nodes.Add("Estelle")
$TreeView.Nodes[1].Nodes.Add("Angus")
$TreeView.Nodes[1].Nodes.Add("Eugene")
$TreeView.Nodes[1].Nodes.Add("Marvin")
$TreeView.SelectedNode = $TreeView.Nodes[0].Nodes[1].Nodes[0]
$Win.ActiveControl = $TreeView
$Node.Text = $TreeView.SelectedNode.Text

tinker_node_selected.ps1:

function TreeNodeSelected {
# Fill in the "Node" and Path: fields on the form, based on the node just selected.
    $Node.Text = $_.Node.Text
    $NodePath.Text = $_.Node.FullPath
} # end of TreeViewMouseClickEvent function

Next up, in Part 3, we'll do this using the file system.

Notes About PowerShell: Adding a TreeView to the GUI, Part 1

Previously we built the Simplest PowerShell GUI, and then added a few features to it here.

The script below is that last more complex GUI (with a few edits). It presents a window frame, with an OK button object, and a textbox object that asks for a name to be entered. We'll build on this script to add a treeview object. A treeview allows you to browse a tree-like structure, such as the file system, or an Active Directory domain, or the Windows Registry. So fire up the ol' PowerShell ISE, copy and paste in the following code (making sure the quotation marks don't turn into "smart" quotation marks), and then save the file as something like "tinker.ps1". Then try running it to make sure it works.

# Initialize the PowerShell GUI
Add-Type -AssemblyName System.Windows.Forms
  
# Create a new window form.
$Win = New-Object System.Windows.Forms.Form
$Win.ClientSize = '490,460'                              # Size of window frame.
$Win.text = "My Parent Window"                           # Title of the Win frame.
$Win.BackColor = "#ffffff"                               # Background color of the Win frame.

# Create an OK button.
$buttonOK = New-Object 'System.Windows.Forms.Button'     # Create the button.
$buttonOK.Text = "OK"                                    # Puts text on the button.
$buttonOK.Location = '350, 240'
$buttonOK.Size = '60, 25'
$buttonOK.Anchor = 'Bottom, Right'                       # Keep button in relative bottom-right of Win.
$buttonOK.DialogResult = "OK"                            # "None|OK|Cancel|Abort|Retry|Ignore|Yes|No".
$Win.Controls.Add($buttonOK)                             # Add the button to the form.
$Win.AcceptButton = $buttonOK                            # Click = "Close form; I accept it as it now is."

# A textbox.
$NameBox = New-Object "System.Windows.Forms.Textbox"
$NameBox.Size = "175,25"
$NameBox.Location = "290,40"
$NameBox.Text = "Babushka"                               # Pre-fill the box.
$Win.Controls.Add($NameBox)
$Win.ActiveControl = $NameBox                            # Once form shows, select this box.
  # Put a label with the box.
$Label_NameBox = New-Object 'System.Windows.Forms.Label'
$Label_NameBox.Text = "Enter your name:"
$Label_NameBox.Size = "150, 25"
$Label_NameBox.Location = '290, 20'
$Win.Controls.Add($Label_NameBox)

# Display the form.
$Win.ShowDialog() 

# The computer is now looping forever waiting for user input.
# Once the input is the OK button or the Window's X (or
# keyboard equivalent), the window closes and the code below runs.
If ($Win.DialogResult -eq "OK") {
    Write-Host("The OK button was pressed. The name in the box is `"$($NameBox.Text)`".")
} elseif ($Win.DialogResult -eq "Cancel") {
    Write-Host("The X was pressed. I`'m not going to tell you the name that is in the box.")
} # end of If

Now we're ready to create the empty treeview object. The text in bold below is what we're adding.

   ...
  # Put a label with the box.
$Label_NameBox = New-Object 'System.Windows.Forms.Label'
$Label_NameBox.Text = "Enter your name:"
$Label_NameBox.Size = "175, 23"
$Label_NameBox.Location = '10, 20'
$Win.Controls.Add($Label_NameBox)

# The TreeView object.
$TreeView = New-Object System.Windows.Forms.TreeView
$TreeView.Location = "10,40"
$TreeView.Size = "250,400"
$Win.Controls.Add($TreeView)                             # Add the tree view to the main window form.
  # Put a label with the object.
$Label_TreeView = New-Object System.Windows.Forms.Label
$Label_TreeView.Location = "10,20"
$Label_TreeView.Size = "150,25"
$Label_TreeView.Text = "My Tree Object:"
$Win.Controls.Add($Label_TreeView)                       # Add the label to the form.

# Display the form.
$Win.ShowDialog()
   ...

That's a lot of code to be scrolling up and down in, and it's pretty easy to get lost in it. It'd be good if we could break up this big monolithic piece of code into smaller, more manageable chunks.

Most of this code is involved in the actual GUI-building, so the breaking up into chunks won't do a great deal for us at this point, but it will help to compartmentalize the different functional parts. We'll break this code into two pieces, and put them in separate files. On the one hand, that's a disadvantage, because now you're dealing with multiple files for your project, but on the other hand, it's an advantage, in that it breaks up the project into more manageable bite-size pieces.

So click on Edit / Select All in your PowerShell ISE, then Edit / Copy, to copy all the text into your Clipboard. Now click on click on File / New to open up a new blank tab, and then Edit / Paste to paste the script into a new tab. Go to the very bottom of the file and select everything after the "$Win.ShowDialog()" line, and then Edit / Cut that selection into the Clipboard. Now save this tab as something like "tinker_GUI.ps1".

You now have two almost identical files, one named "tinker.ps1" that has the entire script, and one named "tinker_GUI.ps1" that has everything except the last few lines.

Now go back to the "tinker.ps1" tab and with all the text still selected, Edit / Paste.

Now you have transferred all the GUI-building pieces of the script to an external file named "tinker_GUI.ps1", and left just a little bit of code in the original file, "tinker.ps1".

But the original "tinker.ps1" no longer knows anything about the code in the external "tinker_GUI.ps1" file, so we'll have to tell it about the external file. Here now is what the "tinker.ps1" file should look like, with code in bold that you need to add:


# Offload GUI-building into a different file to decrease clutter here.
. "$PSScriptRoot\tinker_GUI.ps1"

# The computer is now looping forever waiting for user input.
# Once the input is the OK button or the Window's X (or
# keyboard equivalent), the window closes and the code below runs.
If ($Win.DialogResult -eq "OK") {
    Write-Host("The OK button was pressed. The name in the box is `"$($NameBox.Text)`".")
} elseif ($Win.DialogResult -eq "Cancel") {
    Write-Host("The X was pressed. I`'m not going to tell you the name that is in the box.")
} # end of If

Save the file, and try running it. Note that the "tinker.ps1" tab must be the active tab to run the program. If the active tab is tinker_GUI.ps1, that portion of code will run, but the tinker.ps1 portion of code won't run. In this case, it really doesn't matter all that much. That won't always be the case, though; you need to run the tab that contains the "main" piece of code.

This dot-sourcing trick essentially tells the first script to go find the referenced file and paste that file's contents in at this dot-source location. When the script runs, that second file's contents replace this line, so it's just like all that second file's contents are in this file, but without the eyeball-clutter of having those contents actually in this file.

Now we can focus just on the mechanics of working with the tree. We'll look at that in "Notes About PowerShell: Adding a TreeView to the GUI - Part 2".

Thursday, October 27, 2022

Notes About PowerShell: A More Complex GUI

Building on the simplest GUI (see here); also for more info, see The Lazy Admin), we can modify the parent window's size, title, and background color, with the following script:

# Initialize the PowerShell GUI
Add-Type -AssemblyName System.Windows.Forms
  
# Create a new window form
$ParentWindow = New-Object System.Windows.Forms.Form

# Define the size, title, and background color
$ParentWindow.ClientSize = '500,300'
$ParentWindow.text = "My Parent Window"
$ParentWindow.BackColor = "#ffffff"
  
# Display the form
$ParentWindow.ShowDialog()

Now let's add an "OK" button:

# Initialize the PowerShell GUI
Add-Type -AssemblyName System.Windows.Forms
  
# Create a new window form
$ParentWindow = New-Object System.Windows.Forms.Form

# Define the size, title, and background color
$ParentWindow.ClientSize = '500,300'
$ParentWindow.text = "My Parent Window"
$ParentWindow.BackColor = "#ffffff"
  
# The OK button.
$buttonOK = New-Object 'System.Windows.Forms.Button'
$ParentWindow.Controls.Add($buttonOK)

# Display the form
$ParentWindow.ShowDialog()

When you run this script, you should see a rectangle inside the main window. It doesn't have a label, is in the wrong spot, and doesn't do anything. Let's make some changes. Also, "$ParentWindow" is a lot of typing. We could shorten it by two letters to "$MainWindow", but we don't have any daughter- or subordinate windows, so we could just call it "$Window", but let's save even more typing:

# Initialize the PowerShell GUI.
Add-Type -AssemblyName System.Windows.Forms

# Create a new window form.
$Win = New-Object System.Windows.Forms.Form
# Define the size, title, and background color.
$Win.ClientSize = '500,300'
$Win.text = "My Parent Window"
$Win.BackColor = "#ffffff"

# The OK button.
$Okee-Dokee = New-Object 'System.Windows.Forms.Button'    # Create the button.
$Okee-Dokee.Text = "Okely-dokely, good neighbor!"         # Puts text on the button.
$Okee-Dokee.Location = '400, 240'                         # 100 pixels from the 500 edge.
$Win.Controls.Add($Okee-Dokee)                            # Add the button to the form.
# Display the form
$Win.ShowDialog() 

Better, but still not quite there. Let's add a size spec (and rename the button control back to something more meaningful):

   ...
# The OK button.
$buttonOK = New-Object 'System.Windows.Forms.Button'      # Create the button.
$buttonOK.Text = "Okely-dokely, good neighbor!"           # Puts text on the button.
$buttonOK.Location = '400, 240'                           # 100 pixels from the 500 edge.
$buttonOK.Size = '175, 23'
$Win.Controls.Add($buttonOK)                              # Add the button to the form.
   ...

And now move the button:

   ...
# The OK button.
$buttonOK = New-Object 'System.Windows.Forms.Button       # Create the button.
$buttonOK.Text = "Okely-dokely, good neighbor!"           # Puts text on the button.
$buttonOK.Location = '300, 240'                           # 200 pixels from the 500 edge.
$buttonOK.Size = '175, 23'
$Win.Controls.Add($buttonOK)                              # Add the button to the form.
   ...

Note that it doesn't matter what order these elements are defined, as long as they are defined before the "Add".

Now let's have the button do something. We'll have it close the window, and print the message "OK" to the console. (Allowable results are "None, OK, Cancel, Abort, Retry, Ignore, Yes, No".) We'll also set the button to be the "I accept the window as it is, so close it" button.

# Initialize the PowerShell GUI
Add-Type -AssemblyName System.Windows.Forms

# Create a new window form
$Win = New-Object System.Windows.Forms.Form
# Define the size, title and background color
$Win.ClientSize = '500,300'
$Win.text = "My Parent Window"
$Win.BackColor = "#ffffff"

# The OK button.
$buttonOK = New-Object 'System.Windows.Forms.Button'
$buttonOK.Anchor = 'Bottom, Right'
$buttonOK.text = "Okeley-dokely, good neighbor!"
$buttonOK.Size = '175, 23'
$buttonOK.Location = '300, 240'
$buttonOK.DialogResult = "OK"
$Win.Controls.Add($buttonOK)
$Win.AcceptButton = $buttonOK

# Display the form
$Win.ShowDialog()

Write-Host("The button was pressed, returning the response: $($Win.DialogResult)")

Now let's put a text box on the form, pre-fill it with some text, pre-select that text, and then return whatever text is in the text box when the OK button is pressed. Let's also put a label atop the text. The changes to the script are bolded below.

# Initialize the PowerShell GUI
Add-Type -AssemblyName System.Windows.Forms
  
# Create a new window form
$Win = New-Object System.Windows.Forms.Form
# Define the size, title and background color
$Win.ClientSize = '500,300'
$Win.text = "My Parent Window"
$Win.BackColor = "#ffffff"
  
# The OK button.
$buttonOK = New-Object 'System.Windows.Forms.Button'
$buttonOK.Anchor = 'Bottom, Right'
$buttonOK.text = "Okeley-dokely, good neighbor!"
$buttonOK.Size = '175, 23'
$buttonOK.Location = '300, 240'
$buttonOK.DialogResult = "OK"
$Win.Controls.Add($buttonOK)
$Win.AcceptButton = $buttonOK
  
# The Name textbox.
$NameBox = New-Object "System.Windows.Forms.Textbox"
$NameBox.Size = "175,23"
$NameBox.Location = "10,40"
$NameBox.Text = "Babushka"
$Win.Controls.Add($NameBox)
$Win.ActiveControl = $NameBox
# And its label.
$Label_NameBox = New-Object 'System.Windows.Forms.Label'
$Label_NameBox.Text = "Enter your name:"
$Label_NameBox.Size = "175,23"
$Label_NameBox.Location = '10,20'
$Win.Controls.Add($Label_NameBox)

# Display the form
$Win.ShowDialog()
  
Write-Host("The button was pressed, returning the word: $($Win.DialogResult)")

# The computer is now looping forever waiting for user input.
# Once the input is the OK button or the Window's X (or
# keyboard equivalent), the window closes and the code below runs.
If ($Win.DialogResult -eq "OK")
  {
    Write-Host("The OK button was pressed. The name in the box is `"$($NameBox.Text)`".")
  }
elseif ($Win.DialogResult -eq "Cancel")
  {
    Write-Host("The X was pressed. I`'m not going to tell you the name that is in the box.")
  } # end of If

If you wanted to pre-select just a portion of the default text of the text box:

   ...
$NameBox.Text = "Babushka"
$NameBox.SelectionStart = 4
$NameBox.SelectionLength = 3
$Win.Controls.Add($NameBox)
   ...

Notes About PowerShell: The Simplest GUI

Create the following script (in PowerShell ISE), and then run it:

# Initialize the PowerShell GUI
Add-Type -AssemblyName System.Windows.Forms
  
# Create a new window form
$MasterFrame = New-Object System.Windows.Forms.Form
  
# Display the form
$MasterFrame.ShowDialog()

That's all there is to creating a GUI window using PowerShell. Here's another rendition, just for comparison:

# Import the GUI pieces
Add-Type -AssemblyName System.Windows.Forms

# Create a new form<
$form_1 = New-Object System.Windows.Forms.Form
  
# Display the form
$form_1.ShowDialog()

For a more complex look, see here.

Tuesday, October 25, 2022

Notes About Powershell: Quickie About ADSI in Powershell on non-AD-bound Windows PC, to access AD

Two Methods for Accessing AD Info from Powershell

There are two basic methods for accessing Active Directory information from within Powershell scripts: using Active Directory Service Interfaces (ADSI), and the Powershell ActiveDirectory module.

This article is about ADSI.

ADSI is the method I'm using below. There are two basic ADSI tools used with this method: [adsi] is an accelerator (or "alias") to System.DirectoryServices.DirectoryEntry, which points to actual objects within AD, and [adsisearcher] is an accelerator to System.DirectoryServices.DirectorySearcher, which is used for searching through AD.

Using the ADSI Method for accessing information in Active Directory

Suppose you want to search, from within Powershell, for data in Active Directory on a Windows PC that is not bound to a domain, but is on the same network as a domain server.
 
You'll need to tell the Searcher what domain credentials to use.
 
To do this:
 
# Prompt user for creds; store them in $creds.UserName and $creds.Password
$creds = Get-Credential
 
# Specify the domain we want to search.
$DomainName = "LDAP://mydomain.com/DC=mydomain,DC=com" 

# Create a directory-entry object to that domain, with appropriate creds.
$DirEntry = New-Object `
     -TypeName System.DirectoryServices.DirectoryEntry `
     ArgumentList $DomainName,
     $creds.UserName,
     $($creds.GetNetworkCredential().Password)

We now have the Directory Entry object that points to the root of the Active Directory tree, along with the credentials needed for accessing that root.

Now we're ready to build our Searcher, and then to run it.

$Searcher = New-Object -type System.DirectoryServices.DirectorySearcher

Notice the similarity in types between the Searcher object and the Directory Entry object. That's the difference between [adsi] and [adsisearcher] you might see elsewhere.
 
Now we'll plug our Directory Entry object into the Searcher object:
 
$Searcher.SearchRoot = $DirEntry 

And now we'll do our search (not limiting it in any way; expect a deluge of info).
 
$Searcher.FindAll()

Splooge!

Sunday, October 23, 2022

Notes About Powershell: Getting Credentials

Sometimes when writing a Powershell script you need user credentials. Here are a few ways of doing that. We'll be using "johndoe" as the username, and "SuperSecret" as the password.

The Simplest Way - Hardcode them in your command

This is also the ugliest and least secure way. In this hypothetical case, the commands simply expect the username and password as arguments to the command:

PS > command_1 "johndoe" "SuperSecret"
PS > command_2 "johndoe" "SuperSecret"

Use Variables

Not quite as ugly, but still ugly.

PS > $UserName = "johndoe"
PS > $Password = "SuperSecret"
PS > command_1 $UserName $Password
PS > command_2 $UserName $Password

Prompt the User for a PSCredential Object

Now we're getting to a more secure option.

PS > $Creds = Get-Credential

This will pop up a window, prompting the user to enter his username and password. The script can then access the username with:

$Creds.UserName

and the password with:

$Creds.Password

Assuming our commands take credentials in the format of a PSCredential object, the commands might look like this:

PS > command_1 $Creds
PS > command_2 $Creds

But if they require an actual username/password, you might think that this will work:

PS > command_1 $Creds.UserName $Creds.Password
PS > command_2 $Creds.UserName $Creds.Password

But it won't, because the "$Creds.Password" value is in a special format itself, called a "SecureString". You can see this by simply typing the name of the variable:

PS > $Creds = Get-Credential
cmdlet Get-Credential at command pipeline position 1
Supply values for the following parameters:

PS > $Creds

UserName                     Password
--------                     --------
johndoe  System.Security.SecureString


PS > $Creds.UserName
johndoe

PS > $Creds.Password
System.Security.SecureString

But it's easy to convert that SecureString back into plain text:

PS > $Creds.GetNetworkCredential().Password
SuperSecret

So now our commands would become:

PS > command_1 $Creds.UserName $Creds.GetNetworkCredential().Password
PS > command_2 $Creds.UserName $Creds.GetNetworkCredential().Password
 
Ideally our command would be a Powershell command that natively understands the PSCredential format:

PS > PScommand_1 --Credential $Creds 

Load Up a PSCredential Object From Within the Script

This is another insecure method, but should be known about.

Since, as mentioned before, the Credential object requires a SecureString for the password, we first need to create a SecureString password:

$pwd = ConvertTo-SecureString "SuperSecret" -AsPlainText -Force

Normally the "ConvertTo-SecureString" routine expects its input to be in the format of an encrypted string of text. It's "native languages" are SecureString and Encrypted String. You can see the Encrypted String format by converting the other way:

$pwd | ConvertFrom-SecureString

which will produce something that looks like this:

01000000d08c9ddf0115d1118c7a00c04fc297eb01000000fde23916e5b4734ab18e12ca886af24900000000020000000000106600000001000020000000ff6caf244bddbe34bc6f7c8b9768317738d85d9008de0d3acaac4ee9133bc4870
00000000e80000000020000200000008ce0cd22aa3fcbb938f08f640201e1bb1c91500b679346a02e48815a385101a2200000006e2055f816b37e010e1938e59938d244c6170307ef651db8e95920f0c06b16d540000000f6b72c9148c628
c579209cd761fd0a2d6ebbdfbae888462733b06f08aec293e02f3a62b83ded1a10d24e370ab5b584bc31bf1816273e8761577b0a7455545881

So to be clear, the "ConvertTo.../...From..." routines convert (natively) between SecureString and Encrypted Strings, not to/from plain text. To include the plain text in the process, we have to tell the "ConvertTo..." routine that its input is "AsPlainText", and to get the plain text back out, we have to use a completely different routine, as we did above, the "GetNetworkCredential()" routine.

Now that we have the password as a SecureString, we can create the PSCredential object:

$Creds = New-Object System.Management.Automation.PSCredential ("johndoe", $pwd)

And now you can use the PSCredential object just as we did above:

PS > command_1 $Creds.UserName $Creds.GetNetworkCredential().Password
PC > PScommand_1 --Credential $Creds

Load Up a PSCredential Object From an External File

This is kind of a compromise between the security of prompting the user for the credentials and having the credentials associated with the script itself. The password will be in an Encrypted format, and in a separate file, but it would still be pretty easy for a "hacker" to grab the file and read the password. So it's certainly not secure. You could put the external file on a secure server, but then you'd have to deal with the credentials for logging into the secure server, so all that does is kick the problem down the road a ways. Still, it might be the best we can do without going to extremes.

But the most onerous caveat about this method, at least for general purpose scripts, is that the password encryption can only be decrypted by the same user account on the same computer as was used to do the encryption. You can't write/develop the script that uses this method on one computer, and then run it on another computer, or as a different user.

Some of the following is likely a repeat of what was said above; it's a copy-and-paste from another article I had started/

Reading the Creds From a File

If you don't have a user sitting in front of the computer when the script runs, you can store the username and an encrypted form of the password in a file, beforehand, and then read it from that file when it's needed. Be aware that this is still not secure, but it's more so.

Saving the Credentials to an External File, Beforehand

Not in your script, but just at a Powershell prompt, enter the following:

PS > Set-Content -Path ".\extras.zip" -Value "johndoe"

This step creates a file named "extras.zip", over-writing any existing files of that name, in the current directory. The file contains the username. You can verify the contents of this newly-created file with:

PS > type ".\extras.zip"

As you can see, it's not really a .zip file; it's just a plain text file. But naming it as a .zip is a minor mindgame, hoping to discourage the casual "hackers" from bothering to try and open / look into the file. It's a weak form of "security via obscurity"; probably completely useless, but I know when I'm casually looking at files with which I'm unfamiliar, I tend to look at .txt files and to leave .zip files along. But you can name your file any way you want.

PS > Add-Content -Path ".\extras.zip" -Value $("SuperSecret") | ConvertFrom-SecureString -AsPlainText -Force)

This step adds the username to the now-already-existing "extras.zip" file. The value that gets added to the file is the password, which before getting added to the file, is piped to a converter that takes the plain text of "SuperSecret" and converts it to a SecureString.

You should now have a file named "extras.zip" with something like the following (which you can see with "type .\extras.zip":

johndoe 01000000d08c9ddf0115d1118c7a00c04fc297eb010000001150a84662a6cd45af512286977fabcc00000000020000000000106600000001000020000000943962d675717d4a12cfe44a8a22b6a5f0ca77762c7bb61e6929515af684db5d000000000e80000000020000200000006370779b6f82983d4f417eccad5be5a80b0ae8a71d2820e736862d2b25453ce220000000c96767ea12fd28ab840fcefb49b4f86d84e97f4676e806a8d7511a86532d3c3f400000008a26d44b1d1df760436be9be186cb87a6cca6220364752e435af61188330043ac201f634ff88fd751b17aff9394c00e6ada09a2f1dd93c27702f9802e813f90d

Normally the ConvertTo-SecureString expects its input to be a standard encrypted string of plain text, like the line with numbers above. But we're starting with the plain text of "SuperSecret". If you feed it this plain unencrypted text, it complains. You can see this for yourself with this command:

PS > "SuperSecret" | ConvertTo-SecureString

That's why we use the "-AsPlainText" argument, to override that behavior. You can see this for yourself like so:

PS > 'SuperSecret" | ConvertTo-SecureString -AsPlainText

But then you get another complaint. For security reasons, the command doesn't like to be given secret info out in the open, but you can force it to accept the input anyway, with:

PS > 'SuperSecret" | ConvertTo-SecureString -AsPlainText -Force

and now you see that the result is a SecureString.

However, we can not write this SecureString out to a file, and then recover it later. But what we can do is reverse the process, part-way. We won't reverse it back to a plain-text "SuperSecret", but rather to an encrypted-text form of "SuperSecret", by piping the above command to the counterpart:

PS > 'SuperSecret" | ConvertTo-SecureString -AsPlainText -Force | ConvertFrom-SecureString

The "native language" of these two tools is SecureStrings on one side and encrypted text on the other, so we don't have to specify anything special; it just takes the SecureString in the previous command and converts from that into encrypted text.

Now that we have the credentials stored in an external file, we have to tell our script to read them.

Reading the Credentials from an External File
PS > $contents = Get-Content -Path ".\extras.zip"
PS > $Creds = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $contents[0], $($contents[1] | ConvertTo-SecureString)

Use the PSCredential Object

And now you can use the PSCredential object just as we did above:

PS > command_1 $Creds.UserName $Creds.GetNetworkCredential().Password
PC > PScommand_1 --Credential $Creds