go-toast

Send toast notifications in Windows
Log | Files | Refs | README | LICENSE

bind_windows.go (5282B)


      1 //go:build windows
      2 
      3 package wintoast
      4 
      5 import (
      6 	"errors"
      7 	"fmt"
      8 	"io"
      9 	"os"
     10 	"os/exec"
     11 	"sync/atomic"
     12 	"syscall"
     13 	"unsafe"
     14 
     15 	"git.sr.ht/~jackmordaunt/go-toast/v2/internal/winrt/data/xml/dom"
     16 	"git.sr.ht/~jackmordaunt/go-toast/v2/internal/winrt/ui/notifications"
     17 	"git.sr.ht/~jackmordaunt/go-toast/v2/tmpl"
     18 	"github.com/go-ole/go-ole"
     19 	"golang.org/x/sys/windows"
     20 )
     21 
     22 func pushPowershell(xml string) error {
     23 	f, err := os.CreateTemp("", "*.ps1")
     24 	if err != nil {
     25 		return fmt.Errorf("creating temporary script file: %w", err)
     26 	}
     27 
     28 	defer func() { err = errors.Join(err, os.Remove(f.Name())) }()
     29 
     30 	// This BOM ensures we can support non-ascii characters in the toast content.
     31 	bomUtf8 := []byte{0xef, 0xbb, 0xbf}
     32 	if _, err := f.Write(bomUtf8); err != nil {
     33 		return fmt.Errorf("writing utf8 byte marker: %w", err)
     34 	}
     35 
     36 	if err := buildPowershell(xml, f); err != nil {
     37 		return fmt.Errorf("generating powershell script: %w", err)
     38 	}
     39 
     40 	if err := f.Close(); err != nil {
     41 		return fmt.Errorf("closing script file: %w", err)
     42 	}
     43 
     44 	cmd := exec.Command("PowerShell", "-ExecutionPolicy", "Bypass", "-File", f.Name())
     45 	cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
     46 	if out, err := cmd.CombinedOutput(); err != nil {
     47 		return fmt.Errorf("executing powershell: %q: %w", string(out), err)
     48 	}
     49 
     50 	return nil
     51 }
     52 
     53 func buildPowershell(xml string, w io.Writer) error {
     54 	type scriptData struct {
     55 		AppID string
     56 		XML   string
     57 	}
     58 	return tmpl.ScriptTemplate.Execute(w, scriptData{AppID: appData.AppID, XML: xml})
     59 }
     60 
     61 // HRESULT E_NOINTERFACE
     62 const errNoInterface = 0x80004002
     63 
     64 var comDisabled atomic.Bool
     65 
     66 func pushCOM(appID, xml string) (err error) {
     67 	if comDisabled.Load() {
     68 		return nil
     69 	}
     70 
     71 	defer func() {
     72 		// On Windows 7 WinRT interfaces can be stubbed out, and fail to produce
     73 		// error values. This leads to a panic when trying to use the interface.
     74 		// This recover transforms such panics back into an error value for the
     75 		// caller.
     76 		//
     77 		// If the error is "interface not supported" we will permanently disable
     78 		// this API henceforth.
     79 		if v := recover(); v != nil {
     80 			if verr, ok := v.(error); ok {
     81 				err = verr
     82 			}
     83 			if oleErr, ok := v.(*ole.OleError); ok {
     84 				if oleErr.Code() == errNoInterface {
     85 					comDisabled.Store(true)
     86 				}
     87 			}
     88 		}
     89 	}()
     90 
     91 	if err := initialize(); err != nil {
     92 		return err
     93 	}
     94 
     95 	if err := registerClassFactory(ClassFactory); err != nil {
     96 		return fmt.Errorf("registering class factory: %w", err)
     97 	}
     98 
     99 	doc, err := dom.NewXmlDocument()
    100 	if err != nil {
    101 		return fmt.Errorf("dom.NewXmlDocument(): %w", err)
    102 	}
    103 
    104 	defer doc.Release()
    105 
    106 	if err := doc.LoadXml(xml); err != nil {
    107 		return fmt.Errorf("doc.LoadXml(tmpl): %w", err)
    108 	}
    109 
    110 	manager, err := notifications.GetDefault()
    111 	if err != nil {
    112 		return fmt.Errorf("notifications.GetDefault(): %w", err)
    113 	}
    114 
    115 	defer manager.Release()
    116 
    117 	notifier, err := manager.CreateToastNotifierWithId(appID)
    118 	if err != nil {
    119 		return fmt.Errorf("manager.CreateToastNotifier(%q): %w", appID, err)
    120 	}
    121 
    122 	defer notifier.Release()
    123 
    124 	toast, err := notifications.CreateToastNotification(doc)
    125 	if err != nil {
    126 		return fmt.Errorf("notifications.CreateToastNotification(doc): %w", err)
    127 	}
    128 
    129 	defer toast.Release()
    130 
    131 	if err := notifier.Show(toast); err != nil {
    132 		return fmt.Errorf("notifier.Show(): %w", err)
    133 	}
    134 
    135 	return nil
    136 }
    137 
    138 func setAppData(data AppData) (err error) {
    139 	appDataMu.Lock()
    140 	defer appDataMu.Unlock()
    141 
    142 	// Early out if we have already set this data.
    143 	//
    144 	// In the case the data is empty, we don't want to overrite
    145 	// all of the registry entries to empty.
    146 	//
    147 	// This allows the caller to either globally set the app data
    148 	// or provide it per notification.
    149 	if appData == data || data.AppID == "" {
    150 		return nil
    151 	}
    152 
    153 	if data.GUID != "" {
    154 		GUID_ImplNotificationActivationCallback = ole.NewGUID(data.GUID)
    155 	}
    156 
    157 	// Keep a copy of the saved data for later.
    158 	defer func() {
    159 		if err == nil {
    160 			appData = data
    161 		}
    162 	}()
    163 
    164 	if err := setAppDataFunc(data); err != nil {
    165 		return err
    166 	}
    167 
    168 	return nil
    169 }
    170 
    171 var initialized atomic.Bool
    172 
    173 // initialize attempts to initialize the Windows Runtime.
    174 // Each invocation will retry RoInitialize until a successful initialization
    175 // is achieved. Once initialized, we avoid invoking RoInitialize since subsequent
    176 // reinitialization generates errors.
    177 func initialize() (err error) {
    178 	if initialized.CompareAndSwap(false, true) {
    179 		if err := ole.RoInitialize(1); err != nil {
    180 			return fmt.Errorf("RoInitialize: %w", err)
    181 		}
    182 	}
    183 	return nil
    184 }
    185 
    186 // sliceUserDataFromUnsafe builds a slice of UserData out of an unsafe pointer.
    187 func sliceUserDataFromUnsafe(ptr unsafe.Pointer, count int) []UserData {
    188 	// Layout mirrors the memory layout of the C struct that contains this data.
    189 	// I'm not sure if there's special alignment or packing - though I don't notice
    190 	// anything in the definition to indicate as such.
    191 	type layout struct {
    192 		Key   unsafe.Pointer
    193 		Value unsafe.Pointer
    194 	}
    195 
    196 	// Create a new slice with the appropriate length
    197 	out := make([]UserData, count)
    198 
    199 	// Create a slice with the unsafe data layout.
    200 	tmp := unsafe.Slice((*layout)(ptr), count)
    201 
    202 	// Convert the unsafe layout to safe strings.
    203 	for ii, it := range tmp {
    204 		out[ii] = UserData{
    205 			Key:   windows.UTF16PtrToString((*uint16)(it.Key)),
    206 			Value: windows.UTF16PtrToString((*uint16)(it.Value)),
    207 		}
    208 	}
    209 
    210 	return out
    211 }