wails/v3/pkg/application/events.go
Lea Anthony 9a363d7be5
feat(v3): add server mode for headless HTTP deployment (#4903)
* feat(v3): add server mode for headless HTTP deployment

Server mode allows Wails applications to run as pure HTTP servers
without native GUI dependencies. Enable with `-tags server` build tag.

Features:
- HTTP server with configurable host/port via ServerOptions
- WAILS_SERVER_HOST and WAILS_SERVER_PORT env var overrides
- WebSocket event broadcasting to connected browsers
- Browser clients represented as BrowserWindow (Window interface)
- Health check endpoint at /health
- Graceful shutdown with configurable timeout
- Docker support with Dockerfile.server template and tasks

Build and run:
  wails3 task build:server
  wails3 task run:server
  wails3 task build:docker
  wails3 task run:docker

Documentation at docs/guides/server-build.mdx

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(v3): add server mode for headless HTTP deployment

Server mode allows Wails applications to run as pure HTTP servers
without native GUI dependencies. Enable with `-tags server` build tag.

Features:
- HTTP server with configurable host/port via ServerOptions
- WAILS_SERVER_HOST and WAILS_SERVER_PORT env var overrides
- WebSocket event broadcasting to connected browsers
- Browser clients represented as BrowserWindow (Window interface)
- Health check endpoint at /health
- Graceful shutdown with configurable timeout
- Docker support with Dockerfile.server template and tasks

Build and run:
  wails3 task build:server
  wails3 task run:server
  wails3 task build:docker
  wails3 task run:docker

Documentation at docs/guides/server-build.mdx

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address CodeRabbit review comments

- Fix corrupted test file with embedded terminal output
- Fix module name mismatch in gin-routing (was gin-example)
- Fix replace directive version mismatch in gin-service
- Fix placeholder module name in ios example (was changeme)
- Fix Dockerfile COPY path to work from both build contexts
- Fix bare URL in README (MD034 compliance)
- Fix comment accuracy in getScreens (returns error, not empty slice)
- Remove deprecated docker-compose version field
- Add port documentation in Taskfile template

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address CodeRabbit review comments

- Add note about healthcheck wget not being available in distroless images
- Add !server build constraint to menu_windows.go and menu_darwin.go
- Downgrade window-visibility-test go.mod from 1.25 to 1.24 to match CI

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 14:33:44 +11:00

366 lines
9.4 KiB
Go

package application
import (
"fmt"
"reflect"
"slices"
"sync"
"sync/atomic"
"encoding/json"
"github.com/wailsapp/wails/v3/pkg/events"
)
type ApplicationEvent struct {
Id uint
ctx *ApplicationEventContext
cancelled atomic.Bool
}
func (w *ApplicationEvent) Context() *ApplicationEventContext {
return w.ctx
}
func newApplicationEvent(id events.ApplicationEventType) *ApplicationEvent {
return &ApplicationEvent{
Id: uint(id),
ctx: newApplicationEventContext(),
}
}
func (w *ApplicationEvent) Cancel() {
w.cancelled.Store(true)
}
func (w *ApplicationEvent) IsCancelled() bool {
return w.cancelled.Load()
}
var applicationEvents = make(chan *ApplicationEvent, 5)
type windowEvent struct {
WindowID uint
EventID uint
}
var windowEvents = make(chan *windowEvent, 5)
var menuItemClicked = make(chan uint, 5)
type CustomEvent struct {
Name string `json:"name"`
Data any `json:"data"`
// Sender records the name of the window sending the event,
// or "" if sent from application.
Sender string `json:"sender,omitempty"`
cancelled atomic.Bool
}
func (e *CustomEvent) Cancel() {
e.cancelled.Store(true)
}
func (e *CustomEvent) IsCancelled() bool {
return e.cancelled.Load()
}
func (e *CustomEvent) ToJSON() string {
marshal, err := json.Marshal(&e)
if err != nil {
// TODO: Fatal error? log?
return ""
}
return string(marshal)
}
// WailsEventListener is an interface for receiving all emitted Wails events.
// Used by transport layers (IPC, WebSocket) to broadcast events.
type WailsEventListener interface {
DispatchWailsEvent(event *CustomEvent)
}
type hook struct {
callback func(*CustomEvent)
}
// eventListener holds a callback function which is invoked when
// the event listened for is emitted. It has a counter which indicates
// how the total number of events it is interested in. A value of zero
// means it does not expire (default).
type eventListener struct {
callback func(*CustomEvent) // Function to call with emitted event data
counter int // The number of times this callback may be called. -1 = infinite
delete bool // Flag to indicate that this listener should be deleted
}
// EventProcessor handles custom events
type EventProcessor struct {
// Go event listeners
listeners map[string][]*eventListener
notifyLock sync.RWMutex
dispatchEventToWindows func(*CustomEvent)
hooks map[string][]*hook
hookLock sync.RWMutex
}
func NewWailsEventProcessor(dispatchEventToWindows func(*CustomEvent)) *EventProcessor {
return &EventProcessor{
listeners: make(map[string][]*eventListener),
dispatchEventToWindows: dispatchEventToWindows,
hooks: make(map[string][]*hook),
}
}
// On is the equivalent of Javascript's `addEventListener`
func (e *EventProcessor) On(eventName string, callback func(event *CustomEvent)) func() {
return e.registerListener(eventName, callback, -1)
}
// OnMultiple is the same as `OnApplicationEvent` but will unregister after `count` events
func (e *EventProcessor) OnMultiple(eventName string, callback func(event *CustomEvent), counter int) func() {
return e.registerListener(eventName, callback, counter)
}
// Once is the same as `OnApplicationEvent` but will unregister after the first event
func (e *EventProcessor) Once(eventName string, callback func(event *CustomEvent)) func() {
return e.registerListener(eventName, callback, 1)
}
// Emit sends an event to all listeners.
//
// If the event is globally registered, it validates associated data
// against the expected data type. In case of mismatches,
// it cancels the event and returns an error.
func (e *EventProcessor) Emit(thisEvent *CustomEvent) error {
if thisEvent == nil {
return nil
}
// Validate data type; in case of mismatches cancel and report error.
if err := validateCustomEvent(thisEvent); err != nil {
thisEvent.Cancel()
return err
}
// If we have any hooks, run them first and check if the event was cancelled
if e.hooks != nil {
if hooks, ok := e.hooks[thisEvent.Name]; ok {
for _, thisHook := range hooks {
thisHook.callback(thisEvent)
if thisEvent.IsCancelled() {
return nil
}
}
}
}
go func() {
defer handlePanic()
e.dispatchEventToListeners(thisEvent)
}()
go func() {
defer handlePanic()
e.dispatchEventToWindows(thisEvent)
}()
return nil
}
func (e *EventProcessor) Off(eventName string) {
e.unRegisterListener(eventName)
}
func (e *EventProcessor) OffAll() {
e.notifyLock.Lock()
defer e.notifyLock.Unlock()
e.listeners = make(map[string][]*eventListener)
}
// registerListener provides a means of subscribing to events of type "eventName"
func (e *EventProcessor) registerListener(eventName string, callback func(*CustomEvent), counter int) func() {
// Create new eventListener
thisListener := &eventListener{
callback: callback,
counter: counter,
delete: false,
}
e.notifyLock.Lock()
// Append the new listener to the listeners slice
e.listeners[eventName] = append(e.listeners[eventName], thisListener)
e.notifyLock.Unlock()
return func() {
e.notifyLock.Lock()
defer e.notifyLock.Unlock()
if _, ok := e.listeners[eventName]; !ok {
return
}
e.listeners[eventName] = slices.DeleteFunc(e.listeners[eventName], func(l *eventListener) bool {
return l == thisListener
})
}
}
// RegisterHook provides a means of registering methods to be called before emitting the event
func (e *EventProcessor) RegisterHook(eventName string, callback func(*CustomEvent)) func() {
// Create new hook
thisHook := &hook{
callback: callback,
}
e.hookLock.Lock()
// Append the new listener to the listeners slice
e.hooks[eventName] = append(e.hooks[eventName], thisHook)
e.hookLock.Unlock()
return func() {
e.hookLock.Lock()
defer e.hookLock.Unlock()
if _, ok := e.hooks[eventName]; !ok {
return
}
e.hooks[eventName] = slices.DeleteFunc(e.hooks[eventName], func(h *hook) bool {
return h == thisHook
})
}
}
// unRegisterListener provides a means of unsubscribing to events of type "eventName"
func (e *EventProcessor) unRegisterListener(eventName string) {
e.notifyLock.Lock()
defer e.notifyLock.Unlock()
delete(e.listeners, eventName)
}
// dispatchEventToListeners calls all registered listeners event name
func (e *EventProcessor) dispatchEventToListeners(event *CustomEvent) {
e.notifyLock.Lock()
defer e.notifyLock.Unlock()
listeners := e.listeners[event.Name]
if listeners == nil {
return
}
// We have a dirty flag to indicate that there are items to delete
itemsToDelete := false
// Callback in goroutine
for _, listener := range listeners {
if listener.counter > 0 {
listener.counter--
}
go func() {
if event.IsCancelled() {
return
}
defer handlePanic()
listener.callback(event)
}()
if listener.counter == 0 {
listener.delete = true
itemsToDelete = true
}
}
// Do we have items to delete?
if itemsToDelete == true {
e.listeners[event.Name] = slices.DeleteFunc(listeners, func(l *eventListener) bool {
return l.delete == true
})
}
}
// Void will be translated by the binding generator to the TypeScript type 'void'.
// It can be used as an event data type to register events that must not have any associated data.
type Void interface {
sentinel()
}
var registeredEvents sync.Map
var voidType = reflect.TypeFor[Void]()
// RegisterEvent registers a custom event name and associated data type.
// Events may be registered at most once.
// Duplicate calls for the same event name trigger a panic.
//
// The binding generator emits typing information for all registered custom events.
// [App.EmitEvent] and [Window.EmitEvent] check the data type for registered events.
// Data types are matched exactly and no conversion is performed.
//
// It is recommended to call RegisterEvent directly,
// with constant arguments, and only from init functions.
// Indirect calls or instantiations are not discoverable by the binding generator.
func RegisterEvent[Data any](name string) {
if events.IsKnownEvent(name) {
panic(fmt.Errorf("'%s' is a known system event name", name))
}
if typ, ok := registeredEvents.Load(name); ok {
panic(fmt.Errorf("event '%s' is already registered with data type %s", name, typ))
}
registeredEvents.Store(name, reflect.TypeFor[Data]())
eventRegistered(name)
}
func validateCustomEvent(event *CustomEvent) error {
r, ok := registeredEvents.Load(event.Name)
if !ok {
warnAboutUnregisteredEvent(event.Name)
return nil
}
typ := r.(reflect.Type)
if typ == voidType {
if event.Data == nil {
return nil
}
} else if typ.Kind() == reflect.Interface {
if reflect.TypeOf(event.Data).Implements(typ) {
return nil
}
} else {
if reflect.TypeOf(event.Data) == typ {
return nil
}
}
return fmt.Errorf(
"data of type %s for event '%s' does not match registered data type %s",
reflect.TypeOf(event.Data),
event.Name,
typ,
)
}
func decodeEventData(name string, data []byte) (result any, err error) {
r, ok := registeredEvents.Load(name)
if !ok {
// Unregistered events unmarshal to any.
err = json.Unmarshal(data, &result)
return
}
typ := r.(reflect.Type)
if typ == voidType {
// When typ is voidType, perform a null check
err = json.Unmarshal(data, &result)
if err == nil && result != nil {
err = fmt.Errorf("non-null data for event '%s' does not match registered data type %s", name, typ)
}
} else {
value := reflect.New(typ.(reflect.Type))
err = json.Unmarshal(data, value.Interface())
if err == nil {
result = value.Elem().Interface()
}
}
return
}