wintoast
Fire-and-forget Windows toast notifications and taskbar/terminal progress for Ruby — inbox WinRT and shell APIs only, no App SDK, no packaging, no COM server.
Scripts deserve native notifications. When a backup finishes, a build breaks, or
a long job crosses 50%, a plain ruby.exe should be able to pop a real Windows
toast and light up the taskbar — without bundling a runtime or registering a COM
activation server.
That gap is real. PowerShell's BurntToast exists, but there is nothing
native-and-thin for Ruby; the Windows App SDK notification path drags in a
NuGet runtime, an MSIX singleton package, and a bootstrapper — against this
suite's OS-libraries-only rule. wintoast uses the inbox WinRT notification
API (Windows.UI.Notifications, shipped with Windows since 10240) via
RoGetActivationFactory, plus ITaskbarList3 and the terminal OSC 9;4 progress
sequence. Nothing to install but the gem.
Not to be confused with mohabouje/WinToast, a C++ library — different ecosystem, same idea.
require "wintoast"
Wintoast.toast("Backup finished", "1,204 files in 38 s")
# => nil (a banner pops; it persists in the Notification Center)
| What | API |
|---|---|
| Pop a toast | Wintoast.toast(title, body, ...) |
| Brand it (opt-in) | Wintoast.register! / Wintoast.unregister! |
| Taskbar + tab progress | Wintoast.progress / Wintoast.progress_clear |
| Inspect the XML | Wintoast::Payload.build |
Requirements
- Windows 10 1809+ or Windows 11 with a native MSVC (mswin) Ruby
(
x64-mswin64). On a MinGW/UCRT Ruby this gem is not supported — itsextconf.rbwill say so. - Visual Studio 2017+ or the Build Tools with the Desktop development with
C++ workload (for
cl.exe+ the Windows SDK headers/libs). - x64. arm64-mswin is expected to work (all code is arch-neutral) but is untested and unsupported until an arm64-mswin Ruby distribution exists.
Building uses
vcvarsto load the MSVC toolchain automatically — no "Developer Command Prompt" needed. If a build fails, runvcvars doctor.
Install
gem install wintoast
Quick start
Zero setup — works on a stock machine (branded "Windows PowerShell"):
require "wintoast"
Wintoast.toast("Backup finished", "1,204 files in 38 s")
# => nil (a banner pops; it persists in the Notification Center)
# Soften the borrowed branding with an attribution line:
Wintoast.toast("Backup finished", "1,204 files in 38 s", attribution: "via backup.rb")
Own branding — one-time, per-user, reversible, no admin:
AUMID = Wintoast.register!(aumid: "Acme.BackupTool", display_name: "Acme Backup",
icon: "C:/Acme/backup.png") # => "Acme.BackupTool"
Wintoast.toast("Backup finished", aumid: AUMID,
image: "C:/Acme/backup.png", circle: true,
audio: :mail, duration: :long,
expires_in: 3600, tag: "backup", group: "acme")
# A later toast with tag: "backup", group: "acme" REPLACES this one in Action Center.
Wintoast.unregister!(aumid: "Acme.BackupTool") # => true
Progress around a work loop (taskbar under conhost, tab ring under Windows Terminal):
begin
files.each_with_index do |f, i|
process(f)
Wintoast.progress(i + 1, of: files.size)
end
Wintoast.toast("Done", "#{files.size} files processed")
rescue => e
Wintoast.progress(100, state: :error) # red bar
Wintoast.toast("Failed", e., audio: :reminder)
raise
ensure
Wintoast.progress_clear
end
Failure paths, demonstrated:
Wintoast.toast("hi", image: "logo.png")
# => ArgumentError: wintoast: image: must be an absolute path to an existing file
Wintoast.toast("hi", aumid: "Not.Registered.Anywhere")
# => nil — and NOTHING is displayed. Unregistered AUMIDs are silently dropped by
# Windows (no error, no Action Center entry). Use register! or the default AUMID.
Wintoast.progress(50, state: :indeterminate)
# => ArgumentError: wintoast: state: :indeterminate ignores value — pass value nil
The one trap you must know about
An unregistered AUMID is silently dropped by Windows. Show() returns
success, but no banner pops, nothing lands in the Notification Center, and there
is no error, no event-log breadcrumb — nothing. This is the platform's behavior,
not the gem's, and it is structurally undetectable.
What "registered" means — one of:
- a Start-Menu shortcut carrying an
AppUserModelID(what most installed apps have), or - a per-user registry key
HKCU\Software\Classes\AppUserModelId\<aumid>with aDisplayName— exactly whatWintoast.register!writes, or - package identity (MSIX) — out of scope here.
SetCurrentProcessExplicitAppUserModelID is not registration: it stamps the
process AUMID for taskbar grouping / Jump Lists, and does nothing for
notifications. (A future winshell concern, not this gem's.)
That is why Wintoast.toast defaults to Windows PowerShell's AUMID
(Wintoast::POWERSHELL_AUMID): it is registered on every Windows box via
PowerShell's Start-Menu shortcut, so Wintoast.toast("hi") visibly works on a
stock machine. The trade-offs of borrowing it:
- the toast header reads "Windows PowerShell" (soften it with
attribution:, or switch to your own AUMID viaregister!); - the per-app Settings toggle is shared — turning off "Windows PowerShell"
notifications kills every tool borrowing the default.
register!gives you a private toggle.
Wintoast.toast never touches the registry. Registration is an explicit,
consented, reversible act. There is deliberately no registered? query: it
could only check the registry route and would lie false for the common
shortcut-registered case (including the default AUMID).
Toasts
Wintoast.toast(title, body = nil,
aumid: Wintoast::POWERSHELL_AUMID,
attribution: nil, # small "via ..." line
image: nil, # absolute path -> appLogoOverride
hero: nil, # absolute path -> hero (364x180 @100%)
circle: false, # hint-crop="circle" on image:
audio: :default, # see table; false => silent
duration: :short, # :short | :long
scenario: nil, # nil | :alarm | :incoming_call | :urgent
expires_at: nil, # Time -> ExpirationTime (OS caps at 3 days)
expires_in: nil, # Numeric seconds from now (mutually exclusive)
tag: nil, # String <= 64 chars (dedup/replace key)
group: nil) -> nil # String <= 64 chars
A normal return (nil) means the OS ACCEPTED the toast — NOT that it was
displayed. Banners can be suppressed undetectably by Focus Assist /
Do-Not-Disturb (the toast still lands in the Notification Center), the per-app
toggle, or the NoToastApplicationNotification group policy. The return value is
nil, not true/self, precisely because the gem cannot honestly promise
display.
Audio (audio:) — only the system sounds Windows honors for unpackaged
apps; arbitrary file paths silently fall back to the default sound and are not
accepted:
| value | effect |
|---|---|
:default |
OS default sound (no <audio> element) |
false |
silent |
:im :mail :reminder :sms |
the matching ms-winsoundevent sound |
:alarm, :alarm2..:alarm10 |
looping alarm sounds |
:call, :call2..:call10 |
looping call sounds |
Looping sounds only audibly loop when the toast is pinned by
scenario: :alarm / :incoming_call (a short alarm sound is still valid).
Scenario (scenario:) — :alarm and :incoming_call pin the toast and
loop audio; :urgent breaks through Do-Not-Disturb (Windows 11 build 22546+;
older builds ignore the attribute and show a normal toast). :reminder is
deliberately not accepted — without a button it is ignored by Windows, i.e.
useless for fire-and-forget.
Expiration (expires_at: Time / expires_in: seconds, mutually exclusive)
maps to ExpirationTime (removal from the Notification Center). Local toasts are
retained at most 3 days regardless; larger values pass through and are
clamped by the OS. expires_in <= 0 raises; a past expires_at passes through
(the OS treats it as already expired).
Tag / group (tag:, group:, each ≤ 64 chars) map to
IToastNotification2.Tag/Group: a later toast with the same tag (+ group) under
the same AUMID replaces the earlier one in the Action Center. Pure
pass-through; there is no history/removal API in v1.
Images (image:, hero:) are local absolute paths only (http(s) images
need package identity and are rejected by the absolute-path check). A
relative/missing path raises ArgumentError so a typo fails loudly instead of
rendering an imageless toast. Paths are emitted as plain absolute paths (no
file:// URI), sidestepping percent-encoding of spaces and Unicode.
Desktop apps cannot schedule toasts (ScheduledToastNotification needs
package identity) — out of scope.
Progress
Wintoast.progress(value = nil, of: 100, state: nil) -> true | false
Wintoast.progress_clear -> true | false
Every call drives both progress surfaces, unconditionally — exactly one takes effect per host, the other no-ops (the strategy blessed in microsoft/terminal#14268):
- OSC 9;4 written to the console — the Windows Terminal tab ring + WT-window taskbar progress (also ConEmu and a growing set of terminals). Emitted only when STDOUT is a real console (never pollutes redirected/piped output).
ITaskbarList3onGetConsoleWindow()— classic-conhost taskbar progress.
| call | meaning |
|---|---|
progress(50) / progress(7, of: 23) |
determinate, green |
progress(50, state: :error) |
determinate, red |
progress(50, state: :paused) |
determinate, yellow |
progress(state: :indeterminate) |
marquee / ring (value must be nil) |
progress(nil) / progress(:clear) / progress_clear |
remove |
Host matrix:
| host | taskbar (ITaskbarList3) | tab/taskbar (OSC 9;4) |
|---|---|---|
| classic conhost | ✓ | swallowed |
| Windows Terminal | accepts, invisible (hidden ConPTY window) | ✓ |
| Windows Terminal + redirected stdout | accepts, invisible | ✗ (no OSC to a pipe) |
| ConEmu | — | ✓ |
| no console (rubyw / detached) | — | — → returns false |
The return value means accepted, not visible — the same honesty rule as
toast's nil. true means at least one OS surface accepted the update (OSC
bytes reached a real console, or every ITaskbarList3 call succeeded against a
non-NULL console window). Under a ConPTY host the taskbar leg accepts against a
hidden window that can never render, so e.g. running under Windows Terminal with
stdout redirected returns true with nothing visible. false is guaranteed
only when no surface accepted at all.
Environmental failure is a false, never an exception — a progress bar must
not be able to crash the app. Only argument misuse raises ArgumentError
(of: 0, a missing value for a determinate state, a value for :indeterminate,
an unknown state, a non-Numeric value). Overshoot like progress(101) is
clamped, not raised.
Clearing on exit is the caller's job — put Wintoast.progress_clear in an
ensure. v1 installs no at_exit hook.
Two console side effects, both standard CLI behavior: progress enables
ENABLE_VIRTUAL_TERMINAL_PROCESSING once when missing and leaves it on
(per-call restore would race other writers); and under a classic conhost in
select mode (the user is holding a text selection) the OSC write can block.
The GVL is released so every other Ruby thread keeps running, but the parked
write itself is not interruptible (Thread#kill / Timeout will not break
it) — exact parity with Kernel#puts, whose console write is equally stuck.
Library API
Wintoast.toast(title, body = nil, **opts) # => nil (accepted, not necessarily shown)
Wintoast.register!(aumid:, display_name:, icon: nil) # => aumid String (HKCU branding)
Wintoast.unregister!(aumid:) # => true | false (false = wasn't there)
Wintoast.progress(value = nil, of: 100, state: nil) # => true | false (accepted, not visible)
Wintoast.progress_clear # => true | false
Wintoast::Payload.build(title, body = nil, **opts) # => String (debugging: the exact XML sent)
Wintoast::VERSION # => "0.1.0"
Wintoast::POWERSHELL_AUMID # the registered-everywhere default AUMID
How it works
Toasts are sent through the inbox WinRT API activated directly via
RoGetActivationFactory — no Windows App SDK, no C++/WinRT codegen, no NuGet.
The C++ extension assembles the ToastGeneric XML in Ruby, hands it to
Windows.Data.Xml.Dom.XmlDocument.LoadXml, builds a ToastNotification, and
calls ToastNotifier.Show — all synchronous, milliseconds.
register! writes only HKCU\Software\Classes\AppUserModelId\<aumid>
(DisplayName, optional IconUri) — per-user, no elevation, no Start-Menu
shortcut, no HKLM, no COM activator. unregister! deletes that key tree.
Progress drives both ITaskbarList3 and OSC 9;4 every call.
Every native operation is stateless and complete within one call — WinRT/COM is initialized and uninitialized per call, owning no resources across calls. That makes every API idempotent and thread-safe: call them from any thread, concurrently, with no locks.
Errors
StandardError
└─ Wintoast::Error misuse / non-OS failures (e.g. invalid UTF-8)
└─ Wintoast::OSError OS API failures; #code => Integer
(HRESULT for COM/WinRT, Win32 code for registry)
Plain argument-shape problems raise Ruby's own ArgumentError / TypeError.
These are not errors (by design — the platform cannot report them):
- a toast sent to an unregistered AUMID is silently dropped;
- Focus Assist / Do-Not-Disturb suppresses the banner (the toast still reaches the Notification Center);
- a per-app or policy toggle disables toasts;
progressreturnsfalsewhen no console surface accepted.
License
MIT.