Override functions multiple times for inheritant call stack logging - windows

Let's assume the following CmdLets:
Write-MyColl
function Write-MyColl {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true, ValueFromPipeline = $true)]
[psobject] $InputObject
)
$verb = Get-Command Write-Verbose
$fixedName = $PSCmdlet.MyInvocation.InvocationName
function Write-Verbose ($Object) {
& $verb "[$fixedName] $Object"
}
if ($InputObject.name) {
Write-Verbose "Writing `$InputObject's Name: `"$($InputObject.name)`""
}
}
New-MyColl
function New-MyColl {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string[]] $Names
)
$verb = Get-Command Write-Verbose
$fixedName = $PSCmdlet.MyInvocation.InvocationName
function Write-Verbose ($Object) {
& $verb "[$fixedName] $Object"
}
Write-Verbose "Names: $Names"
foreach ($name in $Names) {
#{
id = 123;
name = $name
} | Write-MyColl
}
}
As you can see New-MyColl overrides Microsoft.PowerShell.Utility\Write-Verbose with it's own Write-Verbose. This works perfectly and outputs:
VERBOSE: [New-MyColl] Names: [...]
Now, when Write-MyColl is invoked it should override Function:\Write-Verbose from New-MyColl and I want it to output something like:
VERBOSE: [New-MyColl] [Write-MyColl] Writing `$InputObject's Name: "[...]"
As you might already think, it doesn't work. Write-Verbose recursively calls itself until the end of days. I already tried local function definitions and local variables, but they won't be visible to the invoked CmdLet.
I think for my specific use-case, I will use Get-PsCallStack, however I'd love to see a solution to override overriden functions like described above.
What's hard for me to understand is, why the 2nd Write-Verbose calls itself, so $verb seemingly points to a function, which is not yet defined. Shouldn't it point to the prior defined function? I already tried to Remove-Item the funtion at the end of the script, but it didn't change anything.
Do you have any idea how to achieve this call-stack-behavior?

Thanks to PetSerAI's comment, I came to the following solution:
New-Item function::local:Write-Verbose -Value (
New-Module -ScriptBlock { param($verb, $fixedName, $verbose) } -ArgumentList #(
(Get-Command Write-Verbose),
$PSCmdlet.MyInvocation.InvocationName,
$PSCmdlet.MyInvocation.BoundParameters["Verbose"].IsPresent
)
).NewBoundScriptBlock{
param($Message)
if ($verbose) {
& $verb -Message "=>$fixedName $Message" -Verbose
} else {
& $verb -Message "=>$fixedName $Message"
}
} | Write-Verbose
The -Verbose Parameter isn't passed to the newly created function; that's why I added it to the -ArgumentList. I'm sure, it's not very pretty, but it does exactly what it should. Overwrite Write-Verbose for each call.
Now the output looks like:
VERBOSE: =>[New-MyColl] Names: [...]
VERBOSE: =>[New-MyColl]=>[Write-MyColl] Writing `$InputObject's Name: "[...]"

Related

PowerShell terminate all RDP sessions of a user

I need a script that terminates all RDP sessions of an AD user.
Only the username should be given, whereupon the script terminates all RDP sessions of this user (if necessary also enforces them).
Unfortunately, the Get-RDUserSession cmdlet does not work (the ConnectionBroker cannot be found).
Unfortunately, I cannot process the result of the CMD command qwinsta in PowerShell.
Any ideas or tips?
Thank you.
You can create custom objects from qwinsta's output, filter them and use rwinsta to kill the session.
Function Get-TSSessions
{
param (
[Parameter(Mandatory = $true, Position = 0 )]
[String]$ComputerName
) # End Parameter Block
qwinsta /server:$ComputerName |
ForEach-Object{
If($_ -notmatch "SESSIONNAME")
{
New-Object -TypeName PSObject -Property `
#{
"ID" = [Int]$_.SubString(41,05).Trim()
"ComputerName" = $Computer
"User" = $_.SubString(19,22).Trim()
"State" = $_.SubString(47,08).Trim()
}
}
}
} # End Function Get-TSSessions
Get-TSSessions -ComputerName <ServerName> |
Where-Object{$_.User -eq "SomeUser"} |
ForEach{ & "rwinsta /Server:$($_.ComputerName) $($_.ID)" }
Obviously, you can improve by wrapping up the rwinsta command in its own function. At the moment I only have reporting work written around this sort of thing, so in the spirit of answering the question without writing the whole thing, this should get you through.
Also, I believe there are a number of scripts and functions available for this on the PowerShell Gallery. In fact, I think there were functions Get/Stop-TerminalSession in the PowerShell Community Extensions, which you can install as a module.
param
(
[Parameter(Mandatory = $false,
HelpMessage = 'Specifies the user name (SamAccountName).',
DontShow = $false)]
[SupportsWildcards()]
[ValidateNotNullOrEmpty()]
[ValidateScript({
Import-Module -Name 'ActiveDirectory' -Force
if (Get-ADUser -Filter "sAMAccountName -eq '$_'") {
return $true
} else {
return $false
}
})]
[string]$Username = $env:USERNAME
)
$ErrorActionPreference = 'SilentlyContinue'
Import-Module -Name 'ActiveDirectory' -Force
foreach ($system in (Get-ADComputer -Filter ("Name -ne '$env:COMPUTERNAME' -and OperatingSystem -like 'Windows Server*'"))) {
[string]$system = $system.Name
$session = ((quser /server:$system | Where-Object {
$_ -match $Username
}) -split ' +')[3]
if ($session) {
logoff $session /server:$system
}
}

How to correctly compose Invoke-Expression command to build variable value based on config file values

Please see latest code that is now working, there is no longer any need for any Invoke cmdlet:
$ClassificationList = $null
$classifications = $null
$ClassificationList = $ConfigFile.Settings.Project.Classifications
If ( $ClassificationList )
{
$ClassificationList = $ClassificationList -replace ',','|'
$classifications = $wsus.GetUpdateClassifications() |
where title -match $ClassificationList
$updatescope.Classifications.Clear()
$updatescope.Classifications.AddRange($classifications)
}
Original Question:
This question has been condensed to avoid confusion.
When executing the below code:
$ScriptText =
#"
`$classifications = `$wsus.GetUpdateClassifications() |
? {
$_.Title -eq 'Critical Updates' `
-OR `
$_.Title -eq 'Security Updates' `
-OR `
$_.Title -eq 'Definition Updates'
}
"#
$scriptBlock = [Scriptblock]::Create($ScriptText)
Invoke-Command -ScriptBlock {$scriptBlock}
Write-Host $classifications
The variable $classifications does not get populated, but executing the code without wrapping it into a script block works fine. I am trying to read from a config file all classifications I want to search WSUS for and dynamically add them to the above script, but executing that script when it is built does not appear to work, though no errors are thrown.
I would do it this way.
$wsus.GetUpdateClassifications() |
where title -match 'critical updates|security updates|definition updates'
Don't define your code as a string and then put that string in a scriptblock.
Invoke-Command -Scriptblock {$ScriptText}
If you must create a scriptblock from a string you'd do it like this:
$ScriptText = "if ( 1 -ne 2 ) {
Write-Host 'Hello'
} else {
Write-Host 'GoodBye'
}"
Invoke-Command -ScriptBlock ([Scriptblock]::Create($ScriptText))
However, normally you'd create the scriptblock as a literal, either as a variable
$scriptblock = {
if ( 1 -ne 2 ) {
Write-Host 'Hello'
} else {
Write-Host 'GoodBye'
}
}
Invoke-Command -ScriptBlock $scriptblock
or inline
Invoke-Command -ScriptBlock {
if ( 1 -ne 2 ) {
Write-Host 'Hello'
} else {
Write-Host 'GoodBye'
}
}

End script when Powershell input validation fails or generates exception

I have the following code in my powershell script to validate user input (The first positional argument in the script):
function validatePath {
Param
(
[Parameter(Mandatory=$true)]
[ValidateScript({
If ($_ -match "^([a-z]:\\(?:[-\\w\\.\\d])*)") {
$True
} Else {
Write-Host "Please enter a valid path,$_ is not a valid path."
Write-debug $_.Exception
Break
}
})]
[string]$filePath
)
Process
{
Write-Host "The path is "$filePath
}
}
validatePath -filePath $args[0]
My problem is, currently when the validation fails, and the code goes in the Else block and hits Break, instead of stopping the entire script from continue running, it goes to the next block and everything continues and more errors come out.
Question is, how can I modify my code so that When the validation fails to match the regex, it will throw an appropriate error message and stops the entire script from running?
Set the $ErrorActionPreference variable inside the script to Stop:
$ErrorActionPreference = 'Stop'
function validatePath {
Param
(
[Parameter(Mandatory=$true)]
[ValidateScript({
If ($_ -match "^([a-z]:\\(?:[-\\w\\.\\d])*)") {
$True
} Else {
Write-Host "Please enter a valid path,$_ is not a valid path."
Write-debug $_.Exception
Break
}
})]
[string]$filePath
)
Process
{
Write-Host "The path is "$filePath
}
}
validatePath -filePath $args[0]
Do-MoreStuff # this won't execute if parameter validation fails in the previous call
See the about_Preference_Variables help file for more information
use "exit" to stop the entire script. Put any message before the "exit".

Powershell: Export User Rights Assignment

I'm new to PowerShell (PS). Currently I'm using windows server 2012 and I'm interested to know whether there is any way to export User Rights Assignment into a txt file. I tried
secedit /export /areas USER_RIGHTS /cfg d:\policies.txt
The above should should export it.
So, I get this: Current Output.
Is there any way to export User Rights Assignment and make it look like (even with using batch files): Expected Output.
P.S
Is There anyway to output those values in console? So i would be enable to redirect them to a txt file.
Here's a PowerShell script that outputs usable objects with translated names and SIDs:
#requires -version 2
# Fail script if we can't find SecEdit.exe
$SecEdit = Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::System)) "SecEdit.exe"
if ( -not (Test-Path $SecEdit) ) {
Write-Error "File not found - '$SecEdit'" -Category ObjectNotFound
exit
}
# LookupPrivilegeDisplayName Win32 API doesn't resolve logon right display
# names, so use this hashtable
$UserLogonRights = #{
"SeBatchLogonRight" = "Log on as a batch job"
"SeDenyBatchLogonRight" = "Deny log on as a batch job"
"SeDenyInteractiveLogonRight" = "Deny log on locally"
"SeDenyNetworkLogonRight" = "Deny access to this computer from the network"
"SeDenyRemoteInteractiveLogonRight" = "Deny log on through Remote Desktop Services"
"SeDenyServiceLogonRight" = "Deny log on as a service"
"SeInteractiveLogonRight" = "Allow log on locally"
"SeNetworkLogonRight" = "Access this computer from the network"
"SeRemoteInteractiveLogonRight" = "Allow log on through Remote Desktop Services"
"SeServiceLogonRight" = "Log on as a service"
}
# Create type to invoke LookupPrivilegeDisplayName Win32 API
$Win32APISignature = #'
[DllImport("advapi32.dll", SetLastError=true)]
public static extern bool LookupPrivilegeDisplayName(
string systemName,
string privilegeName,
System.Text.StringBuilder displayName,
ref uint cbDisplayName,
out uint languageId
);
'#
$AdvApi32 = Add-Type advapi32 $Win32APISignature -Namespace LookupPrivilegeDisplayName -PassThru
# Use LookupPrivilegeDisplayName Win32 API to get display name of privilege
# (except for user logon rights)
function Get-PrivilegeDisplayName {
param(
[String] $name
)
$displayNameSB = New-Object System.Text.StringBuilder 1024
$languageId = 0
$ok = $AdvApi32::LookupPrivilegeDisplayName($null, $name, $displayNameSB, [Ref] $displayNameSB.Capacity, [Ref] $languageId)
if ( $ok ) {
$displayNameSB.ToString()
}
else {
# Doesn't lookup logon rights, so use hashtable for that
if ( $UserLogonRights[$name] ) {
$UserLogonRights[$name]
}
else {
$name
}
}
}
# Outputs list of hashtables as a PSObject
function Out-Object {
param(
[System.Collections.Hashtable[]] $hashData
)
$order = #()
$result = #{}
$hashData | ForEach-Object {
$order += ($_.Keys -as [Array])[0]
$result += $_
}
New-Object PSObject -Property $result | Select-Object $order
}
# Translates a SID in the form *S-1-5-... to its account name;
function Get-AccountName {
param(
[String] $principal
)
if ( $principal[0] -eq "*" ) {
$sid = New-Object System.Security.Principal.SecurityIdentifier($principal.Substring(1))
$sid.Translate([Security.Principal.NTAccount])
}
else {
$principal
}
}
$TemplateFilename = Join-Path ([IO.Path]::GetTempPath()) ([IO.Path]::GetRandomFileName())
$LogFilename = Join-Path ([IO.Path]::GetTempPath()) ([IO.Path]::GetRandomFileName())
$StdOut = & $SecEdit /export /cfg $TemplateFilename /areas USER_RIGHTS /log $LogFilename
if ( $LASTEXITCODE -eq 0 ) {
Select-String '^(Se\S+) = (\S+)' $TemplateFilename | Foreach-Object {
$Privilege = $_.Matches[0].Groups[1].Value
$Principals = $_.Matches[0].Groups[2].Value -split ','
foreach ( $Principal in $Principals ) {
Out-Object `
#{"Privilege" = $Privilege},
#{"PrivilegeName" = Get-PrivilegeDisplayName $Privilege},
#{"Principal" = Get-AccountName $Principal}
}
}
}
else {
$OFS = ""
Write-Error "$StdOut"
}
Remove-Item $TemplateFilename,$LogFilename -ErrorAction SilentlyContinue
in addition to Eric's change i also needed to add a try catch to one of the functions in Bill_Stewart's post. if the SID being translated is from an object that no longer exists this will return the SID instead of sending an error for translate.
# Translates a SID in the form *S-1-5-... to its account name;
function Get-AccountName {
param(
[String] $principal
)
if ( $principal[0] -eq "*" ) {
$sid = New-Object System.Security.Principal.SecurityIdentifier($principal.Substring(1))
Try {$out = $sid.Translate([Security.Principal.NTAccount])}
catch
{
$out = $principal
}
$out
}
else {
$principal
}
}
Great script overall. Thank you for your efforts. One change I needed to make however to get it to output all principals assigned a right was to change the regex to '^(Se\S+) = (.+)' so that principals that were already resolved with a space in the name such as 'Domain users' were matched. Before that it would just report 'Domain.'
To save the output to a file, add a >> filename after the closing bracket of the last foreach-object
Ex:
}
} >> 'outFile.txt'
or to output as delimited file (e.g., csv) use the following:
} | convertto-csv -delimiter '~' -notypeinformation >> 'outFile.txt'
Hope this helps.

powershell Gui progress bar with batch file

I found this gui progress bar on a different site. The way that it explains, should solve my problem which when the job is running not let the gui freeze.
However, since I'm dealing with batch files (installing applications) I need to do a foreach app and install them one by one and not let the gui freeze.
Here is the link to the site link
EDIT: UPDATE the SCRIPT. so far this works but all batch files install at the same time which is causing them to fail. I have more applications but for testing I just added 3.
I am only assuming that the $job is not passing its status to "updatescript" and "completedscript"
$Appname = #("Adobe_FlashPlayer", "Acrobat_Reader, "Microsoft_RDP_8.1")
$formJobProgress_Load={
#TODO: Initialize Form Controls here
$timer1.Interval = 1000
$timer1.Tag = 0
$timer1.Start()
[System.Windows.Forms.Application]::DoEvents()
}
$formMain_FormClosed=[System.Windows.Forms.FormClosedEventHandler]{
#Event Argument: $_ = [System.Windows.Forms.FormClosedEventArgs]
#Stop any pending jobs
Stop-JobTracker
}
$timerJobTracker_Tick={
Update-JobTracker
}
#region Job Tracker
$JobTrackerList = New-Object System.Collections.ArrayList
function Add-JobTracker
{
Param (
#[ValidateNotNull()]
#[Parameter(Mandatory = $true)]
[string]$Name,
#[ValidateNotNull()]
#[Parameter(Mandatory = $true)]
[ScriptBlock]$CompletedScript,
[ScriptBlock]$UpdateScript,
[ScriptBlock]$JobScript,
$ArgumentList = $null)
$job = Start-Job -ScriptBlock $JobScript -ArgumentList $ArgumentList
if($job -ne $null)
{
#Create a Custom Object to keep track of the Job & Script Blocks
$psObject = New-Object System.Management.Automation.PSObject
Add-Member -InputObject $psObject -MemberType 'NoteProperty' -Name Job -Value $job
Add-Member -InputObject $psObject -MemberType 'NoteProperty' -Name CompleteScript -Value $CompletedScript
Add-Member -InputObject $psObject -MemberType 'NoteProperty' -Name UpdateScript -Value $UpdateScript
[void]$JobTrackerList.Add($psObject)
#Start the Timer
if(-not $timerJobTracker.Enabled)
{
$timerJobTracker.Start()
}
}
elseif($CompletedScript -ne $null)
{
#Failed
Invoke-Command -ScriptBlock $CompletedScript -ArgumentList $null
}
}
function Update-JobTracker
{
<#
.SYNOPSIS
Checks the status of each job on the list.
#>
#Poll the jobs for status updates
$timerJobTracker.Stop() #Freeze the Timer
for($index =0; $index -lt $JobTrackerList.Count; $index++)
{
$psObject = $JobTrackerList[$index]
if($psObject -ne $null)
{
if($psObject.Job -ne $null)
{
if($psObject.Job.State -ne "Running")
{
#Call the Complete Script Block
if($psObject.CompleteScript -ne $null)
{
#$results = Receive-Job -Job $psObject.Job
Invoke-Command -ScriptBlock $psObject.CompleteScript - ArgumentList $psObject.Job
}
$JobTrackerList.RemoveAt($index)
Remove-Job -Job $psObject.Job
$index-- #Step back so we don't skip a job
}
elseif($psObject.UpdateScript -ne $null)
{
#Call the Update Script Block
Invoke-Command -ScriptBlock $psObject.UpdateScript -ArgumentList $psObject.Job
}
}
}
else
{
$JobTrackerList.RemoveAt($index)
$index-- #Step back so we don't skip a job
}
}
if($JobTrackerList.Count -gt 0)
{
$timerJobTracker.Start()#Resume the timer
}
}
function Stop-JobTracker
{
<#
.SYNOPSIS
Stops and removes all Jobs from the list.
#>
#Stop the timer
$timerJobTracker.Stop()
#Remove all the jobs
while($JobTrackerList.Count -gt 0)
{
$job = $JobTrackerList[0].Job
$JobTrackerList.RemoveAt(0)
Stop-Job $job
Remove-Job $job
}
}#endregion
$buttonStartJob_Click= {
$progressbaroverlay1.Value = 0
$progressbaroverlay1.Step = 1
$progressbaroverlay1.Maximum = $Appname.Count
$this.Enabled = $false
$buttonStartJob.Enabled = $false
#Create a New Job using the Job Tracker
foreach ($app in $Appname)
{
$install = "C:\pstools\Update\cmd\$app\install.cmd"
$run = "C:\Windows\System32\cmd.exe"
Add-JobTracker -Name "test"`
-JobScript {
param (
[string]$batchFilePath
)
Write-Verbose "Launching: [$batchFilePath]" -Verbose
Set-Location $env:windir
& ($run, $batchFilePath)
}` -ArgumentList $install -CompletedScript {
Param ($Job)
#$progressbar1.Value = 100
#Enable the Button
$ProgressBarOverlay1.PerformStep()
$buttonStartJob.ImageIndex = -1
$buttonStartJob.Enabled = $true
}`
-UpdateScript {
Param ($Job)
$results = Receive-Job -Job $Job | Select-Object -Last 1
if ($results -is [int])
{
$progressbaroverlay1.Value = $results
}
}
}
}
$timer1_Tick={
#TODO: Place custom script here
#if ([timespan]::FromSeconds($timerUpdate.Tag) -ge [timespan]::Fromminutes(1))
IF($progressbaroverlay1.Value -eq 100)
{
$timerUpdate.stop()
$formSampleTimer.Close()
}
else
{
[System.Windows.Forms.Application]::DoEvents()
$label1.Text = [timespan]::FromSeconds($timer1.Tag++)
}
}
I am assuming that you either copied this from somewhere and modified it to kind of work for you, or you used something like PowerGUI or Sapien to generate the script for you because this is seriously hard to read code in my opinion.
If I understand it right you create $JobTrackerList as an ArrayList object. Then for each application that you want to install you add a PSCustomObject to that ArrayList with 3 properties, one being a Job that runs in the background, one being a script to run when that is completed, and one being a script to update your progress bar. So each object has a background job that is running, and this is the root issue at this point. But wait, we're not done, you have a timer going on (not actually defined in the code you gave us) that runs, and every 1000 ticks it stops, checks each job, if it is completed it runs the "completed" scriptblock that you defined for that object, and then removes the object from the ArrayList. If it is still not completed it updates the progressbar with the "update" scriptblock. Then it starts the timer again.
Here's what I think needs to happen for this to work like you want... You need to go ahead and make your ArrayList, and add objects to it for each application that you want to run, but do not create jobs for them all! Once you create all the objects start a job for the first application (and add it as a member of the object like you did before). Then when your script checks for completed jobs, before it deletes the object it is looking at it should start the job for the next object.
So, that's going to require a bit of re-write of your code. I'm not sure where to really start with this, because the code that you have is so very different to how I would have written it, so I've given you my suggestions, and now I'll let you run with it from here. If you need help writing the code (at least try first) let me know and I'll see what I can do. Probably not today, I'm dead tired and not on my A game, but I'm sure I can come up with something tomorrow if you end up needing help.
Edit: Ok, I think I have code that should do what you want, and work with what you already have in place:
$formJobProgress_Load={
#TODO: Initialize Form Controls here
$timer1.Interval = 1000
$timer1.Tag = 0
$timer1.Start()
[System.Windows.Forms.Application]::DoEvents()
}
$timerJobTracker_Tick={
$timerJobTracker.Stop() #Freeze the Timer
for($index =0; $index -lt $JobTrackerList.Count; $index++){
$psObject = $JobTrackerList[$index]
if($psObject -ne $null -AND $psObject.Job -ne $null -AND $psObject.Job.State -ne "Running"){
#Perform old 'Complete' scriptblock to update progressbar
$ProgressBarOverlay1.PerformStep()
#Check if this is the only job, and if not start the next one
If($JobTrackerList.count -gt 1){
$NextJob = $JobTrackerList[($index+1)]
$NextJob.Job = Start-Job -ScriptBlock $NextJob.JobScript
}
$JobTrackerList.RemoveAt($index)
Remove-Job -Job $psObject.Job
$index-- #Step back so we don't skip a job
}Else{
$results = Receive-Job -Job $Job | Select-Object -Last 1
if ($results -is [int]){
$progressbaroverlay1.Value = $results
}
}
}
if($JobTrackerList.Count -gt 0){
$timerJobTracker.Start()#Resume the timer
}
}
$formMain_FormClosed=[System.Windows.Forms.FormClosedEventHandler]{
#Stop the timer
$timerJobTracker.Stop()
#Remove all the jobs
while($JobTrackerList.Count -gt 0){
$job = $JobTrackerList[0].Job
$JobTrackerList.RemoveAt(0)
Stop-Job $job
Remove-Job $job
}
}
$Appname = #("Adobe_FlashPlayer", "Acrobat_Reader", "Microsoft_RDP_8.1")
$JobTrackerList = New-Object System.Collections.ArrayList
$buttonStartJob_Click= {
$progressbaroverlay1.Value = 0
$progressbaroverlay1.Step = 1
$progressbaroverlay1.Maximum = $Appname.Count
$this.Enabled = $false
$buttonStartJob.Enabled = $false
ForEach($App in $Appname){
$install = "C:\pstools\Update\cmd\$app\install.cmd"
$run = "C:\Windows\System32\cmd.exe"
$JobScript = {
Write-Verbose "Launching: [$install]" -Verbose
Set-Location $env:windir
& $install
}
[void]$JobTrackerList.Add((New-Object PSObject -Property #{
'Application' = $Name
'JobScript' = $JobScript
'Job' = $null
}))
}
$JobTrackerList[0].Job = Start-Job -ScriptBlock $JobTrackerList[0].JobScript
}
$timer1_Tick={
#TODO: Place custom script here
#if ([timespan]::FromSeconds($timerUpdate.Tag) -ge [timespan]::Fromminutes(1))
IF($progressbaroverlay1.Value -eq 100)
{
$timerUpdate.stop()
$formSampleTimer.Close()
}
else
{
[System.Windows.Forms.Application]::DoEvents()
$label1.Text = [timespan]::FromSeconds($timer1.Tag++)
}
}
I hope that works for you.

Resources