powershell

How do I refocus a Powershell script after a Start-Process?


I have a Pwsh script with a menu performing several different operations to help me on a daily basis.

For one of the operations, I launch a process with Start-Process, except that it focuses the launched window, but for ease I would like the script to be focused again after the launch and not the launched process.

I don't want to use the “PassThru” or “NoNewWindow” agruments because I don't want the launched process to launch itself in the script, nor “WindowStyle” with Minimized because that focuses the launched process anyway, nor Hidden because I still need to see the process window somewhere.

I've tried this method that seems to work best:

Start-Process -FilePath "MyProcessPath"

Start-Sleep -Seconds 1

Add-Type @"
using System;
using System.Runtime.InteropServices;
public class WinAp {
 [DllImport("user32.dll")]
 [return: MarshalAs(UnmanagedType.Bool)]
 public static extern bool SetForegroundWindow(IntPtr hWnd);

 [DllImport("user32.dll")]
 [return: MarshalAs(UnmanagedType.Bool)]
 public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
}
"@

$p = Get-Process | Where-Object { $_.MainWindowTitle -like "*ScriptTitle*" -and $_.ProcessName -eq "powershell" }

$h = $p.MainWindowHandle
[void] [WinAp]::SetForegroundWindow($h)
[void] [WinAp]::ShowWindow($h, 1)

But unfortunately, it doesn't work. It does seem to do something to the window because it flashes in the taskbar, but it doesn't focus. It seems that although the script is run as administrator, it doesn't work to focus a Admin window like that?

However, for another part of my script, I did this:

$tempPath = [System.IO.Path]::GetTempFileName().Replace(".tmp", ".vbs")
$code = @'
Set WshShell = WScript.CreateObject("WScript.Shell")
WshShell.AppActivate "App.exe"
WScript.Sleep 250
WshShell.SendKeys "{ENTER}"
'@

$code | Out-File $tempPath -Encoding ASCII
Start-Process "wscript.exe" $tempPath -Wait
Remove-Item $tempPath

And it works fine... it focuses the window, and then it presses the requested key (even though this part isn't necessary in my current problem). I tried for my script, but.. nothing. Especially as the App is launched with the script, so as administrator too I guess? So I don't understand why it works, but not for the script itself.

Thank you!


Solution

  • Windows by default prevents activating arbitrary windows programmatically, unless the calling process owns the active (focused) foreground window at the time of the call, loosely speaking; the exact criteria are listed in the "Remarks" section of the SetForegroundWindow() help topic.

    If the criteria aren't met, a process' attempt to programmatically set the foreground window results in the target process' taskbar icon flashing a few times, with no change in the active window, which is what you saw.
    (Whether or not your script is run with elevation (as administrator) is irrelevant.)

    This happens in your case, because if Start-Process launches a process that creates a (non-transient) window, the latter receives the focus (is activated),[1] and therefore prevents your PowerShell script from reactivating its own console window later.

    You can (temporarily) lift this restriction with a P/Invoke call to the SystemParametersInfo WinAPI function, via setting its SPI_SETFOREGROUNDLOCKTIMEOUT parameter to 0.


    The following is a self-contained example that demonstrates the solution; for simplicity, it uses the WScript.Shell COM object's .AppActivate() method to reactivate the PowerShell's session console window, using the session's process ID, as reflected in the automatic $PID variable.[2]

    # Disable the restriction on programmatically activating arbitrary windows
    # from processes not owning the current foreground window, 
    # for the remainder session of the user's Windows session.  
    # using a P/Invoke call:
    
    # Compile a helper type...
    $helperType = 
      Add-Type -PassThru -ErrorAction Stop -Namespace ('NS_{0}_{1}' -f ((Split-Path -Leaf $PSCommandPath) -replace '[^a-z0-9]'), $PID) -Name CGetSetForegroundLockTimeout -MemberDefinition @'
        [DllImport("user32.dll", EntryPoint="SystemParametersInfo", SetLastError=true)]
        static extern bool SystemParametersInfo_Set_UInt32(uint uiAction, uint uiParam, UInt32 pvParam, uint fWinIni);
        public static void EnableCrossProcessWindowActivation(bool enable = true) 
        {
          uint timeout = enable ? (uint)0 : (uint)2147483647;
          if (!SystemParametersInfo_Set_UInt32(0x2001 /* SPI_SETFOREGROUNDLOCKTIMEOUT */, 0, timeout /* timeout in msecs. */, 0 /* non-persistent change */)) {
            throw new System.ComponentModel.Win32Exception(System.Runtime.InteropServices.Marshal.GetLastWin32Error(), "Unexpected failure calling SystemParametersInfo() with SPI_SETFOREGROUNDLOCKTIMEOUT. Make sure that the call is made from the process owning the foreground window.");      
          }
        }
    '@
    
    #'# ... and call the method that calls the SystemParametersInfo() WinAPI function
    #       to enable cross-process window activation.
    # IMPORTANT: In order for this call to succeed, it must be made while
    #            this PowerShell script is running in the process that owns
    #            the current foreground window, 
    #            i.e. BEFORE launching your application.
    $helperType::EnableCrossProcessWindowActivation()
    
    # Start your application (Notepad in this example), asynchronously.
    # (Since Notepad is a *GUI* application, you don't strictly need Start-Process here.)
    # This will invariably give the window that Notepad creates the focus. 
    Start-Process notepad.exe
    
    # Sleep a little, to make sure Notepad has started up and received the focus.
    Start-Sleep 2
    
    # Now reactivating this PowerShell session's console window
    # works as intended.
    $null = (New-Object -ComObject WScript.Shell).AppActivate($PID)
    
    # Disable cross-process window activation again.
    # IMPORTANT: Here too the process running this script must be the one that
    #            owns the current foreground window for the call to succeed;
    #            this is what the .AppActivate() call ensured.
    $helperType::EnableCrossProcessWindowActivation($false)
    

    A note re potential alternatives:

    Conceivably, you could try to launch your application in a manner that does not steal focus from your PowerShell script('s console window), which would obviate the need to reactivate the latter.

    For instance, (New-Object -ComObject Shell.Application).ShellExecute('yourApp.exe', '', '', '', 4) may launch yourapp.exe without changing the active window; whether it does depends on whether it was designed to honor the requested startup window style - and it seems that few applications do.

    Also, with window style 4, while the application's window does not become active, it can still obscure your PowerShell script's console.

    If acceptable, style 7 starts the application's window minimized without stealing the focus, which would prevent the obscuration problem, but would require users to discover the launched application via Alt-tabbing or clicking on its taskbar icon.
    However, this too will only work with applications designed to honor startup window styles.

    The .ShellExecute() method of the Shell.Application COM object and the available window styles are documented here.

    [Microsoft.VisualBasic.Interaction]::Shell() offers very similar functionality.


    [1] This also applies if you launch a GUI application directly, without Start-Process. If you do use Start-Process to launch a GUI application, even -WindowStyle Hidden does not prevent loss of focus. GitHub issue #24387 is a feature request asking that that the ability to launch an application without its receiving focus be added to Start-Process, but it was declined, on the grounds that, due to PowerShell (Core) 7's cross-platform nature, Start-Process should not be enhanced with Windows-only features.

    [2] Note that this approach, like the .MainWindowHandle one in the question, only works if the PowerShell process owns the console window it runs in. While this is true for directly launched PowerShell sessions (e.g., from the Start Menu or the taskbar), it is not for PowerShell processes launched from another console application, say from a cmd.exe session. You can use the following alternative for reactivation then, which too requires a P/Invoke approach (which could be combined with the previous one into a single Add-Type call):
    (Add-Type -PassThru -Name ConsoleActivator -Namespace WinApiHelper -MemberDefinition ' [DllImport("kernel32.dll")] static extern IntPtr GetConsoleWindow(); [DllImport("user32.dll")] static extern bool SetForegroundWindow(IntPtr hWnd); public static void ActivateOwnConsoleWindow() { SetForegroundWindow(GetConsoleWindow()); } ')::ActivateOwnConsoleWindow()