One hotkey moves your whole Windows session between a desk setup and a TV: the primary display, every open window, and the default audio device. Press it again to come back.
Built from PowerShell, AutoHotkey v2 for the hotkey, and two NirSoft freeware tools that are included in the repo: MultiMonitorTool (the primary-monitor switch) and NirCmd (the tray balloon).
- Reads which monitor is primary right now and picks the other setup. There is no state file.
- Sets the new primary and polls until Windows confirms it. If it never does, nothing else happens.
- Moves every window to the new primary, or where your rules say, keeping each window's minimized / maximized / normal state. Every move is verified and retried once.
- Nudges the taskbar (Windows 11 sometimes leaves the old primary's taskbar blank).
- Switches the default audio device to the one that belongs to the destination monitor.
- Shows a tray balloon with the result and writes
logs\switch-YYYY-MM-DD.log.
- Clone or download the repo.
- Copy
config.example.jsontoconfig.json(git-ignored, so it stays yours). - Find your monitor IDs and put them in
config.json:Use the Short Monitor ID column (e.g..\switch.ps1 -DryRun # lists every monitor it can see, then the plan; changes nothing .\switch.ps1 -ListAudio # every active playback device and which monitor it belongs to
AOC2401), the serial number, or the\\.\DISPLAYnname. Never a bare number: to MultiMonitorTool2means\\.\DISPLAY2, and Windows renumbers those on driver installs and hot-plugs. - Register the elevated scheduled task once, from an admin shell in this folder. It is what
lets admin windows (an elevated Terminal, Task Manager) move too. Edit the two paths in
MonitorSwitcher-Task.xmlfirst if the repo is not atC:\Users\<you>\dev\monitor_switcher:schtasks /Create /XML "MonitorSwitcher-Task.xml" /TN "MonitorSwitcher" /F
- Run
switch.ahk(double-click). For startup, put a shortcut to it inshell:startup.
| Key | Does |
|---|---|
| Ctrl+Alt+S (or V) | Switch, elevated, via the scheduled task. Falls back to a direct non-elevated run with a tooltip if the task is not registered. |
| Ctrl+Alt+M | Switch directly, non-elevated. Admin windows will not move. |
| Ctrl+Alt+D | Dry run: writes the plan to the log, changes nothing. |
| Ctrl+Alt+X | Monitors off, PC stays on. Move the mouse to wake. |
Edit switch.ahk to change them. After editing, right-click the AutoHotkey tray icon → Reload Script.
.\switch.ps1 # toggle: desk -> couch or couch -> desk, from the live primary
.\switch.ps1 -To desk # force a direction, or re-apply the desk layout without switching
.\switch.ps1 -DryRun # plan only
.\switch.ps1 -ListAudio # active playback endpoints, each with the monitor it belongs to{
"modes": {
"desk": { "monitor": "AOC2401", "audio": { "monitor": "RTK3B3D" }, "defaultPlace": "keep" },
"couch": { "monitor": "MST0030", "audio": { "monitor": "MST0030" }, "defaultPlace": "max" }
},
"audioRoles": [0, 1, 2],
"fallbackMode": "desk",
"rules": [
{ "when": "desk", "match": { "process": "Discord.exe" }, "monitor": "RTK3B3D", "place": "max" },
{ "when": "desk", "match": { "title": "WhatsApp" }, "monitor": "GSM76F9", "place": "right" },
{ "when": "desk", "match": { "class": "CabinetWClass" }, "place": "normal" }
],
"timing": { "primaryTimeoutMs": 8000, "settleMs": 750, "verifyDelayMs": 300 },
"taskbarNudge": true,
"notify": true
}modes.desk/modes.couch:monitoris the primary for that setup.audiois{ "monitor": ID }for audio that comes out of a monitor: HDMI/DP audio, or headphones plugged into a monitor's jack. For a non-monitor device use{ "id": ... }or{ "topology": ... }from-ListAudio;{ "name": ... }also works but Windows resets device names on driver installs.defaultPlaceapplies to windows no rule matches:keep(preserve min/max/normal),normal,max,min,left,right.audioRoles: which defaults to set.[0, 1, 2]= console, multimedia, communications, which is what the Settings app sets. Drop2to keep calls on one device.fallbackMode: used when the current primary is neither desk nor couch.rules: first match wins.whenisdesk,couch, or omitted for both.matchtakes any ofprocess(exe name),class,title(substring); all given keys must match.monitordefaults to the new primary.placeas above;left/rightare halves of that monitor's work area, so they survive resolution and scaling changes.timing:primaryTimeoutMs(how long to wait for Windows to confirm the primary),settleMs(pause after confirmation before windows move),verifyDelayMs.
Read the log. Each failed window is listed with a reason:
access denied: elevated window— the run was not elevated. Register the task and use Ctrl+Alt+S.did not land on the destination— the app repositions itself (some games, some UWP apps). Add a rule or live with it.primary did not change ... within N ms— nothing else was touched. MultiMonitorTool's readme notes that on Windows 11 24H2 the cure is to change something in Display settings once, then retry.audio: nothing matches— run-ListAudioand fix theaudioentry.another switch is still running— you pressed twice; the second press is ignored.
Last checked against the code: 2026-10-09, commit e6c1620.
One PowerShell script does all the work in a single run and then exits. Nothing stays running except the AutoHotkey script that listens for the keys.
| Piece | What it is | Its job |
|---|---|---|
switch.ahk |
AutoHotkey v2 script, always running | Listens for the hotkeys and starts a run. Shows a tooltip. |
MonitorSwitcher-Task.xml |
Windows scheduled task with no trigger, run level "highest available" | Starts switch.ps1 elevated on demand, with no UAC prompt. Ignores a new start while one is running and stops a run after 1 minute. |
switch.ps1 |
PowerShell script with two blocks of inline C# | The whole switch: monitors, windows, audio, log. |
MultiMonitorTool.exe |
NirSoft tool, called as a child process | Lists monitors to a CSV file (/scomma) and sets the primary (/SetPrimary). |
nircmd.exe |
NirSoft tool, called as a child process | Tray balloon. Also "monitors off" for Ctrl+Alt+X, called straight from switch.ahk. |
config.json |
Your settings, git-ignored | The two setups, audio, window rules, timing. |
logs\switch-YYYY-MM-DD.log |
One text file per day, git-ignored | Every run appends what it saw, what it planned and what happened. |
The inline C# is compiled when the script starts. One block declares the user32.dll and
dwmapi.dll calls for monitors and windows. The other declares the Windows audio COM interfaces.
flowchart TD
A["Hotkey press"] --> B{"Is another run<br/>still going?"}
B -- yes --> B1["Log it, exit 2"]
B -- no --> C["List monitors,<br/>load config.json"]
C --> D{"Desk and couch monitors<br/>both found and active?"}
D -- no --> X["Log the error, exit 1"]
D -- yes --> E{"Which monitor is<br/>primary right now?"}
E -- "desk monitor" --> F1["Target: couch"]
E -- "couch monitor" --> F2["Target: desk"]
E -- "another monitor" --> F3["Target: fallbackMode"]
F1 & F2 & F3 --> G["Find the audio device,<br/>list the windows,<br/>plan each move"]
G --> H{"Dry run?"}
H -- yes --> H1["Log the plan, exit 0"]
H -- no --> I{"Is the target<br/>already primary?"}
I -- no --> J["Set primary,<br/>poll Windows"]
J --> K{"Confirmed before<br/>the timeout?"}
K -- no --> X
K -- yes --> L["Move every window,<br/>check where it landed"]
I -- yes --> L
L --> M{"On the planned<br/>monitor?"}
M -- no --> N["Retry once,<br/>log it if it still fails"]
M -- yes --> O["Nudge the taskbar,<br/>set default audio,<br/>read it back"]
N --> O
O --> P["Log the summary,<br/>tray balloon, exit 0"]
-To desk or -To couch skips the "which monitor is primary" question and uses the direction given.
The steps in order:
- Lock. The script takes a named mutex. If another run holds it, this run logs a warning and exits with code 2.
- Monitors. MultiMonitorTool writes the monitor list to a CSV file in the temp folder. Each row gives the display name, EDID short ID, serial, whether it is active and whether it is primary. Every monitor is logged before the config is read, so a first run shows the IDs to use.
- Config.
config.jsonis loaded.modes.desk.monitorandmodes.couch.monitorare required. Both monitors must be in the list and active, or the run stops here. - Direction. If the primary is the desk monitor the target is couch, and the other way round.
If the primary is some third monitor, the target is
fallbackMode(desk if that is not set). - Audio lookup. The audio device for the target setup is found now, but not set yet.
- Plan. The script lists the movable windows and decides a destination monitor and a placement for each one. This happens before the switch, while the old positions are still readable. A dry run stops here.
- Primary. MultiMonitorTool sets the new primary. The script then asks Windows every 200 ms
which monitor is primary, until it matches or
primaryTimeoutMsruns out. On a timeout the run stops and no window or audio device has been touched. After a match it waitssettleMs. - Windows. Monitor geometry is read again, because every coordinate shifts when the primary
changes. Each window is moved, then after
verifyDelayMseach one is checked and failures get one retry. - Taskbar. If
taskbarNudgeis on, a settings-changed message goes to every taskbar window. - Audio. The default playback device is set and read back.
- Report. One summary line goes to the log and, if
notifyis on, to a tray balloon.
A top-level window is moved only if all of these are true:
- it is visible and has a title
- its window class is not on the skip list (desktop, taskbars, input-method and shell helper windows)
- it is not cloaked, which rules out windows on another virtual desktop and suspended store apps
- it is not a tool window, and it has no owner window (dialogs follow their owner). A window that marks itself as an app window passes either way.
- it does not belong to the script itself, MultiMonitorTool or NirCmd
- Its normal (restored) rectangle and its min / max / normal state are read with
GetWindowPlacement. - The first rule in
config.jsonthat matches gives the destination monitor and placement. With no rule, the destination is the new primary and the placement is the setup'sdefaultPlace. If a rule names a monitor that is missing or inactive, the window goes to the new primary. - The new rectangle keeps the window's offset from the corner of its old monitor's work area, then
is shrunk and shifted to fit inside the destination's work area.
leftandrightuse half of that work area instead. SetWindowPlacementwrites the new rectangle. A minimized window stays minimized. A window that should be maximized gets a second call, so it maximizes on the monitor it was just put on.- The check asks Windows which monitor the window is on now. If it is the wrong one, the retry
restores the window, forces the rectangle with
SetWindowPos, re-applies maximize if needed and checks again after 150 ms. A second miss is logged as failed with a reason.
The script sets per-monitor DPI awareness before any window call, so all rectangles are in physical pixels on monitors with different scaling.
The audio entry of the target setup is tried in this order. The first hit wins.
| Order | Config key | Matches on |
|---|---|---|
| 1 | monitor |
The playback device whose PnP container ID equals the container ID of that monitor |
| 2 | id |
Exact endpoint ID |
| 3 | topology |
Substring of the endpoint's topology string |
| 4 | name |
Exact device name. A plain string instead of an object is treated as a name. |
Only active playback devices are considered. The device is set as the default for each role in
audioRoles through the Windows IPolicyConfig COM interface. The read-back confirms it against
the multimedia role.
| Failure | Log | Tray balloon | Exit code |
|---|---|---|---|
| A second press while a run is going | warning | no | 2 |
config.json missing |
error with the monitors seen | no (the notify setting was never loaded) |
1 |
| A setup's monitor is missing or inactive | error with the monitors seen | yes | 1 |
| Primary not confirmed in time | error | yes | 1 |
| A window did not move | warning per window with the reason | summary shows the failed count | 0 |
| Audio device not found | warning | summary says "NOT switched" | 0 |
| Audio set but not confirmed | "NOT CONFIRMED" | summary says "(NOT confirmed)" | 0 |
The reasons are in the next section. This table is the trade-off side.
| Decision | What it costs |
|---|---|
| Monitors are named by EDID short ID, serial or display name | You look the IDs up once with -DryRun. The first monitor that matches is used, so two monitors that report the same short ID need the serial instead. |
| Direction comes from the live primary, no state file | Exactly two setups, desk and couch, are built into the script. A third monitor as primary needs fallbackMode. |
| The primary is set by MultiMonitorTool, a separate program | Each run starts child processes and reads a CSV file from the temp folder. The script trusts only its own poll of Windows, not the tool's exit code. |
| Every step is checked before the next one | A run takes longer: the settle and verify pauses are fixed waits on every switch. |
| Audio is found through the monitor's container ID | It needs the Get-PnpDevice cmdlet, and it only fits audio that belongs to a monitor. Other devices use id, topology or name. |
Audio is set through IPolicyConfig |
That interface is undocumented by Microsoft. |
| Elevation comes from a scheduled task | One admin step at setup, and the task file holds two absolute paths you may need to edit. |
| The C# is inline and compiled at startup | No build step and no extra files, but every run pays the compile. |
| One log file per day, appended | Nothing deletes old logs. |
- Windows only, and the hotkeys need AutoHotkey v2.
- There are no automated tests in the repo.
-DryRunis the check: it logs the full plan and changes nothing. - An app that repositions itself after being moved ends up as a logged failure, not a fix.
- Without the scheduled task, elevated windows stay where they are.
- A dry run still starts MultiMonitorTool to list the monitors and still writes to the log.
The first version of this tool used monitor numbers, device names and a state file, and it "sometimes" broke. Each of those was the reason:
- Monitor numbers are not stable. MultiMonitorTool reads
2as\\.\DISPLAY2. GPU driver installs, virtual-display drivers (Oculus, streaming tools) and hot-plugs all push those numbers up; a machine that started at DISPLAY1–4 was at DISPLAY13–16 a year later, and every numbered command was a silent no-op. EDID short IDs (AOC2401) come from the monitor itself and do not change. - Audio device names are not stable either. Windows recreates display-audio endpoints with stock names on driver installs and leaves your renamed ones as "not present". A display-audio endpoint shares a PnP container ID with its monitor, so "the audio of monitor X" is stable when the name is not.
- A state file drifts. If any step fails, the file and reality disagree and the next press does the wrong thing. Reading the live primary cannot drift.
- Fire-and-forget does not work here. A primary change takes Windows a moment; moving windows before
it settles puts them in the wrong place. And
nircmd.exe/MultiMonitorTool.exeare GUI-subsystem programs, so a shell does not wait for them unless told to; chained commands raced each other. - Maximizing everything to make minimized windows movable destroys the layout.
SetWindowPlacementmoves a minimized window's restore position without un-minimizing it. - Elevated windows can only be moved by an elevated process (UIPI). Hence the scheduled task, which runs elevated without a UAC prompt.
MIT. MultiMonitorTool and NirCmd are freeware by Nir Sofer, redistributed as permitted.