Switch remote deploy to vendored source builds

Move remote deployment to a vendored source bundle built on the target host via Docker so redeploys no longer require local cross-compilation or host Go installation.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
GitHub Actions
2026-05-08 12:19:18 +08:00
parent bb27566e38
commit c1a0fe2949
1320 changed files with 497125 additions and 11 deletions

View File

@@ -0,0 +1 @@
*.exe

View File

@@ -0,0 +1,244 @@
# Architecture
This document will attempt to explain how this code works.
## Windows COM
Windows makes heavy use of it's [COM api](https://en.wikipedia.org/wiki/Component_Object_Model),
(Component Object Model) which is a binary interface - allowing programs that agree on a memory
layout in order to communicate.
COM apis are typically Object Oriented, and based on interfaces. This should be familiar to Go
programmers, since Go includes interfaces as a core part of its language design and type system.
The difference being that in COM we don't get any runtime help, nice syntax or type safety. We
get raw [VTables](https://en.wikipedia.org/wiki/Virtual_method_table) and deal with raw memory.
You can think of COM as like working with a Go api that uses `any` (empty interface) _everywhere_
and typeswitching is required to access methods `file, ok := obj.(File)`.
Some languages like C++ have extensions that support COM and provide convenient wrappers for
generating and using COM apis. Go does not. C also, does not.
However there is a package `go-ole` that allows us to _call_ COM apis with some level of
convenience - which we will use where possible. What go-ole does not expose is a way to
implement a COM object in Go.
## Interacting with COM objects in pure Go
In order to interact with COM objects we need to:
1. locate headers containing the VTable definitions
2. define vtables in Go that are compatable with those definitions
3. invoke the appropriate COM objects using our vtables and the syscall package
### 1. locate headers
Download Windows SDK via the [Visual Studio installer](https://visualstudio.microsoft.com/downloads).
You will need to check "Desktop development with C++".
Once complete you can navigate to the SDK include directory.
In our case we needed `Windows.ui.notifications.h`, which contains the definitions of
the types we want to call, and `NotificationActivationCallback.h` which contains the definition of
`INotificationActivationCallback` which is the interface we need to _implement_.
### 2. define vtables in Go
The VTables are defined in C (mired in macros). We need to define compatible vtables in Go syntax
so we can call the ones defined in the header.
COM objects are structured in a such a way that we want a parent struct who's first field is a pointer
to the vtable struct. A full example is provided later, for now it we need something like this:
```go
type Object struct {
lpvtbl *ObjectVtbl
}
type ObjectVtbl struct {
MethodOne uintptr
MethdoTwo uintptr
//...
}
```
### 3. invoke methods in Go
Using package `syscall` we can invoke these methods (provided the uintptr are valid) using
`syscal.SyscallN`. Paramters and return values are defined in the C headers.
```go
func (v *Object) One() error {
hr, _, _ := syscall.SyscallN(uintptr(v))
if hr != ole.S_OK {
return ole.NewError(hr)
}
return nil
}
```
With that we can inoke methods on a COM object. This is how `go-ole` works.
## Implementing a COM object in pure Go (no cgo!)
To do this we will need to allocate raw memory for the VTables (so that Go garbage collector
doesn't interfere) and write our function pointers to the VTables.
Since these are not safe Go capabilities we will need the help of package `syscall` (on Windows).
Package `syscall` provides two very important functions:
1. `NewProc` - which loads a function from a DLL
2. `NewCallback` - which allocates a C-callable function pointer from a Go function
For the first part, we can load the Windows kernel api via `kernel32.dll` system dll, and
pull out `GlobalAlloc` and `GlobalFree` using `syscall.NewProc`.
For the second part, we can use `syscall.NewCallback` to build a C-callable function pointer
from a Go function and instantiate the VtTables with it. Caveat emptor: memory allocated by
`NewCallback` is never released, and only 1024 callbacks are guaranteed to be allowed. This
is why we only allocate the callbacks once on init.
Thus we can implement a COM object (invokable from C) like this:
```go
// Initialize our kernel functions.
var (
kernel32 = windows.NewLazySystemDLL("kernel32.dll")
procMalloc = kernel32.NewProc("GlobalAlloc")
procFree = kernel32.NewProc("GlobalFree")
)
// malloc allocates raw memory using the Windows kernel.
// In case of out of memory, the returned pointer will be nil.
// The memory is zeroed out to make sure we don't get garbage that looks like
// valid Go data types.
func malloc(size uintptr) unsafe.Pointer {
hr, _, _ := procMalloc.Call(uintptr(GMEM_FIXED|GMEM_ZEROINIT), uintptr(size))
if hr == 0 {
return nil
}
return unsafe.Pointer(hr)
}
// free deallocates raw memory allocated by malloc.
func free(object unsafe.Pointer) {
procFree.Call(uintptr(object))
}
// Object defines our object.
// This is how COM objects are laid out in memory, where the first field is a pointer
// to a vtable, and the vtable's fields are pointers to functions.
type Object struct {
lpvtbl *ObjectVtbl // lpvtbl is a COM conventional name for this field.
}
// ObjectVtbl defines the Vtable of our object.
type ObjectVtbl struct {
MethodOne uintptr
MethodTwo uintptr
MethodThree uintptr
}
// These methods are allocated once as package globals because Go will never reclaim the
// memory allocated for such callbacks.
//
// All arguments must be uintptr sized, and the return must be a uintptr as well.
//
// By convention, the first parameter is a pointer to the parent object.
var (
methodOne = syscall.NewCallback(func(this *Object) uintptr {
fmt.Printf("methodOne invoked\n")
return uintptr(0)
})
methodTwo = syscall.NewCallback(func(this *Object) uintptr {
fmt.Printf("methodTwo invoked\n")
return uintptr(0)
})
methodThree = syscall.NewCallback(func(this *Object) uintptr {
fmt.Printf("methodThree invoked\n")
return uintptr(0)
})
)
func NewObject() *Object {
// Allocate the parent object and the vtable.
obj := (*Object)(malloc(unsafe.Sizeof(Object{})))
vtbl := (*ObjectVtbl)(malloc(unsafe.Sizeof(ObjectVtbl{})))
// Initialize the vtable with our static callback implementations.
vtbl.MethodOne = methodOne
vtbl.MethodTwo = methodTwo
vtbl.MethodThree = methodThree
// The returned object must be freed by GlobalFree.
object.lpvtbl = vtbl
return obj
}
```
## WinRT and Toast Notifications
For this package the vtables we need are located in various headers `Windows.ui.notifications.h` and
`NotificationActivationCallback.h` and `combase.h`.
With all of the vtables replicated in Go as explained above we now need to interact with the Windows
Runtime.
First we need to initialize the Windows Runtime with `RoInitialize`.
```go
ole.RoInitialize(0)
```
Traditional COM uses GUIDs to identify objects and interfaces. WinRT uses strings (mapped to GUIDS
at runtime).
To instantiate a WinRT COM object we invoke `RoGetActivationFactory` with the class string along with
the interface GUID we expect to use.
```go
CLSID_ToastNotification := "Windows.UI.Notifications.ToastNotification"
IID_IToastNotificationFactory := ole.NewGUID("{50AC103F-D235-4598-BBEF-98FE4D1A3AD4}")
factoryObject, err := ole.RoGetActivationFactory(CLSID_ToastNotification, IID_ToastNotificationFactory)
if err != nil {
return nil, fmt.Errorf("getting activation factory: %w", err)
}
```
From there we can unsafe cast to our callback definition (ole doesn't provide direct access to the methods).
```go
factory := (*IToastNotificationFactory)(unsafe.Pointer(factoryObject))
notification, err := factory.CreateToastNotification(xml)
```
Repeat this process per object we need to instantiate.
To generate a toast notification from XML with a callback we need to instantiate several COM objects:
1. `INotificationActivationCallback` our implementation to be invoked by the runtime
1. `ClassFactory` which can instantiate our `INotificationActivationCallback` implementation
1. `XmlDocument` to contain the xml content of the notification
1. `XmlDocumentIO` to provide an IO interface to the xml document (so we can write the xml to it)
1. `Notification` specifying the content of the notification
1. `Notifier` for showing notifications
Finally we register our class factory using `CoRegisterClassObject` so the runtime can call us back
and then we invoke `Notifier.Show` passing in the `Notification` object to display the notification.
In addition to calling and implementing COM objects we need to manipulate registry state to tell the
Windows Runtime metadata about our application.
1. register a CLSID (GUID) for our INotificationActivationCallback; this is how the runtime knows
what object to ask for
2. optionally provide an icon and an activation executable to be invoked when our application is not running
With all of that correctly configured we can generate toast notifications on Windows in pure Go!

View File

@@ -0,0 +1,61 @@
This project is dual-licensed under the UNLICENSE or
the MIT license with the SPDX identifier:
SPDX-License-Identifier: Unlicense OR MIT
You may use the project under the terms of either license.
Both licenses are reproduced below.
----
The MIT License (MIT)
Copyright (c) 2023 Jack Mordaunt
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
---
---
The UNLICENSE
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <https://unlicense.org/>
---

View File

@@ -0,0 +1,86 @@
# go-toast
This package implements Windows toast notifications using the Windows Runtime COM API.
The XML schema used to describe such notifications is here:
https://learn.microsoft.com/en-us/windows/apps/design/shell/tiles-and-notifications/adaptive-interactive-toasts
Package `wintoast` offers a lower-level api.
Package `toast` offers a higher-level wrapper.
`wintoast` uses build tags to guard Windows only code. It will still compile on
non-Windows platforms, however the functions are stubbed out and will do nothing
when invoked.
## Usage
### Basic
```go
noti := toast.Notification{
AppID: "My cool app",
Title: "Title",
Body: "Body",
}
err := noti.Push()
```
### Actions / Inputs with Callback
Additionally, we can respond to notification activation with a callback.
```go
// Set the callback that receives the data from the notification.
// Any data from actions or inputs will be accessible here.
toast.SetActivationCallback(func(args string, data []UserData) {
fmt.Printf("args: %q, data: %v\n", args, data)
})
n := toast.Notification{
AppID: "My cool app",
Title: "Title",
Body: "Body",
}
n.Inputs = append(n.Inputs, toast.Input{
ID: "reply-to:john-doe",
Title: "Reply",
Placeholder: "Reply to John Doe",
})
n.Inputs = append(n.Inputs, toast.Input{
ID: "select-action",
Title: "Selection Action",
Placeholder: "Pick an action to perform",
Selections: []toast.InputSelection{
{
ID: "1",
Content: "do thing one",
},
{
ID: "2",
Content: "do thing two",
},
{
ID: "3",
Content: "do thing three",
},
},
})
n.Actions = append(n.Actions, toast.Action{
Type: toast.Foreground,
Content: "Send",
Arguments: "send",
})
n.Actions = append(n.Actions, toast.Action{
Type: toast.Foreground,
Content: "Close",
Arguments: "close",
})
err := n.Push()
```

View File

@@ -0,0 +1,65 @@
package toast
import "errors"
var (
ErrorInvalidAudio error = errors.New("toast: invalid audio")
ErrorInvalidDuration = errors.New("toast: invalid duration")
)
// toastAudio identifies audio that Windows can play.
type toastAudio = string
const (
Default toastAudio = "ms-winsoundevent:Notification.Default"
IM toastAudio = "ms-winsoundevent:Notification.IM"
Mail toastAudio = "ms-winsoundevent:Notification.Mail"
Reminder toastAudio = "ms-winsoundevent:Notification.Reminder"
SMS toastAudio = "ms-winsoundevent:Notification.SMS"
LoopingAlarm toastAudio = "ms-winsoundevent:Notification.Looping.Alarm"
LoopingAlarm2 toastAudio = "ms-winsoundevent:Notification.Looping.Alarm2"
LoopingAlarm3 toastAudio = "ms-winsoundevent:Notification.Looping.Alarm3"
LoopingAlarm4 toastAudio = "ms-winsoundevent:Notification.Looping.Alarm4"
LoopingAlarm5 toastAudio = "ms-winsoundevent:Notification.Looping.Alarm5"
LoopingAlarm6 toastAudio = "ms-winsoundevent:Notification.Looping.Alarm6"
LoopingAlarm7 toastAudio = "ms-winsoundevent:Notification.Looping.Alarm7"
LoopingAlarm8 toastAudio = "ms-winsoundevent:Notification.Looping.Alarm8"
LoopingAlarm9 toastAudio = "ms-winsoundevent:Notification.Looping.Alarm9"
LoopingAlarm10 toastAudio = "ms-winsoundevent:Notification.Looping.Alarm10"
LoopingCall toastAudio = "ms-winsoundevent:Notification.Looping.Call"
LoopingCall2 toastAudio = "ms-winsoundevent:Notification.Looping.Call2"
LoopingCall3 toastAudio = "ms-winsoundevent:Notification.Looping.Call3"
LoopingCall4 toastAudio = "ms-winsoundevent:Notification.Looping.Call4"
LoopingCall5 toastAudio = "ms-winsoundevent:Notification.Looping.Call5"
LoopingCall6 toastAudio = "ms-winsoundevent:Notification.Looping.Call6"
LoopingCall7 toastAudio = "ms-winsoundevent:Notification.Looping.Call7"
LoopingCall8 toastAudio = "ms-winsoundevent:Notification.Looping.Call8"
LoopingCall9 toastAudio = "ms-winsoundevent:Notification.Looping.Call9"
LoopingCall10 toastAudio = "ms-winsoundevent:Notification.Looping.Call10"
Silent toastAudio = "silent"
)
// toastduration identifies toast duration for audio playback.
type toastDuration = string
const (
Short toastDuration = "short"
Long toastDuration = "long"
)
// ActivationType identifies the method that Windows Runtime will use to handle
// notification interactions.
//
// See https://learn.microsoft.com/en-us/dotnet/api/microsoft.toolkit.uwp.notifications.toastactivationtype
type ActivationType = string
const (
// Protocol is for launching third-party applications using a protocol uri, like https or mailto.
Protocol ActivationType = "protocol"
// Foreground is for launching your foreground application. This is required to enable the activation
// callback. There is a third option: Background, however for Desktop applications Foreground and
// Background behave identically.
//
// See https://learn.microsoft.com/en-us/windows/apps/design/shell/tiles-and-notifications/send-local-toast-desktop-cpp-wrl#foreground-vs-background-activation
Foreground ActivationType = "foreground"
)

View File

@@ -0,0 +1,73 @@
// Code generated by winrt-go-gen. DO NOT EDIT.
//go:build windows
//nolint:all
package dom
import (
"syscall"
"unsafe"
"github.com/go-ole/go-ole"
)
const SignatureXmlDocument string = "rc(Windows.Data.Xml.Dom.XmlDocument;{f7f3a506-1e87-42d6-bcfb-b8c809fa5494})"
type XmlDocument struct {
ole.IUnknown
}
func NewXmlDocument() (*XmlDocument, error) {
inspectable, err := ole.RoActivateInstance("Windows.Data.Xml.Dom.XmlDocument")
if err != nil {
return nil, err
}
return (*XmlDocument)(unsafe.Pointer(inspectable)), nil
}
func (impl *XmlDocument) LoadXml(xml string) error {
itf := impl.MustQueryInterface(ole.NewGUID(GUIDiXmlDocumentIO))
defer itf.Release()
v := (*iXmlDocumentIO)(unsafe.Pointer(itf))
return v.LoadXml(xml)
}
const (
GUIDiXmlDocumentIO string = "6cd0e74e-ee65-4489-9ebf-ca43e87ba637"
SignatureiXmlDocumentIO string = "{6cd0e74e-ee65-4489-9ebf-ca43e87ba637}"
)
type iXmlDocumentIO struct {
ole.IInspectable
}
type iXmlDocumentIOVtbl struct {
ole.IInspectableVtbl
LoadXml uintptr
LoadXmlWithSettings uintptr
SaveToFileAsync uintptr
}
func (v *iXmlDocumentIO) VTable() *iXmlDocumentIOVtbl {
return (*iXmlDocumentIOVtbl)(unsafe.Pointer(v.RawVTable))
}
func (v *iXmlDocumentIO) LoadXml(xml string) error {
xmlHStr, err := ole.NewHString(xml)
if err != nil {
return err
}
hr, _, _ := syscall.SyscallN(
v.VTable().LoadXml,
uintptr(unsafe.Pointer(v)), // this
uintptr(xmlHStr), // in string
)
if hr != 0 {
return ole.NewError(hr)
}
return nil
}

View File

@@ -0,0 +1,88 @@
// Code generated by winrt-go-gen. DO NOT EDIT.
//go:build windows
//nolint:all
package notifications
import (
"syscall"
"unsafe"
"git.sr.ht/~jackmordaunt/go-toast/v2/internal/winrt/data/xml/dom"
"github.com/go-ole/go-ole"
)
const SignatureToastNotification string = "rc(Windows.UI.Notifications.ToastNotification;{997e2675-059e-4e60-8b06-1760917c8b80})"
func CreateToastNotification(content *dom.XmlDocument) (*ToastNotification, error) {
inspectable, err := ole.RoGetActivationFactory("Windows.UI.Notifications.ToastNotification", ole.NewGUID(GUIDiToastNotificationFactory))
if err != nil {
return nil, err
}
v := (*iToastNotificationFactory)(unsafe.Pointer(inspectable))
var out *ToastNotification
hr, _, _ := syscall.SyscallN(
v.VTable().CreateToastNotification,
0, // this is a static func, so there's no this
uintptr(unsafe.Pointer(content)), // in dom.XmlDocument
uintptr(unsafe.Pointer(&out)), // out ToastNotification
)
if hr != 0 {
return nil, ole.NewError(hr)
}
return out, nil
}
type ToastNotification struct {
ole.IUnknown
}
const (
GUIDiToastNotification string = "997e2675-059e-4e60-8b06-1760917c8b80"
SignatureiToastNotification string = "{997e2675-059e-4e60-8b06-1760917c8b80}"
)
type iToastNotification struct {
ole.IInspectable
}
type iToastNotificationVtbl struct {
ole.IInspectableVtbl
GetContent uintptr
SetExpirationTime uintptr
GetExpirationTime uintptr
AddDismissed uintptr
RemoveDismissed uintptr
AddActivated uintptr
RemoveActivated uintptr
AddFailed uintptr
RemoveFailed uintptr
}
func (v *iToastNotification) VTable() *iToastNotificationVtbl {
return (*iToastNotificationVtbl)(unsafe.Pointer(v.RawVTable))
}
const (
GUIDiToastNotificationFactory string = "04124b20-82c6-4229-b109-fd9ed4662b53"
SignatureiToastNotificationFactory string = "{04124b20-82c6-4229-b109-fd9ed4662b53}"
)
type iToastNotificationFactory struct {
ole.IInspectable
}
type iToastNotificationFactoryVtbl struct {
ole.IInspectableVtbl
CreateToastNotification uintptr
}
func (v *iToastNotificationFactory) VTable() *iToastNotificationFactoryVtbl {
return (*iToastNotificationFactoryVtbl)(unsafe.Pointer(v.RawVTable))
}

View File

@@ -0,0 +1,53 @@
// Code generated by winrt-go-gen. DO NOT EDIT.
//go:build windows
//nolint:all
package notifications
import (
"syscall"
"unsafe"
"github.com/go-ole/go-ole"
)
const (
GUIDiToastNotificationManagerStatics5 string = "d6f5f569-d40d-407c-8989-88cab42cfd14"
SignatureiToastNotificationManagerStatics5 string = "{d6f5f569-d40d-407c-8989-88cab42cfd14}"
)
type iToastNotificationManagerStatics5 struct {
ole.IInspectable
}
type iToastNotificationManagerStatics5Vtbl struct {
ole.IInspectableVtbl
GetDefault uintptr
}
func (v *iToastNotificationManagerStatics5) VTable() *iToastNotificationManagerStatics5Vtbl {
return (*iToastNotificationManagerStatics5Vtbl)(unsafe.Pointer(v.RawVTable))
}
func GetDefault() (*ToastNotificationManagerForUser, error) {
inspectable, err := ole.RoGetActivationFactory("Windows.UI.Notifications.ToastNotificationManager", ole.NewGUID(GUIDiToastNotificationManagerStatics5))
if err != nil {
return nil, err
}
v := (*iToastNotificationManagerStatics5)(unsafe.Pointer(inspectable))
var out *ToastNotificationManagerForUser
hr, _, _ := syscall.SyscallN(
v.VTable().GetDefault,
0, // this is a static func, so there's no this
uintptr(unsafe.Pointer(&out)), // out ToastNotificationManagerForUser
)
if hr != 0 {
return nil, ole.NewError(hr)
}
return out, nil
}

View File

@@ -0,0 +1,68 @@
// Code generated by winrt-go-gen. DO NOT EDIT.
//go:build windows
//nolint:all
package notifications
import (
"syscall"
"unsafe"
"github.com/go-ole/go-ole"
)
const SignatureToastNotificationManagerForUser string = "rc(Windows.UI.Notifications.ToastNotificationManagerForUser;{79ab57f6-43fe-487b-8a7f-99567200ae94})"
type ToastNotificationManagerForUser struct {
ole.IUnknown
}
func (impl *ToastNotificationManagerForUser) CreateToastNotifierWithId(applicationId string) (*ToastNotifier, error) {
itf := impl.MustQueryInterface(ole.NewGUID(GUIDiToastNotificationManagerForUser))
defer itf.Release()
v := (*iToastNotificationManagerForUser)(unsafe.Pointer(itf))
return v.CreateToastNotifierWithId(applicationId)
}
const (
GUIDiToastNotificationManagerForUser string = "79ab57f6-43fe-487b-8a7f-99567200ae94"
SignatureiToastNotificationManagerForUser string = "{79ab57f6-43fe-487b-8a7f-99567200ae94}"
)
type iToastNotificationManagerForUser struct {
ole.IInspectable
}
type iToastNotificationManagerForUserVtbl struct {
ole.IInspectableVtbl
CreateToastNotifier uintptr
CreateToastNotifierWithId uintptr
GetHistory uintptr
GetUser uintptr
}
func (v *iToastNotificationManagerForUser) VTable() *iToastNotificationManagerForUserVtbl {
return (*iToastNotificationManagerForUserVtbl)(unsafe.Pointer(v.RawVTable))
}
func (v *iToastNotificationManagerForUser) CreateToastNotifierWithId(applicationId string) (*ToastNotifier, error) {
var out *ToastNotifier
applicationIdHStr, err := ole.NewHString(applicationId)
if err != nil {
return nil, err
}
hr, _, _ := syscall.SyscallN(
v.VTable().CreateToastNotifierWithId,
uintptr(unsafe.Pointer(v)), // this
uintptr(applicationIdHStr), // in string
uintptr(unsafe.Pointer(&out)), // out ToastNotifier
)
if hr != 0 {
return nil, ole.NewError(hr)
}
return out, nil
}

View File

@@ -0,0 +1,64 @@
// Code generated by winrt-go-gen. DO NOT EDIT.
//go:build windows
//nolint:all
package notifications
import (
"syscall"
"unsafe"
"github.com/go-ole/go-ole"
)
const SignatureToastNotifier string = "rc(Windows.UI.Notifications.ToastNotifier;{75927b93-03f3-41ec-91d3-6e5bac1b38e7})"
type ToastNotifier struct {
ole.IUnknown
}
func (impl *ToastNotifier) Show(notification *ToastNotification) error {
itf := impl.MustQueryInterface(ole.NewGUID(GUIDiToastNotifier))
defer itf.Release()
v := (*iToastNotifier)(unsafe.Pointer(itf))
return v.Show(notification)
}
const (
GUIDiToastNotifier string = "75927b93-03f3-41ec-91d3-6e5bac1b38e7"
SignatureiToastNotifier string = "{75927b93-03f3-41ec-91d3-6e5bac1b38e7}"
)
type iToastNotifier struct {
ole.IInspectable
}
type iToastNotifierVtbl struct {
ole.IInspectableVtbl
Show uintptr
Hide uintptr
GetSetting uintptr
AddToSchedule uintptr
RemoveFromSchedule uintptr
GetScheduledToastNotifications uintptr
}
func (v *iToastNotifier) VTable() *iToastNotifierVtbl {
return (*iToastNotifierVtbl)(unsafe.Pointer(v.RawVTable))
}
func (v *iToastNotifier) Show(notification *ToastNotification) error {
hr, _, _ := syscall.SyscallN(
v.VTable().Show,
uintptr(unsafe.Pointer(v)), // this
uintptr(unsafe.Pointer(notification)), // in ToastNotification
)
if hr != 0 {
return ole.NewError(hr)
}
return nil
}

View File

@@ -0,0 +1,14 @@
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
[Windows.UI.Notifications.ToastNotification, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null
$APP_ID = '{{if .AppID}}{{.AppID}}{{else}}Windows App{{end}}'
$template = @"
{{.XML}}
"@
$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
$xml.LoadXml($template)
$toast = New-Object Windows.UI.Notifications.ToastNotification $xml
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($APP_ID).Show($toast)

View File

@@ -0,0 +1,28 @@
// Package tmpl wraps several templates that are used to generate notifications.
//
// The primary template describes the XML structure that the Windows Runtime expects
// to consume. The powershell template describes a script that we can use to execute
// the notification if the Windows Runtime is unavailable.
//
// For more information about the xml schema:
// https://learn.microsoft.com/en-us/uwp/schemas/tiles/toastschema/schema-root
package tmpl
import (
_ "embed"
"text/template"
)
//go:embed xml.go.tmpl
var xml string
//go:embed powershell.go.tmpl
var powershell string
// XMLTemplate describes the XML content that the Windows Runtime uses to build
// toast notifications.
var XMLTemplate = template.Must(template.New("toast-xml").Parse(xml))
// ScriptTemplate describes the Powershell script that will invoke a toast notification
// given some XML.
var ScriptTemplate = template.Must(template.New("script").Parse(powershell))

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<toast activationType="{{.ActivationType}}" launch="{{.ActivationArguments}}" duration="{{.Duration}}">
<visual>
<binding template="ToastGeneric">
{{if .HeroIcon}}
<image placement="hero" src="{{.HeroIcon}}" />
{{end}}
{{if .Icon}}
<image placement="appLogoOverride" src="{{.Icon}}" {{if .IconCrop}} hint-crop="{{.IconCrop}}" {{end}} />
{{end}}
{{if .Title}}
<text hint-maxLines="1"><![CDATA[{{.Title}}]]></text>
{{end}}
{{if .Body}}
<text><![CDATA[{{.Body}}]]></text>
{{end}}
</binding>
</visual>
{{if ne .Audio "silent"}}
<audio src="{{.Audio}}" loop="{{.Loop}}" />
{{else}}
<audio silent="true" />
{{end}}
{{if .Actions}}
<actions>
{{range .Inputs}}
<input id="{{.ID}}" title="{{.Title}}" placeHolderContent="{{.Placeholder}}" {{if .Selections}} type="selection" {{else}} type="text" {{end}}>
{{range .Selections}}
<selection id="{{.ID}}" content="{{.Content}}" />
{{end}}
</input>
{{end}}
{{range .Actions}}
<action activationType="{{.Type}}" content="{{.Content}}" arguments="{{.Arguments}}" {{if .InputID}} hint-inputId="{{.InputID}}" {{end}} />
{{end}}
</actions>
{{end}}
</toast>

View File

@@ -0,0 +1,233 @@
// Package toast wraps the lower-level wintoast api and provides an easy way
// to send and respond to toast notifications on Windows.
//
// First, setup your AppData vis SetAppData function. This will install your
// application metadata into the Windows Registry.
//
// Then, if you want in-process callback to be invoked upon user interaction,
// invoke SetActivationCallback.
//
// Finally, generate your notification by instantiation a toast.Notification
// and pushing it with Push method.
package toast
import (
"bytes"
"git.sr.ht/~jackmordaunt/go-toast/v2/tmpl"
"git.sr.ht/~jackmordaunt/go-toast/v2/wintoast"
)
// Notification
//
// The toast notification data. The following fields are strongly recommended;
// - AppID
// - Title
//
// If no toastAudio is provided, then the toast notification will be silent.
//
// The AppID is shown beneath the toast message (in certain cases), and above the notification within the Action
// Center - and is used to group your notifications together. It is recommended that you provide a "pretty"
// name for your app, and not something like "com.example.MyApp". It can be ellided if the value has already
// been set via SetAppData.
//
// If no Title is provided, but a Body is, the body will display as the toast notification's title -
// which is a slightly different font style (heavier).
//
// The Icon should be an absolute path to the icon (as the toast is invoked from a temporary path on the user's
// system, not the working directory).
//
// If you would like the toast to call an external process/open a webpage, then you can set ActivationArguments
// to the uri you would like to trigger when the toast is clicked. For example: "https://google.com" would open
// the Google homepage when the user clicks the toast notification.
// By default, clicking the toast just hides/dismisses it.
//
// The following would show a notification to the user letting them know they received an email, and opens
// gmail.com when they click the notification. It also makes the Windows 10 "mail" sound effect.
//
// toast := toast.Notification{
// AppID: "Google Mail",
// Title: email.Subject,
// Message: email.Preview,
// Icon: "C:/Program Files/Google Mail/icons/logo.png",
// ActivationArguments: "https://gmail.com",
// Audio: toast.Mail,
// }
//
// err := toast.Push()
type Notification struct {
// The name of your app. This value shows up in Windows Action Centre, so make it
// something readable for your users.
AppID string
// The main title/heading for the toast notification.
Title string
// The single/multi line message to display for the toast notification.
Body string
// An optional path to an image on the OS to display to the left of the title & message.
Icon string
// An optional crop style for the Icon.
IconCrop CropStyle
// An optional path to an image to display as a bold hero image.
HeroIcon string
// A color to show as the icon background.
IconBackgroundColor string
// Action to take when the notification is as a whole activated.
ActivationType ActivationType
// The activation/action arguments (invoked when the user clicks the notification).
// This is returned to the callback when activated.
ActivationArguments string
// Optional text input to display before the actions.
Inputs []Input
// Optional action buttons to display below the notification title & message.
Actions []Action
// The audio to play when displaying the toast
Audio toastAudio
// Whether to loop the audio (default false).
Loop bool
// How long the toast should show up for (short/long).
Duration toastDuration
// This is an absolute path to an executable that will launched by the
// Windows Runtime when the COM server is not running. This executable must be able
// to handle the -Embedding flag that Windows invokes it with.
ActivationExe string
}
// CropStyle specifies the hint-crop attribute for an image.
type CropStyle = string
const (
CropStyleEmpty CropStyle = ""
CropStyleSquare CropStyle = "square"
CropStyleCircle CropStyle = "circle"
)
// UserData contains user supplied data from the notification, such as text input
// or a selection.
type UserData = wintoast.UserData
// Input
//
// Defines an input element, generally a text input.
// See https://learn.microsoft.com/en-us/uwp/schemas/tiles/toastschema/element-input for more info.
//
// Inputs are by default textual, however if selections are supplied the input will be rendered
// as a select input.
type Input struct {
ID string
Title string
Placeholder string
Selections []InputSelection
}
// InputSelection
//
// Defines an input selection for use with select inputs.
// See https://learn.microsoft.com/en-us/uwp/schemas/tiles/toastschema/element-selection for more info.
type InputSelection struct {
ID string
Content string
}
// Action
//
// Defines an actionable button.
// See https://msdn.microsoft.com/en-us/windows/uwp/controls-and-patterns/tiles-and-notifications-adaptive-interactive-toasts for more info.
//
// toast.Action{toast.Protocol, "Open Maps", "bingmaps:?q=sushi"}
//
// TODO(jfm): we can likely support an activation callback directly in the Action.
type Action struct {
Type ActivationType
Content string
Arguments string
InputID string // optional ID of any related input, affects styling.
}
// Push the notification to the Windows Runtime via the COM API.
// Ensure [SetAppData] has been called prior to pushing notifications.
//
// notification := toast.Notification{
// AppID: "Example App",
// Title: "My notification",
// Message: "Some message about how important something is...",
// Icon: "go.png",
// Actions: []toast.Action{
// {"protocol", "I'm a button", ""},
// {"protocol", "Me too!", ""},
// },
// }
// err := notification.Push()
// if err != nil {
// log.Fatalln(err)
// }
func (n *Notification) Push() error {
n.applyDefaults()
xml, err := n.buildXML()
if err != nil {
return err
}
return wintoast.Push(n.AppID, xml, wintoast.PowershellFallback)
}
func (n *Notification) applyDefaults() {
if n.ActivationType == "" {
n.ActivationType = Foreground
}
if n.Duration == "" {
n.Duration = Short
}
if n.Audio == "" {
n.Audio = Default
}
}
func (n *Notification) buildXML() (string, error) {
var out bytes.Buffer
err := tmpl.XMLTemplate.Execute(&out, n)
if err != nil {
return "", err
}
return out.String(), nil
}
// SetActivationCallback sets the global activation callback.
//
// The first argument contains application defined data (embedded within the xml),
// which is how the callback knows which part of the toast was activated.
// Argument data is defined by `toast.Action.Arguments` on the notification.
//
// The second argument contains user defined data (input/selected by user).
// All elements of user input will be supplied here, even if the value is empty.
// User inputs correspond to all `toast.Input`s defined on the notification.
//
// This function will be invoked when a toast notification is interacted with.
//
// This will do nothing if the the powershell fallback is in-effect.
func SetActivationCallback(cb func(args string, data []UserData)) {
wintoast.SetActivationCallback(func(appUserModelId, invokedArgs string, userData []wintoast.UserData) {
cb(invokedArgs, userData)
})
}
type AppData = wintoast.AppData
// SetAppData sets application metadata in the Windows Registry.
// This is required to display the application name, as well as any branding.
// Registry is global state, hence it makes sense to set it global.
func SetAppData(data AppData) error {
return wintoast.SetAppData(data)
}

View File

@@ -0,0 +1,89 @@
// Package wintoast provides a pure-Go implementation of toast notifications on Windows.
package wintoast
import "errors"
// AppData describes the application to the Windows Runtime.
// See toast.Notification for more thorough documentation off these fields.
type AppData struct {
AppID string
GUID string
ActivationExe string // optional
IconPath string // optional
IconBackgroundColor string // optional
}
// UserData contains Key:Value pairs generated within the notification, based
// on the XML content of the notification. Specifically, all inputs within
// the XML will generate a corresponding UserData struct.
type UserData struct {
Key string
Value string
}
// Callback is a function that gets invoked when the notification is activated.
type Callback func(appUserModelId string, invokedArgs string, userData []UserData)
// SetAppData teaches the Windows Runtime about our application and establishes the activation GUID
// so Windows will know how to invoke us back.
func SetAppData(data AppData) (err error) {
return setAppData(data)
}
// SetActivationCallback establishes the callback `cb` to be invoked when
// the toast notification is activated. This callback instance should handle
// being activated from any available toast notification.
func SetActivationCallback(cb Callback) {
callback = cb
}
// Push a notification described by the XML to the Windows Runtime.
//
// App data should be set first via a call to SetAppData before calling
// this function.
//
// If the powershell fallback is engaged, activation callbacks will not
// work as expected and the COM error will still be returned.
func Push(appID, xml string, op ...option) error {
var opts options
for _, opt := range op {
opt(&opts)
}
if opts.PowershellPreferred {
return pushPowershell(xml)
}
if appID == "" {
appID = appData.AppID
}
if err := pushCOM(appID, xml); err != nil {
if opts.PowershellFallback {
return errors.Join(err, pushPowershell(xml))
}
return err
}
return nil
}
type options struct {
PowershellFallback bool
PowershellPreferred bool
}
type option func(*options)
// PreferPowershell indicates to use the powershell method by default.
// COM will not be used.
func PreferPowershell(opt *options) {
opt.PowershellPreferred = true
}
// PowershellFallback specifies to use the powershell method as a fallback
// if the COM api fails.
func PowershellFallback(opt *options) {
opt.PowershellFallback = true
}
// callback is the global callback reference that is invoked by Activate.
//
// NOTE(jfm): synchronize access to this?
var callback Callback = func(model, args string, data []UserData) {}

View File

@@ -0,0 +1,21 @@
//go:build !windows
package wintoast
var appData AppData
func setAppData(data AppData) error {
return nil
}
func generateToast(appID string, xml string) error {
return nil
}
func pushPowershell(xml string) error {
return nil
}
func pushCOM(appID, xml string) error {
return nil
}

View File

@@ -0,0 +1,211 @@
//go:build windows
package wintoast
import (
"errors"
"fmt"
"io"
"os"
"os/exec"
"sync/atomic"
"syscall"
"unsafe"
"git.sr.ht/~jackmordaunt/go-toast/v2/internal/winrt/data/xml/dom"
"git.sr.ht/~jackmordaunt/go-toast/v2/internal/winrt/ui/notifications"
"git.sr.ht/~jackmordaunt/go-toast/v2/tmpl"
"github.com/go-ole/go-ole"
"golang.org/x/sys/windows"
)
func pushPowershell(xml string) error {
f, err := os.CreateTemp("", "*.ps1")
if err != nil {
return fmt.Errorf("creating temporary script file: %w", err)
}
defer func() { err = errors.Join(err, os.Remove(f.Name())) }()
// This BOM ensures we can support non-ascii characters in the toast content.
bomUtf8 := []byte{0xef, 0xbb, 0xbf}
if _, err := f.Write(bomUtf8); err != nil {
return fmt.Errorf("writing utf8 byte marker: %w", err)
}
if err := buildPowershell(xml, f); err != nil {
return fmt.Errorf("generating powershell script: %w", err)
}
if err := f.Close(); err != nil {
return fmt.Errorf("closing script file: %w", err)
}
cmd := exec.Command("PowerShell", "-ExecutionPolicy", "Bypass", "-File", f.Name())
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("executing powershell: %q: %w", string(out), err)
}
return nil
}
func buildPowershell(xml string, w io.Writer) error {
type scriptData struct {
AppID string
XML string
}
return tmpl.ScriptTemplate.Execute(w, scriptData{AppID: appData.AppID, XML: xml})
}
// HRESULT E_NOINTERFACE
const errNoInterface = 0x80004002
var comDisabled atomic.Bool
func pushCOM(appID, xml string) (err error) {
if comDisabled.Load() {
return nil
}
defer func() {
// On Windows 7 WinRT interfaces can be stubbed out, and fail to produce
// error values. This leads to a panic when trying to use the interface.
// This recover transforms such panics back into an error value for the
// caller.
//
// If the error is "interface not supported" we will permanently disable
// this API henceforth.
if v := recover(); v != nil {
if verr, ok := v.(error); ok {
err = verr
}
if oleErr, ok := v.(*ole.OleError); ok {
if oleErr.Code() == errNoInterface {
comDisabled.Store(true)
}
}
}
}()
if err := initialize(); err != nil {
return err
}
if err := registerClassFactory(ClassFactory); err != nil {
return fmt.Errorf("registering class factory: %w", err)
}
doc, err := dom.NewXmlDocument()
if err != nil {
return fmt.Errorf("dom.NewXmlDocument(): %w", err)
}
defer doc.Release()
if err := doc.LoadXml(xml); err != nil {
return fmt.Errorf("doc.LoadXml(tmpl): %w", err)
}
manager, err := notifications.GetDefault()
if err != nil {
return fmt.Errorf("notifications.GetDefault(): %w", err)
}
defer manager.Release()
notifier, err := manager.CreateToastNotifierWithId(appID)
if err != nil {
return fmt.Errorf("manager.CreateToastNotifier(%q): %w", appID, err)
}
defer notifier.Release()
toast, err := notifications.CreateToastNotification(doc)
if err != nil {
return fmt.Errorf("notifications.CreateToastNotification(doc): %w", err)
}
defer toast.Release()
if err := notifier.Show(toast); err != nil {
return fmt.Errorf("notifier.Show(): %w", err)
}
return nil
}
func setAppData(data AppData) (err error) {
appDataMu.Lock()
defer appDataMu.Unlock()
// Early out if we have already set this data.
//
// In the case the data is empty, we don't want to overrite
// all of the registry entries to empty.
//
// This allows the caller to either globally set the app data
// or provide it per notification.
if appData == data || data.AppID == "" {
return nil
}
if data.GUID != "" {
GUID_ImplNotificationActivationCallback = ole.NewGUID(data.GUID)
}
// Keep a copy of the saved data for later.
defer func() {
if err == nil {
appData = data
}
}()
if err := setAppDataFunc(data); err != nil {
return err
}
return nil
}
var initialized atomic.Bool
// initialize attempts to initialize the Windows Runtime.
// Each invocation will retry RoInitialize until a successful initialization
// is achieved. Once initialized, we avoid invoking RoInitialize since subsequent
// reinitialization generates errors.
func initialize() (err error) {
if initialized.CompareAndSwap(false, true) {
if err := ole.RoInitialize(1); err != nil {
return fmt.Errorf("RoInitialize: %w", err)
}
}
return nil
}
// sliceUserDataFromUnsafe builds a slice of UserData out of an unsafe pointer.
func sliceUserDataFromUnsafe(ptr unsafe.Pointer, count int) []UserData {
// Layout mirrors the memory layout of the C struct that contains this data.
// I'm not sure if there's special alignment or packing - though I don't notice
// anything in the definition to indicate as such.
type layout struct {
Key unsafe.Pointer
Value unsafe.Pointer
}
// Create a new slice with the appropriate length
out := make([]UserData, count)
// Create a slice with the unsafe data layout.
tmp := unsafe.Slice((*layout)(ptr), count)
// Convert the unsafe layout to safe strings.
for ii, it := range tmp {
out[ii] = UserData{
Key: windows.UTF16PtrToString((*uint16)(it.Key)),
Value: windows.UTF16PtrToString((*uint16)(it.Value)),
}
}
return out
}

View File

@@ -0,0 +1,179 @@
//go:build windows
// This file contains our pure-Go implementations of two COM objects that we need
// to render toast notifications: IClassFactory and INotificationActivationCallback.
//
// More specifically we allocate the C callable functions that can be used to populate
// the vtable at runtime.
//
// Unfortunately these functions have to be declared as var not const because the callbacks
// are built at runtime. They are declared globally because `syscall.NewCallback` never
// releases the memory it allocates for the functions thus causing an unsolvable memory
// leak if we were to allocate these per-notification.
//
// The other COM interfaces we are interacting with are auto-generated from metadata.
// However the INotificationActivationCallback is undocumented, so we have to define
// it entirely ourselves.
//
// The definitions are derived from:
// - <combase.h>
// - <NotificationActivationCallback.h>
package wintoast
import (
"runtime"
"syscall"
"unsafe"
"github.com/go-ole/go-ole"
"golang.org/x/sys/windows"
)
// Interface GUIDS. These GUIDS are predefined by the Windows Runtime, identifying the various
// interfaces we want to make use of.
var (
IID_IClassFactory = ole.NewGUID("{00000001-0000-0000-C000-000000000046}")
IID_INotificationActivationCallback = ole.NewGUID("{53E31837-6600-4A81-9395-75CFFE746F94}")
)
// This default GUID is for our implementation.
// This was generated and should not collide with any other GUID.
// It's preferable for the application to override this value with its own generated GUID.
var GUID_ImplNotificationActivationCallback = ole.NewGUID("{0F82E845-CB89-4039-BDBF-67CA33254C76}")
type (
// IClassFactory defines the factory that builds our INotificationActivationCallback instance.
// Windows Runtime loves factories.
IClassFactory struct {
VTable *IClassFactoryVtbl
}
IClassFactoryVtbl struct {
ole.IUnknownVtbl
CreateInstance uintptr
LockServer uintptr
}
)
type (
// INotificationActivationCallback receives activations from toast notifications.
INotificationActivationCallback struct {
VTable *INotificationActivationCallbackVtbl
}
INotificationActivationCallbackVtbl struct {
ole.IUnknownVtbl
Activate uintptr
}
)
/*
Strictly speaking we shouldn't need to pin the static objects. They
are package-globals and wont be garabge collected. No harm in being
extra careful, though.
*/
var pinner runtime.Pinner
func init() {
pinner.Pin(ClassFactory)
pinner.Pin(ClassFactory.VTable)
pinner.Pin(NotificationActivationCallback)
pinner.Pin(NotificationActivationCallback.VTable)
}
// Static implementations for the IClassFactory.
var (
ClassFactory = &IClassFactory{
VTable: &IClassFactoryVtbl{
IUnknownVtbl: ole.IUnknownVtbl{
QueryInterface: IClassFactory_QueryInterface,
AddRef: IClassFactory_AddRef,
Release: IClassFactory_Release,
},
LockServer: IClassFactory_LockServer,
CreateInstance: IClassFactory_CreateInstance,
},
}
IClassFactory_AddRef = syscall.NewCallback(func(this *IClassFactory) (re uintptr) {
return uintptr(1)
})
IClassFactory_Release = syscall.NewCallback(func(this *IClassFactory) (re uintptr) {
return uintptr(1)
})
IClassFactory_QueryInterface = syscall.NewCallback(func(this *IClassFactory, riid *ole.GUID, out unsafe.Pointer) (re uintptr) {
if !ole.IsEqualGUID(riid, IID_IClassFactory) &&
!ole.IsEqualGUID(riid, ole.IID_IUnknown) {
return ole.E_NOINTERFACE
}
*(**IClassFactory)(out) = this
return ole.S_OK
})
IClassFactory_LockServer = syscall.NewCallback(func(this *IClassFactory, flock uintptr) (ret uintptr) {
return ole.S_OK
})
IClassFactory_CreateInstance = syscall.NewCallback(func(this *IClassFactory, punkOuter *ole.IUnknown, riid *ole.GUID, out unsafe.Pointer) (re uintptr) {
if punkOuter != nil {
// Should be CLASS_E_NOAGGREGATION but ole doesn't define this.
return ole.E_NOINTERFACE
}
if !ole.IsEqualGUID(riid, IID_INotificationActivationCallback) &&
!ole.IsEqualGUID(riid, ole.IID_IUnknown) {
return ole.E_NOINTERFACE
}
*(**INotificationActivationCallback)(out) = NotificationActivationCallback
return ole.S_OK
})
)
// Static implementations for the INotificationActivationCallback.
var (
NotificationActivationCallback = &INotificationActivationCallback{
VTable: &INotificationActivationCallbackVtbl{
IUnknownVtbl: ole.IUnknownVtbl{
QueryInterface: INotificationActivationCallback_QueryInterface,
AddRef: INotificationActivationCallback_AddRef,
Release: INotificationActivationCallback_Release,
},
Activate: INotificationActivationCallback_Activate,
},
}
INotificationActivationCallback_AddRef = syscall.NewCallback(func(this *INotificationActivationCallback) (re uintptr) {
return uintptr(1)
})
INotificationActivationCallback_Release = syscall.NewCallback(func(this *INotificationActivationCallback) (re uintptr) {
return uintptr(1)
})
INotificationActivationCallback_QueryInterface = syscall.NewCallback(func(this *INotificationActivationCallback, riid *ole.GUID, out unsafe.Pointer) (re uintptr) {
if !ole.IsEqualGUID(riid, IID_INotificationActivationCallback) &&
!ole.IsEqualGUID(riid, ole.IID_IUnknown) {
return ole.E_NOINTERFACE
}
*(**INotificationActivationCallback)(out) = this
return ole.S_OK
})
// Activate is our re-entrance into Go from Windows. This is the magic.
INotificationActivationCallback_Activate = syscall.NewCallback(func(
this unsafe.Pointer,
appUserModelId unsafe.Pointer,
invokedArgs unsafe.Pointer,
data unsafe.Pointer,
count uint32,
) (ret uintptr) {
callback(
windows.UTF16PtrToString((*uint16)(appUserModelId)),
windows.UTF16PtrToString((*uint16)(invokedArgs)),
sliceUserDataFromUnsafe(data, int(count)),
)
return
})
)

View File

@@ -0,0 +1,37 @@
//go:build windows
package wintoast
import (
"unsafe"
"github.com/go-ole/go-ole"
"golang.org/x/sys/windows"
)
var (
// Define procs that go-ole doesn't provide. This is how we register our Go-implemented
// COM objects.
modcombase = windows.NewLazySystemDLL("combase.dll")
procRegisterClassObject = modcombase.NewProc("CoRegisterClassObject")
)
// registerClassFactory teaches the Windows Runtime about our factory that can allocate
// instances of our ActivationCallback.
func registerClassFactory(factory *IClassFactory) error {
// cookie is used as a handle to this class. It is used when calling CoRevokeClassObject
// which unregisters the class. We don't need it until we plan to revoke this registration
// for some reason.
var cookie int64
hr, _, _ := procRegisterClassObject.Call(
uintptr(unsafe.Pointer(GUID_ImplNotificationActivationCallback)),
uintptr(unsafe.Pointer(factory)),
uintptr(ole.CLSCTX_LOCAL_SERVER),
uintptr(1), /* REGCLS_MULTIPLEUSE */
uintptr(unsafe.Pointer(&cookie)),
)
if hr != ole.S_OK {
return ole.NewError(hr)
}
return nil
}

View File

@@ -0,0 +1,110 @@
//go:build windows
// This file contains registry manipulation code.
// This logic is orthogonal to, but works in tandem with the COM code; since the
// Windows Runtime uses the registry as it's primary source of state.
package wintoast
import (
"fmt"
"path/filepath"
"sync"
"golang.org/x/sys/windows/registry"
)
var (
// allows diffing the new call from the previous so that we can early-out,
// and avoid touching the registry more than necessary.
// It also allows empty app data to be supplied to the Notifcation type,
// without erasing the data that has been set via the global function.
appData AppData
appDataMu sync.Mutex
)
// Overridden in testing.
var (
writeStringValue = writeStringValueImpl
setAppDataFunc = setAppDataImpl
)
var (
// appKeyRoot is the root path for app metadata.
appKeyRoot = filepath.Join("SOFTWARE", "Classes", "AppUserModelId")
// activationKey is the root path to the activation executable.
activationKey = filepath.Join("SOFTWARE", "Classes", "CLSID", GUID_ImplNotificationActivationCallback.String(), "LocalServer32")
)
// The Windows registry package uses empty string for the "(Default)" key.
const registryDefaultKey string = ""
func setAppDataImpl(data AppData) error {
if data.AppID == "" {
return fmt.Errorf("empty app ID")
}
appKey := filepath.Join(appKeyRoot, data.AppID)
if err := writeStringValue(appKey, "DisplayName", data.AppID); err != nil {
return err
}
// CustomActivator teaches Window what COM class to use as the callback when
// a toast notification is activated.
if err := writeStringValue(appKey, "CustomActivator", GUID_ImplNotificationActivationCallback.String()); err != nil {
return err
}
if data.IconPath != "" {
if err := writeStringValue(appKey, "IconUri", data.IconPath); err != nil {
return err
}
}
if data.IconBackgroundColor != "" {
if err := writeStringValue(appKey, "IconBackgroundColor", data.IconBackgroundColor); err != nil {
return err
}
}
if data.ActivationExe != "" {
if err := writeStringValue(activationKey, registryDefaultKey, data.ActivationExe); err != nil {
return fmt.Errorf("setting activation executable: %w", err)
}
}
return nil
}
// writeStringValue writes a string value to the path, where name is the subkey and
// value is the literal value.
func writeStringValueImpl(path, name, value string) error {
if keyExists(path, name) {
return nil
}
key, _, err := registry.CreateKey(registry.CURRENT_USER, path, registry.SET_VALUE)
if err != nil {
return fmt.Errorf("opening registry key: %s: %w", path, err)
}
if err := key.SetStringValue(name, value); err != nil {
return fmt.Errorf("setting string value: (%s) %s=%s: %w", path, name, value, err)
}
if err := key.Close(); err != nil {
return fmt.Errorf("closing key: %s: %w", path, err)
}
return nil
}
// keyExists returns true if the key exists.
func keyExists(path, name string) bool {
key, err := registry.OpenKey(registry.CURRENT_USER, path, registry.READ)
if err != nil {
return false
}
defer key.Close()
v, _, err := key.GetStringValue(name)
if err != nil {
return false
}
return v != ""
}