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,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 != ""
}