Skip to content

About

Switch between desk and couch setups: toggle primary display, move all windows, and switch audio devices with hotkeys

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

Monitor Switcher

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).

What a press does

  1. Reads which monitor is primary right now and picks the other setup. There is no state file.
  2. Sets the new primary and polls until Windows confirms it. If it never does, nothing else happens.
  3. 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.
  4. Nudges the taskbar (Windows 11 sometimes leaves the old primary's taskbar blank).
  5. Switches the default audio device to the one that belongs to the destination monitor.
  6. Shows a tray balloon with the result and writes logs\switch-YYYY-MM-DD.log.

Setup

  1. Clone or download the repo.
  2. Copy config.example.json to config.json (git-ignored, so it stays yours).
  3. Find your monitor IDs and put them in config.json:
    .\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
    Use the Short Monitor ID column (e.g. AOC2401), the serial number, or the \\.\DISPLAYn name. Never a bare number: to MultiMonitorTool 2 means \\.\DISPLAY2, and Windows renumbers those on driver installs and hot-plugs.
  4. 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.xml first if the repo is not at C:\Users\<you>\dev\monitor_switcher:
    schtasks /Create /XML "MonitorSwitcher-Task.xml" /TN "MonitorSwitcher" /F
  5. Run switch.ahk (double-click). For startup, put a shortcut to it in shell:startup.

Hotkeys

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.

Command line

.\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

config.json

{
  "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: monitor is the primary for that setup. audio is { "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. defaultPlace applies 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. Drop 2 to keep calls on one device.
  • fallbackMode: used when the current primary is neither desk nor couch.
  • rules: first match wins. when is desk, couch, or omitted for both. match takes any of process (exe name), class, title (substring); all given keys must match. monitor defaults to the new primary. place as above; left/right are 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.

When something does not move

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 -ListAudio and fix the audio entry.
  • another switch is still running — you pressed twice; the second press is ignored.

How it works

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.

The pieces

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.

One run

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"]
Loading

-To desk or -To couch skips the "which monitor is primary" question and uses the direction given.

The steps in order:

  1. Lock. The script takes a named mutex. If another run holds it, this run logs a warning and exits with code 2.
  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.
  3. Config. config.json is loaded. modes.desk.monitor and modes.couch.monitor are required. Both monitors must be in the list and active, or the run stops here.
  4. 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).
  5. Audio lookup. The audio device for the target setup is found now, but not set yet.
  6. 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.
  7. Primary. MultiMonitorTool sets the new primary. The script then asks Windows every 200 ms which monitor is primary, until it matches or primaryTimeoutMs runs out. On a timeout the run stops and no window or audio device has been touched. After a match it waits settleMs.
  8. Windows. Monitor geometry is read again, because every coordinate shifts when the primary changes. Each window is moved, then after verifyDelayMs each one is checked and failures get one retry.
  9. Taskbar. If taskbarNudge is on, a settings-changed message goes to every taskbar window.
  10. Audio. The default playback device is set and read back.
  11. Report. One summary line goes to the log and, if notify is on, to a tray balloon.

Which windows move

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

How one window is moved

  1. Its normal (restored) rectangle and its min / max / normal state are read with GetWindowPlacement.
  2. The first rule in config.json that matches gives the destination monitor and placement. With no rule, the destination is the new primary and the placement is the setup's defaultPlace. If a rule names a monitor that is missing or inactive, the window goes to the new primary.
  3. 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. left and right use half of that work area instead.
  4. SetWindowPlacement writes 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.
  5. 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.

How the audio device is found

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.

What you see when it fails

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

Decisions and what they cost

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.

Known limits

  • Windows only, and the hotkeys need AutoHotkey v2.
  • There are no automated tests in the repo. -DryRun is 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.

Why it identifies things the way it does

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 2 as \\.\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.exe are 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. SetWindowPlacement moves 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.

License

MIT. MultiMonitorTool and NirCmd are freeware by Nir Sofer, redistributed as permitted.

About

Switch between desk and couch setups: toggle primary display, move all windows, and switch audio devices with hotkeys

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Contributors

Languages