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 — its extconf.rb will 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 vcvars to load the MSVC toolchain automatically — no "Developer Command Prompt" needed. If a build fails, run vcvars 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.message, 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 a DisplayName — exactly what Wintoast.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 via register!);
  • 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):

  1. 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).
  2. ITaskbarList3 on GetConsoleWindow() — 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;
  • progress returns false when no console surface accepted.

License

MIT.