open for WSL: hand it a file, a directory or a URL and get the Windows
application that claims it.
open report.pdf # the default Windows app
open . # Explorer, here
open https://example.com # your browser
open -R notes.md # reveal in Explorer, highlighted
open -a chrome.exe --args --incognito
echo "scratch" | open -f # stdin into the text editorIt does the job of xdg-open — and can take its place,
so links opened by editors and CLI tools go through it too — with the command
set of macOS open(1): reveal, wait, choose the application, the text editor,
stdin. And it does one thing no other WSL opener does:
URLs open on the virtual desktop you are looking at,
instead of switching you to the desktop where your browser already is.
wslview, from wslu, is archived and no longer packaged in Ubuntu 26.04.
winopen began as a shell function written to replace it on a fresh install, and
its author uses it every day. The repository is winopen; the command it
installs is open.
Needs WSL: the tool works by running wslpath, cmd.exe, explorer.exe and
powershell.exe from the Linux side. Without powershell.exe three things
fall back: URLs go straight to Windows, -t uses notepad.exe, and -n
opens normally and says so. --check-update and --update need curl, tar
and sha256sum.
The install script fetches the release tarball, checks it against the
published SHA256SUMS, and installs open and the
desktop helper into ~/.local
— no root, at any point:
curl -fsSL https://raw.githubusercontent.com/yannlugrin/winopen/main/install.sh | bashIt never runs sudo for you; nothing in winopen does. Point it at a prefix you
do not own and it downloads, verifies, then prints the privileged commands and
stops.
Or by hand. It is two files — the script, and a PowerShell helper it looks for beside itself. Read them, then put them in place:
curl -fsSLO https://raw.githubusercontent.com/yannlugrin/winopen/main/open
curl -fsSLO https://raw.githubusercontent.com/yannlugrin/winopen/main/libexec/open-url.ps1
less open # 800 lines; the helper is another 150
sudo install -m 755 open /usr/local/bin/open
sudo install -d /usr/local/libexec/winopen
sudo install -m 644 open-url.ps1 /usr/local/libexec/winopen/The helper is optional: without it, open hands URLs straight to Windows, like
every other tool does. To remove either file, delete it. Both come from main,
the maintained branch; the script takes a checked release instead.
Put assignments on the right of the pipe. On the left they are set for
curl, which does not care, and the script never sees them:
curl -fsSL https://raw.githubusercontent.com/yannlugrin/winopen/main/install.sh | PREFIX=/usr/local bash # yes
PREFIX=/usr/local curl -fsSL https://raw.githubusercontent.com/yannlugrin/winopen/main/install.sh | bash # silently ignoredVERSION=1.2.0 pins a release. WITHOUT_DESKTOP=1 skips the helper, and takes
away an installed one — the tool and the helper are one version.
Asked for /usr/local, the script finishes like this rather than escalating:
Downloaded and verified winopen 1.2.0.
/usr/local/bin is not yours to write, so the rest needs root:
sudo install -d /usr/local/bin
sudo install -m 755 /tmp/tmp.XXXX/winopen-1.2.0/open /usr/local/bin/open
...
~/.local/bin is on PATH for login shells on most distributions, but not
always for programs started by services. If you want open reachable from
everywhere, install to /usr/local.
With just, the project's task runner:
git clone https://github.com/yannlugrin/winopen.git
cd winopen
sudo just install # and: sudo just uninstallPass a different prefix as an argument, not through the environment —
sudo resets the environment, so sudo PREFIX=... just install may quietly
install to /usr/local anyway:
just prefix=~/.local install # no sudo needed
sudo just prefix=/opt installjust install --without-desktop leaves out the desktop
helper, as WITHOUT_DESKTOP=1
does for the install script.
If you installed the xdg-open shim, remove it with open --uninstall-xdg
before uninstalling the tool, while open is still there to do it.
Open a URL from a desktop with no browser window on it, and Windows hands it to
a browser window on another desktop — then switches you there, or leaves the
tab where you cannot see it. Microsoft's own IVirtualDesktopManager
documentation
describes exactly this case and says the browser should open a new window on
the active desktop instead, and Chromium's design
notes
say the same. In practice, a URL handed over from outside the browser does not
get that treatment.
winopen does it from the outside. With no browser window on the desktop in view, it asks the browser for a new window, which Windows places where you are. With one, it activates that window before handing over the URL, so the tab lands in it rather than in whichever window was last active elsewhere.
Nothing needs doing — it is what open https://example.com does once the
helper is installed. It costs about 300ms over handing the URL straight to
Windows.
How it decides: it asks Windows which browser owns the scheme with
AssocQueryString, not by reading the UserChoice registry key, which can name
a browser the shell does not actually launch. It lists that browser's visible,
titled top-level windows and asks the documented IVirtualDesktopManager
interface which of them are on the current desktop. A query it cannot answer
counts as "no": doubt opens a new window rather than risking another desktop.
It steps aside whenever you have already said what you want, or it cannot do better:
-a,-n,-Wor--args: the URL goes to Windows as given.WINOPEN_DESKTOP=0: off for one command.- No
powershell.exe, or no helper installed: falls back silently. - An unknown scheme, or a browser it has no window flag for: falls back
silently. It knows Chrome, Edge, Brave, Vivaldi, Opera and Thorium
(
--new-window), and Firefox, LibreWolf and Waterfox (-new-window).
Known limitation: if the topmost browser window on your desktop is a PWA or app window rather than a tabbed one, the browser may still route the tab to a tabbed window elsewhere.
open [flags] [target ...]
open https://example.com # URL, in the default browser
open vscode://file/tmp/x # any scheme Windows has registered
open -u mailto:me@example.com # force URL, even if a file of that name exists
open . # this directory, in Explorer
open ~/Documents/report.pdf # the default Windows app for the type
open -R ~/Documents/report.pdf # reveal in Explorer, highlighted
open -D ~/Documents/report.pdf # the enclosing folder, in Explorer
open -e ~/.bashrc # notepad.exe
open -t ~/.bashrc # the default text editor
open -a notepad.exe ~/.bashrc # a specific Windows application
open -a notepad.exe # ...or just launch it
open -W somefile.txt # wait for the application to exit
open -n https://example.com # a new browser window
echo "hello" | open -f # stdin, into the text editor
open file1.txt file2.txt # several targets
open -a chrome.exe --args --incognito
open -- -weird-name.txt # a target whose name starts with a dash| Flag | Meaning |
|---|---|
-a <app> |
Open with a specific Windows application |
-u <url> |
Open a URL with whatever claims its scheme, even if a file of that name exists |
-R |
Reveal in Explorer, with the file highlighted |
-D |
Open the enclosing folder in Explorer (extension) |
-e |
Open in notepad.exe |
-t |
Open in the default text editor: $WINOPEN_EDITOR, else whatever Windows registered for .txt |
-W |
Wait for the application to exit before returning |
-n |
Open a new instance rather than reusing a running one — partial |
-f |
Read stdin into a temp file and open it as -t |
--args <...> |
Pass everything that follows to the launched application |
-- |
Everything that follows is a target (extension) |
-h, --help |
Help |
-V, --version |
Version (extension) |
--check-update, --update |
See Updating (extension) |
--install-xdg, --uninstall-xdg |
See Registering as xdg-open (extension) |
Flags come from macOS open(1) unless marked as an extension. The
parity section lists what is missing and why.
| Variable | Effect |
|---|---|
WINOPEN_EDITOR |
The editor -t (and so -f) uses. Unset, -t asks Windows what handles .txt. -e ignores it |
WINOPEN_DESKTOP |
0 hands URLs straight to Windows, ignoring virtual desktops |
WINOPEN_XDG |
0 bypasses the xdg-open shim for one command |
-e always uses notepad.exe. -t uses $WINOPEN_EDITOR if set, and
otherwise opens the file with whatever Windows has registered for .txt —
whatever the file's own extension happens to be. That last part asks Windows
directly, via ShellExecuteEx with SEE_MASK_CLASSNAME, because there is no
cheaper way to reach it: on Windows 11 the .txt handler is a Store app that
ftype cannot name and whose path is unreadable from WSL. It needs
powershell.exe; without it, -t falls back to notepad.exe.
-n forces a new window rather than letting a running instance take the
target. Windows has no general "new instance" switch — ShellExecute asks the
application, and most single-instance themselves, which is exactly why a
browser handed a URL puts the tab in whichever window was last active. What
works is the application's own flag, so -n only knows the browsers it has a
flag for: Chrome, Edge, Brave, Vivaldi, Opera and Thorium take --new-window;
Firefox, LibreWolf and Waterfox take -new-window.
For anything else the target is still opened, with a note that the guarantee was not available:
$ open -n -a notepad.exe file.txt
open: -n: no new-instance flag known for notepad.exe; opening normally, which may reuse a running window
It opens anyway on purpose. Whether an unknown application reuses a window or
starts a fresh one is its own business, and many start a fresh one regardless —
refusing would cost you the whole operation over a preference that may well
have been satisfied. A flag that contradicts the target is different: -R on
a URL is refused outright, because there is no enclosing folder to reveal.
Without -a, the application has to be resolved before it can be asked for a
new window, which needs powershell.exe. Without it, -n says so and opens
normally.
--args passes everything after it to the launched application rather than
opening it, as open(1) does. -- is the opposite: everything after it is a
target, however it is spelled — the only way to open a file whose name begins
with a dash. Whichever of the two comes first claims the rest of the command
line.
Any scheme Windows has registered is handed to it as-is — https:, mailto:,
vscode:, ms-settings:, file:, whatever else is installed. There is no
allowlist. An argument that looks like a URL but names an existing file is
treated as the file; -u says the opposite.
-a, -W and --args apply to URLs as they do to files, so
open -a chrome.exe https://example.com really does use Chrome. -R, -D,
-e and -t need a file to point at and are rejected for a URL rather than
silently ignored.
Windows reports success for a scheme nothing has registered, so open cannot
tell you when a URL had nowhere to go: a 0 means the URL was handed over, not
that anything opened it.
-f writes standard input to a temporary .txt file and opens it in the text
editor -t resolves. The file is not deleted: start returns as soon as
Windows has been handed it, long before the application has read it, so
deleting it would race whatever is opening it. It is left in /tmp for the
system to reap.
open with no arguments prints its usage to stderr and exits 1. It does not
default to the current directory — open . is the idiom, as with macOS and
code. open -a <app> with no file is not a mistake: it launches the
application with no document, as macOS does.
By itself, open only helps when you type it. Links opened by other programs —
editors, CLI tools, anything calling xdg-open — go elsewhere. Installing the
shim routes those through winopen too:
open --install-xdg # beside `open` itself; PREFIX=... puts it elsewhere
open --uninstall-xdg # remove it, restoring anything it replacedInstalling winopen does not install the shim. It is a separate, explicit step, and a separately reversible one.
The shim goes beside the tool, so a ~/.local install needs no root. Sending
it to /usr/local/bin does, and as everywhere in winopen, open will not
become root for you — it says what it would do and leaves you to run it:
/usr/local/bin is not writable by you, so this needs root.
It would run:
ln -s /home/you/.local/bin/open /usr/local/bin/xdg-open
Nothing has been changed. Run it again as root:
sudo PREFIX=/usr/local /home/you/.local/bin/open --install-xdg
Under sudo the directory simply is writable, so it goes ahead.
xdg-utils ships /usr/bin/xdg-open. The shim's directory comes first on
PATH, so it takes precedence while the packaged file is left untouched —
package upgrades do not fight it, and removing the shim hands control straight
back. Only a file already sitting at the shim's own path is ever moved, and
then it is backed up alongside and restored on uninstall.
~/.local/bin is typically only on PATH in interactive shells, so a shim
installed there is invisible to programs started by services, or launched into
WSL from Windows — the case the shim exists for. PREFIX=/usr/local open --install-xdg puts it where they look, and open says so on every install.
The shim always opens through Windows. It does not try Windows and fall back to
the xdg-open it shadows, because Windows gives it no failure to detect —
start reports success even for a scheme nothing has registered. To bypass it
deliberately, for one command or one program's environment:
WINOPEN_XDG=0 xdg-open ~/notes.md # the shadowed xdg-open insteadThe shim follows xdg-open's contract rather than open's: exactly one
target, and these codes.
| Code | Meaning |
|---|---|
| 0 | Handed to Windows |
| 1 | Error in command line syntax |
| 2 | The file did not exist |
| 3 | WINOPEN_XDG=0 and no other xdg-open to delegate to |
xdg-open also defines 4, "the action failed". winopen never returns it,
because Windows does not report whether the action succeeded.
open cannot set environment variables in your shell. To route $BROWSER
through winopen as well, add this yourself:
export BROWSER=/usr/local/bin/xdg-open--check-update asks GitHub what the latest release is. --update takes that
release's tarball, checks it against the SHA256SUMS published beside it,
unpacks it and puts open in place — the same path install.sh takes, and for
the same reason: a tag can be moved, and raw.githubusercontent.com follows it,
while a release asset cannot. There is no fallback for a release without assets,
because an update only ever goes to the latest one.
It refreshes the desktop helper out of that same tarball when one is installed beside the tool — the two are one release, and a stale helper fails silently rather than loudly. It never installs one that is not there.
If the installed open is not yours to write, --update does not ask for
root, and does not tell you to re-run the whole command as root either, which
would put the download under root too. It fetches and verifies as you, then
hands you the privileged steps:
Updating open 1.1.0 -> 1.2.0...
Downloaded and verified 1.2.0 to /tmp/winopen-update-SrGyM5/winopen-1.2.0/open.
/usr/local/bin/open is not writable by you, so the last step needs root:
sudo install -m 755 /tmp/winopen-update-SrGyM5/winopen-1.2.0/open /usr/local/bin/open
sudo install -m 644 /tmp/winopen-update-SrGyM5/winopen-1.2.0/libexec/open-url.ps1 /usr/local/libexec/winopen/open-url.ps1
The installer and --update verify the tarball against a SHA256SUMS
published in the same release. That protects against a truncated download or a mis-published
asset — integrity against accident. It does not protect against a
compromised source: whoever could replace the tarball could replace the
checksum beside it, and both arrive over the same connection from the same
host.
Where it pays off is letting you pin. A dotfiles repository can record a version and its SHA and refuse anything else:
sha256sum -c <<< "8f423d... winopen-1.2.0.tar.gz"Real integrity is signing, which is a separate question and not answered here.
macOS open(1) is the reference for flag names and semantics. Where winopen
diverges it is on purpose, and it is listed here.
| Flag | Why |
|---|---|
-b <bundle id> |
Windows has no bundle identifiers |
-g |
Windows offers no way to do it. ShellExecuteEx with SW_SHOWNOACTIVATE is only a hint, and every application tested ignores it and takes the foreground anyway — verified against a Store app, a Win32 app, and a cold start, on Windows 11 build 26200. It was built, measured, and removed |
-F |
No Windows equivalent of launching without restoring windows |
-j |
Launch hidden: no clean equivalent, little value |
-s <sdk> |
Xcode-specific, and paired with macOS's -h |
--env |
Niche |
--stdin, --stdout, --stderr |
Niche |
--arch |
Not applicable |
-h means help here. On macOS it searches header locations for a matching
header and opens it. This is the one place winopen actively contradicts
open(1), and it is not going to change: -h is help everywhere else on this
platform.
-n is a partial guarantee. macOS has an API for launching a new instance;
Windows does not. winopen passes the application's own new-window flag where
it knows one, and otherwise opens the target and says so rather than refusing —
see New instances.
-e, -t, -R and -D reject URLs. They need a file to point at. macOS
is not explicit about this; ignoring the flag silently seemed worse than saying
so.
An unopenable URL cannot be reported. macOS open errors when nothing
claims a scheme. Windows reports success unconditionally.
-D, --, -V/--version, --check-update/--update and
--install-xdg/--uninstall-xdg have no open(1) counterpart, and are marked
as such in the flags table. -D is not a macOS flag at all —
open(1) has -R but no -D. -- is undocumented in open(1), but it is
the usual Unix convention and the only way to open a file whose name begins
with a dash. The xdg-open shim follows xdg-open's contract, not open(1)'s.
New and small: one maintainer, first substantial release, used daily.
Everything on the Windows side is verified on one machine — Ubuntu 26.04 under
WSL2, Windows 11 build 26200. Other builds, other browsers and other Windows
versions are untested, and the desktop handling and -n only know the nine
browsers listed above. Releases are checksummed, not signed.
If it breaks, the useful report has open -V, the Windows build, the browser,
the command line, and what happened versus what open claimed. Silent
successes are the bugs this tool attracts.
The checks that must pass are the ones CI runs:
just # list the recipes
just check # what CI runs: lint and tests, about a second
just test # the suite that runs anywhere
just lint # bash -n and shellcheck
just hooks # run `just check` before every pushjust hooks installs a pre-push hook, so the checks run when work is about
to become public rather than on every work-in-progress commit. git push --no-verify skips it once; just unhooks removes it.
Linting needs shellcheck — your
package manager has it, or take a static binary from its releases page. Without
it just lint still runs the syntax checks and says what it skipped. CI pins
the version (just shellcheck-version), so a finding cannot appear there and
nowhere else.
Only one of them can be automated.
tests/cli.sh, tests/xdg.sh and tests/install.sh run anywhere,
including a Linux CI runner with no WSL at all. The tool reaches Windows only by
spawning wslpath, cmd.exe, explorer.exe, powershell.exe and curl by
name, so stubs for them first on a scrubbed PATH make every crossing
observable: the tests assert the exact command line the tool builds, and its
exit status. A change to what crosses to Windows comes with a test here.
tests/windows.sh needs a real WSL machine and is run by hand before a
release:
just test-windowsIt carries the one check only a real desktop can make: that a URL's tab landed on the desktop in view rather than on another one, and it tells you how many windows were elsewhere — because with everything on one desktop, the tab could not have gone wrong and passing proves nothing.
It exists because the interesting failures here are invisible from an exit
code. cmd.exe /C start returns 0 for a scheme nothing has registered,
explorer.exe returns 1 when it succeeded, and ShellExecuteEx reports
success while opening nothing at all. Only looking at the result catches those,
so that suite opens real windows and leaves them for you to check.
The tool runs on the Linux side of WSL, so no language can call Win32 directly:
a rewrite in Go or Rust would still shell out to powershell.exe, and change
everything except the part that hurts. Meanwhile a readable script is worth
something for a tool that installs itself as the system xdg-open and writes
to /usr/local/bin. Win32 work lives beside the tool in libexec/, off your
PATH; a compiled helper waits until something needs one.
The release workflow checks that the VERSION constant matches the pushed tag
before publishing, then ships a tarball and SHA256SUMS. tests/windows.sh
runs by hand first.
MIT