fix: add involves edge from task to agent:nomos at creation
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

Plus sync vendor directory for Docker build compatibility.
This commit is contained in:
2026-08-11 22:03:12 +02:00
parent 7d6a3320d4
commit febc153b7f
3384 changed files with 945212 additions and 2 deletions

View File

@@ -0,0 +1,10 @@
Features
- [ ] AssetServer
- [ ] Offline page if navigating to external URL
- [x] Application menu
Bugs
- [ ] Resize Window
- [ ] Fullscreen/Maximise/Minimise/Restore

View File

@@ -0,0 +1,7 @@
//go:build android && !production
package application
// androidVerboseLogging enables the framework's internal diagnostic logging
// in debug builds. See android_logging_production.go for the release value.
const androidVerboseLogging = true

View File

@@ -0,0 +1,7 @@
//go:build android && production
package application
// androidVerboseLogging is disabled in production builds: the framework's
// internal diagnostic logging compiles to a no-op.
const androidVerboseLogging = false

View File

@@ -0,0 +1,948 @@
package application
import (
"context"
"embed"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"runtime"
"slices"
"strconv"
"strings"
"sync"
"github.com/wailsapp/wails/v3/internal/assetserver"
"github.com/wailsapp/wails/v3/internal/assetserver/bundledassets"
"github.com/wailsapp/wails/v3/internal/assetserver/webview"
"github.com/wailsapp/wails/v3/internal/capabilities"
"github.com/wailsapp/wails/v3/pkg/updater"
)
//go:embed assets/*
var alphaAssets embed.FS
var globalApplication *App
// AlphaAssets is the default assets for the alpha application
var AlphaAssets = AssetOptions{
Handler: BundledAssetFileServer(alphaAssets),
}
type EventListener struct {
callback func(app *ApplicationEvent)
}
func Get() *App {
return globalApplication
}
func New(appOptions Options) *App {
// If we were spawned as an updater helper the process must perform the
// swap and exit before any application machinery touches the disk. This
// is a no-op when the sentinel env vars are absent, so normal startup is
// unaffected.
updater.HandleHelperMode()
if globalApplication != nil {
return globalApplication
}
mergeApplicationDefaults(&appOptions)
result := newApplication(appOptions)
globalApplication = result
fatalHandler(result.handleFatalError)
if result.Logger == nil {
if result.isDebugMode {
result.Logger = DefaultLogger(result.options.LogLevel)
} else {
result.Logger = slog.New(slog.NewTextHandler(io.Discard, nil))
}
}
// Set up signal handling (platform-specific)
result.setupSignalHandler(appOptions)
result.logStartup()
result.logPlatformInfo()
result.customEventProcessor = NewWailsEventProcessor(result.Event.dispatch)
messageProc := NewMessageProcessor(result.Logger)
result.messageProcessor = messageProc
// Initialize transport (default to HTTP if not specified)
transport := appOptions.Transport
if transport == nil {
transport = NewHTTPTransport(HTTPTransportWithLogger(result.Logger))
}
err := transport.Start(result.ctx, messageProc)
if err != nil {
result.fatal("failed to start custom transport: %w", err)
}
// Register shutdown task to stop transport
result.OnShutdown(func() {
if err := transport.Stop(); err != nil {
result.error("failed to stop custom transport: %w", err)
}
})
// Auto-wire events if transport supports event delivery
if eventTransport, ok := transport.(WailsEventListener); ok {
result.wailsEventListeners = append(result.wailsEventListeners, eventTransport)
} else {
// otherwise fallback to IPC
result.wailsEventListeners = append(result.wailsEventListeners, &EventIPCTransport{
app: result,
})
}
middlewares := []assetserver.Middleware{
func(next http.Handler) http.Handler {
if m := appOptions.Assets.Middleware; m != nil {
return m(next)
}
return next
},
func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
path := req.URL.Path
switch path {
case "/wails/runtime.js":
err := assetserver.ServeFile(rw, path, bundledassets.RuntimeJS)
if err != nil {
result.fatal("unable to serve runtime.js: %w", err)
}
case "/wails/transport.js":
err := assetserver.ServeFile(rw, path, transport.JSClient())
if err != nil {
result.fatal("unable to serve transport.js: %w", err)
}
case "/wails/custom.js":
// custom.js is only served in server mode.
// Return 404 so the runtime's loadOptionalScript skips it.
http.NotFound(rw, req)
default:
next.ServeHTTP(rw, req)
}
})
},
}
if handler, ok := transport.(TransportHTTPHandler); ok {
middlewares = append(middlewares, handler.Handler())
}
opts := &assetserver.Options{
Handler: appOptions.Assets.Handler,
Middleware: assetserver.ChainMiddleware(middlewares...),
Logger: result.Logger,
}
if appOptions.Assets.DisableLogging {
opts.Logger = slog.New(slog.NewTextHandler(io.Discard, nil))
}
srv, err := assetserver.NewAssetServer(opts)
if err != nil {
result.fatal("application initialisation failed: %w", err)
}
result.assets = srv
result.assets.LogDetails()
// If transport implements AssetServerTransport, configure it to serve assets
if assetTransport, ok := transport.(AssetServerTransport); ok {
err := assetTransport.ServeAssets(srv)
if err != nil {
result.fatal("failed to configure transport for serving assets: %w", err)
}
result.debug("Transport configured to serve assets")
}
result.bindings = NewBindings(appOptions.MarshalError, appOptions.BindAliases)
result.options.Services = slices.Clone(appOptions.Services)
// Process keybindings
if result.options.KeyBindings != nil {
result.keyBindings = processKeyBindingOptions(result.options.KeyBindings)
}
if appOptions.OnShutdown != nil {
result.OnShutdown(appOptions.OnShutdown)
}
// Initialize single instance manager if enabled
if appOptions.SingleInstance != nil {
manager, err := newSingleInstanceManager(result, appOptions.SingleInstance)
if err != nil {
if errors.Is(err, alreadyRunningError) && manager != nil {
err = manager.notifyFirstInstance()
if err != nil {
globalApplication.error("failed to notify first instance: %w", err)
}
os.Exit(appOptions.SingleInstance.ExitCode)
}
result.fatal("failed to initialize single instance manager: %w", err)
} else {
result.singleInstanceManager = manager
}
}
return result
}
func mergeApplicationDefaults(o *Options) {
if o.Name == "" {
o.Name = "My Wails Application"
}
if o.Description == "" {
o.Description = "An application written using Wails"
}
if o.Windows.WndClass == "" {
o.Windows.WndClass = "WailsWebviewWindow"
}
}
type (
platformApp interface {
run() error
destroy()
setApplicationMenu(menu *Menu)
name() string
getCurrentWindowID() uint
showAboutDialog(name string, description string, icon []byte)
setIcon(icon []byte)
on(id uint)
dispatchOnMainThread(id uint)
hide()
show()
getPrimaryScreen() (*Screen, error)
getScreens() ([]*Screen, error)
GetFlags(options Options) map[string]any
isOnMainThread() bool
isDarkMode() bool
getAccentColor() string
}
runnable interface {
Run()
}
)
// Messages sent from javascript get routed here
type windowMessage struct {
windowId uint
message string
originInfo *OriginInfo
}
type OriginInfo struct {
Origin string
TopOrigin string
IsMainFrame bool
}
var windowMessageBuffer = make(chan *windowMessage, 64)
// DropTargetDetails contains information about the HTML element
// where files were dropped (the element with data-file-drop-target attribute).
type DropTargetDetails struct {
X int `json:"x"`
Y int `json:"y"`
ElementID string `json:"id"`
ClassList []string `json:"classList"`
Attributes map[string]string `json:"attributes,omitempty"`
}
type dragAndDropMessage struct {
windowId uint
filenames []string
X int
Y int
DropTarget *DropTargetDetails
}
var windowDragAndDropBuffer = make(chan *dragAndDropMessage, 5)
func addDragAndDropMessage(windowId uint, filenames []string, dropTarget *DropTargetDetails) {
windowDragAndDropBuffer <- &dragAndDropMessage{
windowId: windowId,
filenames: filenames,
DropTarget: dropTarget,
}
}
var _ webview.Request = &webViewAssetRequest{}
const webViewRequestHeaderWindowId = "x-wails-window-id"
const webViewRequestHeaderWindowName = "x-wails-window-name"
type webViewAssetRequest struct {
Request webview.Request
windowId uint
windowName string
}
var windowKeyEvents = make(chan *windowKeyEvent, 5)
type windowKeyEvent struct {
windowId uint
acceleratorString string
}
func (r *webViewAssetRequest) URL() (string, error) {
return r.Request.URL()
}
func (r *webViewAssetRequest) Method() (string, error) {
return r.Request.Method()
}
func (r *webViewAssetRequest) Header() (http.Header, error) {
h, err := r.Request.Header()
if err != nil {
return nil, err
}
hh := h.Clone()
hh.Set(webViewRequestHeaderWindowId, strconv.FormatUint(uint64(r.windowId), 10))
if r.windowName != "" {
hh.Set(webViewRequestHeaderWindowName, r.windowName)
}
return hh, nil
}
func (r *webViewAssetRequest) Body() (io.ReadCloser, error) {
return r.Request.Body()
}
func (r *webViewAssetRequest) Response() webview.ResponseWriter {
return r.Request.Response()
}
func (r *webViewAssetRequest) Close() error {
return r.Request.Close()
}
var webviewRequests = make(chan *webViewAssetRequest, 256)
type eventHook struct {
callback func(event *ApplicationEvent)
}
type App struct {
ctx context.Context
cancel context.CancelFunc
options Options
applicationEventListeners map[uint][]*EventListener
applicationEventListenersLock sync.RWMutex
applicationEventHooks map[uint][]*eventHook
applicationEventHooksLock sync.RWMutex
// Manager pattern for organized API
Window *WindowManager
ContextMenu *ContextMenuManager
KeyBinding *KeyBindingManager
Browser *BrowserManager
Env *EnvironmentManager
Dialog *DialogManager
Event *EventManager
Menu *MenuManager
Screen *ScreenManager
Clipboard *ClipboardManager
SystemTray *SystemTrayManager
Autostart *AutostartManager
GlobalShortcut *GlobalShortcutManager
Updater *updater.Updater
// Windows
windows map[uint]Window
windowsLock sync.RWMutex
// System Trays
systemTrays map[uint]*SystemTray
systemTraysLock sync.Mutex
systemTrayID uint
systemTrayIDLock sync.RWMutex
// MenuItems
menuItems map[uint]*MenuItem
menuItemsLock sync.Mutex
// Starting and running
starting bool
running bool
runLock sync.Mutex
pendingRun []runnable
bindings *Bindings
// platform app
impl platformApp
// The main application menu (private - use app.Menu.GetApplicationMenu/SetApplicationMenu)
applicationMenu *Menu
clipboard *Clipboard
customEventProcessor *EventProcessor
Logger *slog.Logger
contextMenus map[string]*ContextMenu
contextMenusLock sync.RWMutex
assets *assetserver.AssetServer
startURL string
// Hooks
windowCreatedCallbacks []func(window Window)
pid int
// Capabilities
capabilities capabilities.Capabilities
isDebugMode bool
// Keybindings
keyBindings map[string]func(window Window)
keyBindingsLock sync.RWMutex
// Shutdown
performingShutdown bool
shutdownLock sync.Mutex
serviceShutdownLock sync.Mutex
// Shutdown tasks are run when the application is shutting down.
// They are run in the order they are added and run on the main thread.
// The application option `OnShutdown` is run first.
shutdownTasks []func()
// Platform-specific fields (includes signal handler on desktop)
platformSignalHandler
// Wails ApplicationEvent Listener related
wailsEventListenerLock sync.Mutex
wailsEventListeners []WailsEventListener
// singleInstanceManager handles single instance functionality
singleInstanceManager *singleInstanceManager
// messageProcessor handles runtime messages
messageProcessor *MessageProcessor
}
func (a *App) Config() Options {
return a.options
}
// Context returns the application context that is canceled when the application shuts down.
// This context should be used for graceful shutdown of goroutines and long-running operations.
func (a *App) Context() context.Context {
return a.ctx
}
func (a *App) handleWarning(msg string) {
if a.options.WarningHandler != nil {
a.options.WarningHandler(msg)
} else {
a.Logger.Warn(msg)
}
}
func (a *App) handleError(err error) {
if a.options.ErrorHandler != nil {
a.options.ErrorHandler(err)
} else {
a.Logger.Error(err.Error())
}
}
// RegisterService appends the given service to the list of bound services.
// Registered services will be bound and initialised
// in registration order upon calling [App.Run].
//
// RegisterService will log an error message
// and discard the given service
// if called after [App.Run].
func (a *App) RegisterService(service Service) {
a.runLock.Lock()
defer a.runLock.Unlock()
if a.starting || a.running {
a.error(
"services must be registered before running the application. Service '%s' will not be registered.",
getServiceName(service),
)
return
}
a.options.Services = append(a.options.Services, service)
}
func (a *App) handleFatalError(err error) {
a.handleError(&FatalError{err: err})
os.Exit(1)
}
func (a *App) init() {
a.ctx, a.cancel = context.WithCancel(context.Background())
a.applicationEventHooks = make(map[uint][]*eventHook)
a.applicationEventListeners = make(map[uint][]*EventListener)
a.windows = make(map[uint]Window)
a.systemTrays = make(map[uint]*SystemTray)
a.contextMenus = make(map[string]*ContextMenu)
a.keyBindings = make(map[string]func(window Window))
a.Logger = a.options.Logger
a.pid = os.Getpid()
a.wailsEventListeners = make([]WailsEventListener, 0)
// Initialize managers
a.Window = newWindowManager(a)
a.ContextMenu = newContextMenuManager(a)
a.KeyBinding = newKeyBindingManager(a)
a.Browser = newBrowserManager(a)
a.Env = newEnvironmentManager(a)
a.Dialog = newDialogManager(a)
a.Event = newEventManager(a)
a.Menu = newMenuManager(a)
a.Screen = newScreenManager(a)
a.Clipboard = newClipboardManager(a)
a.SystemTray = newSystemTrayManager(a)
a.Autostart = newAutostartManager(a)
a.GlobalShortcut = newGlobalShortcutManager(a)
a.Updater = updater.New(newUpdaterHost(a))
}
func (a *App) Capabilities() capabilities.Capabilities {
return a.capabilities
}
func (a *App) GetPID() int {
return a.pid
}
func (a *App) info(message string, args ...any) {
if a.Logger != nil {
go func() {
defer handlePanic()
a.Logger.Info(message, args...)
}()
}
}
func (a *App) debug(message string, args ...any) {
if a.Logger != nil {
go func() {
defer handlePanic()
a.Logger.Debug(message, args...)
}()
}
}
func (a *App) fatal(message string, args ...any) {
err := fmt.Errorf(message, args...)
a.handleFatalError(err)
}
func (a *App) warning(message string, args ...any) {
msg := fmt.Sprintf(message, args...)
a.handleWarning(msg)
}
func (a *App) error(message string, args ...any) {
a.handleError(fmt.Errorf(message, args...))
}
func (a *App) Run() error {
a.runLock.Lock()
// Prevent double invocations.
if a.starting || a.running {
a.runLock.Unlock()
return errors.New("application is running or a previous run has failed")
}
// Block further service registrations.
a.starting = true
a.runLock.Unlock()
// Ensure application context is cancelled in case of failures.
defer a.cancel()
// Call post-create hooks
err := a.preRun()
if err != nil {
return err
}
a.impl = newPlatformApp(a)
// Ensure services are shut down in case of failures.
defer a.shutdownServices()
// Ensure application context is canceled before service shutdown (duplicate calls don't hurt).
defer a.cancel()
// startup performs the remaining startup sequence: start services, spawn the
// event-handling reader goroutines, run any pending windows, and apply the
// menu/icon. On desktop this runs inline on the main goroutine. On iOS it is
// deferred to a background goroutine (see below).
startup := func() error {
// Startup services before dispatching any events.
// No need to hold the lock here because a.options.Services may only change when a.running is false.
services := a.options.Services
a.options.Services = nil
for i, service := range services {
if err := a.startupService(service); err != nil {
return fmt.Errorf("error starting service '%s': %w", getServiceName(service), err)
}
// Schedule started services for shutdown.
a.options.Services = services[:i+1]
}
// Start the MCP server when the application is built with -tags mcp.
// All configuration is read from environment variables (WAILS_MCP_HOST,
// WAILS_MCP_PORT, WAILS_MCP_TIMEOUT, WAILS_MCP_HIDE_CURSOR).
if err := startMCPServer(a); err != nil {
return fmt.Errorf("mcp: %w", err)
}
go func() {
for {
event := <-applicationEvents
go a.Event.handleApplicationEvent(event)
}
}()
go func() {
for {
event := <-windowEvents
go a.handleWindowEvent(event)
}
}()
go func() {
for {
request := <-webviewRequests
go a.handleWebViewRequest(request)
}
}()
go func() {
for {
event := <-windowMessageBuffer
go a.handleWindowMessage(event)
}
}()
go func() {
for {
event := <-windowKeyEvents
go a.handleWindowKeyEvent(event)
}
}()
go func() {
for {
dragAndDropMessage := <-windowDragAndDropBuffer
go a.handleDragAndDropMessage(dragAndDropMessage)
}
}()
go func() {
for {
menuItemID := <-menuItemClicked
go a.Menu.handleMenuItemClicked(menuItemID)
}
}()
a.runLock.Lock()
a.running = true
a.runLock.Unlock()
// Bind any global shortcuts that were registered before the app started.
a.GlobalShortcut.flushPending()
// No need to hold the lock here because
// - a.pendingRun may only change while a.running is false.
// - runnables are scheduled asynchronously anyway.
for _, pending := range a.pendingRun {
go func() {
defer handlePanic()
pending.Run()
}()
}
a.pendingRun = nil
// set the application menu
if runtime.GOOS == "darwin" {
a.impl.setApplicationMenu(a.applicationMenu)
}
if a.options.Icon != nil {
a.impl.setIcon(a.options.Icon)
}
return nil
}
if err := startup(); err != nil {
return err
}
return a.impl.run()
}
func (a *App) startupService(service Service) error {
err := a.bindings.Add(service)
if err != nil {
return fmt.Errorf("cannot bind service methods: %w", err)
}
if service.options.Route != "" {
handler, ok := service.Instance().(http.Handler)
if !ok {
handler = http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
http.Error(
rw,
fmt.Sprintf(
"Service '%s' does not handle HTTP requests",
getServiceName(service),
),
http.StatusServiceUnavailable,
)
})
}
a.assets.AttachServiceHandler(service.options.Route, handler)
}
if s, ok := service.instance.(ServiceStartup); ok {
a.debug("Starting up service:", "name", getServiceName(service))
return s.ServiceStartup(a.ctx, service.options)
}
return nil
}
func (a *App) shutdownServices() {
// Acquire lock to prevent double calls (defer in Run() + OnShutdown)
a.serviceShutdownLock.Lock()
defer a.serviceShutdownLock.Unlock()
// Ensure app context is cancelled first (duplicate calls don't hurt).
a.cancel()
for len(a.options.Services) > 0 {
last := len(a.options.Services) - 1
service := a.options.Services[last]
a.options.Services = a.options.Services[:last] // Prevent double shutdowns
if s, ok := service.instance.(ServiceShutdown); ok {
a.debug("Shutting down service:", "name", getServiceName(service))
if err := s.ServiceShutdown(); err != nil {
a.error("error shutting down service '%s': %w", getServiceName(service), err)
}
}
}
}
func (a *App) handleDragAndDropMessage(event *dragAndDropMessage) {
defer handlePanic()
a.windowsLock.Lock()
window, ok := a.windows[event.windowId]
a.windowsLock.Unlock()
if !ok {
a.warning("WebviewWindow #%d not found", event.windowId)
return
}
window.handleDragAndDropMessage(event.filenames, event.DropTarget)
}
func (a *App) handleWindowMessage(event *windowMessage) {
defer handlePanic()
// Get window from window map
a.windowsLock.RLock()
window, ok := a.windows[event.windowId]
// Debug: log all window IDs
var ids []uint
for id := range a.windows {
ids = append(ids, id)
}
a.windowsLock.RUnlock()
a.debug("handleWindowMessage: Looking for window", "windowId", event.windowId, "availableIDs", ids)
if !ok {
a.warning("WebviewWindow #%d not found", event.windowId)
return
}
// Check if the message starts with "wails:"
if strings.HasPrefix(event.message, "wails:") {
a.debug("handleWindowMessage: Processing wails message", "message", event.message)
window.HandleMessage(event.message)
} else {
if a.options.RawMessageHandler != nil {
a.options.RawMessageHandler(window, event.message, event.originInfo)
}
}
}
func (a *App) handleWebViewRequest(request *webViewAssetRequest) {
defer handlePanic()
// Log that we're processing the request
url, _ := request.Request.URL()
a.debug("handleWebViewRequest: Processing request", "url", url)
// IMPORTANT: pass the wrapper request so our injected headers (x-wails-window-id/name) are used
a.assets.ServeWebViewRequest(request)
a.debug("handleWebViewRequest: Request processing complete", "url", url)
}
func (a *App) handleWindowEvent(event *windowEvent) {
defer handlePanic()
// Get window from window map
a.windowsLock.RLock()
window, ok := a.windows[event.WindowID]
a.windowsLock.RUnlock()
if !ok {
a.warning("Window #%d not found", event.WindowID)
return
}
window.HandleWindowEvent(event.EventID)
}
// OnShutdown adds a function to be run when the application is shutting down.
func (a *App) OnShutdown(f func()) {
if f == nil {
return
}
a.shutdownLock.Lock()
if !a.performingShutdown {
defer a.shutdownLock.Unlock()
a.shutdownTasks = append(a.shutdownTasks, f)
return
}
a.shutdownLock.Unlock()
InvokeAsync(f)
}
func (a *App) cleanup() {
a.shutdownLock.Lock()
if a.performingShutdown {
a.shutdownLock.Unlock()
return
}
a.cancel() // Cancel app context before running shutdown hooks.
a.performingShutdown = true
a.shutdownLock.Unlock()
// No need to hold the lock here because a.shutdownTasks
// may only change while a.performingShutdown is false.
for _, shutdownTask := range a.shutdownTasks {
InvokeSync(shutdownTask)
}
// Release any global shortcuts the application registered with the OS.
if a.GlobalShortcut != nil {
if err := a.GlobalShortcut.UnregisterAll(); err != nil {
a.handleError(err)
}
}
InvokeSync(func() {
a.shutdownServices()
a.windowsLock.Lock()
for _, window := range a.windows {
window.Close()
}
a.windows = nil
a.windowsLock.Unlock()
a.systemTraysLock.Lock()
for _, systray := range a.systemTrays {
systray.destroy()
}
a.systemTrays = nil
a.systemTraysLock.Unlock()
// Cleanup single instance manager
if a.singleInstanceManager != nil {
a.singleInstanceManager.cleanup()
}
a.postQuit()
if a.options.PostShutdown != nil {
a.options.PostShutdown()
}
})
}
func (a *App) Quit() {
if a.impl != nil {
InvokeSync(a.impl.destroy)
}
}
func (a *App) SetIcon(icon []byte) {
if a.impl != nil {
a.impl.setIcon(icon)
}
}
func (a *App) dispatchOnMainThread(fn func()) {
// If we are on the main thread, just call the function
if a.impl.isOnMainThread() {
fn()
return
}
mainThreadFunctionStoreLock.Lock()
id := generateFunctionStoreID()
mainThreadFunctionStore[id] = fn
mainThreadFunctionStoreLock.Unlock()
// Call platform specific dispatch function
a.impl.dispatchOnMainThread(id)
}
func (a *App) Hide() {
if a.impl != nil {
a.impl.hide()
}
}
func (a *App) Show() {
if a.impl != nil {
a.impl.show()
}
}
func (a *App) runOrDeferToAppRun(r runnable) {
a.runLock.Lock()
if !a.running {
defer a.runLock.Unlock() // Defer unlocking for panic tolerance.
a.pendingRun = append(a.pendingRun, r)
return
}
// Unlock immediately to prevent deadlocks.
// No TOC/TOU risk here because a.running can never switch back to false.
a.runLock.Unlock()
r.Run()
}
func (a *App) handleWindowKeyEvent(event *windowKeyEvent) {
defer handlePanic()
// Get window from window map
a.windowsLock.RLock()
window, ok := a.windows[event.windowId]
a.windowsLock.RUnlock()
if !ok {
a.warning("WebviewWindow #%d not found", event.windowId)
return
}
// Get callback from window
window.HandleKeyEvent(event.acceleratorString)
}
func (a *App) shouldQuit() bool {
if a.options.ShouldQuit != nil {
return a.options.ShouldQuit()
}
return true
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,190 @@
//go:build android && !cgo && !server
package application
// This file keeps GOOS=android builds compiling without cgo (used by
// tooling such as `wails3 generate bindings`). A real Android app is always
// built with CGO_ENABLED=1 — see application_android.go for the JNI bridge.
import (
"fmt"
"sync"
"github.com/wailsapp/wails/v3/pkg/events"
)
var (
globalApp *App
globalAppLock sync.RWMutex
androidMainFunc func()
androidMainLock sync.Mutex
)
func androidLogf(level string, format string, a ...interface{}) {
println(fmt.Sprintf("[Android/%s] %s", level, fmt.Sprintf(format, a...)))
}
func androidDebugLogf(format string, a ...interface{}) {
if androidVerboseLogging {
androidLogf("debug", format, a...)
}
}
// RegisterAndroidMain registers the main function to be called when the
// Android app starts. Call it from init() in your main package.
func RegisterAndroidMain(mainFunc func()) {
androidMainLock.Lock()
defer androidMainLock.Unlock()
androidMainFunc = mainFunc
}
// Go-level bridge call API stubs (no JNI without cgo)
func androidBridgeString(method string) (string, bool) {
return "", false
}
func androidBridgeVoidString(method string, arg string) {}
func androidBridgeVoidInt(method string, v int) {}
func androidBridgeVoidIntString(method string, id int, arg string) {}
func androidBridgeBool(method string) bool {
return false
}
func executeJavaScript(js string) {
androidLogf("warn", "executeJavaScript called but cgo is not enabled")
}
func (a *App) platformRun() {
globalAppLock.Lock()
globalApp = a
globalAppLock.Unlock()
applicationEvents <- newApplicationEvent(events.Android.ActivityCreated)
// Block forever - Android manages the app lifecycle via JNI callbacks
select {}
}
func (a *App) platformQuit() {
}
func (a *App) isDarkMode() bool {
return false
}
func (a *App) isWindows() bool {
return false
}
// Platform-specific app implementation for Android
type androidApp struct {
parent *App
}
func newPlatformApp(app *App) *androidApp {
return &androidApp{
parent: app,
}
}
func (a *androidApp) run() error {
a.setupCommonEvents()
a.parent.platformRun()
return nil
}
func (a *androidApp) destroy() {
}
func (a *androidApp) setIcon(_ []byte) {
}
func (a *androidApp) name() string {
return a.parent.options.Name
}
func (a *androidApp) GetFlags(options Options) map[string]any {
return nil
}
func (a *androidApp) getAccentColor() string {
return ""
}
func (a *androidApp) getCurrentWindowID() uint {
return 0
}
func (a *androidApp) hide() {
}
func (a *androidApp) isDarkMode() bool {
return a.parent.isDarkMode()
}
func (a *androidApp) on(eventID uint) {
registerAndroidListener(eventID)
}
func (a *androidApp) setApplicationMenu(_ *Menu) {
}
func (a *androidApp) show() {
}
func (a *androidApp) showAboutDialog(_ string, _ string, _ []byte) {
}
func (a *androidApp) getPrimaryScreen() (*Screen, error) {
if a.parent.Screen.GetPrimary() == nil {
screens, err := getScreens()
if err != nil {
return nil, err
}
if err := a.parent.Screen.LayoutScreens(screens); err != nil {
return nil, err
}
}
return a.parent.Screen.GetPrimary(), nil
}
func (a *androidApp) getScreens() ([]*Screen, error) {
if len(a.parent.Screen.GetAll()) == 0 {
screens, err := getScreens()
if err != nil {
return nil, err
}
if err := a.parent.Screen.LayoutScreens(screens); err != nil {
return nil, err
}
}
return a.parent.Screen.GetAll(), nil
}
func (a *App) logPlatformInfo() {
}
func (a *App) platformEnvironment() map[string]any {
return map[string]any{
"platform": "android",
}
}
func fatalHandler(errFunc func(error)) {
}
var (
androidEventListeners = make(map[uint]bool)
androidEventListenersLock sync.RWMutex
)
func registerAndroidListener(eventID uint) {
androidEventListenersLock.Lock()
defer androidEventListenersLock.Unlock()
androidEventListeners[eventID] = true
}

View File

@@ -0,0 +1,774 @@
//go:build darwin && !ios && !server
package application
/*
#cgo CFLAGS: -mmacosx-version-min=10.13 -x objective-c
#cgo LDFLAGS: -framework Cocoa -mmacosx-version-min=10.13
#include "application_darwin.h"
#include "application_darwin_delegate.h"
#include "webview_window_darwin.h"
#include <stdlib.h>
extern void registerListener(unsigned int event);
#import <Cocoa/Cocoa.h>
#import <Foundation/Foundation.h>
static AppDelegate *appDelegate = nil;
static void init(void) {
[NSApplication sharedApplication];
appDelegate = [[AppDelegate alloc] init];
[NSApp setDelegate:appDelegate];
[NSEvent addLocalMonitorForEventsMatchingMask:NSEventMaskLeftMouseDown handler:^NSEvent * _Nullable(NSEvent * _Nonnull event) {
NSWindow* eventWindow = [event window];
if (eventWindow == nil ) {
return event;
}
WebviewWindowDelegate* windowDelegate = (WebviewWindowDelegate*)[eventWindow delegate];
if (windowDelegate == nil) {
return event;
}
if ([windowDelegate respondsToSelector:@selector(handleLeftMouseDown:)]) {
[windowDelegate handleLeftMouseDown:event];
}
return event;
}];
[NSEvent addLocalMonitorForEventsMatchingMask:NSEventMaskLeftMouseUp handler:^NSEvent * _Nullable(NSEvent * _Nonnull event) {
NSWindow* eventWindow = [event window];
if (eventWindow == nil ) {
return event;
}
WebviewWindowDelegate* windowDelegate = (WebviewWindowDelegate*)[eventWindow delegate];
if (windowDelegate == nil) {
return event;
}
if ([windowDelegate respondsToSelector:@selector(handleLeftMouseUp:)]) {
[windowDelegate handleLeftMouseUp:eventWindow];
}
return event;
}];
NSDistributedNotificationCenter *center = [NSDistributedNotificationCenter defaultCenter];
[center addObserver:appDelegate selector:@selector(themeChanged:) name:@"AppleInterfaceThemeChangedNotification" object:nil];
// Workspace power notifications are posted on a separate notification
// center from the default one — apps must observe NSWorkspace's centre
// to receive sleep/wake events. Mirrors WM_POWERBROADCAST on Windows.
NSNotificationCenter *workspaceCenter = [[NSWorkspace sharedWorkspace] notificationCenter];
[workspaceCenter addObserver:appDelegate selector:@selector(workspaceWillSleep:) name:NSWorkspaceWillSleepNotification object:nil];
[workspaceCenter addObserver:appDelegate selector:@selector(workspaceDidWake:) name:NSWorkspaceDidWakeNotification object:nil];
[workspaceCenter addObserver:appDelegate selector:@selector(workspaceScreensDidSleep:) name:NSWorkspaceScreensDidSleepNotification object:nil];
[workspaceCenter addObserver:appDelegate selector:@selector(workspaceScreensDidWake:) name:NSWorkspaceScreensDidWakeNotification object:nil];
// Register the custom URL scheme handler
StartCustomProtocolHandler();
}
static bool isDarkMode(void) {
NSUserDefaults* userDefaults = [NSUserDefaults standardUserDefaults];
if (userDefaults == nil) {
return false;
}
NSString *interfaceStyle = [userDefaults stringForKey:@"AppleInterfaceStyle"];
if (interfaceStyle == nil) {
return false;
}
return [interfaceStyle isEqualToString:@"Dark"];
}
static char* getAccentColor(void) {
@autoreleasepool {
NSColor *accentColor;
if (@available(macOS 10.14, *)) {
accentColor = [NSColor controlAccentColor];
} else {
// Fallback to system blue for older macOS versions
accentColor = [NSColor systemBlueColor];
}
// Convert to RGB color space
NSColor *rgbColor = [accentColor colorUsingColorSpace:[NSColorSpace sRGBColorSpace]];
if (rgbColor == nil) {
rgbColor = accentColor;
}
// Get RGB components
CGFloat red, green, blue, alpha;
[rgbColor getRed:&red green:&green blue:&blue alpha:&alpha];
// Convert to 0-255 range and format as rgb() string
int r = (int)(red * 255);
int g = (int)(green * 255);
int b = (int)(blue * 255);
NSString *colorString = [NSString stringWithFormat:@"rgb(%d,%d,%d)", r, g, b];
return strdup([colorString UTF8String]);
}
}
static void setApplicationShouldTerminateAfterLastWindowClosed(bool shouldTerminate) {
// Get the NSApp delegate
AppDelegate *appDelegate = (AppDelegate*)[NSApp delegate];
// Set the applicationShouldTerminateAfterLastWindowClosed boolean
appDelegate.shouldTerminateWhenLastWindowClosed = shouldTerminate;
}
static void setActivationPolicy(int policy) {
[NSApp setActivationPolicy:policy];
}
static void activateIgnoringOtherApps() {
[NSApp activateIgnoringOtherApps:YES];
}
static void run(void) {
@autoreleasepool {
[NSApp run];
[appDelegate release];
[NSApp abortModal];
}
}
// destroyApp destroys the application
static void destroyApp(void) {
[NSApp terminate:nil];
}
// Set the application menu
static void setApplicationMenu(void *menu) {
NSMenu *nsMenu = (__bridge NSMenu *)menu;
[NSApp setMainMenu:menu];
}
// Get the application name
static char* getAppName(void) {
NSString *appName = [NSRunningApplication currentApplication].localizedName;
if( appName == nil ) {
appName = [[NSProcessInfo processInfo] processName];
}
return strdup([appName UTF8String]);
}
// get the current window ID
static unsigned int getCurrentWindowID(void) {
// AppKit must be accessed on the main thread. This function may be called
// from arbitrary Go goroutines, so we hop to the main queue when needed.
__block unsigned int result = 0;
if (NSApp == nil) {
return result;
}
void (^resolve)(void) = ^{
NSWindow *window = [NSApp keyWindow];
if (window == nil) {
window = [NSApp mainWindow];
}
if (window == nil) {
return;
}
// System panels (e.g. PMPrintPanelController) can become the key window;
// their delegates are not WebviewWindowDelegate and would crash on windowId.
id delegateObj = [window delegate];
if (![delegateObj isKindOfClass:[WebviewWindowDelegate class]]) {
return;
}
WebviewWindowDelegate *delegate = (WebviewWindowDelegate*)delegateObj;
if (delegate != nil) {
result = delegate.windowId;
}
};
if ([NSThread isMainThread]) {
resolve();
} else {
dispatch_sync(dispatch_get_main_queue(), resolve);
}
return result;
}
// Set the application icon
static void setApplicationIcon(void *icon, int length) {
// On main thread
dispatch_async(dispatch_get_main_queue(), ^{
NSImage *image = [[NSImage alloc] initWithData:[NSData dataWithBytes:icon length:length]];
[NSApp setApplicationIconImage:image];
});
}
// Hide the application
static void hide(void) {
[NSApp hide:nil];
}
// Show the application
static void show(void) {
[NSApp unhide:nil];
}
static const char* serializationNSDictionary(void *dict) {
@autoreleasepool {
NSDictionary *nsDict = (__bridge NSDictionary *)dict;
if ([NSJSONSerialization isValidJSONObject:nsDict]) {
NSError *error;
NSData *data = [NSJSONSerialization dataWithJSONObject:nsDict options:kNilOptions error:&error];
NSString *result = [[NSString alloc]initWithData:data encoding:NSUTF8StringEncoding];
return strdup([result UTF8String]);
}
}
return nil;
}
static void startSingleInstanceListener(const char *uniqueID) {
// Convert to NSString
NSString *uid = [NSString stringWithUTF8String:uniqueID];
[[NSDistributedNotificationCenter defaultCenter] addObserver:appDelegate
selector:@selector(handleSecondInstanceNotification:) name:uid object:nil];
}
*/
import "C"
import (
"sync"
"time"
"unsafe"
"encoding/json"
"github.com/wailsapp/wails/v3/internal/assetserver/webview"
"github.com/wailsapp/wails/v3/internal/operatingsystem"
"github.com/wailsapp/wails/v3/pkg/events"
)
type macosApp struct {
applicationMenu unsafe.Pointer
parent *App
}
func (m *macosApp) isDarkMode() bool {
return bool(C.isDarkMode())
}
func (m *macosApp) getAccentColor() string {
accentColorC := C.getAccentColor()
defer C.free(unsafe.Pointer(accentColorC))
return C.GoString(accentColorC)
}
func getNativeApplication() *macosApp {
return globalApplication.impl.(*macosApp)
}
func (m *macosApp) hide() {
C.hide()
}
func (m *macosApp) show() {
C.show()
}
func (m *macosApp) on(eventID uint) {
C.registerListener(C.uint(eventID))
}
func (m *macosApp) setIcon(icon []byte) {
C.setApplicationIcon(unsafe.Pointer(&icon[0]), C.int(len(icon)))
}
func (m *macosApp) name() string {
appName := C.getAppName()
defer C.free(unsafe.Pointer(appName))
return C.GoString(appName)
}
func (m *macosApp) getCurrentWindowID() uint {
return uint(C.getCurrentWindowID())
}
func (m *macosApp) setApplicationMenu(menu *Menu) {
if menu == nil {
// Create a default menu for mac
menu = DefaultApplicationMenu()
}
menu.Update()
// Convert impl to macosMenu object
m.applicationMenu = (menu.impl).(*macosMenu).nsMenu
C.setApplicationMenu(m.applicationMenu)
}
func (m *macosApp) run() error {
if m.parent.options.SingleInstance != nil {
cUniqueID := C.CString(m.parent.options.SingleInstance.UniqueID)
defer C.free(unsafe.Pointer(cUniqueID))
C.startSingleInstanceListener(cUniqueID)
}
// Add a hook to the ApplicationDidFinishLaunching event
m.parent.Event.OnApplicationEvent(
events.Mac.ApplicationDidFinishLaunching,
func(*ApplicationEvent) {
C.setApplicationShouldTerminateAfterLastWindowClosed(
C.bool(m.parent.options.Mac.ApplicationShouldTerminateAfterLastWindowClosed),
)
C.setActivationPolicy(C.int(m.parent.options.Mac.ActivationPolicy))
C.activateIgnoringOtherApps()
if err := m.processAndCacheScreens(); err != nil {
m.parent.handleError(err)
}
},
)
// Refresh screen cache when display configuration changes
m.parent.Event.OnApplicationEvent(
events.Mac.ApplicationDidChangeScreenParameters,
func(*ApplicationEvent) {
if err := m.processAndCacheScreens(); err != nil {
m.parent.handleError(err)
}
},
)
m.setupCommonEvents()
// setup event listeners
for eventID := range m.parent.applicationEventListeners {
m.on(eventID)
}
C.run()
return nil
}
func (m *macosApp) destroy() {
C.destroyApp()
}
func (m *macosApp) GetFlags(options Options) map[string]any {
if options.Flags == nil {
options.Flags = make(map[string]any)
}
return options.Flags
}
func newPlatformApp(app *App) *macosApp {
C.init()
return &macosApp{
parent: app,
}
}
//export processApplicationEvent
func processApplicationEvent(eventID C.uint, data unsafe.Pointer) {
event := newApplicationEvent(events.ApplicationEventType(eventID))
if data != nil {
dataCStrJSON := C.serializationNSDictionary(data)
if dataCStrJSON != nil {
defer C.free(unsafe.Pointer(dataCStrJSON))
dataJSON := C.GoString(dataCStrJSON)
var result map[string]any
err := json.Unmarshal([]byte(dataJSON), &result)
if err != nil {
panic(err)
}
event.Context().setData(result)
}
}
switch event.Id {
case uint(events.Mac.ApplicationDidChangeTheme):
isDark := globalApplication.Env.IsDarkMode()
event.Context().setIsDarkMode(isDark)
}
applicationEvents <- event
}
//export processWindowEvent
func processWindowEvent(windowID C.uint, eventID C.uint) {
windowEvents <- &windowEvent{
WindowID: uint(windowID),
EventID: uint(eventID),
}
}
//export processMessage
func processMessage(windowID C.uint, message *C.char, origin *C.char, isMainFrame bool) {
o := ""
if origin != nil {
o = C.GoString(origin)
}
windowMessageBuffer <- &windowMessage{
windowId: uint(windowID),
message: C.GoString(message),
originInfo: &OriginInfo{
Origin: o,
IsMainFrame: isMainFrame,
},
}
}
//export processURLRequest
func processURLRequest(windowID C.uint, wkUrlSchemeTask unsafe.Pointer) {
window, ok := globalApplication.Window.GetByID(uint(windowID))
if !ok || window == nil {
globalApplication.debug("could not find window with id", "windowID", windowID)
return
}
webviewRequests <- &webViewAssetRequest{
Request: webview.NewRequest(wkUrlSchemeTask),
windowId: uint(windowID),
windowName: window.Name(),
}
}
//export processWindowKeyDownEvent
func processWindowKeyDownEvent(windowID C.uint, acceleratorString *C.char) {
windowKeyEvents <- &windowKeyEvent{
windowId: uint(windowID),
acceleratorString: C.GoString(acceleratorString),
}
}
//export processDragItems
func processDragItems(windowID C.uint, arr **C.char, length C.int, x C.int, y C.int) {
var filenames []string
// Convert the C array to a Go slice
goSlice := (*[1 << 30]*C.char)(unsafe.Pointer(arr))[:length:length]
for _, str := range goSlice {
filenames = append(filenames, C.GoString(str))
}
globalApplication.debug(
"[DragDropDebug] processDragItems called",
"windowID",
windowID,
"fileCount",
len(filenames),
"x",
x,
"y",
y,
)
targetWindow, ok := globalApplication.Window.GetByID(uint(windowID))
if !ok || targetWindow == nil {
println("Error: processDragItems could not find window with ID:", uint(windowID))
return
}
globalApplication.debug(
"[DragDropDebug] processDragItems: Calling targetWindow.InitiateFrontendDropProcessing",
)
targetWindow.InitiateFrontendDropProcessing(filenames, int(x), int(y))
}
//export macosOnDragEnter
func macosOnDragEnter(windowID C.uint) {
window, ok := globalApplication.Window.GetByID(uint(windowID))
if !ok || window == nil {
return
}
// Call JavaScript to show drag entered state
window.ExecJS("window._wails.handleDragEnter();")
}
//export macosOnDragExit
func macosOnDragExit(windowID C.uint) {
window, ok := globalApplication.Window.GetByID(uint(windowID))
if !ok || window == nil {
return
}
// Call JavaScript to clean up drag state
window.ExecJS("window._wails.handleDragLeave();")
}
var (
// Pre-allocated buffer for drag JS calls to avoid allocations
dragOverJSBuffer = make([]byte, 128) // Increased for safety
dragOverJSMutex sync.Mutex // Protects dragOverJSBuffer
dragOverJSPrefix = []byte("window._wails.handleDragOver(")
// Cache window references to avoid repeated lookups
windowImplCache sync.Map // windowID -> *macosWebviewWindow
// Per-window drag throttle state
dragThrottle sync.Map // windowID -> *dragThrottleState
)
type dragThrottleState struct {
mu sync.Mutex // Protects all fields below
lastX, lastY int
timer *time.Timer
pendingX int
pendingY int
hasPending bool
}
// clearWindowDragCache removes cached references for a window
func clearWindowDragCache(windowID uint) {
windowImplCache.Delete(windowID)
// Cancel any pending timer
if throttleVal, ok := dragThrottle.Load(windowID); ok {
if throttle, ok := throttleVal.(*dragThrottleState); ok {
throttle.mu.Lock()
if throttle.timer != nil {
throttle.timer.Stop()
}
throttle.mu.Unlock()
}
}
dragThrottle.Delete(windowID)
}
// writeInt writes an integer to a byte slice and returns the number of bytes written
func writeInt(buf []byte, n int) int {
if n < 0 {
if len(buf) == 0 {
return 0
}
buf[0] = '-'
return 1 + writeInt(buf[1:], -n)
}
if n == 0 {
if len(buf) == 0 {
return 0
}
buf[0] = '0'
return 1
}
// Count digits
tmp := n
digits := 0
for tmp > 0 {
digits++
tmp /= 10
}
// Bounds check
if digits > len(buf) {
return 0
}
// Write digits in reverse
for i := digits - 1; i >= 0; i-- {
buf[i] = byte('0' + n%10)
n /= 10
}
return digits
}
//export macosOnDragOver
func macosOnDragOver(windowID C.uint, x C.int, y C.int) {
winID := uint(windowID)
intX, intY := int(x), int(y)
// Get or create throttle state
throttleKey := winID
throttleVal, _ := dragThrottle.LoadOrStore(throttleKey, &dragThrottleState{
lastX: intX,
lastY: intY,
})
throttle := throttleVal.(*dragThrottleState)
throttle.mu.Lock()
// Update pending position
throttle.pendingX = intX
throttle.pendingY = intY
throttle.hasPending = true
// If timer is already running, just update the pending position
if throttle.timer != nil {
throttle.mu.Unlock()
return
}
// Apply 5-pixel threshold for immediate update
dx := intX - throttle.lastX
dy := intY - throttle.lastY
if dx < 0 {
dx = -dx
}
if dy < 0 {
dy = -dy
}
// Check if we should send an immediate update
shouldSendNow := dx >= 5 || dy >= 5
if shouldSendNow {
// Update last position
throttle.lastX = intX
throttle.lastY = intY
throttle.hasPending = false
// Send this update immediately (unlock before JS call to avoid deadlock)
throttle.mu.Unlock()
sendDragUpdate(winID, intX, intY)
throttle.mu.Lock()
}
// Start 50ms timer for next update (whether we sent now or not)
throttle.timer = time.AfterFunc(50*time.Millisecond, func() {
// Execute on main thread to ensure UI updates
InvokeSync(func() {
throttle.mu.Lock()
// Clear timer reference
throttle.timer = nil
// Send pending update if any
if throttle.hasPending {
pendingX, pendingY := throttle.pendingX, throttle.pendingY
throttle.lastX = pendingX
throttle.lastY = pendingY
throttle.hasPending = false
throttle.mu.Unlock()
sendDragUpdate(winID, pendingX, pendingY)
} else {
throttle.mu.Unlock()
}
})
})
throttle.mu.Unlock()
}
// sendDragUpdate sends the actual drag update to JavaScript
func sendDragUpdate(winID uint, x, y int) {
// Try cached implementation first
var darwinImpl *macosWebviewWindow
var needsExecJS bool
if cached, found := windowImplCache.Load(winID); found {
darwinImpl = cached.(*macosWebviewWindow)
if darwinImpl != nil && darwinImpl.nsWindow != nil {
needsExecJS = true
} else {
// Invalid cache entry, remove it
windowImplCache.Delete(winID)
}
}
if !needsExecJS {
// Fallback to full lookup
window, ok := globalApplication.Window.GetByID(winID)
if !ok || window == nil {
return
}
// Type assert to WebviewWindow
webviewWindow, ok := window.(*WebviewWindow)
if !ok || webviewWindow == nil {
return
}
// Get implementation
darwinImpl, ok = webviewWindow.impl.(*macosWebviewWindow)
if !ok {
return
}
// Cache for next time
windowImplCache.Store(winID, darwinImpl)
needsExecJS = true
}
if !needsExecJS || darwinImpl == nil {
return
}
// Protect shared buffer access
dragOverJSMutex.Lock()
// Build JS string with zero allocations
// Format: "window._wails.handleDragOver(X,Y)"
// Max length with int32 coords: 30 + 11 + 1 + 11 + 1 + 1 = 55 bytes
n := copy(dragOverJSBuffer[:], dragOverJSPrefix)
n += writeInt(dragOverJSBuffer[n:], x)
if n < len(dragOverJSBuffer) {
dragOverJSBuffer[n] = ','
n++
}
n += writeInt(dragOverJSBuffer[n:], y)
if n < len(dragOverJSBuffer) {
dragOverJSBuffer[n] = ')'
n++
}
if n < len(dragOverJSBuffer) {
dragOverJSBuffer[n] = 0 // null terminate for C
} else {
// Buffer overflow - this should not happen with 128 byte buffer
dragOverJSMutex.Unlock()
return
}
// Call JavaScript with zero allocations
darwinImpl.execJSDragOver(dragOverJSBuffer[:n+1]) // Include null terminator
dragOverJSMutex.Unlock()
}
//export processMenuItemClick
func processMenuItemClick(menuID C.uint) {
menuItemClicked <- uint(menuID)
}
//export shouldQuitApplication
func shouldQuitApplication() C.bool {
// TODO: This should be configurable
return C.bool(globalApplication.shouldQuit())
}
//export cleanup
func cleanup() {
globalApplication.cleanup()
}
func (a *App) logPlatformInfo() {
info, err := operatingsystem.Info()
if err != nil {
a.error("error getting OS info: %w", err)
return
}
a.info("Platform Info:", info.AsLogSlice()...)
}
func (a *App) platformEnvironment() map[string]any {
return map[string]any{}
}
func fatalHandler(errFunc func(error)) {
return
}
//export HandleOpenFile
func HandleOpenFile(filePath *C.char) {
goFilepath := C.GoString(filePath)
// Create new application event context
eventContext := newApplicationEventContext()
eventContext.setOpenedWithFile(goFilepath)
// EmitEvent application started event
applicationEvents <- &ApplicationEvent{
Id: uint(events.Common.ApplicationOpenedWithFile),
ctx: eventContext,
}
}
//export HandleOpenURL
func HandleOpenURL(urlCString *C.char) {
urlString := C.GoString(urlCString)
eventContext := newApplicationEventContext()
eventContext.setURL(urlString)
// Emit the standard event with the URL string as data
applicationEvents <- &ApplicationEvent{
Id: uint(events.Common.ApplicationLaunchedWithUrl),
ctx: eventContext,
}
}

View File

@@ -0,0 +1,11 @@
//go:build darwin && !ios
#ifndef application_h
#define application_h
static void init(void);
static void run(void);
static void setActivationPolicy(int policy);
static char *getAppName(void);
#endif

View File

@@ -0,0 +1,25 @@
//go:build darwin && !ios
#ifndef appdelegate_h
#define appdelegate_h
#import <Cocoa/Cocoa.h>
@interface AppDelegate : NSResponder <NSApplicationDelegate>
@property bool shouldTerminateWhenLastWindowClosed;
@property bool shuttingDown;
- (BOOL)applicationSupportsSecureRestorableState:(NSApplication *)app;
@end
extern void HandleOpenFile(char *);
// Declarations for Apple Event based custom URL handling and universal link
extern void HandleOpenURL(char*);
@interface CustomProtocolSchemeHandler : NSObject
+ (void)handleGetURLEvent:(NSAppleEventDescriptor *)event withReplyEvent:(NSAppleEventDescriptor *)replyEvent;
@end
void StartCustomProtocolHandler(void);
#endif /* appdelegate_h */

View File

@@ -0,0 +1,227 @@
//go:build darwin && !ios && !server
#import "application_darwin_delegate.h"
#import "../events/events_darwin.h"
#import <CoreServices/CoreServices.h> // For Apple Event constants
extern bool hasListeners(unsigned int);
extern bool shouldQuitApplication();
extern void cleanup();
extern void handleSecondInstanceData(char * message);
@implementation AppDelegate
- (void)dealloc
{
[super dealloc];
}
-(BOOL)application:(NSApplication *)sender openFile:(NSString *)filename
{
const char* utf8FileName = filename.UTF8String;
HandleOpenFile((char*)utf8FileName);
return YES;
}
- (BOOL)application:(NSApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSArray<id<NSUserActivityRestoring>> * _Nullable))restorationHandler {
if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) {
NSURL *url = userActivity.webpageURL;
if (url) {
HandleOpenURL((char*)[[url absoluteString] UTF8String]);
return YES;
}
}
return NO;
}
// Create the applicationShouldTerminateAfterLastWindowClosed: method
- (BOOL)applicationShouldTerminateAfterLastWindowClosed:(NSApplication *)theApplication
{
return self.shouldTerminateWhenLastWindowClosed;
}
- (void)themeChanged:(NSNotification *)notification {
if( hasListeners(EventApplicationDidChangeTheme) ) {
processApplicationEvent(EventApplicationDidChangeTheme, NULL);
}
}
- (void)workspaceWillSleep:(NSNotification *)notification {
if( hasListeners(EventApplicationWillSleep) ) {
processApplicationEvent(EventApplicationWillSleep, NULL);
}
}
- (void)workspaceDidWake:(NSNotification *)notification {
if( hasListeners(EventApplicationDidWake) ) {
processApplicationEvent(EventApplicationDidWake, NULL);
}
}
- (void)workspaceScreensDidSleep:(NSNotification *)notification {
if( hasListeners(EventApplicationScreensDidSleep) ) {
processApplicationEvent(EventApplicationScreensDidSleep, NULL);
}
}
- (void)workspaceScreensDidWake:(NSNotification *)notification {
if( hasListeners(EventApplicationScreensDidWake) ) {
processApplicationEvent(EventApplicationScreensDidWake, NULL);
}
}
- (NSApplicationTerminateReply)applicationShouldTerminate:(NSApplication *)sender {
if( ! shouldQuitApplication() ) {
return NSTerminateCancel;
}
if( !self.shuttingDown ) {
self.shuttingDown = true;
cleanup();
}
return NSTerminateNow;
}
- (BOOL)applicationSupportsSecureRestorableState:(NSApplication *)app
{
return YES;
}
- (BOOL)applicationShouldHandleReopen:(NSNotification *)notification
hasVisibleWindows:(BOOL)flag { // Changed from NSApplication to NSNotification
if( hasListeners(EventApplicationShouldHandleReopen) ) {
processApplicationEvent(EventApplicationShouldHandleReopen, @{@"hasVisibleWindows": @(flag)});
}
return TRUE;
}
- (void)handleSecondInstanceNotification:(NSNotification *)note;
{
if (note.object != nil) {
NSString *message = (NSString *)note.object;
const char* utf8Message = message.UTF8String;
handleSecondInstanceData((char*)utf8Message);
}
}
// GENERATED EVENTS START
- (void)applicationDidBecomeActive:(NSNotification *)notification {
if( hasListeners(EventApplicationDidBecomeActive) ) {
processApplicationEvent(EventApplicationDidBecomeActive, NULL);
}
}
- (void)applicationDidChangeBackingProperties:(NSNotification *)notification {
if( hasListeners(EventApplicationDidChangeBackingProperties) ) {
processApplicationEvent(EventApplicationDidChangeBackingProperties, NULL);
}
}
- (void)applicationDidChangeEffectiveAppearance:(NSNotification *)notification {
if( hasListeners(EventApplicationDidChangeEffectiveAppearance) ) {
processApplicationEvent(EventApplicationDidChangeEffectiveAppearance, NULL);
}
}
- (void)applicationDidChangeIcon:(NSNotification *)notification {
if( hasListeners(EventApplicationDidChangeIcon) ) {
processApplicationEvent(EventApplicationDidChangeIcon, NULL);
}
}
- (void)applicationDidChangeOcclusionState:(NSNotification *)notification {
if( hasListeners(EventApplicationDidChangeOcclusionState) ) {
processApplicationEvent(EventApplicationDidChangeOcclusionState, NULL);
}
}
- (void)applicationDidChangeScreenParameters:(NSNotification *)notification {
if( hasListeners(EventApplicationDidChangeScreenParameters) ) {
processApplicationEvent(EventApplicationDidChangeScreenParameters, NULL);
}
}
- (void)applicationDidChangeStatusBarFrame:(NSNotification *)notification {
if( hasListeners(EventApplicationDidChangeStatusBarFrame) ) {
processApplicationEvent(EventApplicationDidChangeStatusBarFrame, NULL);
}
}
- (void)applicationDidChangeStatusBarOrientation:(NSNotification *)notification {
if( hasListeners(EventApplicationDidChangeStatusBarOrientation) ) {
processApplicationEvent(EventApplicationDidChangeStatusBarOrientation, NULL);
}
}
- (void)applicationDidFinishLaunching:(NSNotification *)notification {
if( hasListeners(EventApplicationDidFinishLaunching) ) {
processApplicationEvent(EventApplicationDidFinishLaunching, NULL);
}
}
- (void)applicationDidHide:(NSNotification *)notification {
if( hasListeners(EventApplicationDidHide) ) {
processApplicationEvent(EventApplicationDidHide, NULL);
}
}
- (void)applicationDidResignActive:(NSNotification *)notification {
if( hasListeners(EventApplicationDidResignActive) ) {
processApplicationEvent(EventApplicationDidResignActive, NULL);
}
}
- (void)applicationDidUnhide:(NSNotification *)notification {
if( hasListeners(EventApplicationDidUnhide) ) {
processApplicationEvent(EventApplicationDidUnhide, NULL);
}
}
- (void)applicationDidUpdate:(NSNotification *)notification {
if( hasListeners(EventApplicationDidUpdate) ) {
processApplicationEvent(EventApplicationDidUpdate, NULL);
}
}
- (void)applicationWillBecomeActive:(NSNotification *)notification {
if( hasListeners(EventApplicationWillBecomeActive) ) {
processApplicationEvent(EventApplicationWillBecomeActive, NULL);
}
}
- (void)applicationWillFinishLaunching:(NSNotification *)notification {
if( hasListeners(EventApplicationWillFinishLaunching) ) {
processApplicationEvent(EventApplicationWillFinishLaunching, NULL);
}
}
- (void)applicationWillHide:(NSNotification *)notification {
if( hasListeners(EventApplicationWillHide) ) {
processApplicationEvent(EventApplicationWillHide, NULL);
}
}
- (void)applicationWillResignActive:(NSNotification *)notification {
if( hasListeners(EventApplicationWillResignActive) ) {
processApplicationEvent(EventApplicationWillResignActive, NULL);
}
}
- (void)applicationWillTerminate:(NSNotification *)notification {
if( hasListeners(EventApplicationWillTerminate) ) {
processApplicationEvent(EventApplicationWillTerminate, NULL);
}
}
- (void)applicationWillUnhide:(NSNotification *)notification {
if( hasListeners(EventApplicationWillUnhide) ) {
processApplicationEvent(EventApplicationWillUnhide, NULL);
}
}
- (void)applicationWillUpdate:(NSNotification *)notification {
if( hasListeners(EventApplicationWillUpdate) ) {
processApplicationEvent(EventApplicationWillUpdate, NULL);
}
}
// GENERATED EVENTS END
@end
// Implementation for Apple Event based custom URL handling
@implementation CustomProtocolSchemeHandler
+ (void)handleGetURLEvent:(NSAppleEventDescriptor *)event withReplyEvent:(NSAppleEventDescriptor *)replyEvent {
NSString *urlStr = [[event paramDescriptorForKeyword:keyDirectObject] stringValue];
if (urlStr) {
HandleOpenURL((char*)[urlStr UTF8String]);
}
}
@end
void StartCustomProtocolHandler(void) {
NSAppleEventManager *appleEventManager = [NSAppleEventManager sharedAppleEventManager];
[appleEventManager setEventHandler:[CustomProtocolSchemeHandler class]
andSelector:@selector(handleGetURLEvent:withReplyEvent:)
forEventClass:kInternetEventClass
andEventID: kAEGetURL];
}

View File

@@ -0,0 +1,67 @@
//go:build !production
package application
import (
"github.com/wailsapp/wails/v3/internal/git"
"github.com/wailsapp/wails/v3/internal/lo"
"github.com/wailsapp/wails/v3/internal/version"
"path/filepath"
"runtime/debug"
)
// BuildSettings contains the build settings for the application
var BuildSettings map[string]string
// BuildInfo contains the build info for the application
var BuildInfo *debug.BuildInfo
func init() {
var ok bool
BuildInfo, ok = debug.ReadBuildInfo()
if !ok {
return
}
BuildSettings = lo.Associate(BuildInfo.Settings, func(setting debug.BuildSetting) (string, string) {
return setting.Key, setting.Value
})
}
// We use this to patch the application to production mode.
func newApplication(options Options) *App {
result := &App{
isDebugMode: true,
options: options,
}
result.init()
return result
}
func (a *App) logStartup() {
var args []any
// BuildInfo is nil when build with garble
if BuildInfo == nil {
return
}
wailsPackage, _ := lo.Find(BuildInfo.Deps, func(dep *debug.Module) bool {
return dep.Path == "github.com/wailsapp/wails/v3"
})
wailsVersion := version.String()
if wailsPackage != nil && wailsPackage.Replace != nil {
wailsVersion = "(local) => " + filepath.ToSlash(wailsPackage.Replace.Path)
// Get the latest commit hash
if hash, err := git.HeadHash(filepath.Join(wailsPackage.Replace.Path, "..")); err == nil {
wailsVersion += " (" + hash + ")"
}
}
args = append(args, "Wails", wailsVersion)
args = append(args, "Compiler", BuildInfo.GoVersion)
for key, value := range BuildSettings {
args = append(args, key, value)
}
a.info("Build Info:", args...)
}

View File

@@ -0,0 +1,51 @@
//go:build !production
package application
import (
"net/http"
"time"
"github.com/wailsapp/wails/v3/internal/assetserver"
)
var devMode = false
func (a *App) preRun() error {
// Check for frontend server url
frontendURL := assetserver.GetDevServerURL()
if frontendURL != "" {
devMode = true
// We want to check if the frontend server is running by trying to http get the url
// and if it is not, we wait 500ms and try again for a maximum of 10 times. If it is
// still not available, we return an error.
// This is to allow the frontend server to start up before the backend server.
client := http.Client{}
a.Logger.Info("Waiting for frontend dev server to start...", "url", frontendURL)
for i := 0; i < 10; i++ {
_, err := client.Get(frontendURL)
if err == nil {
a.Logger.Info("Connected to frontend dev server!")
return nil
}
// Wait 500ms
time.Sleep(500 * time.Millisecond)
if i%2 == 0 {
a.Logger.Info("Retrying...")
}
}
a.fatal("unable to connect to frontend server. Please check it is running - FRONTEND_DEVSERVER_URL='%s'", frontendURL)
}
return nil
}
func (a *App) postQuit() {
if devMode {
a.Logger.Info("The application has terminated, but the watcher is still running.")
a.Logger.Info("To terminate the watcher, press CTRL+C")
}
}
func (a *App) enableDevTools() {
}

View File

@@ -0,0 +1,451 @@
//go:build ios && !server
package application
/*
#cgo CFLAGS: -x objective-c -fobjc-arc
#cgo LDFLAGS: -framework Foundation -framework UIKit -framework WebKit -framework UniformTypeIdentifiers -framework Network
#include <stdlib.h>
#include <string.h>
#include "application_ios.h"
#include "webview_window_ios.h"
*/
import "C"
import (
"fmt"
"strings"
"sync"
"unsafe"
"encoding/json"
"github.com/wailsapp/wails/v3/internal/assetserver/webview"
"github.com/wailsapp/wails/v3/pkg/events"
)
func iosConsoleLogf(level string, format string, a ...interface{}) {
msg := fmt.Sprintf(format, a...)
clevel := C.CString(level)
cmsg := C.CString(msg)
defer C.free(unsafe.Pointer(clevel))
defer C.free(unsafe.Pointer(cmsg))
C.ios_console_log(clevel, cmsg)
}
// iosDebugLogf is for the framework's internal diagnostics. It compiles to a
// no-op in production builds (see ios_logging_production.go).
func iosDebugLogf(format string, a ...interface{}) {
if iosVerboseLogging {
iosConsoleLogf("debug", format, a...)
}
}
//export init_go
func init_go() {
// Called from the iOS main function to initialize the Go runtime.
}
// iosLaunched is closed when UIApplicationDelegate's
// didFinishLaunchingWithOptions fires, signalling that UIKit is ready.
var (
iosLaunched = make(chan struct{})
iosLaunchedOnce sync.Once
)
//export iosApplicationDidLaunch
func iosApplicationDidLaunch() {
iosLaunchedOnce.Do(func() {
close(iosLaunched)
})
}
func (a *App) platformRun() {
iosDebugLogf("[application_ios.go] platformRun: initialising")
// Propagate the logging verbosity to the native layer.
C.ios_set_verbose_logging(C.bool(iosVerboseLogging))
C.ios_app_init()
// The Go runtime is started by the UIApplication delegate's
// didFinishLaunchingWithOptions (see application_ios_delegate.m), so by the
// time we get here UIKit has already launched and appDelegate/window exist.
// Release the window-creation waiter and keep the runtime alive. The OS main
// thread is owned by UIApplicationMain (in main.m); this runs on the
// background goroutine the delegate started.
iosLaunchedOnce.Do(func() {
close(iosLaunched)
})
select {}
}
func (a *App) platformQuit() {
C.ios_app_quit()
}
func (a *App) isDarkMode() bool {
return bool(C.ios_is_dark_mode())
}
func (a *App) isWindows() bool {
return false
}
//export LogInfo
func LogInfo(source *C.char, message *C.char) {
goSource := C.GoString(source)
goMessage := C.GoString(message)
if globalApplication != nil && globalApplication.Logger != nil {
globalApplication.info("iOS log", "source", goSource, "message", goMessage)
} else {
iosDebugLogf("[iOS-%s] %s", goSource, goMessage)
}
}
// Platform-specific app implementation for iOS
type iosApp struct {
parent *App
}
// newPlatformApp creates an iosApp for the provided App and applies iOS-specific
// configuration derived from app.options. It sets input accessory visibility,
// scrolling/bounce/indicator behavior, navigation gestures, link preview,
// media playback, inspector, user agent strings, app background color, and
// native tabs (marshaling items to JSON when enabled). The function invokes
// platform bindings to apply these settings and returns the configured *iosApp.
func newPlatformApp(app *App) *iosApp {
result := &iosApp{
parent: app,
}
// Configure input accessory visibility according to options
// Default: false (show accessory) when not explicitly set to true
disable := false
if app != nil {
disable = app.options.IOS.DisableInputAccessoryView
}
C.ios_set_disable_input_accessory(C.bool(disable))
// Scrolling / Bounce / Indicators (defaults enabled; using Disable* flags)
C.ios_set_disable_scroll(C.bool(app.options.IOS.DisableScroll))
C.ios_set_disable_bounce(C.bool(app.options.IOS.DisableBounce))
C.ios_set_disable_scroll_indicators(C.bool(app.options.IOS.DisableScrollIndicators))
// Navigation gestures (Enable*)
C.ios_set_enable_back_forward_gestures(C.bool(app.options.IOS.EnableBackForwardNavigationGestures))
// Link preview (Disable*)
C.ios_set_disable_link_preview(C.bool(app.options.IOS.DisableLinkPreview))
// Media playback
C.ios_set_enable_inline_media_playback(C.bool(app.options.IOS.EnableInlineMediaPlayback))
C.ios_set_enable_autoplay_without_user_action(C.bool(app.options.IOS.EnableAutoplayWithoutUserAction))
// Inspector (Disable*)
C.ios_set_disable_inspectable(C.bool(app.options.IOS.DisableInspectable))
// User agent strings
if ua := strings.TrimSpace(app.options.IOS.UserAgent); ua != "" {
cua := C.CString(ua)
C.ios_set_user_agent(cua)
C.free(unsafe.Pointer(cua))
}
if appName := strings.TrimSpace(app.options.IOS.ApplicationNameForUserAgent); appName != "" {
cname := C.CString(appName)
C.ios_set_app_name_for_user_agent(cname)
C.free(unsafe.Pointer(cname))
}
// App-wide background colour for the iOS window (shown before the WebView
// paints). A non-zero BackgroundColour is applied; the zero value (RGBA{})
// means "unset" and the delegate falls back to white.
if app.options.IOS.BackgroundColour != (RGBA{}) {
rgba := app.options.IOS.BackgroundColour
C.ios_set_app_background_color(
C.uchar(rgba.Red), C.uchar(rgba.Green), C.uchar(rgba.Blue), C.uchar(rgba.Alpha), C.bool(true),
)
} else {
// Not set: the delegate falls back to white.
C.ios_set_app_background_color(255, 255, 255, 255, C.bool(false))
}
// Native tabs option: only enable when explicitly requested
if app.options.IOS.EnableNativeTabs {
if len(app.options.IOS.NativeTabsItems) > 0 {
if data, err := json.Marshal(app.options.IOS.NativeTabsItems); err == nil {
cjson := C.CString(string(data))
C.ios_native_tabs_set_items_json(cjson)
C.free(unsafe.Pointer(cjson))
} else if globalApplication != nil {
globalApplication.error("Failed to marshal IOS.NativeTabsItems: %v", err)
}
}
C.ios_native_tabs_set_enabled(C.bool(true))
}
return result
}
func (a *iosApp) run() error {
// CRITICAL (gomobile/Gio model): nothing may run on the main thread before
// UIApplicationMain, or UIKit never delivers the launch to the delegate on a
// physical device (blank screen). So defer ALL startup work — including the
// pure-Go common-event wiring — to a goroutine. The main goroutine does
// nothing but call UIApplicationMain (via platformRun) below.
go func() {
// Wire common events (maps ApplicationDidFinishLaunching ->
// Common.ApplicationStarted) before the launch event is emitted.
a.setupCommonEvents()
<-iosLaunched
// Populate the ScreenManager so Screens.* runtime calls return data.
if screens, err := getScreens(); err == nil && len(screens) > 0 {
if err := a.parent.Screen.LayoutScreens(screens); err != nil {
iosConsoleLogf("error", "[application_ios.go] LayoutScreens failed: %v", err)
}
}
// Start the native system-event monitors (battery, network, lock, theme,
// app lifecycle, memory). They emit "system:*" custom events to JS.
C.ios_start_system_event_monitors()
// Emit the launch event now that listeners are wired and UIKit is up.
applicationEvents <- newApplicationEvent(events.IOS.ApplicationDidFinishLaunching)
}()
// Hand the main thread to UIKit. platformRun calls UIApplicationMain and does
// not return for the lifetime of the app.
a.parent.platformRun()
iosConsoleLogf("error", "[application_ios.go] ERROR: platformRun() returned unexpectedly")
return nil
}
func (a *iosApp) destroy() {
// Cleanup iOS resources
}
func (a *iosApp) setIcon(_ []byte) {
// iOS app icon is set through Info.plist
}
func (a *iosApp) name() string {
return a.parent.options.Name
}
func (a *iosApp) GetFlags(options Options) map[string]any {
return nil
}
// dispatchOnMainThread is implemented in mainthread_ios.go
func (a *iosApp) getAccentColor() string {
// iOS accent color
return ""
}
func (a *iosApp) getCurrentWindowID() uint {
// iOS current window ID
return 0
}
func (a *iosApp) hide() {
// iOS hide application - minimize to background
}
func (a *iosApp) isDarkMode() bool {
return a.parent.isDarkMode()
}
// isOnMainThread is implemented in mainthread_ios.go
func (a *iosApp) on(eventID uint) {
registerIOSListener(eventID)
}
func (a *iosApp) setApplicationMenu(_ *Menu) {
// iOS doesn't have application menus
}
func (a *iosApp) show() {
// iOS show application
}
func (a *iosApp) showAboutDialog(_ string, _ string, _ []byte) {
// iOS about dialog
}
func (a *iosApp) getPrimaryScreen() (*Screen, error) {
screens, err := getScreens()
if err != nil || len(screens) == 0 {
return nil, err
}
return screens[0], nil
}
func (a *iosApp) getScreens() ([]*Screen, error) {
return getScreens()
}
func (a *App) logPlatformInfo() {
// Log iOS platform info
}
func (a *App) platformEnvironment() map[string]any {
return map[string]any{
"platform": "ios",
}
}
func fatalHandler(errFunc func(error)) {
// iOS fatal handler
}
// ExecuteJavaScript runs JavaScript code in the WebView
func (a *App) ExecuteJavaScript(windowID uint, js string) {
cjs := C.CString(js)
defer C.free(unsafe.Pointer(cjs))
C.ios_execute_javascript(C.uint(windowID), cjs)
}
// iosRuntimeReadyWindows tracks windows for which a synthetic
// "wails:runtime:ready" has been injected (see ServeAssetRequest).
var iosRuntimeReadyWindows sync.Map
// ServeAssetRequest handles requests from the WebView
//
//export ServeAssetRequest
func ServeAssetRequest(windowID C.uint, urlSchemeTask unsafe.Pointer) {
// Run synchronously on the calling (UIKit) thread and hand the request off to
// the long-lived reader goroutine via the buffered webviewRequests channel.
//
// We deliberately do NOT spawn a goroutine here. A goroutine started from
// within a cgo //export call is not reliably scheduled during cold launch:
// it could be created but never run, leaving asset requests unserved and the
// WebView blank (an intermittent white/blank screen on relaunch). Waking the
// already-running reader via a channel send is reliable, and webviewRequests
// is buffered generously so this send does not block the UIKit thread.
req := webview.NewRequest(urlSchemeTask)
url, _ := req.URL()
// The JS runtime announces itself via a "wails:runtime:ready" postMessage,
// but that can be dropped during the initial load; a /wails/runtime call
// proves the runtime is mounted, so treat the first one as an implicit ready
// signal (duplicate ready messages are handled gracefully).
if strings.Contains(url, "/wails/runtime") {
if _, alreadyReady := iosRuntimeReadyWindows.LoadOrStore(uint(windowID), true); !alreadyReady {
windowMessageBuffer <- &windowMessage{
windowId: uint(windowID),
message: "wails:runtime:ready",
}
}
}
// Resolve the window name so the AssetServer receives both x-wails-window-id
// and x-wails-window-name headers.
winName := ""
if globalApplication != nil {
if window, ok := globalApplication.Window.GetByID(uint(windowID)); ok && window != nil {
winName = window.Name()
}
}
webviewRequests <- &webViewAssetRequest{
Request: req,
windowId: uint(windowID),
windowName: winName,
}
}
// HandleJSMessage handles messages from JavaScript
//
//export HandleJSMessage
func HandleJSMessage(windowID C.uint, message *C.char) {
msg := C.GoString(message)
if msg == "" {
return
}
iosDebugLogf("[iOS-message] window %d: %s", windowID, msg)
// Structured payloads carry the message in a "name" or "message" field;
// plain strings (e.g. "wails:runtime:ready") are forwarded as-is.
var msgData map[string]interface{}
if err := json.Unmarshal([]byte(msg), &msgData); err == nil && msgData != nil {
if name, ok := msgData["name"].(string); ok && name != "" {
msg = name
} else if name, ok := msgData["message"].(string); ok && name != "" {
msg = name
}
}
windowMessageBuffer <- &windowMessage{
windowId: uint(windowID),
message: msg,
}
}
// Note: applicationEvents and windowEvents are already defined in events.go
// We'll use those existing channels
type iosWindowEvent struct {
WindowID uint
EventID uint
}
//export processApplicationEvent
func processApplicationEvent(eventID C.uint, data unsafe.Pointer) {
iosDebugLogf("[application_ios.go] application event: %d", eventID)
// Create and send the application event
event := newApplicationEvent(events.ApplicationEventType(eventID))
// Mobile system events (battery, network, theme, …) pass a JSON object
// string as data; attach it to the event context so Go listeners can read
// it via event.Context().Data() / IsDarkMode(). Lifecycle events pass NULL.
if data != nil {
if jsonStr := C.GoString((*C.char)(data)); jsonStr != "" {
var m map[string]any
if err := json.Unmarshal([]byte(jsonStr), &m); err == nil && m != nil {
event.Context().setData(m)
}
}
}
// Send to the applicationEvents channel for processing
applicationEvents <- event
}
//export processWindowEvent
func processWindowEvent(windowID C.uint, eventID C.uint) {
iosDebugLogf("[application_ios.go] window event: window %d, event %d", windowID, eventID)
windowEvents <- &windowEvent{
WindowID: uint(windowID),
EventID: uint(eventID),
}
}
// iosEventListeners records which native event IDs have at least one Go-side
// listener. Registration happens via iosApp.on / iosWebviewWindow.on, which
// the cross-platform layer invokes whenever a listener is added. Listeners
// are never unregistered natively (same behaviour as macOS).
var (
iosEventListeners = make(map[uint]bool)
iosEventListenersLock sync.RWMutex
)
func registerIOSListener(eventID uint) {
iosEventListenersLock.Lock()
defer iosEventListenersLock.Unlock()
iosEventListeners[eventID] = true
}
//export hasListeners
func hasListeners(eventID C.uint) C.bool {
iosEventListenersLock.RLock()
defer iosEventListenersLock.RUnlock()
return C.bool(iosEventListeners[uint(eventID)])
}

View File

@@ -0,0 +1,167 @@
//go:build ios
#ifndef APPLICATION_IOS_H
#define APPLICATION_IOS_H
#include <stdbool.h>
#import <UIKit/UIKit.h>
// Forward declarations
@class WailsViewController;
@class WailsAppDelegate;
// Global references
extern WailsAppDelegate *appDelegate;
extern unsigned int nextWindowID;
// Initialize the iOS application
void ios_app_init(void);
// Run the iOS application main loop
void ios_app_run(void);
// Quit the iOS application
void ios_app_quit(void);
// Check if dark mode is enabled
bool ios_is_dark_mode(void);
// Configure/show state for iOS WKWebView input accessory view (keyboard toolbar)
// If disabled, the accessory view will be hidden.
void ios_set_disable_input_accessory(bool disabled);
bool ios_is_input_accessory_disabled(void);
// Scrolling & bounce & indicators
void ios_set_disable_scroll(bool disabled);
bool ios_is_scroll_disabled(void);
void ios_set_disable_bounce(bool disabled);
bool ios_is_bounce_disabled(void);
void ios_set_disable_scroll_indicators(bool disabled);
bool ios_is_scroll_indicators_disabled(void);
// Navigation gestures
void ios_set_enable_back_forward_gestures(bool enabled);
bool ios_is_back_forward_gestures_enabled(void);
// Link previews
void ios_set_disable_link_preview(bool disabled);
bool ios_is_link_preview_disabled(void);
// Media playback
void ios_set_enable_inline_media_playback(bool enabled);
bool ios_is_inline_media_playback_enabled(void);
void ios_set_enable_autoplay_without_user_action(bool enabled);
bool ios_is_autoplay_without_user_action_enabled(void);
// Inspector
void ios_set_disable_inspectable(bool disabled);
bool ios_is_inspectable_disabled(void);
// User agent customization
void ios_set_user_agent(const char* ua);
const char* ios_get_user_agent(void);
void ios_set_app_name_for_user_agent(const char* name);
const char* ios_get_app_name_for_user_agent(void);
// Live runtime mutations (apply to existing WKWebView instances)
// These functions iterate current view controllers and update the active webviews on the main thread.
void ios_runtime_set_scroll_enabled(bool enabled);
void ios_runtime_set_bounce_enabled(bool enabled);
void ios_runtime_set_scroll_indicators_enabled(bool enabled);
void ios_runtime_set_back_forward_gestures_enabled(bool enabled);
void ios_runtime_set_link_preview_enabled(bool enabled);
void ios_runtime_set_inspectable_enabled(bool enabled);
void ios_runtime_set_custom_user_agent(const char* ua);
// Native bottom tab bar (UITabBar) controls
void ios_native_tabs_set_enabled(bool enabled);
bool ios_native_tabs_is_enabled(void);
void ios_native_tabs_select_index(int index);
// Configure native tabs items as a JSON array: [{"Title":"...","SystemImage":"..."}]
void ios_native_tabs_set_items_json(const char* json);
const char* ios_native_tabs_get_items_json(void);
// App-wide background colour control
// Setter accepts RGBA (0-255) and a flag indicating whether the colour is intentionally set by the app.
void ios_set_app_background_color(unsigned char r, unsigned char g, unsigned char b, unsigned char a, bool isSet);
// Getter returns true if a colour was set; outputs RGBA components via pointers when non-null.
bool ios_get_app_background_color(unsigned char* r, unsigned char* g, unsigned char* b, unsigned char* a);
// Create a WebView window and return its ID
unsigned int ios_create_webview(void);
// Create a WebView window with specified Wails ID and return its native handle
void* ios_create_webview_with_id(unsigned int wailsID);
// Execute JavaScript in a WebView by ID (legacy)
void ios_execute_javascript(unsigned int windowID, const char* js);
// Direct JavaScript execution on a specific window handle
void ios_window_exec_js(void* viewController, const char* js);
// Loaders
void ios_window_load_url(void* viewController, const char* url);
void ios_window_set_html(void* viewController, const char* html);
// Get the window ID from a native handle
unsigned int ios_window_get_id(void* viewController);
// Release a native handle when window is destroyed
void ios_window_release_handle(void* viewController);
// Go callbacks
extern void ServeAssetRequest(unsigned int windowID, void* urlSchemeTask);
extern void HandleJSMessage(unsigned int windowID, char* message);
extern bool hasListeners(unsigned int eventId);
// iOS Runtime bridges
// Trigger haptic impact with a style: "light"|"medium"|"heavy"|"soft"|"rigid"
void ios_haptics_impact(const char* style);
// Returns a JSON string with basic device info. Caller is responsible for freeing with free().
const char* ios_device_info_json(void);
// Framework diagnostic logging. Disabled by default; Go enables it for debug
// (non-production) builds. WailsVLog gates native NSLog diagnostics.
void ios_set_verbose_logging(bool enabled);
bool ios_is_verbose_logging(void);
#define WailsVLog(...) do { if (ios_is_verbose_logging()) NSLog(__VA_ARGS__); } while (0)
// Screen metrics. Returns a JSON object for the main screen:
// {"pointWidth":..,"pointHeight":..,"pixelWidth":..,"pixelHeight":..,"scale":..,
// "safeTop":..,"safeBottom":..,"safeLeft":..,"safeRight":..}
// Caller is responsible for freeing with free().
const char* ios_screen_info_json(void);
// Clipboard (UIPasteboard). Getter result must be freed with free(); returns
// NULL when the pasteboard has no string.
void ios_clipboard_set_text(const char* text);
const char* ios_clipboard_get_text(void);
// Message dialog (UIAlertController). buttonsJSON is a JSON array of
// {"label":string,"isCancel":bool,"isDefault":bool}. When the user picks a
// button, iosDialogCallback(callbackID, buttonIndex) fires (index into the
// array, or -1 for the implicit OK of a button-less dialog).
void ios_show_message_dialog(const char* title, const char* message, const char* buttonsJSON, unsigned int callbackID);
// Document picker (UIDocumentPickerViewController). Selected paths are
// delivered via iosOpenFileCallback (one call per path) followed by
// iosOpenFileCallbackEnd. Cancellation delivers only the End callback.
// Files are imported as copies into the app sandbox; directories are opened
// in place with security-scoped access.
void ios_show_document_picker(unsigned int callbackID, bool directories, bool multiple);
// Go callbacks for the above
extern void iosDialogCallback(unsigned int callbackID, int buttonIndex);
extern void iosOpenFileCallback(unsigned int callbackID, char* path);
extern void iosOpenFileCallbackEnd(unsigned int callbackID);
extern void iosApplicationDidLaunch(void);
// Start the native system-event monitors (battery, network, screen lock).
// Each fires processApplicationEvent(EventXxx, json) so the Go side delivers a
// typed ios: application event (mapped to a common: event) with its payload on
// the event context. Safe to call once after launch; the observer setup is
// dispatched to the main thread. (Theme is handled in the view controller's
// traitCollectionDidChange; lifecycle/memory by the generated delegate events.)
void ios_start_system_event_monitors(void);
#endif // APPLICATION_IOS_H

View File

@@ -0,0 +1,483 @@
//go:build ios
#import <UIKit/UIKit.h>
#import <WebKit/WebKit.h>
#import <UniformTypeIdentifiers/UniformTypeIdentifiers.h>
#import "application_ios.h"
#import "application_ios_delegate.h"
#import "webview_window_ios.h"
#import <sys/utsname.h>
#import <stdlib.h>
#import <string.h>
#import <os/log.h>
// Forward declarations for Go callbacks
void ServeAssetRequest(unsigned int windowID, void* urlSchemeTask);
void HandleJSMessage(unsigned int windowID, char* message);
// Global references - declare after interface
WailsAppDelegate *appDelegate = nil;
unsigned int nextWindowID = 1;
static bool g_disableInputAccessory = false; // default: enabled (shown)
// New global flags with sensible iOS defaults
static bool g_disableScroll = false; // default: scrolling enabled
static bool g_disableBounce = false; // default: bounce enabled
static bool g_disableScrollIndicators = false; // default: indicators shown
static bool g_enableBackForwardGestures = false; // default: gestures disabled
static bool g_disableLinkPreview = false; // default: link preview enabled
static bool g_enableInlineMediaPlayback = false; // default: inline playback disabled
static bool g_enableAutoplayNoUserAction = false; // default: autoplay requires user action
static bool g_disableInspectable = false; // default: inspector enabled
static NSString* g_userAgent = nil;
static NSString* g_appNameForUA = nil; // default applied in code when nil
static bool g_enableNativeTabs = false; // default: off
static NSString* g_nativeTabsItemsJSON = nil; // JSON array of items
// App-wide background colour storage (RGBA 0-255)
static bool g_appBGSet = false;
static unsigned char g_appBG_R = 255;
static unsigned char g_appBG_G = 255;
static unsigned char g_appBG_B = 255;
static unsigned char g_appBG_A = 255;
// Framework diagnostic logging (off unless enabled from Go in debug builds)
static bool g_verboseLogging = false;
void ios_set_verbose_logging(bool enabled) { g_verboseLogging = enabled; }
bool ios_is_verbose_logging(void) { return g_verboseLogging; }
// Note: The WailsAppDelegate implementation resides in application_ios_delegate.m
// C interface implementation
void ios_app_init(void) {
// This will be called from Go's init
// Explicitly reference WailsAppDelegate to ensure the class is linked and registered.
(void)[WailsAppDelegate class];
// The actual UI startup happens via UIApplicationMain in main.m
}
void ios_app_run(void) {
// No-op: UIApplicationMain is invoked from main.m (the C entry point) on the
// real OS main thread. The Go runtime is started by the app delegate's
// didFinishLaunchingWithOptions, i.e. only after UIKit has launched.
}
void ios_app_quit(void) {
dispatch_async(dispatch_get_main_queue(), ^{
exit(0);
});
}
bool ios_is_dark_mode(void) {
if (@available(iOS 13.0, *)) {
UIUserInterfaceStyle style = [[UITraitCollection currentTraitCollection] userInterfaceStyle];
return style == UIUserInterfaceStyleDark;
}
return false;
}
void ios_set_disable_input_accessory(bool disabled) {
g_disableInputAccessory = disabled;
}
bool ios_is_input_accessory_disabled(void) {
return g_disableInputAccessory;
}
// Scrolling & bounce & indicators
void ios_set_disable_scroll(bool disabled) { g_disableScroll = disabled; }
bool ios_is_scroll_disabled(void) { return g_disableScroll; }
void ios_set_disable_bounce(bool disabled) { g_disableBounce = disabled; }
bool ios_is_bounce_disabled(void) { return g_disableBounce; }
void ios_set_disable_scroll_indicators(bool disabled) { g_disableScrollIndicators = disabled; }
bool ios_is_scroll_indicators_disabled(void) { return g_disableScrollIndicators; }
// Navigation gestures
void ios_set_enable_back_forward_gestures(bool enabled) { g_enableBackForwardGestures = enabled; }
bool ios_is_back_forward_gestures_enabled(void) { return g_enableBackForwardGestures; }
// Link previews
void ios_set_disable_link_preview(bool disabled) { g_disableLinkPreview = disabled; }
bool ios_is_link_preview_disabled(void) { return g_disableLinkPreview; }
// Media playback
void ios_set_enable_inline_media_playback(bool enabled) { g_enableInlineMediaPlayback = enabled; }
bool ios_is_inline_media_playback_enabled(void) { return g_enableInlineMediaPlayback; }
void ios_set_enable_autoplay_without_user_action(bool enabled) { g_enableAutoplayNoUserAction = enabled; }
bool ios_is_autoplay_without_user_action_enabled(void) { return g_enableAutoplayNoUserAction; }
// Inspector
void ios_set_disable_inspectable(bool disabled) { g_disableInspectable = disabled; }
bool ios_is_inspectable_disabled(void) { return g_disableInspectable; }
// User agent customization
void ios_set_user_agent(const char* ua) {
if (ua == NULL) { g_userAgent = nil; return; }
g_userAgent = [NSString stringWithUTF8String:ua];
}
const char* ios_get_user_agent(void) {
if (g_userAgent == nil) return NULL;
return [g_userAgent UTF8String];
}
void ios_set_app_name_for_user_agent(const char* name) {
if (name == NULL) { g_appNameForUA = nil; return; }
g_appNameForUA = [NSString stringWithUTF8String:name];
}
const char* ios_get_app_name_for_user_agent(void) {
if (g_appNameForUA == nil) return NULL;
return [g_appNameForUA UTF8String];
}
// Live runtime mutations (apply to existing WKWebView instances)
static void forEachViewController(void (^block)(WailsViewController *vc)) {
if (!appDelegate || !appDelegate.viewControllers) return;
void (^applyBlock)(void) = ^{
for (WailsViewController *vc in appDelegate.viewControllers) {
if (!vc || !vc.webView) continue;
block(vc);
}
};
if ([NSThread isMainThread]) {
applyBlock();
} else {
dispatch_async(dispatch_get_main_queue(), applyBlock);
}
}
void ios_runtime_set_scroll_enabled(bool enabled) {
g_disableScroll = !enabled;
forEachViewController(^(WailsViewController *vc){
vc.webView.scrollView.scrollEnabled = enabled ? YES : NO;
});
}
void ios_runtime_set_bounce_enabled(bool enabled) {
g_disableBounce = !enabled;
forEachViewController(^(WailsViewController *vc){
UIScrollView *sv = vc.webView.scrollView;
sv.bounces = enabled ? YES : NO;
sv.alwaysBounceVertical = enabled ? YES : NO;
sv.alwaysBounceHorizontal = enabled ? YES : NO;
});
}
void ios_runtime_set_scroll_indicators_enabled(bool enabled) {
g_disableScrollIndicators = !enabled;
forEachViewController(^(WailsViewController *vc){
UIScrollView *sv = vc.webView.scrollView;
sv.showsVerticalScrollIndicator = enabled ? YES : NO;
sv.showsHorizontalScrollIndicator = enabled ? YES : NO;
});
}
void ios_runtime_set_back_forward_gestures_enabled(bool enabled) {
g_enableBackForwardGestures = enabled;
forEachViewController(^(WailsViewController *vc){
vc.webView.allowsBackForwardNavigationGestures = enabled ? YES : NO;
});
}
void ios_runtime_set_link_preview_enabled(bool enabled) {
g_disableLinkPreview = !enabled;
forEachViewController(^(WailsViewController *vc){
vc.webView.allowsLinkPreview = enabled ? YES : NO;
});
}
void ios_runtime_set_inspectable_enabled(bool enabled) {
g_disableInspectable = !enabled;
forEachViewController(^(WailsViewController *vc){
BOOL inspectorOn = enabled ? YES : NO;
if (@available(iOS 16.4, *)) {
vc.webView.inspectable = inspectorOn;
} else {
@try { [vc.webView setValue:@(inspectorOn) forKey:@"inspectable"]; } @catch (__unused NSException *e) {}
}
});
}
void ios_runtime_set_custom_user_agent(const char* ua) {
ios_set_user_agent(ua);
NSString *uaStr = (ua ? [NSString stringWithUTF8String:ua] : nil);
forEachViewController(^(WailsViewController *vc){
vc.webView.customUserAgent = uaStr;
});
}
// Forward declaration used by getters
static const char* dupCString(NSString *str);
// Native bottom tab bar controls
void ios_native_tabs_set_enabled(bool enabled) {
g_enableNativeTabs = enabled;
forEachViewController(^(WailsViewController *vc){
[vc enableNativeTabs:(enabled ? YES : NO)];
});
}
bool ios_native_tabs_is_enabled(void) {
return g_enableNativeTabs;
}
void ios_native_tabs_select_index(int index) {
forEachViewController(^(WailsViewController *vc){
[vc selectNativeTabIndex:(NSInteger)index];
});
}
void ios_native_tabs_set_items_json(const char* json) {
if (json == NULL) {
g_nativeTabsItemsJSON = nil;
return;
}
g_nativeTabsItemsJSON = [NSString stringWithUTF8String:json];
// Apply to existing controllers if visible
forEachViewController(^(WailsViewController *vc){
if (vc.tabBar && !vc.tabBar.isHidden) {
// Re-enable to rebuild items from JSON
[vc enableNativeTabs:YES];
}
});
}
const char* ios_native_tabs_get_items_json(void) {
return dupCString(g_nativeTabsItemsJSON);
}
// App-wide background colour control
void ios_set_app_background_color(unsigned char r, unsigned char g, unsigned char b, unsigned char a, bool isSet) {
g_appBGSet = isSet;
if (isSet) {
g_appBG_R = r; g_appBG_G = g; g_appBG_B = b; g_appBG_A = a;
}
}
bool ios_get_app_background_color(unsigned char* r, unsigned char* g, unsigned char* b, unsigned char* a) {
if (!g_appBGSet) return false;
if (r) *r = g_appBG_R;
if (g) *g = g_appBG_G;
if (b) *b = g_appBG_B;
if (a) *a = g_appBG_A;
return true;
}
// iOS Runtime bridges
void ios_haptics_impact(const char* cstyle) {
if (cstyle == NULL) return;
NSString *style = [NSString stringWithUTF8String:cstyle];
dispatch_async(dispatch_get_main_queue(), ^{
WailsVLog(@"[ios_haptics_impact] requested style=%@", style);
if (@available(iOS 13.0, *)) {
UIImpactFeedbackStyle feedbackStyle = UIImpactFeedbackStyleMedium;
if ([style isEqualToString:@"light"]) feedbackStyle = UIImpactFeedbackStyleLight;
else if ([style isEqualToString:@"medium"]) feedbackStyle = UIImpactFeedbackStyleMedium;
else if ([style isEqualToString:@"heavy"]) feedbackStyle = UIImpactFeedbackStyleHeavy;
#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 130000
else if ([style isEqualToString:@"soft"]) feedbackStyle = UIImpactFeedbackStyleSoft;
else if ([style isEqualToString:@"rigid"]) feedbackStyle = UIImpactFeedbackStyleRigid;
#endif
UIImpactFeedbackGenerator *generator = [[UIImpactFeedbackGenerator alloc] initWithStyle:feedbackStyle];
[generator prepare];
[generator impactOccurred];
#if TARGET_OS_SIMULATOR
WailsVLog(@"[ios_haptics_impact] Simulator detected: no physical haptic feedback will be felt.");
#endif
} else {
WailsVLog(@"[ios_haptics_impact] iOS version < 13.0: no haptic API available.");
}
});
}
static const char* dupCString(NSString *str) {
if (str == nil) return NULL;
const char* utf8 = [str UTF8String];
if (utf8 == NULL) return NULL;
size_t len = strlen(utf8) + 1;
char* out = (char*)malloc(len);
if (out) memcpy(out, utf8, len);
return out;
}
// Run a block on the main thread, synchronously, without deadlocking when
// already on the main thread.
static void runOnMainSync(void (^block)(void)) {
if ([NSThread isMainThread]) {
block();
} else {
dispatch_sync(dispatch_get_main_queue(), block);
}
}
// Returns the view controller that should present modal UI (alerts, pickers).
static UIViewController* wailsTopViewController(void) {
UIViewController *vc = appDelegate.window.rootViewController;
while (vc.presentedViewController != nil && !vc.presentedViewController.isBeingDismissed) {
vc = vc.presentedViewController;
}
return vc;
}
// MARK: - Screen metrics
const char* ios_screen_info_json(void) {
__block NSString *json = nil;
runOnMainSync(^{
UIScreen *screen = [UIScreen mainScreen];
CGRect bounds = screen.bounds; // points, current orientation
CGRect native = screen.nativeBounds; // pixels, portrait-up
CGFloat scale = screen.scale;
UIEdgeInsets safe = UIEdgeInsetsZero;
if (appDelegate && appDelegate.window) {
safe = appDelegate.window.safeAreaInsets;
}
json = [NSString stringWithFormat:
@"{\"pointWidth\":%d,\"pointHeight\":%d,\"pixelWidth\":%d,\"pixelHeight\":%d,\"scale\":%.2f,"
"\"safeTop\":%d,\"safeBottom\":%d,\"safeLeft\":%d,\"safeRight\":%d}",
(int)bounds.size.width, (int)bounds.size.height,
(int)native.size.width, (int)native.size.height,
(double)scale,
(int)safe.top, (int)safe.bottom, (int)safe.left, (int)safe.right];
});
return dupCString(json);
}
// MARK: - Clipboard
void ios_clipboard_set_text(const char* text) {
NSString *str = text ? [NSString stringWithUTF8String:text] : @"";
runOnMainSync(^{
[UIPasteboard generalPasteboard].string = str;
});
}
const char* ios_clipboard_get_text(void) {
__block NSString *str = nil;
runOnMainSync(^{
str = [UIPasteboard generalPasteboard].string;
});
return dupCString(str);
}
// MARK: - Message dialogs
void ios_show_message_dialog(const char* ctitle, const char* cmessage, const char* cbuttonsJSON, unsigned int callbackID) {
NSString *title = ctitle ? [NSString stringWithUTF8String:ctitle] : nil;
NSString *message = cmessage ? [NSString stringWithUTF8String:cmessage] : nil;
NSString *buttonsJSON = cbuttonsJSON ? [NSString stringWithUTF8String:cbuttonsJSON] : nil;
dispatch_async(dispatch_get_main_queue(), ^{
UIAlertController *alert = [UIAlertController alertControllerWithTitle:title
message:message
preferredStyle:UIAlertControllerStyleAlert];
NSArray *buttons = nil;
if (buttonsJSON.length) {
NSData *data = [buttonsJSON dataUsingEncoding:NSUTF8StringEncoding];
id obj = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
if ([obj isKindOfClass:[NSArray class]]) {
buttons = (NSArray *)obj;
}
}
if (buttons.count == 0) {
// No buttons configured: show a plain OK
[alert addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault
handler:^(UIAlertAction *action) {
iosDialogCallback(callbackID, -1);
}]];
} else {
NSInteger idx = 0;
for (id entry in buttons) {
if (![entry isKindOfClass:[NSDictionary class]]) { idx++; continue; }
NSDictionary *d = (NSDictionary *)entry;
NSString *label = [d[@"label"] isKindOfClass:[NSString class]] ? d[@"label"] : @"";
BOOL isCancel = [d[@"isCancel"] boolValue];
BOOL isDefault = [d[@"isDefault"] boolValue];
NSInteger buttonIndex = idx;
UIAlertActionStyle style = isCancel ? UIAlertActionStyleCancel : UIAlertActionStyleDefault;
UIAlertAction *action = [UIAlertAction actionWithTitle:label style:style
handler:^(UIAlertAction *a) {
iosDialogCallback(callbackID, (int)buttonIndex);
}];
[alert addAction:action];
if (isDefault) {
alert.preferredAction = action;
}
idx++;
}
}
[wailsTopViewController() presentViewController:alert animated:YES completion:nil];
});
}
// MARK: - Document picker
@interface WailsDocumentPickerDelegate : NSObject <UIDocumentPickerDelegate>
@property (nonatomic, assign) unsigned int callbackID;
@property (nonatomic, assign) BOOL opensInPlace;
@end
// Keep delegates alive until the picker completes
static NSMutableDictionary<NSNumber*, WailsDocumentPickerDelegate*> *g_pickerDelegates;
@implementation WailsDocumentPickerDelegate
- (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentsAtURLs:(NSArray<NSURL *> *)urls {
for (NSURL *url in urls) {
// Files are imported with asCopy:YES, so they are sandbox copies that
// need no security-scoped access. Only directories are opened in place
// and require it; that access is held for the app session (bookmark
// persistence is not implemented).
if (self.opensInPlace) {
[url startAccessingSecurityScopedResource];
}
iosOpenFileCallback(self.callbackID, (char *)[url.path UTF8String]);
}
iosOpenFileCallbackEnd(self.callbackID);
[g_pickerDelegates removeObjectForKey:@(self.callbackID)];
}
- (void)documentPickerWasCancelled:(UIDocumentPickerViewController *)controller {
iosOpenFileCallbackEnd(self.callbackID);
[g_pickerDelegates removeObjectForKey:@(self.callbackID)];
}
@end
void ios_show_document_picker(unsigned int callbackID, bool directories, bool multiple) {
dispatch_async(dispatch_get_main_queue(), ^{
UIDocumentPickerViewController *picker;
if (directories) {
// Folders open in place (asCopy is not supported for folders)
picker = [[UIDocumentPickerViewController alloc] initForOpeningContentTypes:@[UTTypeFolder]];
} else {
// Import file copies into the app sandbox so the app can read
// them without managing security-scoped access.
picker = [[UIDocumentPickerViewController alloc] initForOpeningContentTypes:@[UTTypeItem] asCopy:YES];
}
picker.allowsMultipleSelection = multiple ? YES : NO;
WailsDocumentPickerDelegate *delegate = [[WailsDocumentPickerDelegate alloc] init];
delegate.callbackID = callbackID;
delegate.opensInPlace = directories ? YES : NO;
if (!g_pickerDelegates) {
g_pickerDelegates = [NSMutableDictionary dictionary];
}
g_pickerDelegates[@(callbackID)] = delegate;
picker.delegate = delegate;
[wailsTopViewController() presentViewController:picker animated:YES completion:nil];
});
}
const char* ios_device_info_json(void) {
UIDevice *device = [UIDevice currentDevice];
struct utsname systemInfo;
uname(&systemInfo);
NSString *model = [NSString stringWithUTF8String:systemInfo.machine];
NSString *systemName = device.systemName ?: @"iOS";
NSString *systemVersion = device.systemVersion ?: @"";
#if TARGET_OS_SIMULATOR
BOOL isSimulator = YES;
#else
BOOL isSimulator = NO;
#endif
NSString *json = [NSString stringWithFormat:
@"{\"model\":\"%@\",\"systemName\":\"%@\",\"systemVersion\":\"%@\",\"isSimulator\":%@}",
model, systemName, systemVersion, isSimulator ? @"true" : @"false"
];
return dupCString(json);
}

View File

@@ -0,0 +1,15 @@
//go:build ios
#ifndef application_ios_delegate_h
#define application_ios_delegate_h
#import <UIKit/UIKit.h>
@class WailsViewController;
@interface WailsAppDelegate : UIResponder <UIApplicationDelegate>
@property (strong, nonatomic) UIWindow *window;
@property (nonatomic, strong) NSMutableArray<WailsViewController *> *viewControllers;
@end
#endif /* application_ios_delegate_h */

View File

@@ -0,0 +1,114 @@
//go:build ios
#import "application_ios_delegate.h"
#import "../events/events_ios.h"
#import "application_ios.h"
extern void processApplicationEvent(unsigned int, void* data);
extern void processWindowEvent(unsigned int, unsigned int);
extern bool hasListeners(unsigned int);
extern void iosApplicationDidLaunch(void);
// WailsIOSMain (app's generated main_ios.go) runs the user's main()/app.Run().
// The delegate starts it AFTER UIKit has launched (see below).
extern void WailsIOSMain(void);
// Registers the UNUserNotificationCenter delegate so local notifications are
// shown while the app is in the foreground (and taps are handled). Apple
// requires this be set before launch finishes, hence the call below.
extern void ios_notifications_init(void);
@implementation WailsAppDelegate
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
// Set global appDelegate reference and bring up a window if needed
appDelegate = self;
if (self.window == nil) {
// Start the window with the launch-screen colour (a "LaunchBackground"
// colour asset, also referenced by UILaunchScreen) so there's no white
// flash between the launch screen and the first WebView paint. The Go
// options set the colour too, but that happens after this delegate runs,
// so it can't colour the initial window. Falls back to white if the
// asset isn't present.
UIColor *launchBG = [UIColor colorNamed:@"LaunchBackground"] ?: [UIColor whiteColor];
self.window = [[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]];
self.window.backgroundColor = launchBG;
UIViewController *rootVC = [[UIViewController alloc] init];
rootVC.view.backgroundColor = launchBG;
self.window.rootViewController = rootVC;
[self.window makeKeyAndVisible];
}
// Apply app-wide background colour if configured
unsigned char r = 255, g = 255, b = 255, a = 255;
if (ios_get_app_background_color(&r, &g, &b, &a)) {
CGFloat fr = ((CGFloat)r) / 255.0;
CGFloat fg = ((CGFloat)g) / 255.0;
CGFloat fb = ((CGFloat)b) / 255.0;
CGFloat fa = ((CGFloat)a) / 255.0;
UIColor *color = [UIColor colorWithRed:fr green:fg blue:fb alpha:fa];
self.window.backgroundColor = color;
self.window.rootViewController.view.backgroundColor = color;
}
if (!self.viewControllers) {
self.viewControllers = [NSMutableArray array];
}
// Register the notification-center delegate before launch finishes so local
// notifications appear while the app is foregrounded (otherwise iOS delivers
// them silently and no banner is shown).
ios_notifications_init();
// Unconditional launch signal for the Go runtime. platformRun waits on
// this and emits ApplicationDidFinishLaunching from the Go side once the
// event listeners are wired up - emitting it from here would race the Go
// runtime's startup and the event could be dropped.
// Start the Go runtime NOW only after UIKit has delivered the launch and
// the window exists. Starting Go earlier (concurrently with UIApplicationMain)
// intermittently corrupts the FrontBoard launch handshake on a physical
// device, so this method never fires (blank cold launch / 0x8BADF00D). Run it
// on a background thread so app.Run()'s blocking loop never touches the main
// thread. WailsIOSMain -> user main() -> app.Run(); the window's run() then
// creates the WebView (appDelegate/window are already set above).
dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
WailsIOSMain();
});
return YES;
}
// GENERATED EVENTS START
- (void)applicationDidBecomeActive:(UIApplication *)application {
if( hasListeners(EventApplicationDidBecomeActive) ) {
processApplicationEvent(EventApplicationDidBecomeActive, NULL);
}
}
- (void)applicationDidEnterBackground:(UIApplication *)application {
if( hasListeners(EventApplicationDidEnterBackground) ) {
processApplicationEvent(EventApplicationDidEnterBackground, NULL);
}
}
- (void)applicationDidFinishLaunching:(UIApplication *)application {
if( hasListeners(EventApplicationDidFinishLaunching) ) {
processApplicationEvent(EventApplicationDidFinishLaunching, NULL);
}
}
- (void)applicationDidReceiveMemoryWarning:(UIApplication *)application {
if( hasListeners(EventApplicationDidReceiveMemoryWarning) ) {
processApplicationEvent(EventApplicationDidReceiveMemoryWarning, NULL);
}
}
- (void)applicationWillEnterForeground:(UIApplication *)application {
if( hasListeners(EventApplicationWillEnterForeground) ) {
processApplicationEvent(EventApplicationWillEnterForeground, NULL);
}
}
- (void)applicationWillResignActive:(UIApplication *)application {
if( hasListeners(EventApplicationWillResignActive) ) {
processApplicationEvent(EventApplicationWillResignActive, NULL);
}
}
- (void)applicationWillTerminate:(UIApplication *)application {
if( hasListeners(EventApplicationWillTerminate) ) {
processApplicationEvent(EventApplicationWillTerminate, NULL);
}
}
// GENERATED EVENTS END
@end

View File

@@ -0,0 +1,388 @@
//go:build linux && cgo && !gtk3 && !android && !server
package application
/*
#include <gtk/gtk.h>
#include <webkit/webkit.h>
static guint get_compiled_gtk_major_version() { return GTK_MAJOR_VERSION; }
static guint get_compiled_gtk_minor_version() { return GTK_MINOR_VERSION; }
static guint get_compiled_gtk_micro_version() { return GTK_MICRO_VERSION; }
static guint get_compiled_webkit_major_version() { return WEBKIT_MAJOR_VERSION; }
static guint get_compiled_webkit_minor_version() { return WEBKIT_MINOR_VERSION; }
static guint get_compiled_webkit_micro_version() { return WEBKIT_MICRO_VERSION; }
*/
import "C"
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"github.com/godbus/dbus/v5"
"github.com/wailsapp/wails/v3/internal/operatingsystem"
"github.com/wailsapp/wails/v3/pkg/events"
)
var invalidAppNameChars = regexp.MustCompile(`[^a-zA-Z0-9_-]`)
var leadingDigits = regexp.MustCompile(`^[0-9]+`)
func sanitizeAppName(name string) string {
name = invalidAppNameChars.ReplaceAllString(name, "_")
name = leadingDigits.ReplaceAllString(name, "_$0")
for strings.Contains(name, "__") {
name = strings.ReplaceAll(name, "__", "_")
}
name = strings.Trim(name, "_")
if name == "" {
name = "wailsapp"
}
return strings.ToLower(name)
}
func init() {
// Disable DMA-BUF renderer on any session type with NVIDIA to prevent blank windows and
// "Error 71 (Protocol error)" crashes. NVIDIA proprietary drivers fail gbm_bo_map() when
// importing DMA-BUF, causing blank/white screens on both X11 and Wayland.
// See: https://bugs.webkit.org/show_bug.cgi?id=262607
// See: https://github.com/wailsapp/wails/issues/4985
if os.Getenv("WEBKIT_DISABLE_DMABUF_RENDERER") == "" && isNVIDIAGPU() {
_ = os.Setenv("WEBKIT_DISABLE_DMABUF_RENDERER", "1")
}
}
func isNVIDIAGPU() bool {
if _, err := os.Stat("/sys/module/nvidia"); err == nil {
return true
}
return false
}
type linuxApp struct {
application pointer
parent *App
activated chan struct{}
activatedOnce sync.Once
windowMap map[windowPointer]uint
windowMapLock sync.Mutex
theme string
icon pointer
}
func (a *linuxApp) GetFlags(options Options) map[string]any {
if options.Flags == nil {
options.Flags = make(map[string]any)
}
return options.Flags
}
func (a *linuxApp) name() string {
return appName()
}
func (a *linuxApp) run() error {
a.parent.Event.OnApplicationEvent(events.Linux.ApplicationStartup, func(evt *ApplicationEvent) {
if err := a.processAndCacheScreens(); err != nil {
a.parent.handleError(err)
}
})
a.setupCommonEvents()
// Theme changes are already monitored by listenForSystemThemeChanges via init();
// it uses the portal-standard org.freedesktop.appearance namespace.
a.monitorPowerEvents()
return appRun(a.application)
}
func (a *linuxApp) destroy() {
if !globalApplication.shouldQuit() {
return
}
globalApplication.cleanup()
appDestroy(a.application)
}
func (a *linuxApp) getApplicationMenu() *Menu {
return nil
}
func (a *linuxApp) setApplicationMenu(menu *Menu) {}
func (a *linuxApp) hide() {
a.hideAllWindows()
}
func (a *linuxApp) show() {
a.showAllWindows()
}
func (a *linuxApp) on(eventID uint) {
}
func (a *linuxApp) isOnMainThread() bool {
return isOnMainThread()
}
func (a *linuxApp) appendGTKVersion(result map[string]string) {
result["GTK"] = fmt.Sprintf("%d.%d.%d",
C.get_compiled_gtk_major_version(),
C.get_compiled_gtk_minor_version(),
C.get_compiled_gtk_micro_version())
result["WebKit"] = fmt.Sprintf("%d.%d.%d",
C.get_compiled_webkit_major_version(),
C.get_compiled_webkit_minor_version(),
C.get_compiled_webkit_micro_version())
}
func (a *linuxApp) init(_ *App, options Options) {
osInfo, _ := operatingsystem.Info()
a.parent.info("Compiled with GTK %d.%d.%d",
C.get_compiled_gtk_major_version(),
C.get_compiled_gtk_minor_version(),
C.get_compiled_gtk_micro_version())
a.parent.info("Compiled with WebKitGTK %d.%d.%d",
C.get_compiled_webkit_major_version(),
C.get_compiled_webkit_minor_version(),
C.get_compiled_webkit_micro_version())
a.parent.info("Using %s", osInfo.Name)
if options.Icon != nil {
a.setIcon(options.Icon)
}
go listenForSystemThemeChanges(a)
}
func listenForSystemThemeChanges(a *linuxApp) {
conn, err := dbus.SessionBus()
if err != nil {
a.parent.error("failed to connect to session bus: %v", err)
return
}
if err = conn.AddMatchSignal(
dbus.WithMatchInterface("org.freedesktop.portal.Settings"),
dbus.WithMatchMember("SettingChanged"),
); err != nil {
return
}
c := make(chan *dbus.Signal, 10)
conn.Signal(c)
for s := range c {
if len(s.Body) < 3 {
continue
}
namespace, ok := s.Body[0].(string)
if !ok || namespace != "org.freedesktop.appearance" {
continue
}
key, ok := s.Body[1].(string)
if !ok || key != "color-scheme" {
continue
}
processApplicationEvent(C.uint(events.Linux.SystemThemeChanged), nil)
}
}
func (a *linuxApp) registerWindow(window pointer, id uint) {
a.windowMapLock.Lock()
a.windowMap[windowPointer(window)] = id
a.windowMapLock.Unlock()
}
func (a *linuxApp) unregisterWindow(window windowPointer) {
a.windowMapLock.Lock()
delete(a.windowMap, window)
remainingWindows := len(a.windowMap)
a.windowMapLock.Unlock()
if remainingWindows == 0 && !a.parent.options.Linux.DisableQuitOnLastWindowClosed {
a.destroy()
}
}
func newPlatformApp(parent *App) *linuxApp {
name := sanitizeAppName(parent.options.Name)
app := &linuxApp{
parent: parent,
application: appNew(name),
activated: make(chan struct{}),
windowMap: map[windowPointer]uint{},
}
if parent.options.Linux.ProgramName != "" {
setProgramName(parent.options.Linux.ProgramName)
}
return app
}
func (a *linuxApp) markActivated() {
a.activatedOnce.Do(func() {
close(a.activated)
})
}
func (a *linuxApp) waitForActivation() {
<-a.activated
}
func (a *linuxApp) getIconForFile(filename string) ([]byte, error) {
if filename == "" {
return nil, nil
}
ext := filepath.Ext(filename)
iconMap := map[string]string{
".txt": "text-x-generic",
".pdf": "application-pdf",
".doc": "x-office-document",
".docx": "x-office-document",
".xls": "x-office-spreadsheet",
".xlsx": "x-office-spreadsheet",
".ppt": "x-office-presentation",
".pptx": "x-office-presentation",
".zip": "package-x-generic",
".tar": "package-x-generic",
".gz": "package-x-generic",
".jpg": "image-x-generic",
".jpeg": "image-x-generic",
".png": "image-x-generic",
".gif": "image-x-generic",
".mp3": "audio-x-generic",
".wav": "audio-x-generic",
".mp4": "video-x-generic",
".avi": "video-x-generic",
".html": "text-html",
".css": "text-css",
".js": "text-javascript",
".json": "text-json",
".xml": "text-xml",
}
iconName := "application-x-generic"
if name, ok := iconMap[ext]; ok {
iconName = name
}
return getIconBytes(iconName)
}
func getIconBytes(iconName string) ([]byte, error) {
return nil, fmt.Errorf("icon lookup is not currently implemented for the GTK4 build path; build with -tags gtk3 for the legacy implementation")
}
func (a *linuxApp) isDarkMode() bool {
conn, err := dbus.SessionBus()
if err != nil {
return false
}
obj := conn.Object("org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop")
call := obj.Call("org.freedesktop.portal.Settings.Read", 0, "org.freedesktop.appearance", "color-scheme")
if call.Err != nil {
return false
}
var result dbus.Variant
if err := call.Store(&result); err != nil {
return false
}
innerVariant, ok := result.Value().(dbus.Variant)
if !ok {
return false
}
colorScheme, ok := innerVariant.Value().(uint32)
if !ok {
return false
}
return colorScheme == 1
}
func (a *linuxApp) getAccentColor() string {
return "rgb(0,122,255)"
}
func (a *linuxApp) isVisible() bool {
windows := a.getWindows()
for _, window := range windows {
if C.gtk_widget_is_visible((*C.GtkWidget)(window)) != 0 {
return true
}
}
return false
}
func getNativeApplication() *linuxApp {
return globalApplication.impl.(*linuxApp)
}
// logPlatformInfo logs the platform information to the console
func (a *App) logPlatformInfo() {
info, err := operatingsystem.Info()
if err != nil {
a.error("error getting OS info: %w", err)
return
}
platformInfo := info.AsLogSlice()
platformInfo = append(platformInfo, "GTK", fmt.Sprintf("%d.%d.%d",
C.get_compiled_gtk_major_version(),
C.get_compiled_gtk_minor_version(),
C.get_compiled_gtk_micro_version()))
platformInfo = append(platformInfo, "WebKitGTK", fmt.Sprintf("%d.%d.%d",
C.get_compiled_webkit_major_version(),
C.get_compiled_webkit_minor_version(),
C.get_compiled_webkit_micro_version()))
a.info("Platform Info:", platformInfo...)
}
func buildVersionString(major, minor, micro C.guint) string {
return fmt.Sprintf("%d.%d.%d", uint(major), uint(minor), uint(micro))
}
func (a *App) platformEnvironment() map[string]any {
result := map[string]any{}
result["gtk4-compiled"] = buildVersionString(
C.get_compiled_gtk_major_version(),
C.get_compiled_gtk_minor_version(),
C.get_compiled_gtk_micro_version(),
)
result["gtk4-runtime"] = buildVersionString(
C.gtk_get_major_version(),
C.gtk_get_minor_version(),
C.gtk_get_micro_version(),
)
result["webkitgtk6-compiled"] = buildVersionString(
C.get_compiled_webkit_major_version(),
C.get_compiled_webkit_minor_version(),
C.get_compiled_webkit_micro_version(),
)
result["webkitgtk6-runtime"] = buildVersionString(
C.webkit_get_major_version(),
C.webkit_get_minor_version(),
C.webkit_get_micro_version(),
)
result["compositor"] = detectCompositor()
result["wayland"] = isWayland()
result["focusFollowsMouse"] = detectFocusFollowsMouse()
return result
}
func fatalHandler(errFunc func(error)) {
// Stub for windows function
return
}

View File

@@ -0,0 +1,153 @@
//go:build linux && cgo && !android && !server
package application
import (
"github.com/godbus/dbus/v5"
"github.com/wailsapp/wails/v3/pkg/events"
)
func (a *linuxApp) monitorThemeChanges() {
go func() {
defer handlePanic()
conn, err := dbus.ConnectSessionBus()
if err != nil {
a.parent.warning(
"[WARNING] Failed to connect to session bus; monitoring for theme changes will not function: %v",
err,
)
return
}
defer conn.Close()
if err = conn.AddMatchSignal(
dbus.WithMatchObjectPath("/org/freedesktop/portal/desktop"),
); err != nil {
a.parent.warning(
"[WARNING] Failed to subscribe to portal SettingChanged; theme changes will not fire: %v",
err,
)
return
}
c := make(chan *dbus.Signal, 10)
conn.Signal(c)
getTheme := func(body []interface{}) (string, bool) {
if len(body) < 3 {
return "", false
}
if entry, ok := body[0].(string); !ok || entry != "org.gnome.desktop.interface" {
return "", false
}
if entry, ok := body[1].(string); !ok || entry != "color-scheme" {
return "", false
}
variant, ok := body[2].(dbus.Variant)
if !ok {
return "", false
}
value, ok := variant.Value().(string)
if !ok {
return "", false
}
return value, true
}
for v := range c {
theme, ok := getTheme(v.Body)
if !ok {
continue
}
if theme != a.theme {
a.theme = theme
event := newApplicationEvent(events.Linux.SystemThemeChanged)
event.Context().setIsDarkMode(a.isDarkMode())
applicationEvents <- event
}
}
}()
}
// monitorPowerEvents subscribes to systemd-logind's PrepareForSleep signal on
// the system bus and translates it into Linux.SystemWillSleep (arg=true, just
// before suspend) and Linux.SystemDidWake (arg=false, immediately on resume).
// Mirrors NSWorkspace willSleep/didWake on macOS and WM_POWERBROADCAST on
// Windows.
//
// On systems without systemd or logind/elogind reachable on the system bus
// (Alpine, Void, some Devuan setups), we log a warning and exit cleanly so
// the rest of the app keeps working.
func (a *linuxApp) monitorPowerEvents() {
go func() {
defer handlePanic()
conn, err := dbus.ConnectSystemBus()
if err != nil {
a.parent.warning(
"[WARNING] Failed to connect to system bus; sleep/wake events will not fire: %v",
err,
)
return
}
defer conn.Close()
// Probe for logind/elogind ownership of org.freedesktop.login1 on the
// system bus. Without this check, AddMatchSignal would succeed on any
// systemd-less distro and the goroutine would block forever on a
// channel that never receives — silently masking the missing service.
var hasOwner bool
if err := conn.BusObject().Call(
"org.freedesktop.DBus.NameHasOwner", 0, "org.freedesktop.login1",
).Store(&hasOwner); err != nil {
a.parent.warning(
"[WARNING] Failed to probe org.freedesktop.login1; sleep/wake events will not fire: %v",
err,
)
return
}
if !hasOwner {
a.parent.warning(
"[WARNING] systemd-logind/elogind not reachable on the system bus; sleep/wake events will not fire",
)
return
}
// Constrain the sender to logind's well-known name so a hostile
// connection on the system bus can't spoof PrepareForSleep signals.
if err = conn.AddMatchSignal(
dbus.WithMatchSender("org.freedesktop.login1"),
dbus.WithMatchInterface("org.freedesktop.login1.Manager"),
dbus.WithMatchMember("PrepareForSleep"),
dbus.WithMatchObjectPath("/org/freedesktop/login1"),
); err != nil {
a.parent.warning(
"[WARNING] Failed to subscribe to logind PrepareForSleep; sleep/wake events will not fire: %v",
err,
)
return
}
c := make(chan *dbus.Signal, 4)
conn.Signal(c)
for v := range c {
if v.Name != "org.freedesktop.login1.Manager.PrepareForSleep" {
continue
}
if len(v.Body) < 1 {
continue
}
willSleep, ok := v.Body[0].(bool)
if !ok {
continue
}
if willSleep {
applicationEvents <- newApplicationEvent(events.Linux.SystemWillSleep)
} else {
applicationEvents <- newApplicationEvent(events.Linux.SystemDidWake)
}
}
}()
}

View File

@@ -0,0 +1,305 @@
//go:build linux && cgo && gtk3 && !android && !server
package application
/*
#include "gtk/gtk.h"
#include "webkit2/webkit2.h"
static guint get_compiled_gtk_major_version() { return GTK_MAJOR_VERSION; }
static guint get_compiled_gtk_minor_version() { return GTK_MINOR_VERSION; }
static guint get_compiled_gtk_micro_version() { return GTK_MICRO_VERSION; }
static guint get_compiled_webkit_major_version() { return WEBKIT_MAJOR_VERSION; }
static guint get_compiled_webkit_minor_version() { return WEBKIT_MINOR_VERSION; }
static guint get_compiled_webkit_micro_version() { return WEBKIT_MICRO_VERSION; }
*/
import "C"
import (
"fmt"
"os"
"regexp"
"slices"
"strings"
"sync"
"path/filepath"
"github.com/wailsapp/wails/v3/internal/operatingsystem"
"github.com/wailsapp/wails/v3/pkg/events"
)
// sanitizeAppName sanitizes the application name to be a valid GTK/D-Bus application ID.
// Valid IDs contain only alphanumeric characters, hyphens, and underscores.
// They must not start with a digit.
var invalidAppNameChars = regexp.MustCompile(`[^a-zA-Z0-9_-]`)
var leadingDigits = regexp.MustCompile(`^[0-9]+`)
func sanitizeAppName(name string) string {
// Replace invalid characters with underscores
name = invalidAppNameChars.ReplaceAllString(name, "_")
// Prefix with underscore if starts with digit
name = leadingDigits.ReplaceAllString(name, "_$0")
// Remove consecutive underscores
for strings.Contains(name, "__") {
name = strings.ReplaceAll(name, "__", "_")
}
// Trim leading/trailing underscores
name = strings.Trim(name, "_")
if name == "" {
name = "wailsapp"
}
return strings.ToLower(name)
}
func init() {
// FIXME: This should be handled appropriately in the individual files most likely.
// Set GDK_BACKEND=x11 if currently unset and XDG_SESSION_TYPE is unset, unspecified or x11 to prevent warnings
if os.Getenv("GDK_BACKEND") == "" &&
(os.Getenv("XDG_SESSION_TYPE") == "" || os.Getenv("XDG_SESSION_TYPE") == "unspecified" || os.Getenv("XDG_SESSION_TYPE") == "x11") {
_ = os.Setenv("GDK_BACKEND", "x11")
}
// Disable DMA-BUF renderer on any session type with NVIDIA to prevent blank windows and
// "Error 71 (Protocol error)" crashes. NVIDIA proprietary drivers fail gbm_bo_map() when
// importing DMA-BUF, causing blank/white screens on both X11 and Wayland.
// See: https://bugs.webkit.org/show_bug.cgi?id=262607
// See: https://github.com/wailsapp/wails/issues/4985
if os.Getenv("WEBKIT_DISABLE_DMABUF_RENDERER") == "" && isNVIDIAGPU() {
_ = os.Setenv("WEBKIT_DISABLE_DMABUF_RENDERER", "1")
}
}
// isNVIDIAGPU checks if an NVIDIA GPU is present by looking for the nvidia kernel module.
func isNVIDIAGPU() bool {
// Check if nvidia module is loaded (most reliable for proprietary driver)
if _, err := os.Stat("/sys/module/nvidia"); err == nil {
return true
}
return false
}
type linuxApp struct {
application pointer
parent *App
startupActions []func()
// Native -> uint
windowMap map[windowPointer]uint
windowMapLock sync.Mutex
theme string
icon pointer
}
func (a *linuxApp) GetFlags(options Options) map[string]any {
if options.Flags == nil {
options.Flags = make(map[string]any)
}
return options.Flags
}
func getNativeApplication() *linuxApp {
return globalApplication.impl.(*linuxApp)
}
func (a *linuxApp) hide() {
a.hideAllWindows()
}
func (a *linuxApp) show() {
a.showAllWindows()
}
func (a *linuxApp) on(eventID uint) {
// TODO: Test register/unregister events
//C.registerApplicationEvent(l.application, C.uint(eventID))
}
func (a *linuxApp) name() string {
return appName()
}
type rnr struct {
f func()
}
func (r rnr) run() {
r.f()
}
func (a *linuxApp) setApplicationMenu(menu *Menu) {
// FIXME: How do we avoid putting a menu?
if menu == nil {
// Create a default menu
menu = DefaultApplicationMenu()
globalApplication.applicationMenu = menu
}
}
func (a *linuxApp) run() error {
if len(os.Args) == 2 { // Case: program + 1 argument
arg1 := os.Args[1]
// Check if the argument is likely a URL from a custom protocol invocation
if strings.Contains(arg1, "://") {
a.parent.debug("Application launched with argument, potentially a URL from custom protocol", "url", arg1)
eventContext := newApplicationEventContext()
eventContext.setURL(arg1)
applicationEvents <- &ApplicationEvent{
Id: uint(events.Common.ApplicationLaunchedWithUrl),
ctx: eventContext,
}
} else {
// Check if the argument matches any file associations
matched := false
if a.parent.options.FileAssociations != nil {
ext := filepath.Ext(arg1)
if slices.Contains(a.parent.options.FileAssociations, ext) {
a.parent.debug("File opened via file association", "file", arg1, "extension", ext)
eventContext := newApplicationEventContext()
eventContext.setOpenedWithFile(arg1)
applicationEvents <- &ApplicationEvent{
Id: uint(events.Common.ApplicationOpenedWithFile),
ctx: eventContext,
}
matched = true
}
}
if !matched {
a.parent.debug("Application launched with single argument (not a URL), potential file open?", "arg", arg1)
}
}
} else if len(os.Args) > 2 {
// Log if multiple arguments are passed
a.parent.debug("Application launched with multiple arguments", "args", os.Args[1:])
}
a.parent.Event.OnApplicationEvent(events.Linux.ApplicationStartup, func(evt *ApplicationEvent) {
if err := a.processAndCacheScreens(); err != nil {
a.parent.handleError(err)
}
})
a.setupCommonEvents()
a.monitorThemeChanges()
a.monitorPowerEvents()
return appRun(a.application)
}
func (a *linuxApp) unregisterWindow(w windowPointer) {
a.windowMapLock.Lock()
delete(a.windowMap, w)
a.windowMapLock.Unlock()
// If this was the last window...
if len(a.windowMap) == 0 && !a.parent.options.Linux.DisableQuitOnLastWindowClosed {
a.destroy()
}
}
func (a *linuxApp) destroy() {
if !globalApplication.shouldQuit() {
return
}
globalApplication.cleanup()
appDestroy(a.application)
}
func (a *linuxApp) isOnMainThread() bool {
return isOnMainThread()
}
// register our window to our parent mapping
func (a *linuxApp) registerWindow(window pointer, id uint) {
a.windowMapLock.Lock()
a.windowMap[windowPointer(window)] = id
a.windowMapLock.Unlock()
}
func (a *linuxApp) isDarkMode() bool {
return strings.Contains(a.theme, "dark")
}
func (a *linuxApp) getAccentColor() string {
// Linux doesn't have a unified system accent color API
// Return a default blue color
return "rgb(0,122,255)"
}
func newPlatformApp(parent *App) *linuxApp {
name := sanitizeAppName(parent.options.Name)
app := &linuxApp{
parent: parent,
application: appNew(name),
windowMap: map[windowPointer]uint{},
}
if parent.options.Linux.ProgramName != "" {
setProgramName(parent.options.Linux.ProgramName)
}
return app
}
// logPlatformInfo logs the platform information to the console
func (a *App) logPlatformInfo() {
info, err := operatingsystem.Info()
if err != nil {
a.error("error getting OS info: %w", err)
return
}
wkVersion := operatingsystem.GetWebkitVersion()
platformInfo := info.AsLogSlice()
platformInfo = append(platformInfo, "Webkit2Gtk", wkVersion)
a.info("Platform Info:", platformInfo...)
}
//export processWindowEvent
func processWindowEvent(windowID C.uint, eventID C.uint) {
windowEvents <- &windowEvent{
WindowID: uint(windowID),
EventID: uint(eventID),
}
}
func buildVersionString(major, minor, micro C.uint) string {
return fmt.Sprintf("%d.%d.%d", uint(major), uint(minor), uint(micro))
}
func (a *App) platformEnvironment() map[string]any {
result := map[string]any{}
result["gtk3-compiled"] = buildVersionString(
C.get_compiled_gtk_major_version(),
C.get_compiled_gtk_minor_version(),
C.get_compiled_gtk_micro_version(),
)
result["gtk3-runtime"] = buildVersionString(
C.gtk_get_major_version(),
C.gtk_get_minor_version(),
C.gtk_get_micro_version(),
)
result["webkit2gtk-compiled"] = buildVersionString(
C.get_compiled_webkit_major_version(),
C.get_compiled_webkit_minor_version(),
C.get_compiled_webkit_micro_version(),
)
result["webkit2gtk-runtime"] = buildVersionString(
C.webkit_get_major_version(),
C.webkit_get_minor_version(),
C.webkit_get_micro_version(),
)
result["compositor"] = detectCompositor()
result["wayland"] = isWayland()
result["focusFollowsMouse"] = detectFocusFollowsMouse()
return result
}
func fatalHandler(errFunc func(error)) {
// Stub for windows function
return
}

View File

@@ -0,0 +1,435 @@
package application
import (
"io/fs"
"log/slog"
"net/http"
"time"
"github.com/wailsapp/wails/v3/internal/assetserver"
)
// Options contains the options for the application
type Options struct {
// Name is the name of the application (used in the default about box)
Name string
// Description is the description of the application (used in the default about box)
Description string
// Icon is the icon of the application (used in the default about box)
Icon []byte
// Mac is the Mac specific configuration for Mac builds
Mac MacOptions
// Windows is the Windows specific configuration for Windows builds
Windows WindowsOptions
// Linux is the Linux specific configuration for Linux builds
Linux LinuxOptions
// IOS is the iOS specific configuration for iOS builds
IOS IOSOptions
// Android is the Android specific configuration for Android builds
Android AndroidOptions
// Services allows you to bind Go methods to the frontend.
Services []Service
// MarshalError will be called if non-nil
// to marshal to JSON the error values returned by service methods.
//
// MarshalError is not allowed to fail,
// but it may return a nil slice to fall back
// to the default error handling mechanism.
//
// If the returned slice is not nil, it must contain valid JSON.
MarshalError func(error) []byte
// BindAliases allows you to specify alias IDs for your bound methods.
// Example: `BindAliases: map[uint32]uint32{1: 1411160069}` states that alias ID 1 maps to the Go method with ID 1411160069.
BindAliases map[uint32]uint32
// Logger is a slog.Logger instance used for logging Wails system messages (not application messages).
// If not defined, a default logger is used.
Logger *slog.Logger
// LogLevel defines the log level of the Wails system logger.
LogLevel slog.Level
// Assets are the application assets to be used.
Assets AssetOptions
// Flags are key value pairs that are available to the frontend.
// This is also used by Wails to provide information to the frontend.
Flags map[string]any
// PanicHandler is called when a panic occurs
PanicHandler func(*PanicDetails)
// DisableDefaultSignalHandler disables the default signal handler
DisableDefaultSignalHandler bool
// KeyBindings is a map of key bindings to functions
KeyBindings map[string]func(window Window)
// OnShutdown is called when the application is about to terminate.
// This is useful for cleanup tasks.
// The shutdown process blocks until this function returns.
OnShutdown func()
// PostShutdown is called after the application
// has finished shutting down, just before process termination.
// This is useful for testing and logging purposes
// on platforms where the Run() method does not return.
// When PostShutdown is called, the application instance is not usable anymore.
// The shutdown process blocks until this function returns.
PostShutdown func()
// ShouldQuit is a function that is called when the user tries to quit the application.
// If the function returns true, the application will quit.
// If the function returns false, the application will not quit.
ShouldQuit func() bool
// RawMessageHandler is called when the frontend sends a raw message.
// This is useful for implementing custom frontend-to-backend communication.
RawMessageHandler func(window Window, message string, originInfo *OriginInfo)
// WarningHandler is called when a warning occurs
WarningHandler func(string)
// ErrorHandler is called when an error occurs
ErrorHandler func(err error)
// File extensions associated with the application
// Example: [".txt", ".md"]
// The '.' is required
FileAssociations []string
// SingleInstance options for single instance functionality
SingleInstance *SingleInstanceOptions
// Transport allows you to provide a custom IPC transport layer.
// When set, Wails will use your transport instead of the default HTTP fetch-based transport.
// This allows you to use WebSockets, custom protocols, or any other transport mechanism
// while retaining all Wails generated bindings and event communication.
//
// The default transport uses HTTP fetch requests to /wails/runtime + events via js.Exec in webview.
// If not specified, the default transport is used.
//
// Example use case: Implementing WebSocket-based or PostMessage IPC.
Transport Transport
// Server configures the HTTP server for server mode.
// Server mode is enabled by building with the "server" build tag:
// go build -tags server
//
// In server mode, the application runs as an HTTP server without a native window.
// This enables deploying the same Wails application as a web server for:
// - Docker/container deployments
// - Server-side rendering
// - Web-only access without desktop dependencies
Server ServerOptions
}
// ServerOptions configures the HTTP server for headless mode.
type ServerOptions struct {
// Host is the address to bind to.
// Default: "localhost" for security. Use "0.0.0.0" for all interfaces.
Host string
// Port is the port to listen on.
// Default: 8080
Port int
// ReadTimeout is the maximum duration for reading the entire request.
// Default: 30 seconds
ReadTimeout time.Duration
// WriteTimeout is the maximum duration before timing out writes of the response.
// Default: 30 seconds
WriteTimeout time.Duration
// IdleTimeout is the maximum duration to wait for the next request.
// Default: 120 seconds
IdleTimeout time.Duration
// ShutdownTimeout is the maximum duration to wait for active connections to close.
// Default: 30 seconds
ShutdownTimeout time.Duration
// TLS configures HTTPS. If nil, HTTP is used.
TLS *TLSOptions
}
// TLSOptions configures HTTPS for the headless server.
type TLSOptions struct {
// CertFile is the path to the TLS certificate file.
CertFile string
// KeyFile is the path to the TLS private key file.
KeyFile string
}
// AssetOptions defines the configuration of the AssetServer.
type AssetOptions struct {
// Handler which serves all the content to the WebView.
Handler http.Handler
// Middleware is a HTTP Middleware which allows to hook into the AssetServer request chain. It allows to skip the default
// request handler dynamically, e.g. implement specialized Routing etc.
// The Middleware is called to build a new `http.Handler` used by the AssetSever and it also receives the default
// handler used by the AssetServer as an argument.
//
// This middleware injects itself before any of Wails internal middlewares.
//
// If not defined, the default AssetServer request chain is executed.
//
// Multiple Middlewares can be chained together with:
// ChainMiddleware(middleware ...Middleware) Middleware
Middleware Middleware
// DisableLogging disables logging of the AssetServer. By default, the AssetServer logs every request.
DisableLogging bool
}
// Middleware defines HTTP middleware that can be applied to the AssetServer.
// The handler passed as next is the next handler in the chain. One can decide to call the next handler
// or implement a specialized handling.
type Middleware func(next http.Handler) http.Handler
// ChainMiddleware allows chaining multiple middlewares to one middleware.
func ChainMiddleware(middleware ...Middleware) Middleware {
return func(h http.Handler) http.Handler {
for i := len(middleware) - 1; i >= 0; i-- {
h = middleware[i](h)
}
return h
}
}
// AssetFileServerFS returns a http handler which serves the assets from the fs.FS.
// If an external devserver has been provided 'FRONTEND_DEVSERVER_URL' the files are being served
// from the external server, ignoring the `assets`.
func AssetFileServerFS(assets fs.FS) http.Handler {
return assetserver.NewAssetFileServer(assets)
}
// BundledAssetFileServer returns a http handler which serves the assets from the fs.FS.
// If an external devserver has been provided 'FRONTEND_DEVSERVER_URL' the files are being served
// from the external server, ignoring the `assets`.
// It also serves the compiled runtime.js file at `/wails/runtime.js`.
// It will provide the production runtime.js file from the embedded assets if the `production` tag is used.
func BundledAssetFileServer(assets fs.FS) http.Handler {
return assetserver.NewBundledAssetFileServer(assets)
}
/******** Mac Options ********/
// ActivationPolicy is the activation policy for the application.
type ActivationPolicy int
const (
// ActivationPolicyRegular is used for applications that have a user interface,
ActivationPolicyRegular ActivationPolicy = iota
// ActivationPolicyAccessory is used for applications that do not have a main window,
// such as system tray applications or background applications.
ActivationPolicyAccessory
ActivationPolicyProhibited
)
// MacOptions contains options for macOS applications.
type MacOptions struct {
// ActivationPolicy is the activation policy for the application. Defaults to
// applicationActivationPolicyRegular.
ActivationPolicy ActivationPolicy
// If set to true, the application will terminate when the last window is closed.
ApplicationShouldTerminateAfterLastWindowClosed bool
}
/****** Windows Options *******/
// WindowsOptions contains options for Windows applications.
type WindowsOptions struct {
// Window class name
// Default: WailsWebviewWindow
WndClass string
// WndProcInterceptor is a function that will be called for every message sent in the application.
// Use this to hook into the main message loop. This is useful for handling custom window messages.
// If `shouldReturn` is `true` then `returnCode` will be returned by the main message loop.
// If `shouldReturn` is `false` then returnCode will be ignored and the message will be processed by the main message loop.
WndProcInterceptor func(hwnd uintptr, msg uint32, wParam, lParam uintptr) (returnCode uintptr, shouldReturn bool)
// DisableQuitOnLastWindowClosed disables the auto quit of the application if the last window has been closed.
DisableQuitOnLastWindowClosed bool
// Path where the WebView2 stores the user data. If empty %APPDATA%\[BinaryName.exe] will be used.
// If the path is not valid, a messagebox will be displayed with the error and the app will exit with error code.
WebviewUserDataPath string
// Path to the directory with WebView2 executables. If empty WebView2 installed in the system will be used.
WebviewBrowserPath string
// EnabledFeatures, DisabledFeatures and AdditionalBrowserArgs configure the WebView2 browser.
// These apply globally to ALL windows because WebView2 shares a single browser environment.
// See: https://learn.microsoft.com/en-us/microsoft-edge/webview2/concepts/webview-features-flags
// AdditionalBrowserArgs must include the "--" prefix, e.g. "--remote-debugging-port=9222"
EnabledFeatures []string
DisabledFeatures []string
AdditionalBrowserArgs []string
// UseVisualHosting forces WebView2 to use IDCompositionVisual hosting
// instead of the default windowed (HWND-child) hosting. Set this to
// true if your app is used over RDP — particularly the Microsoft
// Remote Desktop iOS client, which provisions a Retina-optimised
// virtual monitor mid-session whose DPI context differs from the
// session's. With windowed hosting that DPI mismatch forces a
// synchronous DComp re-marshal on every WebView2 controller call
// (PutIsVisible, MoveFocus, first-paint, surface release), each
// blocking the UI thread for ~2 seconds and persisting until the
// server is rebooted. Visual hosting eliminates that re-marshal.
//
// Implementation: sets the COREWEBVIEW2_FORCED_HOSTING_MODE env var
// to COREWEBVIEW2_HOSTING_MODE_WINDOW_TO_VISUAL before WebView2 is
// initialised. Must be set before app.Run().
//
// See: https://learn.microsoft.com/en-us/microsoft-edge/webview2/concepts/windowed-vs-visual-hosting
// See: https://github.com/MicrosoftEdge/WebView2Feedback/issues/5248
// See: https://github.com/MicrosoftEdge/WebView2Feedback/issues/4485
UseVisualHosting bool
}
/********* Linux Options *********/
// LinuxOptions contains options for Linux applications.
type LinuxOptions struct {
// DisableQuitOnLastWindowClosed disables the auto quit of the application if the last window has been closed.
DisableQuitOnLastWindowClosed bool
// ProgramName is used to set the program's name for the window manager via GTK's g_set_prgname().
//This name should not be localized. [see the docs]
//
//When a .desktop file is created this value helps with window grouping and desktop icons when the .desktop file's Name
//property differs form the executable's filename.
//
//[see the docs]: https://docs.gtk.org/glib/func.set_prgname.html
ProgramName string
}
/********* iOS Options *********/
// IOSOptions contains options for iOS applications.
type IOSOptions struct {
// DisableInputAccessoryView controls whether the iOS WKWebView shows the
// input accessory toolbar (the bar with Next/Previous/Done) above the keyboard.
// Default: false (accessory bar is shown).
// true => accessory view is disabled/hidden
// false => accessory view is enabled/shown
DisableInputAccessoryView bool
// Scrolling & Bounce (defaults: scroll/bounce/indicators are enabled on iOS)
// Use Disable* to keep default true behavior without surprising zero-values.
DisableScroll bool
DisableBounce bool
DisableScrollIndicators bool
// Navigation gestures (default false)
EnableBackForwardNavigationGestures bool
// Link previews (default true on iOS)
// Use Disable* so default (false) means previews are enabled.
DisableLinkPreview bool
// Media playback
// Inline playback (default false) -> Enable*
EnableInlineMediaPlayback bool
// Autoplay without user action (default false) -> Enable*
EnableAutoplayWithoutUserAction bool
// Inspector / Debug (default true in dev)
// Use Disable* so default (false) keeps inspector enabled.
DisableInspectable bool
// User agent customization
// If empty, defaults apply. ApplicationNameForUserAgent defaults to "wails.io".
UserAgent string
ApplicationNameForUserAgent string
// BackgroundColour is the app-wide background colour for the main iOS window,
// shown before the WebView paints. Set it to match your web background to
// avoid a white flash on launch. Defaults to white when left at its zero
// value.
BackgroundColour RGBA
// EnableNativeTabs enables a native iOS UITabBar at the bottom of the screen.
// When enabled, the native tab bar will dispatch a 'nativeTabSelected' CustomEvent
// to the window with detail: { index: number }.
// NOTE: If NativeTabsItems has one or more entries, native tabs are auto-enabled
// regardless of this flag, and the provided items will be used.
EnableNativeTabs bool
// NativeTabsItems configures the labels and optional SF Symbol icons for the
// native UITabBar. If one or more items are provided, native tabs are automatically
// enabled. If empty and EnableNativeTabs is true, default items are used.
NativeTabsItems []NativeTabItem
}
// NativeTabItem describes a single item in the iOS native UITabBar.
// SystemImage is the SF Symbols name to use for the icon (iOS 13+). If empty or
// unavailable on the current OS, no icon is shown.
type NativeTabItem struct {
Title string `json:"Title"`
SystemImage NativeTabIcon `json:"SystemImage"`
}
// NativeTabIcon is a string-based enum for SF Symbols.
// It allows using predefined constants for common symbols while still accepting
// any valid SF Symbols name as a plain string.
//
// Example:
//
// NativeTabsItems: []NativeTabItem{
// { Title: "Home", SystemImage: NativeTabIconHouse },
// { Title: "Settings", SystemImage: "gearshape" }, // arbitrary string still allowed
// }
type NativeTabIcon string
const (
// Common icons
NativeTabIconNone NativeTabIcon = ""
NativeTabIconHouse NativeTabIcon = "house"
NativeTabIconGear NativeTabIcon = "gear"
NativeTabIconStar NativeTabIcon = "star"
NativeTabIconPerson NativeTabIcon = "person"
NativeTabIconBell NativeTabIcon = "bell"
NativeTabIconMagnify NativeTabIcon = "magnifyingglass"
NativeTabIconList NativeTabIcon = "list.bullet"
NativeTabIconFolder NativeTabIcon = "folder"
)
/********* Android Options *********/
// AndroidOptions contains options for Android applications.
type AndroidOptions struct {
// DisableScroll disables scrolling in the WebView
DisableScroll bool
// DisableBounce disables the overscroll bounce effect
DisableOverscroll bool
// EnableZoom allows pinch-to-zoom in the WebView (default: false)
EnableZoom bool
// UserAgent sets a custom user agent string
UserAgent string
// BackgroundColour sets the background colour of the WebView
BackgroundColour RGBA
// DisableHardwareAcceleration disables hardware acceleration for the WebView
DisableHardwareAcceleration bool
}

View File

@@ -0,0 +1,18 @@
//go:build production
package application
func newApplication(options Options) *App {
result := &App{
isDebugMode: false,
options: options,
}
result.init()
return result
}
func (a *App) logStartup() {}
func (a *App) preRun() error { return nil }
func (a *App) postQuit() error { return nil }

View File

@@ -0,0 +1,570 @@
//go:build server
package application
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"os"
"os/signal"
"strconv"
"syscall"
"time"
"unsafe"
)
// serverApp implements platformApp for server mode.
// It provides a minimal implementation that runs an HTTP server
// without any GUI components.
//
// Server mode is enabled by building with the "server" build tag:
//
// go build -tags server
type serverApp struct {
app *App
server *http.Server
listener net.Listener
broadcaster *WebSocketBroadcaster
}
// newPlatformApp creates a new server-mode platform app.
// This function is only compiled when building with the "server" tag.
func newPlatformApp(app *App) *serverApp {
app.info("Server mode enabled (built with -tags server)")
return &serverApp{
app: app,
}
}
// parsePort parses a port string into an integer.
func parsePort(s string) (int, error) {
p, err := strconv.Atoi(s)
if err != nil {
return 0, err
}
if p < 1 || p > 65535 {
return 0, errors.New("port out of range")
}
return p, nil
}
// run starts the HTTP server and blocks until shutdown.
func (h *serverApp) run() error {
// Set up common events
h.setupCommonEvents()
// Create WebSocket broadcaster for events
h.broadcaster = NewWebSocketBroadcaster(h.app)
globalBroadcaster = h.broadcaster // Set global reference for browser ID lookups
h.app.wailsEventListenerLock.Lock()
h.app.wailsEventListeners = append(h.app.wailsEventListeners, h.broadcaster)
h.app.wailsEventListenerLock.Unlock()
opts := h.app.options.Server
// Environment variables override config (useful for Docker/containers)
host := os.Getenv("WAILS_SERVER_HOST")
if host == "" {
host = opts.Host
}
if host == "" {
host = "localhost"
}
port := opts.Port
if envPort := os.Getenv("WAILS_SERVER_PORT"); envPort != "" {
if p, err := parsePort(envPort); err == nil {
port = p
}
}
if port == 0 {
port = 8080
}
readTimeout := opts.ReadTimeout
if readTimeout == 0 {
readTimeout = 30 * time.Second
}
writeTimeout := opts.WriteTimeout
if writeTimeout == 0 {
writeTimeout = 30 * time.Second
}
idleTimeout := opts.IdleTimeout
if idleTimeout == 0 {
idleTimeout = 120 * time.Second
}
shutdownTimeout := opts.ShutdownTimeout
if shutdownTimeout == 0 {
shutdownTimeout = 30 * time.Second
}
addr := fmt.Sprintf("%s:%d", host, port)
// Create HTTP handler from asset server
handler := h.createHandler()
h.server = &http.Server{
Addr: addr,
Handler: handler,
ReadTimeout: readTimeout,
WriteTimeout: writeTimeout,
IdleTimeout: idleTimeout,
}
// Create listener
var err error
h.listener, err = net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("failed to listen on %s: %w", addr, err)
}
h.app.info("Server mode starting", "address", addr)
// Start server in goroutine
errCh := make(chan error, 1)
go func() {
if opts.TLS != nil {
errCh <- h.server.ServeTLS(h.listener, opts.TLS.CertFile, opts.TLS.KeyFile)
} else {
errCh <- h.server.Serve(h.listener)
}
}()
// Wait for shutdown signal or error
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
select {
case err := <-errCh:
if err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
case <-quit:
h.app.info("Shutdown signal received")
case <-h.app.ctx.Done():
h.app.info("Application context cancelled")
}
// Graceful shutdown
ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
if err := h.server.Shutdown(ctx); err != nil {
return fmt.Errorf("server shutdown error: %w", err)
}
h.app.info("Server stopped")
return nil
}
// customJS is the JavaScript that sets up WebSocket event connection for server mode.
// Events FROM frontend TO backend use the existing HTTP transport.
// This WebSocket is only for receiving broadcast events FROM backend TO all frontends.
const customJS = `(function() {
var protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
var clientId = window._wails && window._wails.clientId ? window._wails.clientId : '';
var wsUrl = protocol + '//' + location.host + '/wails/events' + (clientId ? '?clientId=' + encodeURIComponent(clientId) : '');
var ws;
function connect() {
ws = new WebSocket(wsUrl);
ws.onopen = function() {
console.log('[Wails] Event WebSocket connected');
};
ws.onmessage = function(e) {
try {
var event = JSON.parse(e.data);
if (window._wails && window._wails.dispatchWailsEvent) {
window._wails.dispatchWailsEvent(event);
}
} catch (err) {
console.error('[Wails] Failed to parse event:', err);
}
};
ws.onclose = function() {
console.log('[Wails] Event WebSocket disconnected, reconnecting...');
setTimeout(connect, 1000);
};
ws.onerror = function() {
ws.close();
};
}
connect();
})();`
// createHandler creates the HTTP handler for server mode.
func (h *serverApp) createHandler() http.Handler {
mux := http.NewServeMux()
// Health check endpoint
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
})
// Serve custom.js for server mode (WebSocket event connection)
mux.HandleFunc("/wails/custom.js", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/javascript")
w.WriteHeader(http.StatusOK)
w.Write([]byte(customJS))
})
// WebSocket endpoint for events
mux.Handle("/wails/events", h.broadcaster)
// Serve all other requests through the asset server
mux.Handle("/", h.app.assets)
return mux
}
// destroy stops the server and cleans up.
func (h *serverApp) destroy() {
if h.server != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
h.server.Shutdown(ctx)
}
h.app.cleanup()
}
// setApplicationMenu is a no-op in server mode.
func (h *serverApp) setApplicationMenu(menu *Menu) {
// No-op: server mode has no GUI
}
// name returns the application name.
func (h *serverApp) name() string {
return h.app.options.Name
}
// getCurrentWindowID returns 0 in server mode (no windows).
func (h *serverApp) getCurrentWindowID() uint {
return 0
}
// showAboutDialog is a no-op in server mode.
func (h *serverApp) showAboutDialog(name string, description string, icon []byte) {
// No-op: server mode has no GUI
h.app.warning("showAboutDialog called in server mode - operation ignored")
}
// setIcon is a no-op in server mode.
func (h *serverApp) setIcon(icon []byte) {
// No-op: server mode has no GUI
}
// on is a no-op in server mode.
func (h *serverApp) on(id uint) {
// No-op: server mode has no platform-specific event handling
}
// dispatchOnMainThread executes the function directly in server mode.
func (h *serverApp) dispatchOnMainThread(id uint) {
// In server mode, there's no "main thread" concept from GUI frameworks
// Execute the function directly
mainThreadFunctionStoreLock.Lock()
fn, ok := mainThreadFunctionStore[id]
if ok {
delete(mainThreadFunctionStore, id)
}
mainThreadFunctionStoreLock.Unlock()
if ok && fn != nil {
fn()
}
}
// hide is a no-op in server mode.
func (h *serverApp) hide() {
// No-op: server mode has no GUI
}
// show is a no-op in server mode.
func (h *serverApp) show() {
// No-op: server mode has no GUI
}
// getPrimaryScreen returns nil in server mode.
func (h *serverApp) getPrimaryScreen() (*Screen, error) {
return nil, errors.New("screen information not available in server mode")
}
// getScreens returns an error in server mode (screen info unavailable).
func (h *serverApp) getScreens() ([]*Screen, error) {
return nil, errors.New("screen information not available in server mode")
}
// GetFlags returns the application flags for server mode.
func (h *serverApp) GetFlags(options Options) map[string]any {
flags := make(map[string]any)
flags["server"] = true
if options.Flags != nil {
for k, v := range options.Flags {
flags[k] = v
}
}
return flags
}
// isOnMainThread always returns true in server mode.
func (h *serverApp) isOnMainThread() bool {
// In server mode, there's no main thread concept
return true
}
// isDarkMode returns false in server mode.
func (h *serverApp) isDarkMode() bool {
return false
}
// getAccentColor returns empty string in server mode.
func (h *serverApp) getAccentColor() string {
return ""
}
// logPlatformInfo logs platform info for server mode.
func (a *App) logPlatformInfo() {
a.info("Platform Info:", "mode", "server")
}
// platformEnvironment returns environment info for server mode.
func (a *App) platformEnvironment() map[string]any {
return map[string]any{
"mode": "server",
}
}
// fatalHandler sets up fatal error handling for server mode.
func fatalHandler(errFunc func(error)) {
// In server mode, fatal errors are handled via standard mechanisms
}
// newClipboardImpl creates a clipboard implementation for server mode.
func newClipboardImpl() clipboardImpl {
return &serverClipboard{}
}
// serverClipboard is a no-op clipboard for server mode.
type serverClipboard struct{}
func (c *serverClipboard) setText(text string) bool {
return false
}
func (c *serverClipboard) text() (string, bool) {
return "", false
}
// newDialogImpl creates a dialog implementation for server mode.
func newDialogImpl(d *MessageDialog) messageDialogImpl {
return &serverDialog{}
}
// serverDialog is a no-op dialog for server mode.
type serverDialog struct{}
func (d *serverDialog) show() {
// No-op in server mode
}
// newOpenFileDialogImpl creates an open file dialog implementation for server mode.
func newOpenFileDialogImpl(d *OpenFileDialogStruct) openFileDialogImpl {
return &serverOpenFileDialog{}
}
// serverOpenFileDialog is a no-op open file dialog for server mode.
type serverOpenFileDialog struct{}
func (d *serverOpenFileDialog) show() (chan string, error) {
ch := make(chan string, 1)
close(ch)
return ch, errors.New("file dialogs not available in server mode")
}
// newSaveFileDialogImpl creates a save file dialog implementation for server mode.
func newSaveFileDialogImpl(d *SaveFileDialogStruct) saveFileDialogImpl {
return &serverSaveFileDialog{}
}
// serverSaveFileDialog is a no-op save file dialog for server mode.
type serverSaveFileDialog struct{}
func (d *serverSaveFileDialog) show() (chan string, error) {
ch := make(chan string, 1)
close(ch)
return ch, errors.New("file dialogs not available in server mode")
}
// newMenuImpl creates a menu implementation for server mode.
func newMenuImpl(menu *Menu) menuImpl {
return &serverMenu{}
}
// serverMenu is a no-op menu for server mode.
type serverMenu struct{}
func (m *serverMenu) update() {
// No-op in server mode
}
// newPlatformLock creates a platform-specific single instance lock for server mode.
func newPlatformLock(manager *singleInstanceManager) (platformLock, error) {
return &serverLock{}, nil
}
// serverLock is a basic lock for server mode.
type serverLock struct{}
func (l *serverLock) acquire(uniqueID string) error {
return nil
}
func (l *serverLock) release() {
// No-op in server mode
}
func (l *serverLock) notify(data string) error {
return errors.New("single instance not supported in server mode")
}
// newSystemTrayImpl creates a system tray implementation for server mode.
func newSystemTrayImpl(s *SystemTray) systemTrayImpl {
return &serverSystemTray{parent: s}
}
// serverSystemTray is a no-op system tray for server mode.
type serverSystemTray struct {
parent *SystemTray
}
func (t *serverSystemTray) setLabel(label string) {}
func (t *serverSystemTray) setTooltip(tooltip string) {}
func (t *serverSystemTray) run() {}
func (t *serverSystemTray) setIcon(icon []byte) {}
func (t *serverSystemTray) setMenu(menu *Menu) {}
func (t *serverSystemTray) setIconPosition(pos IconPosition) {}
func (t *serverSystemTray) setTemplateIcon(icon []byte) {}
func (t *serverSystemTray) destroy() {}
func (t *serverSystemTray) setDarkModeIcon(icon []byte) {}
func (t *serverSystemTray) bounds() (*Rect, error) {
return nil, errors.New("system tray not available in server mode")
}
func (t *serverSystemTray) getScreen() (*Screen, error) {
return nil, errors.New("system tray not available in server mode")
}
func (t *serverSystemTray) positionWindow(w Window, o int) error {
return errors.New("system tray not available in server mode")
}
func (t *serverSystemTray) openMenu() {}
func (t *serverSystemTray) Show() {}
func (t *serverSystemTray) Hide() {}
// newWindowImpl creates a webview window implementation for server mode.
func newWindowImpl(parent *WebviewWindow) *serverWebviewWindow {
return &serverWebviewWindow{parent: parent}
}
// serverWebviewWindow is a no-op webview window for server mode.
type serverWebviewWindow struct {
parent *WebviewWindow
}
// All webviewWindowImpl methods as no-ops for server mode
func (w *serverWebviewWindow) setTitle(title string) {}
func (w *serverWebviewWindow) setSize(width, height int) {}
func (w *serverWebviewWindow) setAlwaysOnTop(alwaysOnTop bool) {}
func (w *serverWebviewWindow) setURL(url string) {}
func (w *serverWebviewWindow) setResizable(resizable bool) {}
func (w *serverWebviewWindow) setMinSize(width, height int) {}
func (w *serverWebviewWindow) setMaxSize(width, height int) {}
func (w *serverWebviewWindow) execJS(js string) {}
func (w *serverWebviewWindow) setBackgroundColour(color RGBA) {}
func (w *serverWebviewWindow) run() {}
func (w *serverWebviewWindow) center() {}
func (w *serverWebviewWindow) size() (int, int) { return 0, 0 }
func (w *serverWebviewWindow) width() int { return 0 }
func (w *serverWebviewWindow) height() int { return 0 }
func (w *serverWebviewWindow) destroy() {}
func (w *serverWebviewWindow) reload() {}
func (w *serverWebviewWindow) forceReload() {}
func (w *serverWebviewWindow) openDevTools() {}
func (w *serverWebviewWindow) zoomReset() {}
func (w *serverWebviewWindow) zoomIn() {}
func (w *serverWebviewWindow) zoomOut() {}
func (w *serverWebviewWindow) getZoom() float64 { return 1.0 }
func (w *serverWebviewWindow) setZoom(zoom float64) {}
func (w *serverWebviewWindow) close() {}
func (w *serverWebviewWindow) zoom() {}
func (w *serverWebviewWindow) setHTML(html string) {}
func (w *serverWebviewWindow) on(eventID uint) {}
func (w *serverWebviewWindow) minimise() {}
func (w *serverWebviewWindow) unminimise() {}
func (w *serverWebviewWindow) maximise() {}
func (w *serverWebviewWindow) unmaximise() {}
func (w *serverWebviewWindow) fullscreen() {}
func (w *serverWebviewWindow) unfullscreen() {}
func (w *serverWebviewWindow) isMinimised() bool { return false }
func (w *serverWebviewWindow) isMaximised() bool { return false }
func (w *serverWebviewWindow) isFullscreen() bool { return false }
func (w *serverWebviewWindow) isNormal() bool { return true }
func (w *serverWebviewWindow) isVisible() bool { return false }
func (w *serverWebviewWindow) isFocused() bool { return false }
func (w *serverWebviewWindow) focus() {}
func (w *serverWebviewWindow) show() {}
func (w *serverWebviewWindow) hide() {}
func (w *serverWebviewWindow) getScreen() (*Screen, error) {
return nil, errors.New("screens not available in server mode")
}
func (w *serverWebviewWindow) setFrameless(frameless bool) {}
func (w *serverWebviewWindow) openContextMenu(menu *Menu, data *ContextMenuData) {}
func (w *serverWebviewWindow) nativeWindow() unsafe.Pointer { return nil }
func (w *serverWebviewWindow) startDrag() error {
return errors.New("drag not available in server mode")
}
func (w *serverWebviewWindow) startResize(border string) error {
return errors.New("resize not available in server mode")
}
func (w *serverWebviewWindow) print() error { return errors.New("print not available in server mode") }
func (w *serverWebviewWindow) setEnabled(enabled bool) {}
func (w *serverWebviewWindow) physicalBounds() Rect { return Rect{} }
func (w *serverWebviewWindow) setPhysicalBounds(bounds Rect) {}
func (w *serverWebviewWindow) bounds() Rect { return Rect{} }
func (w *serverWebviewWindow) setBounds(bounds Rect) {}
func (w *serverWebviewWindow) position() (int, int) { return 0, 0 }
func (w *serverWebviewWindow) setPosition(x int, y int) {}
func (w *serverWebviewWindow) centerOnScreen(_ *Screen) {}
func (w *serverWebviewWindow) relativePosition() (int, int) { return 0, 0 }
func (w *serverWebviewWindow) setRelativePosition(x int, y int) {}
func (w *serverWebviewWindow) flash(enabled bool) {}
func (w *serverWebviewWindow) handleKeyEvent(acceleratorString string) {}
func (w *serverWebviewWindow) getBorderSizes() *LRTB { return &LRTB{} }
func (w *serverWebviewWindow) setMinimiseButtonState(state ButtonState) {}
func (w *serverWebviewWindow) setMaximiseButtonState(state ButtonState) {}
func (w *serverWebviewWindow) setCloseButtonState(state ButtonState) {}
func (w *serverWebviewWindow) setFullscreenButtonState(state ButtonState) {}
func (w *serverWebviewWindow) isIgnoreMouseEvents() bool { return false }
func (w *serverWebviewWindow) setIgnoreMouseEvents(ignore bool) {}
func (w *serverWebviewWindow) cut() {}
func (w *serverWebviewWindow) copy() {}
func (w *serverWebviewWindow) paste() {}
func (w *serverWebviewWindow) undo() {}
func (w *serverWebviewWindow) delete() {}
func (w *serverWebviewWindow) selectAll() {}
func (w *serverWebviewWindow) redo() {}
func (w *serverWebviewWindow) showMenuBar() {}
func (w *serverWebviewWindow) hideMenuBar() {}
func (w *serverWebviewWindow) toggleMenuBar() {}
func (w *serverWebviewWindow) setMenu(menu *Menu) {}
func (w *serverWebviewWindow) snapAssist() {}
func (w *serverWebviewWindow) attachModal(modalWindow *WebviewWindow) {}
func (w *serverWebviewWindow) setContentProtection(enabled bool) {}
func (w *serverWebviewWindow) setNonClientHitTestRegions([]nonClientHitTestRegion) {}

View File

@@ -0,0 +1,478 @@
//go:build windows && !server
package application
import (
"errors"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"unsafe"
"github.com/wailsapp/wails/v3/internal/webview2/webviewloader"
"github.com/wailsapp/wails/v3/internal/operatingsystem"
"github.com/wailsapp/wails/v3/pkg/events"
"github.com/wailsapp/wails/v3/pkg/w32"
)
var (
wmTaskbarCreated = w32.RegisterWindowMessage(w32.MustStringToUTF16Ptr("TaskbarCreated"))
)
type windowsApp struct {
parent *App
windowClass w32.WNDCLASSEX
instance w32.HINSTANCE
windowMap map[w32.HWND]*windowsWebviewWindow
windowMapLock sync.RWMutex
systrayMap map[w32.HMENU]*windowsSystemTray
systrayMapLock sync.RWMutex
mainThreadID w32.HANDLE
mainThreadWindowHWND w32.HWND
// Windows hidden by application.Hide()
hiddenWindows []*windowsWebviewWindow
focusedWindow w32.HWND
// system theme
isCurrentlyDarkMode bool
currentWindowID uint
// Restart taskbar flag
restartingTaskbar atomic.Bool
}
func (m *windowsApp) isDarkMode() bool {
return w32.IsCurrentlyDarkMode()
}
func (m *windowsApp) getAccentColor() string {
accentColor, err := w32.GetAccentColor()
if err != nil {
m.parent.error("failed to get accent color: %w", err)
return "rgb(0,122,255)"
}
return accentColor
}
func (m *windowsApp) isOnMainThread() bool {
return m.mainThreadID == w32.GetCurrentThreadId()
}
func (m *windowsApp) GetFlags(options Options) map[string]any {
if options.Flags == nil {
options.Flags = make(map[string]any)
}
options.Flags["system"] = map[string]any{
"resizeHandleWidth": w32.GetSystemMetrics(w32.SM_CXSIZEFRAME),
"resizeHandleHeight": w32.GetSystemMetrics(w32.SM_CYSIZEFRAME),
}
return options.Flags
}
func (m *windowsApp) getWindowForHWND(hwnd w32.HWND) *windowsWebviewWindow {
m.windowMapLock.RLock()
defer m.windowMapLock.RUnlock()
return m.windowMap[hwnd]
}
func getNativeApplication() *windowsApp {
return globalApplication.impl.(*windowsApp)
}
func (m *windowsApp) hide() {
// Get the current focussed window
m.focusedWindow = w32.GetForegroundWindow()
// Iterate over all windows and hide them if they aren't already hidden
for _, window := range m.windowMap {
if window.isVisible() {
// Add to hidden windows
m.hiddenWindows = append(m.hiddenWindows, window)
window.hide()
}
}
// Switch focus to the next application
hwndNext := w32.GetWindow(m.mainThreadWindowHWND, w32.GW_HWNDNEXT)
w32.SetForegroundWindow(hwndNext)
}
func (m *windowsApp) show() {
// Iterate over all windows and show them if they were previously hidden
for _, window := range m.hiddenWindows {
window.show()
}
// Show the foreground window
w32.SetForegroundWindow(m.focusedWindow)
}
func (m *windowsApp) on(_ uint) {
}
func (m *windowsApp) setIcon(_ []byte) {
}
func (m *windowsApp) name() string {
// appName := C.getAppName()
// defer C.free(unsafe.Pointer(appName))
// return C.GoString(appName)
return ""
}
func (m *windowsApp) getCurrentWindowID() uint {
return m.currentWindowID
}
func (m *windowsApp) setApplicationMenu(menu *Menu) {
if menu == nil {
// Create a default menu for windows
menu = DefaultApplicationMenu()
}
menu.Update()
m.parent.applicationMenu = menu
}
func (m *windowsApp) run() error {
m.setupCommonEvents()
for eventID := range m.parent.applicationEventListeners {
m.on(eventID)
}
// EmitEvent application started event
applicationEvents <- &ApplicationEvent{
Id: uint(events.Windows.ApplicationStarted),
ctx: blankApplicationEventContext,
}
if len(os.Args) == 2 { // Case: program + 1 argument
arg1 := os.Args[1]
// Check if the argument is likely a URL from a custom protocol invocation
if strings.Contains(arg1, "://") {
m.parent.debug("Application launched with argument, potentially a URL from custom protocol", "url", arg1)
eventContext := newApplicationEventContext()
eventContext.setURL(arg1)
applicationEvents <- &ApplicationEvent{
Id: uint(events.Common.ApplicationLaunchedWithUrl),
ctx: eventContext,
}
} else {
// If not a URL-like string, check for file association
if m.parent.options.FileAssociations != nil {
ext := filepath.Ext(arg1)
if slices.Contains(m.parent.options.FileAssociations, ext) {
m.parent.debug("Application launched with file via file association", "file", arg1)
eventContext := newApplicationEventContext()
eventContext.setOpenedWithFile(arg1)
applicationEvents <- &ApplicationEvent{
Id: uint(events.Common.ApplicationOpenedWithFile),
ctx: eventContext,
}
}
}
}
} else if len(os.Args) > 2 {
// Log if multiple arguments are passed, though typical protocol/file launch is a single arg.
m.parent.debug("Application launched with multiple arguments", "args", os.Args[1:])
}
_ = m.runMainLoop()
return nil
}
func (m *windowsApp) destroy() {
if !globalApplication.shouldQuit() {
return
}
globalApplication.cleanup()
// destroy the main thread window
w32.DestroyWindow(m.mainThreadWindowHWND)
// Post a quit message to the main thread
w32.PostQuitMessage(0)
}
func (m *windowsApp) init() {
// Register the window class
icon := w32.LoadIconWithResourceID(m.instance, w32.IDI_APPLICATION)
m.windowClass.Size = uint32(unsafe.Sizeof(m.windowClass))
m.windowClass.Style = w32.CS_HREDRAW | w32.CS_VREDRAW
m.windowClass.WndProc = syscall.NewCallback(m.wndProc)
m.windowClass.Instance = m.instance
m.windowClass.Background = w32.COLOR_BTNFACE + 1
m.windowClass.Icon = icon
m.windowClass.Cursor = w32.LoadCursorWithResourceID(0, w32.IDC_ARROW)
m.windowClass.ClassName = w32.MustStringToUTF16Ptr(m.parent.options.Windows.WndClass)
m.windowClass.MenuName = nil
m.windowClass.IconSm = icon
if ret := w32.RegisterClassEx(&m.windowClass); ret == 0 {
panic(syscall.GetLastError())
}
m.isCurrentlyDarkMode = w32.IsCurrentlyDarkMode()
}
func (m *windowsApp) wndProc(hwnd w32.HWND, msg uint32, wParam, lParam uintptr) uintptr {
// Handle the invoke callback
if msg == wmInvokeCallback {
m.invokeCallback(wParam, lParam)
return 0
}
// If the WndProcInterceptor is set in options, pass the message on
if m.parent.options.Windows.WndProcInterceptor != nil {
returnValue, shouldReturn := m.parent.options.Windows.WndProcInterceptor(hwnd, msg, wParam, lParam)
if shouldReturn {
return returnValue
}
}
// Handle the main thread window
// Quit the application if requested
// Reprocess and cache screens when display settings change
if hwnd == m.mainThreadWindowHWND {
if msg == w32.WM_ENDSESSION || msg == w32.WM_DESTROY || msg == w32.WM_CLOSE {
globalApplication.Quit()
}
if msg == w32.WM_DISPLAYCHANGE || (msg == w32.WM_SETTINGCHANGE && wParam == w32.SPI_SETWORKAREA) {
err := m.processAndCacheScreens()
if err != nil {
m.parent.handleError(err)
}
}
}
switch msg {
case w32.WM_HOTKEY:
// A global shortcut fired. wParam holds the id we passed to
// RegisterHotKey. Route it to the global shortcut manager.
if app := globalApplication; app != nil && app.GlobalShortcut != nil {
app.GlobalShortcut.dispatch(int(wParam))
}
return 0
case wmTaskbarCreated:
if m.restartingTaskbar.Load() {
break
}
m.restartingTaskbar.Store(true)
m.reshowSystrays()
go func() {
// 1 second debounce
time.Sleep(1000)
m.restartingTaskbar.Store(false)
}()
case w32.WM_SETTINGCHANGE:
settingChanged := w32.UTF16PtrToString((*uint16)(unsafe.Pointer(lParam)))
if settingChanged == "ImmersiveColorSet" {
isDarkMode := w32.IsCurrentlyDarkMode()
if isDarkMode != m.isCurrentlyDarkMode {
eventContext := newApplicationEventContext()
eventContext.setIsDarkMode(isDarkMode)
applicationEvents <- &ApplicationEvent{
Id: uint(events.Windows.SystemThemeChanged),
ctx: eventContext,
}
m.isCurrentlyDarkMode = isDarkMode
}
}
return 0
case w32.WM_POWERBROADCAST:
switch wParam {
case w32.PBT_APMPOWERSTATUSCHANGE:
applicationEvents <- newApplicationEvent(events.Windows.APMPowerStatusChange)
case w32.PBT_APMSUSPEND:
applicationEvents <- newApplicationEvent(events.Windows.APMSuspend)
case w32.PBT_APMRESUMEAUTOMATIC:
applicationEvents <- newApplicationEvent(events.Windows.APMResumeAutomatic)
case w32.PBT_APMRESUMESUSPEND:
applicationEvents <- newApplicationEvent(events.Windows.APMResumeSuspend)
case w32.PBT_POWERSETTINGCHANGE:
applicationEvents <- newApplicationEvent(events.Windows.APMPowerSettingChange)
}
return 0
}
if window, ok := m.windowMap[hwnd]; ok {
return window.WndProc(msg, wParam, lParam)
}
m.systrayMapLock.Lock()
systray, ok := m.systrayMap[hwnd]
m.systrayMapLock.Unlock()
if ok {
return systray.wndProc(msg, wParam, lParam)
}
// Dispatch the message to the appropriate window
return w32.DefWindowProc(hwnd, msg, wParam, lParam)
}
func (m *windowsApp) registerWindow(result *windowsWebviewWindow) {
m.windowMapLock.Lock()
m.windowMap[result.hwnd] = result
m.windowMapLock.Unlock()
}
func (m *windowsApp) registerSystemTray(result *windowsSystemTray) {
m.systrayMapLock.Lock()
defer m.systrayMapLock.Unlock()
m.systrayMap[result.hwnd] = result
}
func (m *windowsApp) unregisterSystemTray(result *windowsSystemTray) {
m.systrayMapLock.Lock()
defer m.systrayMapLock.Unlock()
delete(m.systrayMap, result.hwnd)
}
func (m *windowsApp) unregisterWindow(w *windowsWebviewWindow) {
m.windowMapLock.Lock()
delete(m.windowMap, w.hwnd)
m.windowMapLock.Unlock()
// If this was the last window...
if len(m.windowMap) == 0 && !m.parent.options.Windows.DisableQuitOnLastWindowClosed {
w32.PostQuitMessage(0)
}
}
func (m *windowsApp) reshowSystrays() {
m.systrayMapLock.Lock()
defer m.systrayMapLock.Unlock()
for _, systray := range m.systrayMap {
if _, err := systray.show(); err != nil {
globalApplication.warning("failed to re-add system tray icon: %v", err)
}
}
}
func setupDPIAwareness() error {
// https://learn.microsoft.com/en-us/windows/win32/hidpi/setting-the-default-dpi-awareness-for-a-process
// https://learn.microsoft.com/en-us/windows/win32/hidpi/high-dpi-desktop-application-development-on-windows
// Check if DPI awareness has already been set (e.g., via application manifest).
// Windows only allows setting DPI awareness once per process - either via manifest
// or API, not both. If already set, skip the API call to avoid "Access is denied" errors.
// See: https://github.com/wailsapp/wails/issues/4803 and #4835
// Prefer the newer GetThreadDpiAwarenessContext (Windows 10 1607+) over the older
// GetProcessDpiAwareness (SHCORE) because a manifest entry with permonitorv2 sets the
// context via SetProcessDpiAwarenessContext, which GetProcessDpiAwareness may not reflect.
if w32.HasGetThreadDpiAwarenessContextFunc() && w32.HasAreDpiAwarenessContextsEqualFunc() {
ctx := w32.GetThreadDpiAwarenessContext()
if !w32.AreDpiAwarenessContextsEqual(ctx, w32.DPI_AWARENESS_CONTEXT_UNAWARE) {
// DPI awareness already set (likely via manifest), skip API call
return nil
}
} else if w32.HasGetProcessDpiAwarenessFunc() {
awareness, err := w32.GetProcessDpiAwareness()
if err == nil && awareness != w32.PROCESS_DPI_UNAWARE {
// DPI awareness already set (likely via manifest), skip API call
return nil
}
}
if w32.HasSetProcessDpiAwarenessContextFunc() {
// This is most recent version with the best results
// supported beginning with Windows 10, version 1703
return w32.SetProcessDpiAwarenessContext(w32.DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)
}
if w32.HasSetProcessDpiAwarenessFunc() {
// Supported beginning with Windows 8.1
return w32.SetProcessDpiAwareness(w32.PROCESS_PER_MONITOR_DPI_AWARE)
}
if w32.HasSetProcessDPIAwareFunc() {
// If none of the above is supported, fallback to SetProcessDPIAware
// which is supported beginning with Windows Vista
return w32.SetProcessDPIAware()
}
return errors.New("no DPI awareness method supported")
}
func newPlatformApp(app *App) *windowsApp {
// Force WebView2 visual hosting before any WebView2 environment is
// initialised. This is the documented Microsoft workaround for the
// "DPI-context-change hang" — most commonly seen when the Microsoft
// Remote Desktop iOS client provisions a Retina-optimised virtual
// monitor mid-session and every subsequent WebView2 controller call
// blocks the UI thread for ~2 s on synchronous DComp re-marshal.
// See WindowsOptions.UseVisualHosting for the full rationale.
if app.options.Windows.UseVisualHosting {
_ = os.Setenv("COREWEBVIEW2_FORCED_HOSTING_MODE",
"COREWEBVIEW2_HOSTING_MODE_WINDOW_TO_VISUAL")
}
err := setupDPIAwareness()
if err != nil {
app.handleError(err)
}
result := &windowsApp{
parent: app,
instance: w32.GetModuleHandle(""),
windowMap: make(map[w32.HWND]*windowsWebviewWindow),
systrayMap: make(map[w32.HWND]*windowsSystemTray),
}
err = result.processAndCacheScreens()
if err != nil {
app.handleFatalError(err)
}
result.init()
result.initMainLoop()
return result
}
func (a *App) logPlatformInfo() {
var args []any
args = append(args, "Go-WebView2Loader", webviewloader.UsingGoWebview2Loader)
webviewVersion, err := webviewloader.GetAvailableCoreWebView2BrowserVersionString(
a.options.Windows.WebviewBrowserPath,
)
if err != nil {
args = append(args, "WebView2", "Error: "+err.Error())
} else {
args = append(args, "WebView2", webviewVersion)
}
osInfo, _ := operatingsystem.Info()
args = append(args, osInfo.AsLogSlice()...)
a.info("Platform Info:", args...)
}
func (a *App) platformEnvironment() map[string]any {
result := map[string]any{}
webviewVersion, _ := webviewloader.GetAvailableCoreWebView2BrowserVersionString(
a.options.Windows.WebviewBrowserPath,
)
result["Go-WebView2Loader"] = webviewloader.UsingGoWebview2Loader
result["WebView2"] = webviewVersion
return result
}
func fatalHandler(errFunc func(error)) {
w32.Fatal = errFunc
return
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,167 @@
package application
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// resolvedExecutable returns os.Executable() after resolving symlinks so
// registrations don't break when the binary is installed via a symlink farm
// (Homebrew, Scoop). Falls back to the unresolved path if EvalSymlinks fails.
func resolvedExecutable() (string, error) {
exe, err := os.Executable()
if err != nil {
return "", fmt.Errorf("autostart: get executable path: %w", err)
}
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
return resolved, nil
}
return exe, nil
}
// writeFileAtomic writes data to path by way of a tempfile + rename in the
// same directory, so a partial write never leaves a half-formed plist or
// .desktop file in place.
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp")
if err != nil {
return err
}
tmpName := tmp.Name()
cleanup := func() { _ = os.Remove(tmpName) }
// os.File.Write is documented to return an error on short writes, but we
// double-check n == len(data) so a future change of writer type can't
// silently rename a truncated artefact into place.
n, err := tmp.Write(data)
if err == nil && n != len(data) {
err = io.ErrShortWrite
}
if err != nil {
_ = tmp.Close()
cleanup()
return err
}
if err := tmp.Chmod(perm); err != nil {
_ = tmp.Close()
cleanup()
return err
}
if err := tmp.Close(); err != nil {
cleanup()
return err
}
if err := os.Rename(tmpName, path); err != nil {
cleanup()
return err
}
return nil
}
// validateAutostartIdentifier rejects identifiers that contain characters
// that would be unsafe as a filename, registry value, or launchd Label.
func validateAutostartIdentifier(id string) error {
if id == "" {
return nil
}
if len(id) > 200 {
return fmt.Errorf("autostart identifier too long (max 200): %q", id)
}
for _, r := range id {
switch {
case r >= 'a' && r <= 'z',
r >= 'A' && r <= 'Z',
r >= '0' && r <= '9',
r == '.', r == '_', r == '-':
default:
return fmt.Errorf("autostart identifier contains invalid character %q (allowed: A-Za-z0-9._-)", r)
}
}
return nil
}
// autostartSlug turns a free-form application name into something usable as
// the basename of a registration artefact. Empty input is rejected by the
// caller; this helper never returns an empty string for non-empty input.
func autostartSlug(name string) string {
var b strings.Builder
b.Grow(len(name))
for _, r := range name {
switch {
case r >= 'a' && r <= 'z',
r >= '0' && r <= '9',
r == '.', r == '_', r == '-':
b.WriteRune(r)
case r >= 'A' && r <= 'Z':
b.WriteRune(r + ('a' - 'A'))
case r == ' ', r == '\t':
b.WriteByte('-')
}
}
out := strings.Trim(b.String(), "-._")
if out == "" {
return "wails-app"
}
return out
}
// ErrAutostartNotSupported is returned when autostart is not available on
// the current platform (mobile, server builds).
var ErrAutostartNotSupported = errors.New("autostart is not supported on this platform")
// AutostartOptions configures how the application is registered to launch at login.
type AutostartOptions struct {
// Identifier overrides the auto-derived registration ID.
//
// macOS: launchd Label / SMAppService key (reverse-DNS recommended).
// Windows: registry value name under HKCU\…\Run.
// Linux: .desktop filename (without extension).
//
// If empty, a sensible default is derived: on macOS the application's
// bundle identifier (when running from a bundle) or "wails.autostart.<slug>";
// on Windows and Linux a slugified form of the application's Options.Name
// (i.e. application.Options.Name from application.New).
Identifier string
// Arguments are appended to the executable path when launched at login.
Arguments []string
}
// AutostartStrategy names the underlying mechanism a registration used.
//
// On macOS this distinguishes between SMAppService (bundled .app on macOS 13+)
// and a LaunchAgent plist (the fallback path). On Windows it is always
// AutostartStrategyRegistryRun and on Linux always AutostartStrategyXDGAutostart.
// Empty when AutostartStatus.Enabled is false.
type AutostartStrategy string
const (
AutostartStrategyNone AutostartStrategy = ""
AutostartStrategySMAppService AutostartStrategy = "smappservice"
AutostartStrategyLaunchAgent AutostartStrategy = "launchagent"
AutostartStrategyRegistryRun AutostartStrategy = "registry-run"
AutostartStrategyXDGAutostart AutostartStrategy = "xdg-autostart"
)
// AutostartStatus describes the current autostart registration.
type AutostartStatus struct {
// Enabled reports whether a registration exists.
Enabled bool
// Path is the on-disk location of the registration artefact, when
// applicable (plist path, .desktop path, registry sub-key path). Empty if
// Enabled is false.
Path string
// Strategy names the mechanism that registered the application. Empty if
// Enabled is false or the platform has only one mechanism.
Strategy AutostartStrategy
}
type autostartImpl interface {
enable(opts AutostartOptions) error
disable() error
status() (AutostartStatus, error)
}

View File

@@ -0,0 +1,13 @@
//go:build android
package application
type unsupportedAutostart struct{}
func newAutostartImpl(_ *App) autostartImpl { return unsupportedAutostart{} }
func (unsupportedAutostart) enable(AutostartOptions) error { return ErrAutostartNotSupported }
func (unsupportedAutostart) disable() error { return ErrAutostartNotSupported }
func (unsupportedAutostart) status() (AutostartStatus, error) {
return AutostartStatus{}, ErrAutostartNotSupported
}

View File

@@ -0,0 +1,327 @@
//go:build darwin && !ios && !server
package application
import (
"encoding/xml"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"github.com/wailsapp/wails/v3/pkg/mac"
)
type darwinAutostart struct {
app *App
}
func newAutostartImpl(app *App) autostartImpl {
return &darwinAutostart{app: app}
}
// strategy picks SMAppService when running from a bundled .app on macOS 13+,
// otherwise the LaunchAgent plist path. Both paths support unbundled binaries
// (LaunchAgent path) so xbar-style scripts in development still work.
func (a *darwinAutostart) strategy() AutostartStrategy {
if !runningFromAppBundle() {
return AutostartStrategyLaunchAgent
}
if mac.GetBundleID() == "" {
return AutostartStrategyLaunchAgent
}
major, _ := darwinMajorVersion()
if major < 13 {
return AutostartStrategyLaunchAgent
}
return AutostartStrategySMAppService
}
func (a *darwinAutostart) enable(opts AutostartOptions) error {
if err := validateAutostartIdentifier(opts.Identifier); err != nil {
return err
}
switch a.strategy() {
case AutostartStrategySMAppService:
if err := smAppServiceRegister(); err == nil {
return nil
} else if !errors.Is(err, errSMAppServiceUnavailable) {
return fmt.Errorf("SMAppService register: %w", err)
}
fallthrough
default:
return a.enableLaunchAgent(opts)
}
}
func (a *darwinAutostart) disable() error {
// Try both paths and merge errors — a previous version may have used
// the other strategy.
var errs []error
if a.strategy() == AutostartStrategySMAppService {
if err := smAppServiceUnregister(); err != nil && !errors.Is(err, errSMAppServiceUnavailable) && !errors.Is(err, errSMAppServiceNotRegistered) {
errs = append(errs, fmt.Errorf("SMAppService unregister: %w", err))
}
}
if err := a.disableLaunchAgent(); err != nil {
errs = append(errs, err)
}
return errors.Join(errs...)
}
func (a *darwinAutostart) status() (AutostartStatus, error) {
if a.strategy() == AutostartStrategySMAppService {
enabled, err := smAppServiceIsEnabled()
// errSMAppServiceRequiresApproval means the user disabled the
// login item in System Settings — semantically that's "not
// enabled", not a hard error. Treat it like Unavailable so the
// LaunchAgent fallback still gets a chance to find a legacy
// entry from before the app was bundled.
if err != nil &&
!errors.Is(err, errSMAppServiceUnavailable) &&
!errors.Is(err, errSMAppServiceRequiresApproval) {
return AutostartStatus{}, fmt.Errorf("SMAppService status: %w", err)
}
if enabled {
return AutostartStatus{
Enabled: true,
Path: mac.GetBundleID(),
Strategy: AutostartStrategySMAppService,
}, nil
}
}
// LaunchAgent path: also checked when SMAppService said no, so a
// previously-registered LaunchAgent doesn't disappear from view after an
// upgrade to a bundled build.
path, ok, err := a.findLaunchAgent()
if err != nil {
return AutostartStatus{}, err
}
if ok {
return AutostartStatus{
Enabled: true,
Path: path,
Strategy: AutostartStrategyLaunchAgent,
}, nil
}
return AutostartStatus{}, nil
}
func (a *darwinAutostart) launchAgentsDir() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("autostart: %w", err)
}
return filepath.Join(home, "Library", "LaunchAgents"), nil
}
func (a *darwinAutostart) defaultLabel() string {
if id := mac.GetBundleID(); id != "" {
return id
}
return "wails.autostart." + autostartSlug(a.app.options.Name)
}
func (a *darwinAutostart) enableLaunchAgent(opts AutostartOptions) error {
exe, err := resolvedExecutable()
if err != nil {
return err
}
dir, err := a.launchAgentsDir()
if err != nil {
return err
}
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("autostart: create LaunchAgents dir: %w", err)
}
label := opts.Identifier
if label == "" {
label = a.defaultLabel()
}
path := filepath.Join(dir, label+".plist")
body, err := launchAgentPlist(label, exe, opts.Arguments)
if err != nil {
return err
}
if err := writeFileAtomic(path, body, 0644); err != nil {
return fmt.Errorf("autostart: write plist: %w", err)
}
// Best-effort: activate immediately for the current GUI session.
_ = launchctlBootstrap(path)
return nil
}
func (a *darwinAutostart) disableLaunchAgent() error {
path, ok, err := a.findLaunchAgent()
if err != nil {
return err
}
if !ok {
return nil
}
_ = launchctlBootout(path)
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("autostart: remove plist: %w", err)
}
return nil
}
// findLaunchAgent looks for a plist in ~/Library/LaunchAgents whose
// ProgramArguments first element equals the current executable.
func (a *darwinAutostart) findLaunchAgent() (string, bool, error) {
dir, err := a.launchAgentsDir()
if err != nil {
return "", false, err
}
exe, err := resolvedExecutable()
if err != nil {
return "", false, err
}
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return "", false, nil
}
return "", false, fmt.Errorf("autostart: read LaunchAgents dir: %w", err)
}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".plist") {
continue
}
full := filepath.Join(dir, e.Name())
data, err := os.ReadFile(full)
if err != nil {
continue
}
if plistFirstProgramArg(data) == exe {
return full, true, nil
}
}
return "", false, nil
}
// runningFromAppBundle reports whether the current executable lives inside a
// .app bundle (path ends with .app/Contents/MacOS/<name>).
func runningFromAppBundle() bool {
exe, err := resolvedExecutable()
if err != nil {
return false
}
macOSDir := filepath.Dir(exe)
contentsDir := filepath.Dir(macOSDir)
appDir := filepath.Dir(contentsDir)
return filepath.Base(macOSDir) == "MacOS" &&
filepath.Base(contentsDir) == "Contents" &&
strings.HasSuffix(appDir, ".app")
}
func darwinMajorVersion() (int, error) {
out, err := exec.Command("sw_vers", "-productVersion").Output()
if err != nil {
return 0, err
}
ver := strings.TrimSpace(string(out))
if i := strings.IndexByte(ver, '.'); i > 0 {
ver = ver[:i]
}
return strconv.Atoi(ver)
}
// launchctlBootstrap loads a plist into the current GUI session. Best effort —
// errors are ignored (the plist will still be picked up at next login).
//
// Indirected through a package-level variable so unit tests can replace it
// with a no-op: a test plist with RunAtLoad=true that successfully bootstraps
// would respawn the test binary recursively.
var launchctlBootstrap = func(plistPath string) error {
target := fmt.Sprintf("gui/%d", os.Getuid())
return exec.Command("launchctl", "bootstrap", target, plistPath).Run()
}
var launchctlBootout = func(plistPath string) error {
target := fmt.Sprintf("gui/%d", os.Getuid())
return exec.Command("launchctl", "bootout", target, plistPath).Run()
}
// --- plist marshalling ------------------------------------------------------
func launchAgentPlist(label, exe string, args []string) ([]byte, error) {
progArgs := append([]string{exe}, args...)
var sb strings.Builder
sb.WriteString(`<?xml version="1.0" encoding="UTF-8"?>` + "\n")
sb.WriteString(`<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">` + "\n")
sb.WriteString(`<plist version="1.0">` + "\n")
sb.WriteString(" <dict>\n")
sb.WriteString(" <key>Label</key>\n")
sb.WriteString(" <string>" + xmlEscape(label) + "</string>\n")
sb.WriteString(" <key>ProgramArguments</key>\n")
sb.WriteString(" <array>\n")
for _, a := range progArgs {
sb.WriteString(" <string>" + xmlEscape(a) + "</string>\n")
}
sb.WriteString(" </array>\n")
sb.WriteString(" <key>RunAtLoad</key>\n")
sb.WriteString(" <true/>\n")
sb.WriteString(" <key>KeepAlive</key>\n")
sb.WriteString(" <false/>\n")
sb.WriteString(" </dict>\n")
sb.WriteString("</plist>\n")
return []byte(sb.String()), nil
}
func xmlEscape(s string) string {
var b strings.Builder
_ = xml.EscapeText(&b, []byte(s))
return b.String()
}
// plistFirstProgramArg returns the first <string> element under the
// ProgramArguments array in a LaunchAgent plist. Empty string on any parse
// failure — we treat a malformed file as "not ours".
func plistFirstProgramArg(data []byte) string {
dec := xml.NewDecoder(strings.NewReader(string(data)))
dec.Strict = false
var inDict, inArray, captureKey bool
var lastKey string
for {
tok, err := dec.Token()
if err != nil {
return ""
}
switch t := tok.(type) {
case xml.StartElement:
switch t.Name.Local {
case "dict":
inDict = true
case "key":
if inDict {
captureKey = true
}
case "array":
if lastKey == "ProgramArguments" {
inArray = true
}
case "string":
if inArray {
var s string
if err := dec.DecodeElement(&s, &t); err == nil {
return s
}
return ""
}
}
case xml.CharData:
if captureKey {
lastKey = string(t)
captureKey = false
}
case xml.EndElement:
if t.Name.Local == "array" && inArray {
return ""
}
}
}
}

View File

@@ -0,0 +1,141 @@
//go:build darwin && !ios && !server
package application
/*
#cgo CFLAGS: -mmacosx-version-min=10.15 -x objective-c -Wno-unguarded-availability-new
#cgo LDFLAGS: -framework Foundation -framework ServiceManagement
#include <stdlib.h> // free
#include <string.h> // strdup
#import <Foundation/Foundation.h>
#import <ServiceManagement/ServiceManagement.h>
// Return codes shared with the Go side.
enum {
SMAS_OK = 0,
SMAS_UNAVAILABLE = 1, // SMAppService class not present (pre macOS 13)
SMAS_NOT_REGISTERED = 2, // unregister called when nothing was registered
SMAS_REQUIRES_APPROVAL = 3, // user disabled it in System Settings
SMAS_ERROR = 4, // generic failure; *outMsg populated
};
static int smAppServiceRegister(char** outMsg) {
if (@available(macOS 13.0, *)) {
@autoreleasepool {
SMAppService *svc = [SMAppService mainAppService];
NSError *err = nil;
if ([svc registerAndReturnError:&err]) {
return SMAS_OK;
}
if (err != nil) {
*outMsg = strdup([[err localizedDescription] UTF8String]);
}
return SMAS_ERROR;
}
}
return SMAS_UNAVAILABLE;
}
static int smAppServiceUnregister(char** outMsg) {
if (@available(macOS 13.0, *)) {
@autoreleasepool {
SMAppService *svc = [SMAppService mainAppService];
if (svc.status == SMAppServiceStatusNotRegistered ||
svc.status == SMAppServiceStatusNotFound) {
return SMAS_NOT_REGISTERED;
}
NSError *err = nil;
if ([svc unregisterAndReturnError:&err]) {
return SMAS_OK;
}
if (err != nil) {
*outMsg = strdup([[err localizedDescription] UTF8String]);
}
return SMAS_ERROR;
}
}
return SMAS_UNAVAILABLE;
}
// smAppServiceStatus: 0 = unavailable, 1 = not registered / not found,
// 2 = enabled, 3 = requires approval.
static int smAppServiceStatus(void) {
if (@available(macOS 13.0, *)) {
@autoreleasepool {
SMAppService *svc = [SMAppService mainAppService];
switch (svc.status) {
case SMAppServiceStatusEnabled: return 2;
case SMAppServiceStatusRequiresApproval: return 3;
default: return 1;
}
}
}
return 0;
}
*/
import "C"
import (
"errors"
"unsafe"
)
var (
errSMAppServiceUnavailable = errors.New("SMAppService unavailable on this macOS")
errSMAppServiceNotRegistered = errors.New("SMAppService not registered")
errSMAppServiceRequiresApproval = errors.New("SMAppService requires user approval in System Settings")
)
func smAppServiceRegister() error {
var cMsg *C.char
rc := C.smAppServiceRegister(&cMsg)
if cMsg != nil {
defer C.free(unsafe.Pointer(cMsg))
}
switch rc {
case 0:
return nil
case 1:
return errSMAppServiceUnavailable
default:
if cMsg != nil {
return errors.New(C.GoString(cMsg))
}
return errors.New("SMAppService register failed")
}
}
func smAppServiceUnregister() error {
var cMsg *C.char
rc := C.smAppServiceUnregister(&cMsg)
if cMsg != nil {
defer C.free(unsafe.Pointer(cMsg))
}
switch rc {
case 0:
return nil
case 1:
return errSMAppServiceUnavailable
case 2:
return errSMAppServiceNotRegistered
default:
if cMsg != nil {
return errors.New(C.GoString(cMsg))
}
return errors.New("SMAppService unregister failed")
}
}
func smAppServiceIsEnabled() (bool, error) {
switch C.smAppServiceStatus() {
case 0:
return false, errSMAppServiceUnavailable
case 2:
return true, nil
case 3:
return false, errSMAppServiceRequiresApproval
default:
return false, nil
}
}

View File

@@ -0,0 +1,13 @@
//go:build ios
package application
type unsupportedAutostart struct{}
func newAutostartImpl(_ *App) autostartImpl { return unsupportedAutostart{} }
func (unsupportedAutostart) enable(AutostartOptions) error { return ErrAutostartNotSupported }
func (unsupportedAutostart) disable() error { return ErrAutostartNotSupported }
func (unsupportedAutostart) status() (AutostartStatus, error) {
return AutostartStatus{}, ErrAutostartNotSupported
}

View File

@@ -0,0 +1,256 @@
//go:build linux && !android && !server
package application
import (
"fmt"
"os"
"path/filepath"
"strings"
)
type linuxAutostart struct {
app *App
}
func newAutostartImpl(app *App) autostartImpl {
return &linuxAutostart{app: app}
}
func (a *linuxAutostart) enable(opts AutostartOptions) error {
if err := validateAutostartIdentifier(opts.Identifier); err != nil {
return err
}
// A newline inside the executable path or any argument would break the
// .desktop file format (line-based key=value) and could be used to inject
// arbitrary Desktop Entry keys when Arguments are user-influenced.
for i, arg := range opts.Arguments {
if err := validateDesktopExecToken(arg); err != nil {
return fmt.Errorf("autostart argument %d: %w", i, err)
}
}
exe, err := resolvedExecutable()
if err != nil {
return err
}
if err := validateDesktopExecToken(exe); err != nil {
return fmt.Errorf("autostart executable path: %w", err)
}
dir, err := a.autostartDir()
if err != nil {
return err
}
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("create autostart dir: %w", err)
}
id := opts.Identifier
if id == "" {
id = autostartSlug(a.app.options.Name)
}
path := filepath.Join(dir, id+".desktop")
// Remove any stale .desktop file pointing at this binary under a
// different identifier so a previous Enable() with a different
// Identifier (or a slug derived from a renamed Options.Name) doesn't
// leave a second entry behind.
if existing, ferr := a.findDesktopFile(dir); ferr == nil && existing != "" && existing != path {
_ = os.Remove(existing)
}
body := buildDesktopEntry(a.app.options.Name, exe, opts.Arguments)
if err := writeFileAtomic(path, []byte(body), 0644); err != nil {
return fmt.Errorf("write desktop file %s: %w", path, err)
}
return nil
}
func (a *linuxAutostart) disable() error {
dir, err := a.autostartDir()
if err != nil {
return err
}
path, err := a.findDesktopFile(dir)
if err != nil {
return err
}
if path == "" {
return nil
}
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove desktop file: %w", err)
}
return nil
}
func (a *linuxAutostart) status() (AutostartStatus, error) {
dir, err := a.autostartDir()
if err != nil {
return AutostartStatus{}, err
}
path, err := a.findDesktopFile(dir)
if err != nil {
return AutostartStatus{}, err
}
if path == "" {
return AutostartStatus{}, nil
}
return AutostartStatus{
Enabled: true,
Path: path,
Strategy: AutostartStrategyXDGAutostart,
}, nil
}
func (a *linuxAutostart) autostartDir() (string, error) {
cfg := os.Getenv("XDG_CONFIG_HOME")
if cfg == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("autostart: %w", err)
}
cfg = filepath.Join(home, ".config")
}
return filepath.Join(cfg, "autostart"), nil
}
// findDesktopFile looks for a .desktop file in dir whose Exec= entry points at
// the current executable. Returns empty path with no error if none found.
// This survives identifier changes between Enable() calls.
func (a *linuxAutostart) findDesktopFile(dir string) (string, error) {
exe, err := resolvedExecutable()
if err != nil {
return "", err
}
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", fmt.Errorf("read autostart dir: %w", err)
}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".desktop") {
continue
}
full := filepath.Join(dir, e.Name())
data, err := os.ReadFile(full)
if err != nil {
continue
}
if desktopExecPath(string(data)) == exe {
return full, nil
}
}
return "", nil
}
func buildDesktopEntry(appName, exe string, args []string) string {
if appName == "" {
appName = filepath.Base(exe)
}
execLine := quoteExec(exe)
for _, a := range args {
execLine += " " + quoteExec(a)
}
return fmt.Sprintf(`[Desktop Entry]
Type=Application
Name=%s
Exec=%s
X-GNOME-Autostart-enabled=true
Hidden=false
NoDisplay=false
Terminal=false
`, escapeDesktopValue(appName), execLine)
}
// validateDesktopExecToken rejects control characters that would break the
// .desktop file format or allow Desktop Entry key injection when interpolated
// into an Exec= line. Allowed: spaces and tabs (which quoteExec handles by
// double-quoting); rejected: all other ASCII control characters including
// CR/LF.
func validateDesktopExecToken(s string) error {
for _, r := range s {
if r == '\t' || r == ' ' {
continue
}
if r < 0x20 || r == 0x7f {
return fmt.Errorf("control character %U not allowed in Exec field", r)
}
}
return nil
}
// quoteExec escapes a single Exec field token per the freedesktop.org spec:
// reserved chars are " ` $ \ → escape with backslash; if the token contains
// any reserved or whitespace, double-quote it.
func quoteExec(s string) string {
needQuote := false
var b strings.Builder
for _, r := range s {
switch r {
case '"', '`', '$', '\\':
b.WriteByte('\\')
b.WriteRune(r)
needQuote = true
case ' ', '\t':
// Newlines are rejected by validateDesktopExecToken before we
// get here, so any whitespace remaining is safely quotable.
b.WriteRune(r)
needQuote = true
default:
b.WriteRune(r)
}
}
if needQuote {
return `"` + b.String() + `"`
}
return b.String()
}
// escapeDesktopValue escapes characters that are not allowed in raw Desktop
// Entry values (newlines, leading/trailing whitespace).
func escapeDesktopValue(s string) string {
s = strings.ReplaceAll(s, "\r", " ")
s = strings.ReplaceAll(s, "\n", " ")
return strings.TrimSpace(s)
}
func desktopExecPath(contents string) string {
for _, line := range strings.Split(contents, "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "Exec=") {
continue
}
val := strings.TrimPrefix(line, "Exec=")
// First token, possibly quoted.
val = strings.TrimSpace(val)
if strings.HasPrefix(val, `"`) {
end := strings.Index(val[1:], `"`)
if end < 0 {
return ""
}
return unescapeDesktopToken(val[1 : 1+end])
}
if i := strings.IndexAny(val, " \t"); i >= 0 {
return val[:i]
}
return val
}
return ""
}
func unescapeDesktopToken(s string) string {
var b strings.Builder
for i := 0; i < len(s); i++ {
if s[i] == '\\' && i+1 < len(s) {
b.WriteByte(s[i+1])
i++
continue
}
b.WriteByte(s[i])
}
return b.String()
}

View File

@@ -0,0 +1,64 @@
package application
// AutostartManager provides cross-platform control over whether the
// application launches when the user logs in.
//
// Registration takes effect on the next login, not immediately.
//
// Platform behaviour:
//
// - macOS 13+ (bundled .app): SMAppService.mainAppService — works for
// sandboxed and Mac-App-Store apps, no TCC automation prompt.
// - macOS (older or unbundled): a LaunchAgent plist is written to
// ~/Library/LaunchAgents/.
// - Windows: a value is added under
// HKCU\Software\Microsoft\Windows\CurrentVersion\Run.
// - Linux: an .desktop file is written to $XDG_CONFIG_HOME/autostart/
// (defaulting to ~/.config/autostart/).
// - Android / iOS / server builds: ErrAutostartNotSupported.
type AutostartManager struct {
app *App
impl autostartImpl
}
func newAutostartManager(app *App) *AutostartManager {
return &AutostartManager{
app: app,
impl: newAutostartImpl(app),
}
}
// Enable registers the application to launch at user login using default
// options. Calling Enable repeatedly is safe; the registration is overwritten.
func (am *AutostartManager) Enable() error {
return am.impl.enable(AutostartOptions{})
}
// EnableWithOptions registers the application with the given options.
// See AutostartOptions for the meaning of each field.
func (am *AutostartManager) EnableWithOptions(opts AutostartOptions) error {
return am.impl.enable(opts)
}
// Disable removes the autostart registration. Returns nil if the application
// was not registered.
func (am *AutostartManager) Disable() error {
return am.impl.disable()
}
// IsEnabled reports whether the application is currently registered to launch
// at login. It does not verify that the registered executable path still
// points at the running binary; use Status for that.
func (am *AutostartManager) IsEnabled() (bool, error) {
st, err := am.impl.status()
if err != nil {
return false, err
}
return st.Enabled, nil
}
// Status returns the full registration state, including the path of the
// on-disk artefact and the platform mechanism used.
func (am *AutostartManager) Status() (AutostartStatus, error) {
return am.impl.status()
}

View File

@@ -0,0 +1,13 @@
//go:build server
package application
type unsupportedAutostart struct{}
func newAutostartImpl(_ *App) autostartImpl { return unsupportedAutostart{} }
func (unsupportedAutostart) enable(AutostartOptions) error { return ErrAutostartNotSupported }
func (unsupportedAutostart) disable() error { return ErrAutostartNotSupported }
func (unsupportedAutostart) status() (AutostartStatus, error) {
return AutostartStatus{}, ErrAutostartNotSupported
}

View File

@@ -0,0 +1,199 @@
//go:build windows && !server
package application
import (
"errors"
"fmt"
"strings"
"golang.org/x/sys/windows/registry"
)
const defaultAutostartRegistrySubKey = `Software\Microsoft\Windows\CurrentVersion\Run`
type windowsAutostart struct {
app *App
// registrySubKey is overridable for tests; production code reads/writes
// HKCU\Software\Microsoft\Windows\CurrentVersion\Run.
registrySubKey string
}
func newAutostartImpl(app *App) autostartImpl {
return &windowsAutostart{
app: app,
registrySubKey: defaultAutostartRegistrySubKey,
}
}
func (a *windowsAutostart) enable(opts AutostartOptions) error {
if err := validateAutostartIdentifier(opts.Identifier); err != nil {
return err
}
exe, err := resolvedExecutable()
if err != nil {
return err
}
id := opts.Identifier
if id == "" {
id = autostartSlug(a.app.options.Name)
}
cmd := quoteWindowsArg(exe)
for _, arg := range opts.Arguments {
cmd += " " + quoteWindowsArg(arg)
}
key, _, err := registry.CreateKey(registry.CURRENT_USER, a.registrySubKey, registry.SET_VALUE)
if err != nil {
return fmt.Errorf("autostart: open registry key: %w", err)
}
defer key.Close()
// Remove any stale entry pointing at this binary under a different value
// name, so a previous Enable() with a different Identifier (or a slug
// derived from a renamed Options.Name) doesn't leave behind a second
// autostart entry.
if existing, _, ferr := a.find(); ferr == nil && existing != "" && existing != id {
_ = key.DeleteValue(existing)
}
if err := key.SetStringValue(id, cmd); err != nil {
return fmt.Errorf("autostart: write registry value: %w", err)
}
return nil
}
func (a *windowsAutostart) disable() error {
id, _, err := a.find()
if err != nil {
return err
}
if id == "" {
return nil
}
key, err := registry.OpenKey(registry.CURRENT_USER, a.registrySubKey, registry.SET_VALUE)
if err != nil {
if errors.Is(err, registry.ErrNotExist) {
return nil
}
return fmt.Errorf("autostart: open registry key: %w", err)
}
defer key.Close()
if err := key.DeleteValue(id); err != nil && !errors.Is(err, registry.ErrNotExist) {
return fmt.Errorf("autostart: delete registry value: %w", err)
}
return nil
}
func (a *windowsAutostart) status() (AutostartStatus, error) {
id, _, err := a.find()
if err != nil {
return AutostartStatus{}, err
}
if id == "" {
return AutostartStatus{}, nil
}
return AutostartStatus{
Enabled: true,
Path: `HKCU\` + a.registrySubKey + `\` + id,
Strategy: AutostartStrategyRegistryRun,
}, nil
}
// find returns the value name and command of the registry entry whose first
// token equals our current executable. Empty name means not registered.
func (a *windowsAutostart) find() (string, string, error) {
exe, err := resolvedExecutable()
if err != nil {
return "", "", err
}
key, err := registry.OpenKey(registry.CURRENT_USER, a.registrySubKey, registry.QUERY_VALUE)
if err != nil {
if errors.Is(err, registry.ErrNotExist) {
return "", "", nil
}
return "", "", fmt.Errorf("autostart: open registry key: %w", err)
}
defer key.Close()
names, err := key.ReadValueNames(-1)
if err != nil {
return "", "", fmt.Errorf("autostart: list registry values: %w", err)
}
exeLower := strings.ToLower(exe)
for _, name := range names {
val, _, err := key.GetStringValue(name)
if err != nil {
continue
}
if strings.EqualFold(parseWindowsCommandExe(val), exeLower) {
return name, val, nil
}
}
return "", "", nil
}
// parseWindowsCommandExe returns the first token of a Windows command line,
// honouring surrounding double quotes for paths with spaces. Returned in
// lowercase for case-insensitive comparison.
func parseWindowsCommandExe(cmd string) string {
cmd = strings.TrimSpace(cmd)
if cmd == "" {
return ""
}
if cmd[0] == '"' {
end := strings.IndexByte(cmd[1:], '"')
if end < 0 {
return strings.ToLower(cmd[1:])
}
return strings.ToLower(cmd[1 : 1+end])
}
if i := strings.IndexAny(cmd, " \t"); i >= 0 {
return strings.ToLower(cmd[:i])
}
return strings.ToLower(cmd)
}
// quoteWindowsArg wraps an argument in double quotes when it contains
// whitespace or quotes, and escapes embedded quotes. Backslashes preceding a
// quote are doubled per CommandLineToArgvW rules.
func quoteWindowsArg(s string) string {
if s != "" && !strings.ContainsAny(s, ` " `) {
return s
}
var b strings.Builder
b.WriteByte('"')
backslashes := 0
for i := 0; i < len(s); i++ {
c := s[i]
switch c {
case '\\':
backslashes++
case '"':
// Per CommandLineToArgvW: a literal quote preceded by N
// backslashes must be encoded as (2N+1) backslashes + quote.
// Earlier versions emitted only (N+1), which made the parser
// lose the quote (the 2 backslashes toggled quoted state).
for j := 0; j < 2*backslashes; j++ {
b.WriteByte('\\')
}
b.WriteByte('\\')
b.WriteByte('"')
backslashes = 0
default:
for j := 0; j < backslashes; j++ {
b.WriteByte('\\')
}
backslashes = 0
b.WriteByte(c)
}
}
for j := 0; j < backslashes; j++ {
b.WriteByte('\\')
b.WriteByte('\\')
}
b.WriteByte('"')
return b.String()
}

View File

@@ -0,0 +1,494 @@
package application
import (
"context"
"encoding/json"
"errors"
"fmt"
"reflect"
"runtime"
"strings"
"sync"
"github.com/wailsapp/wails/v3/internal/hash"
"github.com/wailsapp/wails/v3/internal/sliceutil"
)
// CallOptions defines the options for a method call.
// Field order is optimized to minimize struct padding.
type CallOptions struct {
MethodName string `json:"methodName"`
Args []json.RawMessage `json:"args"`
MethodID uint32 `json:"methodID"`
}
type ErrorKind string
const (
ReferenceError ErrorKind = "ReferenceError"
TypeError ErrorKind = "TypeError"
RuntimeError ErrorKind = "RuntimeError"
)
// CallError represents an error that occurred during a method call.
// Field order is optimized to minimize struct padding.
type CallError struct {
Message string `json:"message"`
Cause any `json:"cause,omitempty"`
Kind ErrorKind `json:"kind"`
}
func (e *CallError) Error() string {
return e.Message
}
// Parameter defines a Go method parameter
type Parameter struct {
Name string `json:"name,omitempty"`
TypeName string `json:"type"`
ReflectType reflect.Type
}
func newParameter(Name string, Type reflect.Type) *Parameter {
return &Parameter{
Name: Name,
TypeName: Type.String(),
ReflectType: Type,
}
}
// IsType returns true if the given
func (p *Parameter) IsType(typename string) bool {
return p.TypeName == typename
}
// IsError returns true if the parameter type is an error
func (p *Parameter) IsError() bool {
return p.IsType("error")
}
// BoundMethod defines all the data related to a Go method that is
// bound to the Wails application.
// Field order is optimized to minimize struct padding (136 bytes vs 144 bytes).
type BoundMethod struct {
Method reflect.Value `json:"-"`
Name string `json:"name"`
FQN string `json:"-"`
Comments string `json:"comments,omitempty"`
Inputs []*Parameter `json:"inputs,omitempty"`
Outputs []*Parameter `json:"outputs,omitempty"`
marshalError func(error) []byte
ID uint32 `json:"id"`
needsContext bool
isVariadic bool // cached at registration to avoid reflect call per invocation
}
type Bindings struct {
marshalError func(error) []byte
boundMethods map[string]*BoundMethod
boundByID map[uint32]*BoundMethod
methodAliases map[uint32]uint32
}
var registeredBindingMethodIDs sync.Map
// RegisterBindingMethodID registers a stable binding ID for a service method
// expression so the runtime can resolve it without relying on reflection-visible
// names (which may be obfuscated).
func RegisterBindingMethodID(method any, id uint32) {
value := reflect.ValueOf(method)
if value.Kind() != reflect.Func {
panic(fmt.Sprintf("binding method ID registration expects a function, got %s", value.Kind()))
}
registeredBindingMethodIDs.Store(value.Pointer(), id)
}
// UnregisterBindingMethodID removes the stable binding ID for a service method.
// Intended for use in tests to restore global state after calling RegisterBindingMethodID.
func UnregisterBindingMethodID(method any) {
value := reflect.ValueOf(method)
if value.Kind() != reflect.Func {
return
}
registeredBindingMethodIDs.Delete(value.Pointer())
}
func getRegisteredBindingMethodID(method reflect.Method) (uint32, bool) {
id, ok := registeredBindingMethodIDs.Load(method.Func.Pointer())
if !ok {
return 0, false
}
return id.(uint32), true
}
func NewBindings(marshalError func(error) []byte, aliases map[uint32]uint32) *Bindings {
return &Bindings{
marshalError: wrapErrorMarshaler(marshalError, defaultMarshalError),
boundMethods: make(map[string]*BoundMethod),
boundByID: make(map[uint32]*BoundMethod),
methodAliases: aliases,
}
}
// Add adds the given service to the bindings.
func (b *Bindings) Add(service Service) error {
methods, err := getMethods(service.Instance())
if err != nil {
return err
}
marshalError := wrapErrorMarshaler(service.options.MarshalError, defaultMarshalError)
// Validate and log methods.
for _, method := range methods {
if _, ok := b.boundMethods[method.FQN]; ok {
return fmt.Errorf("bound method '%s' is already registered. Please note that you can register at most one service of each type; additional instances must be wrapped in dedicated structs", method.FQN)
}
if boundMethod, ok := b.boundByID[method.ID]; ok {
return fmt.Errorf("oh wow, we're sorry about this! Amazingly, a hash collision was detected for method '%s' (it generates the same hash as '%s'). To use this method, please rename it. Sorry :(", method.FQN, boundMethod.FQN)
}
// Log
attrs := []any{"fqn", method.FQN, "id", method.ID}
if alias, ok := sliceutil.FindMapKey(b.methodAliases, method.ID); ok {
attrs = append(attrs, "alias", alias)
}
globalApplication.debug("Registering bound method:", attrs...)
}
for _, method := range methods {
// Store composite error marshaler
method.marshalError = marshalError
// Register method
b.boundMethods[method.FQN] = method
b.boundByID[method.ID] = method
}
return nil
}
// Get returns the bound method with the given name
func (b *Bindings) Get(options *CallOptions) *BoundMethod {
return b.boundMethods[options.MethodName]
}
// GetByID returns the bound method with the given ID
func (b *Bindings) GetByID(id uint32) *BoundMethod {
// Check method aliases
if b.methodAliases != nil {
if alias, ok := b.methodAliases[id]; ok {
id = alias
}
}
return b.boundByID[id]
}
// internalServiceMethod is a set of methods
// that are handled specially by the binding engine
// and must not be exposed to the frontend.
//
// For simplicity we exclude these by name
// without checking their signatures,
// and so does the binding generator.
var internalServiceMethods = map[string]bool{
"ServiceName": true,
"ServiceStartup": true,
"ServiceShutdown": true,
"ServeHTTP": true,
}
var ctxType = reflect.TypeFor[context.Context]()
// getMethods returns the list of BoundMethod descriptors for the methods of the named pointer type provided by value.
//
// It returns an error if value is not a pointer to a named type, if a function value is supplied (binding functions is deprecated), or if a generic type is supplied.
// The returned BoundMethod slice includes only exported methods that are not listed in internalServiceMethods. Each BoundMethod has its FQN, ID (computed from the FQN), Method reflect.Value, Inputs and Outputs populated, isVariadic cached from the method signature, and needsContext set when the first parameter is context.Context.
func getMethods(value any) ([]*BoundMethod, error) {
// Create result placeholder
var result []*BoundMethod
// Check type
if !isNamed(value) {
if isFunction(value) {
name := runtime.FuncForPC(reflect.ValueOf(value).Pointer()).Name()
return nil, fmt.Errorf("%s is a function, not a pointer to named type. Wails v2 has deprecated the binding of functions. Please define your functions as methods on a struct and bind a pointer to that struct", name)
}
return nil, fmt.Errorf("%s is not a pointer to named type", reflect.ValueOf(value).Type().String())
} else if !isPtr(value) {
return nil, fmt.Errorf("%s is a named type, not a pointer to named type", reflect.ValueOf(value).Type().String())
}
// Process Named Type
namedValue := reflect.ValueOf(value)
ptrType := namedValue.Type()
namedType := ptrType.Elem()
typeName := namedType.Name()
packagePath := namedType.PkgPath()
if strings.Contains(namedType.String(), "[") {
return nil, fmt.Errorf("%s.%s is a generic type. Generic bound types are not supported", packagePath, namedType.String())
}
// Process Methods
for i := range ptrType.NumMethod() {
methodDef := ptrType.Method(i)
methodName := methodDef.Name
method := namedValue.Method(i)
if internalServiceMethods[methodName] {
continue
}
fqn := fmt.Sprintf("%s.%s.%s", packagePath, typeName, methodName)
// Iterate inputs
methodType := method.Type()
// Create new method with cached flags
methodID := hash.Fnv(fqn)
if registeredID, ok := getRegisteredBindingMethodID(methodDef); ok {
methodID = registeredID
}
boundMethod := &BoundMethod{
ID: methodID,
FQN: fqn,
Name: methodName,
Inputs: nil,
Outputs: nil,
Comments: "",
Method: method,
isVariadic: methodType.IsVariadic(), // cache to avoid reflect call per invocation
}
inputParamCount := methodType.NumIn()
var inputs []*Parameter
for inputIndex := 0; inputIndex < inputParamCount; inputIndex++ {
input := methodType.In(inputIndex)
if inputIndex == 0 && input.AssignableTo(ctxType) {
boundMethod.needsContext = true
}
thisParam := newParameter("", input)
inputs = append(inputs, thisParam)
}
boundMethod.Inputs = inputs
outputParamCount := methodType.NumOut()
var outputs []*Parameter
for outputIndex := 0; outputIndex < outputParamCount; outputIndex++ {
output := methodType.Out(outputIndex)
thisParam := newParameter("", output)
outputs = append(outputs, thisParam)
}
boundMethod.Outputs = outputs
// Save method in result
result = append(result, boundMethod)
}
return result, nil
}
func (b *BoundMethod) String() string {
return b.FQN
}
var errorType = reflect.TypeFor[error]()
// Call will attempt to call this bound method with the given args.
// If the call succeeds, result will be either a non-error return value (if there is only one)
// or a slice of non-error return values (if there are more than one).
//
// If the arguments are mistyped, the call returns one or more non-nil error values,
// or the method panics, result is nil and err is an instance of *[CallError].
func (b *BoundMethod) Call(ctx context.Context, args []json.RawMessage) (result any, err error) {
// Convert panics raised by the bound method into a *CallError so the
// frontend call rejects instead of the application dying: the default
// panic handler is fatal, and a bug in one bound method must not take
// down the whole application (#5037). A custom PanicHandler, when
// registered, still observes the panic for logging/telemetry.
defer func() {
e := recover()
if e == nil {
return
}
recoveredErr, ok := e.(error)
if !ok {
recoveredErr = fmt.Errorf("%v", e)
}
// The stack trace is only used for logging or the PanicHandler, so
// only compute it when there is a globalApplication to consume it
// (the CallError below carries the message, not the trace).
if globalApplication != nil {
stackTrace := getStackTrace(3, 5)
if handler := globalApplication.options.PanicHandler; handler != nil {
handler(newPanicDetails(recoveredErr, stackTrace))
} else {
globalApplication.error("panic in bound method %s: %s\n%s", b.FQN, recoveredErr, stackTrace)
}
}
result = nil
err = &CallError{
Message: fmt.Sprintf("%s: panic: %s", b.FQN, recoveredErr),
Kind: RuntimeError,
}
}()
argCount := len(args)
if b.needsContext {
argCount++
}
if argCount != len(b.Inputs) {
err = &CallError{
Message: fmt.Sprintf("%s expects %d arguments, got %d", b.FQN, len(b.Inputs), argCount),
Kind: TypeError,
}
return
}
// Use stack-allocated buffer for common case (<=8 args), heap for larger
var argBuffer [8]reflect.Value
var callArgs []reflect.Value
if argCount <= len(argBuffer) {
callArgs = argBuffer[:argCount]
} else {
callArgs = make([]reflect.Value, argCount)
}
base := 0
if b.needsContext {
callArgs[0] = reflect.ValueOf(ctx)
base++
}
// Iterate over given arguments
for index, arg := range args {
value := reflect.New(b.Inputs[base+index].ReflectType)
err = json.Unmarshal(arg, value.Interface())
if err != nil {
err = &CallError{
Message: fmt.Sprintf("could not parse argument #%d: %s", index, err),
Cause: json.RawMessage(b.marshalError(err)),
Kind: TypeError,
}
return
}
callArgs[base+index] = value.Elem()
}
// Do the call using cached isVariadic flag
var callResults []reflect.Value
if b.isVariadic {
callResults = b.Method.CallSlice(callArgs)
} else {
callResults = b.Method.Call(callArgs)
}
// Process results - optimized for common case of 0-2 return values
// to avoid slice allocation
var firstResult any
var hasFirstResult bool
var nonErrorOutputs []any // only allocated if >1 non-error results
var errorOutputs []error
for _, field := range callResults {
if field.Type() == errorType {
if field.IsNil() {
continue
}
if errorOutputs == nil {
errorOutputs = make([]error, 0, len(callResults))
}
errorOutputs = append(errorOutputs, field.Interface().(error))
} else if errorOutputs == nil {
// Only collect non-error outputs if no errors yet
val := field.Interface()
if !hasFirstResult {
firstResult = val
hasFirstResult = true
} else if nonErrorOutputs == nil {
// Second result - need to allocate slice
nonErrorOutputs = make([]any, 0, len(callResults))
nonErrorOutputs = append(nonErrorOutputs, firstResult, val)
} else {
nonErrorOutputs = append(nonErrorOutputs, val)
}
}
}
if len(errorOutputs) > 0 {
info := make([]json.RawMessage, len(errorOutputs))
for i, err := range errorOutputs {
info[i] = b.marshalError(err)
}
cerr := &CallError{
Message: errors.Join(errorOutputs...).Error(),
Cause: info,
Kind: RuntimeError,
}
if len(info) == 1 {
cerr.Cause = info[0]
}
err = cerr
} else if nonErrorOutputs != nil {
result = nonErrorOutputs
} else if hasFirstResult {
result = firstResult
}
return
}
// wrapErrorMarshaler returns an error marshaling functions
// that calls the primary marshaler first,
// then falls back to the secondary one.
func wrapErrorMarshaler(primary func(error) []byte, secondary func(error) []byte) func(error) []byte {
if primary == nil {
return secondary
}
return func(err error) []byte {
result := primary(err)
if result == nil {
result = secondary(err)
}
return result
}
}
// defaultMarshalError implements the default error marshaling mechanism.
func defaultMarshalError(err error) []byte {
result, jsonErr := json.Marshal(&err)
if jsonErr != nil {
return nil
}
return result
}
// isPtr returns true if the value given is a pointer.
func isPtr(value interface{}) bool {
return reflect.ValueOf(value).Kind() == reflect.Ptr
}
// isFunction returns true if the given value is a function
func isFunction(value interface{}) bool {
return reflect.ValueOf(value).Kind() == reflect.Func
}
// isNamed returns true if the given value is of named type
// or pointer to named type.
func isNamed(value interface{}) bool {
rv := reflect.ValueOf(value)
if rv.Kind() == reflect.Ptr {
rv = rv.Elem()
}
return rv.Type().Name() != ""
}

View File

@@ -0,0 +1,27 @@
package application
import (
"github.com/wailsapp/wails/v3/internal/browser"
)
// BrowserManager manages browser-related operations
type BrowserManager struct {
app *App
}
// newBrowserManager creates a new BrowserManager instance
func newBrowserManager(app *App) *BrowserManager {
return &BrowserManager{
app: app,
}
}
// OpenURL opens a URL in the default browser
func (bm *BrowserManager) OpenURL(url string) error {
return browser.OpenURL(url)
}
// OpenFile opens a file in the default browser
func (bm *BrowserManager) OpenFile(path string) error {
return browser.OpenFile(path)
}

View File

@@ -0,0 +1,152 @@
//go:build server
package application
import (
"fmt"
"unsafe"
"github.com/wailsapp/wails/v3/pkg/events"
)
// BrowserWindow represents a browser client connection in server mode.
// It implements the Window interface so browser clients can be treated
// uniformly with native windows throughout the codebase.
type BrowserWindow struct {
id uint
name string
clientID string // The runtime's nanoid for this client
}
// NewBrowserWindow creates a new browser window with the given ID.
func NewBrowserWindow(id uint, clientID string) *BrowserWindow {
return &BrowserWindow{
id: id,
name: fmt.Sprintf("browser-%d", id),
clientID: clientID,
}
}
// Core identification methods
func (b *BrowserWindow) ID() uint { return b.id }
func (b *BrowserWindow) Name() string { return b.name }
func (b *BrowserWindow) ClientID() string { return b.clientID }
// Event methods - these are meaningful for browser windows
func (b *BrowserWindow) DispatchWailsEvent(event *CustomEvent) {
// Events are dispatched via WebSocket broadcast, not per-window
}
func (b *BrowserWindow) EmitEvent(name string, data ...any) bool {
return globalApplication.Event.Emit(name, data...)
}
// Logging methods
func (b *BrowserWindow) Error(message string, args ...any) {
globalApplication.error(message, args...)
}
func (b *BrowserWindow) Info(message string, args ...any) {
globalApplication.info(message, args...)
}
// No-op methods - these don't apply to browser windows
func (b *BrowserWindow) Center() {}
func (b *BrowserWindow) Close() {}
func (b *BrowserWindow) DisableSizeConstraints() {}
func (b *BrowserWindow) EnableSizeConstraints() {}
func (b *BrowserWindow) ExecJS(js string) {}
func (b *BrowserWindow) Focus() {}
func (b *BrowserWindow) ForceReload() {}
func (b *BrowserWindow) Fullscreen() Window { return b }
func (b *BrowserWindow) GetBorderSizes() *LRTB { return nil }
func (b *BrowserWindow) GetScreen() (*Screen, error) { return nil, nil }
func (b *BrowserWindow) SetScreen(screen *Screen) Window { return b }
func (b *BrowserWindow) GetZoom() float64 { return 1.0 }
func (b *BrowserWindow) handleDragAndDropMessage(filenames []string, dropTarget *DropTargetDetails) {}
func (b *BrowserWindow) InitiateFrontendDropProcessing(filenames []string, x int, y int) {}
func (b *BrowserWindow) HandleMessage(message string) {}
func (b *BrowserWindow) HandleWindowEvent(id uint) {}
func (b *BrowserWindow) Height() int { return 0 }
func (b *BrowserWindow) Hide() Window { return b }
func (b *BrowserWindow) HideMenuBar() {}
func (b *BrowserWindow) IsFocused() bool { return false }
func (b *BrowserWindow) IsFullscreen() bool { return false }
func (b *BrowserWindow) IsIgnoreMouseEvents() bool { return false }
func (b *BrowserWindow) IsMaximised() bool { return false }
func (b *BrowserWindow) IsMinimised() bool { return false }
func (b *BrowserWindow) HandleKeyEvent(acceleratorString string) {}
func (b *BrowserWindow) Maximise() Window { return b }
func (b *BrowserWindow) Minimise() Window { return b }
func (b *BrowserWindow) OnWindowEvent(eventType events.WindowEventType, callback func(event *WindowEvent)) func() {
return func() {}
}
func (b *BrowserWindow) OpenContextMenu(data *ContextMenuData) {}
func (b *BrowserWindow) Position() (int, int) { return 0, 0 }
func (b *BrowserWindow) RelativePosition() (int, int) { return 0, 0 }
func (b *BrowserWindow) Reload() {}
func (b *BrowserWindow) Resizable() bool { return false }
func (b *BrowserWindow) Restore() {}
func (b *BrowserWindow) Run() {}
func (b *BrowserWindow) SetPosition(x, y int) {}
func (b *BrowserWindow) SetAlwaysOnTop(b2 bool) Window { return b }
func (b *BrowserWindow) SetBackgroundColour(colour RGBA) Window { return b }
func (b *BrowserWindow) SetFrameless(frameless bool) Window { return b }
func (b *BrowserWindow) SetHTML(html string) Window { return b }
func (b *BrowserWindow) SetMinimiseButtonState(state ButtonState) Window { return b }
func (b *BrowserWindow) SetMaximiseButtonState(state ButtonState) Window { return b }
func (b *BrowserWindow) SetCloseButtonState(state ButtonState) Window { return b }
func (b *BrowserWindow) SetFullscreenButtonState(state ButtonState) Window { return b }
func (b *BrowserWindow) SetMaxSize(maxWidth, maxHeight int) Window { return b }
func (b *BrowserWindow) SetMinSize(minWidth, minHeight int) Window { return b }
func (b *BrowserWindow) SetRelativePosition(x, y int) Window { return b }
func (b *BrowserWindow) SetResizable(b2 bool) Window { return b }
func (b *BrowserWindow) SetIgnoreMouseEvents(ignore bool) Window { return b }
func (b *BrowserWindow) SetSize(width, height int) Window { return b }
func (b *BrowserWindow) SetTitle(title string) Window { return b }
func (b *BrowserWindow) SetURL(s string) Window { return b }
func (b *BrowserWindow) SetZoom(magnification float64) Window { return b }
func (b *BrowserWindow) Show() Window { return b }
func (b *BrowserWindow) ShowMenuBar() {}
func (b *BrowserWindow) Size() (width int, height int) { return 0, 0 }
func (b *BrowserWindow) OpenDevTools() {}
func (b *BrowserWindow) ToggleFullscreen() {}
func (b *BrowserWindow) ToggleMaximise() {}
func (b *BrowserWindow) ToggleMenuBar() {}
func (b *BrowserWindow) ToggleFrameless() {}
func (b *BrowserWindow) UnFullscreen() {}
func (b *BrowserWindow) UnMaximise() {}
func (b *BrowserWindow) UnMinimise() {}
func (b *BrowserWindow) Width() int { return 0 }
func (b *BrowserWindow) IsVisible() bool { return true }
func (b *BrowserWindow) Bounds() Rect { return Rect{} }
func (b *BrowserWindow) SetBounds(bounds Rect) {}
func (b *BrowserWindow) Zoom() {}
func (b *BrowserWindow) ZoomIn() {}
func (b *BrowserWindow) ZoomOut() {}
func (b *BrowserWindow) ZoomReset() Window { return b }
func (b *BrowserWindow) SetMenu(menu *Menu) {}
func (b *BrowserWindow) SnapAssist() {}
func (b *BrowserWindow) AttachModal(modalWindow Window) {}
func (b *BrowserWindow) SetContentProtection(protection bool) Window { return b }
func (b *BrowserWindow) NativeWindow() unsafe.Pointer { return nil }
func (b *BrowserWindow) SetEnabled(enabled bool) {}
func (b *BrowserWindow) Flash(enabled bool) {}
func (b *BrowserWindow) Print() error { return nil }
func (b *BrowserWindow) RegisterHook(eventType events.WindowEventType, callback func(event *WindowEvent)) func() {
return func() {}
}
func (b *BrowserWindow) shouldUnconditionallyClose() bool { return true }
// Editing methods
func (b *BrowserWindow) cut() {}
func (b *BrowserWindow) copy() {}
func (b *BrowserWindow) paste() {}
func (b *BrowserWindow) undo() {}
func (b *BrowserWindow) redo() {}
func (b *BrowserWindow) delete() {}
func (b *BrowserWindow) selectAll() {}

View File

@@ -0,0 +1,26 @@
package application
type clipboardImpl interface {
setText(text string) bool
text() (string, bool)
}
type Clipboard struct {
impl clipboardImpl
}
func newClipboard() *Clipboard {
return &Clipboard{
impl: newClipboardImpl(),
}
}
func (c *Clipboard) SetText(text string) bool {
return InvokeSyncWithResult(func() bool {
return c.impl.setText(text)
})
}
func (c *Clipboard) Text() (string, bool) {
return InvokeSyncWithResultAndOther(c.impl.text)
}

View File

@@ -0,0 +1,22 @@
//go:build android
package application
// The clipboard is backed by Android's ClipboardManager via the WailsBridge.
// Note: on Android 10+ reading the clipboard only succeeds while the app has
// input focus.
type androidClipboardImpl struct{}
func newClipboardImpl() clipboardImpl {
return &androidClipboardImpl{}
}
func (c *androidClipboardImpl) setText(text string) bool {
androidBridgeVoidString("setClipboardText", text)
return true
}
func (c *androidClipboardImpl) text() (string, bool) {
return androidBridgeString("getClipboardText")
}

View File

@@ -0,0 +1,56 @@
//go:build darwin && !ios && !server
package application
/*
#cgo CFLAGS: -mmacosx-version-min=10.13 -x objective-c
#cgo LDFLAGS: -framework Cocoa -mmacosx-version-min=10.13
#import <Cocoa/Cocoa.h>
#import <stdlib.h>
bool setClipboardText(const char* text) {
NSPasteboard *pasteBoard = [NSPasteboard generalPasteboard];
NSError *error = nil;
NSString *string = [NSString stringWithUTF8String:text];
[pasteBoard clearContents];
return [pasteBoard setString:string forType:NSPasteboardTypeString];
}
const char* getClipboardText() {
NSPasteboard *pasteboard = [NSPasteboard generalPasteboard];
NSString *text = [pasteboard stringForType:NSPasteboardTypeString];
return [text UTF8String];
}
*/
import "C"
import (
"sync"
"unsafe"
)
var clipboardLock sync.RWMutex
type macosClipboard struct{}
func (m macosClipboard) setText(text string) bool {
clipboardLock.Lock()
defer clipboardLock.Unlock()
cText := C.CString(text)
success := C.setClipboardText(cText)
C.free(unsafe.Pointer(cText))
return bool(success)
}
func (m macosClipboard) text() (string, bool) {
clipboardLock.RLock()
defer clipboardLock.RUnlock()
clipboardText := C.getClipboardText()
result := C.GoString(clipboardText)
return result, true
}
func newClipboardImpl() *macosClipboard {
return &macosClipboard{}
}

View File

@@ -0,0 +1,33 @@
//go:build ios
package application
/*
#include <stdlib.h>
#include "application_ios.h"
*/
import "C"
import "unsafe"
type iosClipboardImpl struct{}
func newClipboardImpl() clipboardImpl {
return &iosClipboardImpl{}
}
func (c *iosClipboardImpl) setText(text string) bool {
ctext := C.CString(text)
defer C.free(unsafe.Pointer(ctext))
C.ios_clipboard_set_text(ctext)
return true
}
func (c *iosClipboardImpl) text() (string, bool) {
ctext := C.ios_clipboard_get_text()
if ctext == nil {
return "", false
}
defer C.free(unsafe.Pointer(ctext))
return C.GoString(ctext), true
}

View File

@@ -0,0 +1,28 @@
//go:build linux && !android && !server
package application
import (
"sync"
)
var clipboardLock sync.RWMutex
type linuxClipboard struct{}
func (m linuxClipboard) setText(text string) bool {
clipboardLock.Lock()
defer clipboardLock.Unlock()
clipboardSet(text)
return true
}
func (m linuxClipboard) text() (string, bool) {
clipboardLock.RLock()
defer clipboardLock.RUnlock()
return clipboardGet(), true
}
func newClipboardImpl() *linuxClipboard {
return &linuxClipboard{}
}

View File

@@ -0,0 +1,32 @@
package application
// ClipboardManager manages clipboard operations
type ClipboardManager struct {
app *App
clipboard *Clipboard
}
// newClipboardManager creates a new ClipboardManager instance
func newClipboardManager(app *App) *ClipboardManager {
return &ClipboardManager{
app: app,
}
}
// SetText sets text in the clipboard
func (cm *ClipboardManager) SetText(text string) bool {
return cm.getClipboard().SetText(text)
}
// Text gets text from the clipboard
func (cm *ClipboardManager) Text() (string, bool) {
return cm.getClipboard().Text()
}
// getClipboard returns the clipboard instance, creating it if needed (lazy initialization)
func (cm *ClipboardManager) getClipboard() *Clipboard {
if cm.clipboard == nil {
cm.clipboard = newClipboard()
}
return cm.clipboard
}

View File

@@ -0,0 +1,29 @@
//go:build windows && !server
package application
import (
"github.com/wailsapp/wails/v3/pkg/w32"
"sync"
)
type windowsClipboard struct {
lock sync.RWMutex
}
func (m *windowsClipboard) setText(text string) bool {
m.lock.Lock()
defer m.lock.Unlock()
return w32.SetClipboardText(text) == nil
}
func (m *windowsClipboard) text() (string, bool) {
m.lock.Lock()
defer m.lock.Unlock()
text, err := w32.GetClipboardText()
return text, err == nil
}
func newClipboardImpl() *windowsClipboard {
return &windowsClipboard{}
}

View File

@@ -0,0 +1,62 @@
package application
type Context struct {
// contains filtered or unexported fields
data map[string]any
}
func newContext() *Context {
return &Context{
data: make(map[string]any),
}
}
const (
clickedMenuItem string = "clickedMenuItem"
menuItemIsChecked string = "menuItemIsChecked"
contextMenuData string = "contextMenuData"
)
func (c *Context) ClickedMenuItem() *MenuItem {
result, exists := c.data[clickedMenuItem]
if !exists {
return nil
}
return result.(*MenuItem)
}
func (c *Context) IsChecked() bool {
result, exists := c.data[menuItemIsChecked]
if !exists {
return false
}
return result.(bool)
}
func (c *Context) ContextMenuData() string {
result := c.data[contextMenuData]
if result == nil {
return ""
}
str, ok := result.(string)
if !ok {
return ""
}
return str
}
func (c *Context) withClickedMenuItem(menuItem *MenuItem) *Context {
c.data[clickedMenuItem] = menuItem
return c
}
func (c *Context) withChecked(checked bool) {
c.data[menuItemIsChecked] = checked
}
func (c *Context) withContextMenuData(data *ContextMenuData) *Context {
if data == nil {
return c
}
c.data[contextMenuData] = data.Data
return c
}

View File

@@ -0,0 +1,113 @@
package application
import "log"
var blankApplicationEventContext = &ApplicationEventContext{}
const (
CONTEXT_OPENED_FILES = "openedFiles"
CONTEXT_FILENAME = "filename"
CONTEXT_URL = "url"
)
// ApplicationEventContext is the context of an application event
type ApplicationEventContext struct {
// contains filtered or unexported fields
data map[string]any
}
// OpenedFiles returns the opened files from the event context if it was set
func (c ApplicationEventContext) OpenedFiles() []string {
files, ok := c.data[CONTEXT_OPENED_FILES]
if !ok {
return nil
}
result, ok := files.([]string)
if !ok {
return nil
}
return result
}
func (c ApplicationEventContext) setOpenedFiles(files []string) {
c.data[CONTEXT_OPENED_FILES] = files
}
func (c ApplicationEventContext) setIsDarkMode(mode bool) {
c.data["isDarkMode"] = mode
}
func (c ApplicationEventContext) getBool(key string) bool {
mode, ok := c.data[key]
if !ok {
return false
}
result, ok := mode.(bool)
if !ok {
return false
}
return result
}
// IsDarkMode returns true if the event context has a dark mode
func (c ApplicationEventContext) IsDarkMode() bool {
return c.getBool("isDarkMode")
}
// HasVisibleWindows returns true if the event context has a visible window
func (c ApplicationEventContext) HasVisibleWindows() bool {
return c.getBool("hasVisibleWindows")
}
func (c *ApplicationEventContext) setData(data map[string]any) {
c.data = data
}
// Data returns the raw context data map. Mobile system events (battery,
// network, …) attach their structured payload here; the typed getters above
// (IsDarkMode, Filename, URL, …) cover the well-known keys.
func (c ApplicationEventContext) Data() map[string]any {
return c.data
}
func (c *ApplicationEventContext) setOpenedWithFile(filepath string) {
c.data[CONTEXT_FILENAME] = filepath
}
func (c *ApplicationEventContext) setURL(openedWithURL string) {
c.data[CONTEXT_URL] = openedWithURL
}
// Filename returns the filename from the event context if it was set
func (c ApplicationEventContext) Filename() string {
filename, ok := c.data[CONTEXT_FILENAME]
if !ok {
return ""
}
result, ok := filename.(string)
if !ok {
return ""
}
return result
}
// URL returns the URL from the event context if it was set
func (c ApplicationEventContext) URL() string {
url, ok := c.data[CONTEXT_URL]
if !ok {
log.Println("URL not found in event context")
return ""
}
result, ok := url.(string)
if !ok {
log.Println("URL not a string in event context")
return ""
}
return result
}
func newApplicationEventContext() *ApplicationEventContext {
return &ApplicationEventContext{
data: make(map[string]any),
}
}

View File

@@ -0,0 +1,54 @@
package application
// ContextMenuManager manages all context menu operations
type ContextMenuManager struct {
app *App
}
// newContextMenuManager creates a new ContextMenuManager instance
func newContextMenuManager(app *App) *ContextMenuManager {
return &ContextMenuManager{
app: app,
}
}
// New creates a new context menu
func (cmm *ContextMenuManager) New() *ContextMenu {
return &ContextMenu{
Menu: NewMenu(),
}
}
// Add adds a context menu (replaces Register for consistency)
func (cmm *ContextMenuManager) Add(name string, menu *ContextMenu) {
cmm.app.contextMenusLock.Lock()
defer cmm.app.contextMenusLock.Unlock()
cmm.app.contextMenus[name] = menu
}
// Remove removes a context menu by name (replaces Unregister for consistency)
func (cmm *ContextMenuManager) Remove(name string) {
cmm.app.contextMenusLock.Lock()
defer cmm.app.contextMenusLock.Unlock()
delete(cmm.app.contextMenus, name)
}
// Get retrieves a context menu by name
func (cmm *ContextMenuManager) Get(name string) (*ContextMenu, bool) {
cmm.app.contextMenusLock.RLock()
defer cmm.app.contextMenusLock.RUnlock()
menu, exists := cmm.app.contextMenus[name]
return menu, exists
}
// GetAll returns all registered context menus as a slice
func (cmm *ContextMenuManager) GetAll() []*ContextMenu {
cmm.app.contextMenusLock.RLock()
defer cmm.app.contextMenusLock.RUnlock()
result := make([]*ContextMenu, 0, len(cmm.app.contextMenus))
for _, menu := range cmm.app.contextMenus {
result = append(result, menu)
}
return result
}

View File

@@ -0,0 +1,79 @@
package application
var blankWindowEventContext = &WindowEventContext{}
const (
droppedFiles = "droppedFiles"
dropTargetDetailsKey = "dropTargetDetails"
)
type WindowEventContext struct {
// contains filtered or unexported fields
data map[string]any
}
func (c WindowEventContext) DroppedFiles() []string {
if c.data == nil {
c.data = make(map[string]any)
}
files, ok := c.data[droppedFiles]
if !ok {
return nil
}
result, ok := files.([]string)
if !ok {
return nil
}
return result
}
func (c WindowEventContext) setDroppedFiles(files []string) {
if c.data == nil {
c.data = make(map[string]any)
}
c.data[droppedFiles] = files
}
func (c WindowEventContext) setCoordinates(x, y int) {
if c.data == nil {
c.data = make(map[string]any)
}
c.data["x"] = x
c.data["y"] = y
}
func (c WindowEventContext) setDropTargetDetails(details *DropTargetDetails) {
if c.data == nil {
c.data = make(map[string]any)
}
if details == nil {
c.data[dropTargetDetailsKey] = nil
return
}
c.data[dropTargetDetailsKey] = details
}
// DropTargetDetails retrieves information about the drop target element.
func (c WindowEventContext) DropTargetDetails() *DropTargetDetails {
if c.data == nil {
c.data = make(map[string]any)
}
details, ok := c.data[dropTargetDetailsKey]
if !ok {
return nil
}
if details == nil {
return nil
}
result, ok := details.(*DropTargetDetails)
if !ok {
return nil
}
return result
}
func newWindowEventContext() *WindowEventContext {
return &WindowEventContext{
data: make(map[string]any),
}
}

View File

@@ -0,0 +1,57 @@
package application
// DialogManager manages dialog-related operations
type DialogManager struct {
app *App
}
// newDialogManager creates a new DialogManager instance
func newDialogManager(app *App) *DialogManager {
return &DialogManager{
app: app,
}
}
// OpenFile creates a file dialog for selecting files
func (dm *DialogManager) OpenFile() *OpenFileDialogStruct {
return newOpenFileDialog()
}
// OpenFileWithOptions creates a file dialog with options
func (dm *DialogManager) OpenFileWithOptions(options *OpenFileDialogOptions) *OpenFileDialogStruct {
result := newOpenFileDialog()
result.SetOptions(options)
return result
}
// SaveFile creates a save file dialog
func (dm *DialogManager) SaveFile() *SaveFileDialogStruct {
return newSaveFileDialog()
}
// SaveFileWithOptions creates a save file dialog with options
func (dm *DialogManager) SaveFileWithOptions(options *SaveFileDialogOptions) *SaveFileDialogStruct {
result := newSaveFileDialog()
result.SetOptions(options)
return result
}
// Info creates an information dialog
func (dm *DialogManager) Info() *MessageDialog {
return newMessageDialog(InfoDialogType)
}
// Question creates a question dialog
func (dm *DialogManager) Question() *MessageDialog {
return newMessageDialog(QuestionDialogType)
}
// Warning creates a warning dialog
func (dm *DialogManager) Warning() *MessageDialog {
return newMessageDialog(WarningDialogType)
}
// Error creates an error dialog
func (dm *DialogManager) Error() *MessageDialog {
return newMessageDialog(ErrorDialogType)
}

View File

@@ -0,0 +1,497 @@
package application
import (
"strings"
"sync"
)
type DialogType int
var dialogMapID = make(map[uint]struct{})
var dialogIDLock sync.RWMutex
func getDialogID() uint {
dialogIDLock.Lock()
defer dialogIDLock.Unlock()
var dialogID uint
for {
if _, ok := dialogMapID[dialogID]; !ok {
dialogMapID[dialogID] = struct{}{}
break
}
dialogID++
if dialogID == 0 {
panic("no more dialog IDs")
}
}
return dialogID
}
func freeDialogID(id uint) {
dialogIDLock.Lock()
defer dialogIDLock.Unlock()
delete(dialogMapID, id)
}
var openFileResponses = make(map[uint]chan string)
var saveFileResponses = make(map[uint]chan string)
const (
InfoDialogType DialogType = iota
QuestionDialogType
WarningDialogType
ErrorDialogType
)
type Button struct {
Label string
IsCancel bool
IsDefault bool
Callback func()
}
func (b *Button) OnClick(callback func()) *Button {
b.Callback = callback
return b
}
func (b *Button) SetAsDefault() *Button {
b.IsDefault = true
return b
}
func (b *Button) SetAsCancel() *Button {
b.IsCancel = true
return b
}
type messageDialogImpl interface {
show()
}
type MessageDialogOptions struct {
DialogType DialogType
Title string
Message string
Buttons []*Button
Icon []byte
window Window
}
type MessageDialog struct {
MessageDialogOptions
// platform independent
impl messageDialogImpl
}
var defaultTitles = map[DialogType]string{
InfoDialogType: "Information",
QuestionDialogType: "Question",
WarningDialogType: "Warning",
ErrorDialogType: "Error",
}
func newMessageDialog(dialogType DialogType) *MessageDialog {
return &MessageDialog{
MessageDialogOptions: MessageDialogOptions{
DialogType: dialogType,
},
impl: nil,
}
}
func (d *MessageDialog) SetTitle(title string) *MessageDialog {
d.Title = title
return d
}
func (d *MessageDialog) Show() {
if d.impl == nil {
d.impl = newDialogImpl(d)
}
InvokeSync(d.impl.show)
}
func (d *MessageDialog) SetIcon(icon []byte) *MessageDialog {
d.Icon = icon
return d
}
func (d *MessageDialog) AddButton(s string) *Button {
result := &Button{
Label: s,
}
d.Buttons = append(d.Buttons, result)
return result
}
func (d *MessageDialog) AddButtons(buttons []*Button) *MessageDialog {
d.Buttons = buttons
return d
}
func (d *MessageDialog) AttachToWindow(window Window) *MessageDialog {
d.window = window
return d
}
func (d *MessageDialog) SetDefaultButton(button *Button) *MessageDialog {
for _, b := range d.Buttons {
b.IsDefault = false
}
button.IsDefault = true
return d
}
func (d *MessageDialog) SetCancelButton(button *Button) *MessageDialog {
for _, b := range d.Buttons {
b.IsCancel = false
}
button.IsCancel = true
return d
}
func (d *MessageDialog) SetMessage(message string) *MessageDialog {
d.Message = message
return d
}
type openFileDialogImpl interface {
show() (chan string, error)
}
type FileFilter struct {
DisplayName string // Filter information EG: "Image Files (*.jpg, *.png)"
Pattern string // semicolon separated list of extensions, EG: "*.jpg;*.png"
}
type OpenFileDialogOptions struct {
CanChooseDirectories bool
CanChooseFiles bool
CanCreateDirectories bool
ShowHiddenFiles bool
ResolvesAliases bool
AllowsMultipleSelection bool
HideExtension bool
CanSelectHiddenExtension bool
TreatsFilePackagesAsDirectories bool
AllowsOtherFileTypes bool
Filters []FileFilter
Window Window
Title string
Message string
ButtonText string
Directory string
}
type OpenFileDialogStruct struct {
id uint
canChooseDirectories bool
canChooseFiles bool
canCreateDirectories bool
showHiddenFiles bool
resolvesAliases bool
allowsMultipleSelection bool
hideExtension bool
canSelectHiddenExtension bool
treatsFilePackagesAsDirectories bool
allowsOtherFileTypes bool
filters []FileFilter
title string
message string
buttonText string
directory string
window Window
impl openFileDialogImpl
}
func (d *OpenFileDialogStruct) CanChooseFiles(canChooseFiles bool) *OpenFileDialogStruct {
d.canChooseFiles = canChooseFiles
return d
}
func (d *OpenFileDialogStruct) CanChooseDirectories(canChooseDirectories bool) *OpenFileDialogStruct {
d.canChooseDirectories = canChooseDirectories
return d
}
func (d *OpenFileDialogStruct) CanCreateDirectories(canCreateDirectories bool) *OpenFileDialogStruct {
d.canCreateDirectories = canCreateDirectories
return d
}
func (d *OpenFileDialogStruct) AllowsOtherFileTypes(allowsOtherFileTypes bool) *OpenFileDialogStruct {
d.allowsOtherFileTypes = allowsOtherFileTypes
return d
}
func (d *OpenFileDialogStruct) ShowHiddenFiles(showHiddenFiles bool) *OpenFileDialogStruct {
d.showHiddenFiles = showHiddenFiles
return d
}
func (d *OpenFileDialogStruct) HideExtension(hideExtension bool) *OpenFileDialogStruct {
d.hideExtension = hideExtension
return d
}
func (d *OpenFileDialogStruct) TreatsFilePackagesAsDirectories(treatsFilePackagesAsDirectories bool) *OpenFileDialogStruct {
d.treatsFilePackagesAsDirectories = treatsFilePackagesAsDirectories
return d
}
func (d *OpenFileDialogStruct) AttachToWindow(window Window) *OpenFileDialogStruct {
d.window = window
return d
}
func (d *OpenFileDialogStruct) ResolvesAliases(resolvesAliases bool) *OpenFileDialogStruct {
d.resolvesAliases = resolvesAliases
return d
}
func (d *OpenFileDialogStruct) SetTitle(title string) *OpenFileDialogStruct {
d.title = title
return d
}
func (d *OpenFileDialogStruct) PromptForSingleSelection() (string, error) {
d.allowsMultipleSelection = false
if d.impl == nil {
d.impl = newOpenFileDialogImpl(d)
}
var result string
selections, err := InvokeSyncWithResultAndError(d.impl.show)
if err == nil {
result = <-selections
}
return result, err
}
// AddFilter adds a filter to the dialog. The filter is a display name and a semicolon separated list of extensions.
// EG: AddFilter("Image Files", "*.jpg;*.png")
func (d *OpenFileDialogStruct) AddFilter(displayName, pattern string) *OpenFileDialogStruct {
d.filters = append(d.filters, FileFilter{
DisplayName: strings.TrimSpace(displayName),
Pattern: strings.TrimSpace(pattern),
})
return d
}
func (d *OpenFileDialogStruct) PromptForMultipleSelection() ([]string, error) {
d.allowsMultipleSelection = true
if d.impl == nil {
d.impl = newOpenFileDialogImpl(d)
}
selections, err := InvokeSyncWithResultAndError(d.impl.show)
if err != nil {
return nil, err
}
var result []string
for filename := range selections {
result = append(result, filename)
}
return result, err
}
func (d *OpenFileDialogStruct) SetMessage(message string) *OpenFileDialogStruct {
d.message = message
return d
}
func (d *OpenFileDialogStruct) SetButtonText(text string) *OpenFileDialogStruct {
d.buttonText = text
return d
}
func (d *OpenFileDialogStruct) SetDirectory(directory string) *OpenFileDialogStruct {
d.directory = directory
return d
}
func (d *OpenFileDialogStruct) CanSelectHiddenExtension(canSelectHiddenExtension bool) *OpenFileDialogStruct {
d.canSelectHiddenExtension = canSelectHiddenExtension
return d
}
func (d *OpenFileDialogStruct) SetOptions(options *OpenFileDialogOptions) {
d.title = options.Title
d.message = options.Message
d.buttonText = options.ButtonText
d.directory = options.Directory
d.canChooseDirectories = options.CanChooseDirectories
d.canChooseFiles = options.CanChooseFiles
if !options.CanChooseFiles && !options.CanChooseDirectories {
d.canChooseFiles = true
}
d.canCreateDirectories = options.CanCreateDirectories
d.showHiddenFiles = options.ShowHiddenFiles
d.resolvesAliases = options.ResolvesAliases
d.allowsMultipleSelection = options.AllowsMultipleSelection
d.hideExtension = options.HideExtension
d.canSelectHiddenExtension = options.CanSelectHiddenExtension
d.treatsFilePackagesAsDirectories = options.TreatsFilePackagesAsDirectories
d.allowsOtherFileTypes = options.AllowsOtherFileTypes
d.filters = options.Filters
d.window = options.Window
}
func newOpenFileDialog() *OpenFileDialogStruct {
return &OpenFileDialogStruct{
id: getDialogID(),
canChooseDirectories: false,
canChooseFiles: true,
canCreateDirectories: true,
resolvesAliases: false,
}
}
func newSaveFileDialog() *SaveFileDialogStruct {
return &SaveFileDialogStruct{
id: getDialogID(),
canCreateDirectories: true,
}
}
type SaveFileDialogOptions struct {
CanCreateDirectories bool
ShowHiddenFiles bool
CanSelectHiddenExtension bool
AllowOtherFileTypes bool
HideExtension bool
TreatsFilePackagesAsDirectories bool
Title string
Message string
Directory string
Filename string
ButtonText string
Filters []FileFilter
Window Window
}
type SaveFileDialogStruct struct {
id uint
canCreateDirectories bool
showHiddenFiles bool
canSelectHiddenExtension bool
allowOtherFileTypes bool
hideExtension bool
treatsFilePackagesAsDirectories bool
message string
directory string
filename string
buttonText string
filters []FileFilter
window Window
impl saveFileDialogImpl
title string
}
type saveFileDialogImpl interface {
show() (chan string, error)
}
func (d *SaveFileDialogStruct) SetOptions(options *SaveFileDialogOptions) {
d.title = options.Title
d.canCreateDirectories = options.CanCreateDirectories
d.showHiddenFiles = options.ShowHiddenFiles
d.canSelectHiddenExtension = options.CanSelectHiddenExtension
d.allowOtherFileTypes = options.AllowOtherFileTypes
d.hideExtension = options.HideExtension
d.treatsFilePackagesAsDirectories = options.TreatsFilePackagesAsDirectories
d.message = options.Message
d.directory = options.Directory
d.filename = options.Filename
d.buttonText = options.ButtonText
d.filters = options.Filters
d.window = options.Window
}
// AddFilter adds a filter to the dialog. The filter is a display name and a semicolon separated list of extensions.
// EG: AddFilter("Image Files", "*.jpg;*.png")
func (d *SaveFileDialogStruct) AddFilter(displayName, pattern string) *SaveFileDialogStruct {
d.filters = append(d.filters, FileFilter{
DisplayName: strings.TrimSpace(displayName),
Pattern: strings.TrimSpace(pattern),
})
return d
}
func (d *SaveFileDialogStruct) CanCreateDirectories(canCreateDirectories bool) *SaveFileDialogStruct {
d.canCreateDirectories = canCreateDirectories
return d
}
func (d *SaveFileDialogStruct) CanSelectHiddenExtension(canSelectHiddenExtension bool) *SaveFileDialogStruct {
d.canSelectHiddenExtension = canSelectHiddenExtension
return d
}
func (d *SaveFileDialogStruct) ShowHiddenFiles(showHiddenFiles bool) *SaveFileDialogStruct {
d.showHiddenFiles = showHiddenFiles
return d
}
func (d *SaveFileDialogStruct) SetMessage(message string) *SaveFileDialogStruct {
d.message = message
return d
}
func (d *SaveFileDialogStruct) SetDirectory(directory string) *SaveFileDialogStruct {
d.directory = directory
return d
}
func (d *SaveFileDialogStruct) AttachToWindow(window Window) *SaveFileDialogStruct {
d.window = window
return d
}
func (d *SaveFileDialogStruct) PromptForSingleSelection() (string, error) {
if d.impl == nil {
d.impl = newSaveFileDialogImpl(d)
}
var result string
selections, err := InvokeSyncWithResultAndError(d.impl.show)
if err == nil {
result = <-selections
}
return result, err
}
func (d *SaveFileDialogStruct) SetButtonText(text string) *SaveFileDialogStruct {
d.buttonText = text
return d
}
func (d *SaveFileDialogStruct) SetFilename(filename string) *SaveFileDialogStruct {
d.filename = filename
return d
}
func (d *SaveFileDialogStruct) AllowsOtherFileTypes(allowOtherFileTypes bool) *SaveFileDialogStruct {
d.allowOtherFileTypes = allowOtherFileTypes
return d
}
func (d *SaveFileDialogStruct) HideExtension(hideExtension bool) *SaveFileDialogStruct {
d.hideExtension = hideExtension
return d
}
func (d *SaveFileDialogStruct) TreatsFilePackagesAsDirectories(treatsFilePackagesAsDirectories bool) *SaveFileDialogStruct {
d.treatsFilePackagesAsDirectories = treatsFilePackagesAsDirectories
return d
}

View File

@@ -0,0 +1,174 @@
//go:build android
package application
import (
"encoding/json"
"fmt"
"sync"
)
// Message dialogs are backed by AlertDialog, file dialogs by the Storage
// Access Framework document picker (selected documents are copied into the
// app's cache directory so callers receive real filesystem paths). Save
// dialogs have no Android counterpart that yields a filesystem path (apps
// write into their sandbox and share via intents), so they return an
// explicit error instead of failing silently.
// Pending message dialogs keyed by callback ID
var (
androidDialogsLock sync.Mutex
androidPendingDialogs = make(map[uint]*MessageDialog)
androidNextDialogID uint = 1
)
type androidDialogButton struct {
Label string `json:"label"`
IsCancel bool `json:"isCancel"`
IsDefault bool `json:"isDefault"`
}
type androidDialogOptions struct {
Title string `json:"title"`
Message string `json:"message"`
Buttons []androidDialogButton `json:"buttons"`
}
type androidDialog struct {
dialog *MessageDialog
}
func newDialogImpl(d *MessageDialog) *androidDialog {
return &androidDialog{dialog: d}
}
func (d *androidDialog) show() {
androidDialogsLock.Lock()
id := androidNextDialogID
androidNextDialogID++
androidPendingDialogs[id] = d.dialog
androidDialogsLock.Unlock()
buttons := make([]androidDialogButton, 0, len(d.dialog.Buttons))
for _, b := range d.dialog.Buttons {
buttons = append(buttons, androidDialogButton{
Label: b.Label,
IsCancel: b.IsCancel,
IsDefault: b.IsDefault,
})
}
title := d.dialog.Title
if title == "" {
title = defaultTitles[d.dialog.DialogType]
}
optionsJSON, _ := json.Marshal(androidDialogOptions{
Title: title,
Message: d.dialog.Message,
Buttons: buttons,
})
androidBridgeVoidIntString("showMessageDialog", int(id), string(optionsJSON))
}
// androidDialogCallback is invoked from JNI when a dialog button is pressed
// (buttonIndex is the index into the dialog's button slice, or -1 for a
// dismissal with no matching button).
func androidDialogCallback(callbackID uint, buttonIndex int) {
androidDialogsLock.Lock()
dialog, ok := androidPendingDialogs[callbackID]
delete(androidPendingDialogs, callbackID)
androidDialogsLock.Unlock()
if !ok || dialog == nil {
return
}
if buttonIndex < 0 || buttonIndex >= len(dialog.Buttons) {
return
}
button := dialog.Buttons[buttonIndex]
if button.Callback != nil {
// Run the callback off the JNI thread, mirroring desktop behaviour
go func() {
defer handlePanic()
button.Callback()
}()
}
}
// File dialogs
// Pending file picker channels keyed by dialog ID
var (
androidFileDialogsLock sync.Mutex
androidFileResponses = make(map[uint]chan string)
)
type androidOpenFileDialog struct {
dialog *OpenFileDialogStruct
}
func newOpenFileDialogImpl(d *OpenFileDialogStruct) openFileDialogImpl {
return &androidOpenFileDialog{dialog: d}
}
type androidFilePickerOptions struct {
Multiple bool `json:"multiple"`
}
func (d *androidOpenFileDialog) show() (chan string, error) {
if d.dialog.canChooseDirectories && !d.dialog.canChooseFiles {
return nil, fmt.Errorf("directory selection is not supported on Android: the Storage Access Framework returns document-tree URIs, not filesystem paths")
}
results := make(chan string, 16)
androidFileDialogsLock.Lock()
id := d.dialog.id
androidFileResponses[id] = results
androidFileDialogsLock.Unlock()
optionsJSON, _ := json.Marshal(androidFilePickerOptions{
Multiple: d.dialog.allowsMultipleSelection,
})
androidBridgeVoidIntString("showFilePicker", int(id), string(optionsJSON))
return results, nil
}
// androidFilePickerResult is invoked from JNI once per selected file.
func androidFilePickerResult(callbackID uint, path string) {
if path == "" {
return
}
androidFileDialogsLock.Lock()
channel, ok := androidFileResponses[callbackID]
androidFileDialogsLock.Unlock()
if ok {
channel <- path
}
}
// androidFilePickerDone is invoked from JNI when the picker finishes
// (after all results, or immediately on cancellation).
func androidFilePickerDone(callbackID uint) {
androidFileDialogsLock.Lock()
channel, ok := androidFileResponses[callbackID]
delete(androidFileResponses, callbackID)
androidFileDialogsLock.Unlock()
if ok {
close(channel)
}
}
// Save dialogs
type androidSaveFileDialog struct{}
func newSaveFileDialogImpl(_ *SaveFileDialogStruct) saveFileDialogImpl {
return &androidSaveFileDialog{}
}
func (d *androidSaveFileDialog) show() (chan string, error) {
return nil, fmt.Errorf("save file dialogs are not supported on Android: write the file inside the app sandbox (e.g. the app's files directory) instead")
}

View File

@@ -0,0 +1,607 @@
//go:build darwin && !ios && !server
package application
/*
#cgo CFLAGS: -mmacosx-version-min=10.13 -x objective-c
#cgo LDFLAGS: -framework Cocoa -mmacosx-version-min=10.13 -framework UniformTypeIdentifiers
#import <Cocoa/Cocoa.h>
#import <UniformTypeIdentifiers/UTType.h>
#import "dialogs_darwin_delegate.h"
extern void openFileDialogCallback(uint id, char* path);
extern void openFileDialogCallbackEnd(uint id);
extern void saveFileDialogCallback(uint id, char* path);
extern void dialogCallback(int id, int buttonPressed);
static void showAboutBox(char* title, char *message, void *icon, int length) {
// run on main thread
NSAlert *alert = [[NSAlert alloc] init];
if (title != NULL) {
[alert setMessageText:[NSString stringWithUTF8String:title]];
free(title);
}
if (message != NULL) {
[alert setInformativeText:[NSString stringWithUTF8String:message]];
free(message);
}
if (icon != NULL) {
NSImage *image = [[NSImage alloc] initWithData:[NSData dataWithBytes:icon length:length]];
[alert setIcon:image];
// The alert retains its icon
[image release];
}
[alert setAlertStyle:NSAlertStyleInformational];
[alert runModal];
[alert release];
}
// Create an NSAlert
static void* createAlert(int alertType, char* title, char *message, void *icon, int length) {
NSAlert *alert = [[NSAlert alloc] init];
[alert setAlertStyle:alertType];
if (title != NULL) {
[alert setMessageText:[NSString stringWithUTF8String:title]];
free(title);
}
if (message != NULL) {
[alert setInformativeText:[NSString stringWithUTF8String:message]];
free(message);
}
if (icon != NULL) {
NSImage *image = [[NSImage alloc] initWithData:[NSData dataWithBytes:icon length:length]];
[alert setIcon:image];
// The alert retains its icon
[image release];
} else {
if(alertType == NSAlertStyleCritical || alertType == NSAlertStyleWarning) {
NSImage *image = [NSImage imageNamed:NSImageNameCaution];
[alert setIcon:image];
} else {
NSImage *image = [NSImage imageNamed:NSImageNameInfo];
[alert setIcon:image];
}
}
return alert;
}
static int getButtonNumber(NSModalResponse response) {
int buttonNumber = 0;
if( response == NSAlertFirstButtonReturn ) {
buttonNumber = 0;
}
else if( response == NSAlertSecondButtonReturn ) {
buttonNumber = 1;
}
else if( response == NSAlertThirdButtonReturn ) {
buttonNumber = 2;
} else {
buttonNumber = 3;
}
return buttonNumber;
}
// Run the dialog
static void dialogRunModal(void *dialog, void *parent, int callBackID) {
NSAlert *alert = (__bridge NSAlert *)dialog;
// If the parent is NULL, we are running a modal dialog, otherwise attach the alert to the parent
if( parent == NULL ) {
NSModalResponse response = [alert runModal];
int returnCode = getButtonNumber(response);
dialogCallback(callBackID, returnCode);
} else {
NSWindow *window = (__bridge NSWindow *)parent;
[alert beginSheetModalForWindow:window completionHandler:^(NSModalResponse response) {
int returnCode = getButtonNumber(response);
dialogCallback(callBackID, returnCode);
}];
}
}
// Release the dialog
static void releaseDialog(void *dialog) {
NSAlert *alert = (__bridge NSAlert *)dialog;
[alert release];
}
// Add a button to the dialog
static void alertAddButton(void *dialog, char *label, bool isDefault, bool isCancel) {
NSAlert *alert = (__bridge NSAlert *)dialog;
NSButton *button = [alert addButtonWithTitle:[NSString stringWithUTF8String:label]];
free(label);
if( isDefault ) {
[button setKeyEquivalent:@"\r"];
} else if( isCancel ) {
[button setKeyEquivalent:@"\033"];
} else {
[button setKeyEquivalent:@""];
}
}
static void processOpenFileDialogResults(NSOpenPanel *panel, NSInteger result, uint dialogID) {
const char *path = NULL;
if (result == NSModalResponseOK) {
NSArray *urls = [panel URLs];
if ([urls count] > 0) {
NSArray *urls = [panel URLs];
for (NSURL *url in urls) {
path = [[url path] UTF8String];
openFileDialogCallback(dialogID, (char *)path);
}
} else {
NSURL *url = [panel URL];
path = [[url path] UTF8String];
openFileDialogCallback(dialogID, (char *)path);
}
}
openFileDialogCallbackEnd(dialogID);
}
static void showOpenFileDialog(unsigned int dialogID,
bool canChooseFiles,
bool canChooseDirectories,
bool canCreateDirectories,
bool showHiddenFiles,
bool allowsMultipleSelection,
bool resolvesAliases,
bool hideExtension,
bool treatsFilePackagesAsDirectories,
bool allowsOtherFileTypes,
char *filterPatterns,
unsigned int filterPatternsCount,
char* message,
char* directory,
char* buttonText,
void *window) {
// run on main thread
NSOpenPanel *panel = [NSOpenPanel openPanel];
// print out filterPatterns if length > 0
if (filterPatternsCount > 0) {
OpenPanelDelegate *delegate = [[OpenPanelDelegate alloc] init];
[panel setDelegate:delegate];
// Initialise NSString with bytes and UTF8 encoding
NSString *filterPatternsString = [[NSString alloc] initWithBytes:filterPatterns length:filterPatternsCount encoding:NSUTF8StringEncoding];
// Convert NSString to NSArray
delegate.allowedExtensions = [filterPatternsString componentsSeparatedByString:@";"];
// componentsSeparatedByString: returned a new (retained-by-property) array
[filterPatternsString release];
// Use UTType if macOS 11 or higher to add file filters
#if MAC_OS_X_VERSION_MAX_ALLOWED >= 110000
if (@available(macOS 11, *)) {
NSMutableArray *filterTypes = [NSMutableArray array];
// Iterate the filtertypes, create uti's that are limited to the file extensions then add
for (NSString *filterType in delegate.allowedExtensions) {
[filterTypes addObject:[UTType typeWithFilenameExtension:filterType]];
}
[panel setAllowedContentTypes:filterTypes];
}
#else
[panel setAllowedFileTypes:delegate.allowedExtensions];
#endif
// Free the memory
free(filterPatterns);
}
if (message != NULL) {
[panel setMessage:[NSString stringWithUTF8String:message]];
free(message);
}
if (directory != NULL) {
[panel setDirectoryURL:[NSURL fileURLWithPath:[NSString stringWithUTF8String:directory]]];
free(directory);
}
if (buttonText != NULL) {
[panel setPrompt:[NSString stringWithUTF8String:buttonText]];
free(buttonText);
}
[panel setCanChooseFiles:canChooseFiles];
[panel setCanChooseDirectories:canChooseDirectories];
[panel setCanCreateDirectories:canCreateDirectories];
[panel setShowsHiddenFiles:showHiddenFiles];
[panel setAllowsMultipleSelection:allowsMultipleSelection];
[panel setResolvesAliases:resolvesAliases];
[panel setExtensionHidden:hideExtension];
[panel setTreatsFilePackagesAsDirectories:treatsFilePackagesAsDirectories];
[panel setAllowsOtherFileTypes:allowsOtherFileTypes];
if (window != NULL) {
[panel beginSheetModalForWindow:(__bridge NSWindow *)window completionHandler:^(NSInteger result) {
processOpenFileDialogResults(panel, result, dialogID);
// Release the OpenPanelDelegate created above (the panel's
// delegate property does not own it)
id delegate = panel.delegate;
if (delegate != nil) {
[panel setDelegate:nil];
[delegate release];
}
}];
} else {
[panel beginWithCompletionHandler:^(NSInteger result) {
processOpenFileDialogResults(panel, result, dialogID);
id delegate = panel.delegate;
if (delegate != nil) {
[panel setDelegate:nil];
[delegate release];
}
}];
}
}
static void showSaveFileDialog(unsigned int dialogID,
bool canCreateDirectories,
bool showHiddenFiles,
bool canSelectHiddenExtension,
bool hideExtension,
bool treatsFilePackagesAsDirectories,
bool allowOtherFileTypes,
char* message,
char* directory,
char* buttonText,
char* filename,
void *window) {
NSSavePanel *panel = [NSSavePanel savePanel];
if (message != NULL) {
[panel setMessage:[NSString stringWithUTF8String:message]];
free(message);
}
if (directory != NULL) {
[panel setDirectoryURL:[NSURL fileURLWithPath:[NSString stringWithUTF8String:directory]]];
free(directory);
}
if (filename != NULL) {
[panel setNameFieldStringValue:[NSString stringWithUTF8String:filename]];
free(filename);
}
if (buttonText != NULL) {
[panel setPrompt:[NSString stringWithUTF8String:buttonText]];
free(buttonText);
}
[panel setCanCreateDirectories:canCreateDirectories];
[panel setShowsHiddenFiles:showHiddenFiles];
[panel setCanSelectHiddenExtension:canSelectHiddenExtension];
[panel setExtensionHidden:hideExtension];
[panel setTreatsFilePackagesAsDirectories:treatsFilePackagesAsDirectories];
[panel setAllowsOtherFileTypes:allowOtherFileTypes];
if (window != NULL) {
[panel beginSheetModalForWindow:(__bridge NSWindow *)window completionHandler:^(NSInteger result) {
const char *path = NULL;
if (result == NSModalResponseOK) {
NSURL *url = [panel URL];
path = [[url path] UTF8String];
}
saveFileDialogCallback(dialogID, (char *)path);
}];
} else {
[panel beginWithCompletionHandler:^(NSInteger result) {
const char *path = NULL;
if (result == NSModalResponseOK) {
NSURL *url = [panel URL];
path = [[url path] UTF8String];
}
saveFileDialogCallback(dialogID, (char *)path);
}];
}
}
*/
import "C"
import (
"strings"
"sync"
"unsafe"
)
const NSAlertStyleWarning = C.int(0)
const NSAlertStyleInformational = C.int(1)
const NSAlertStyleCritical = C.int(2)
var alertTypeMap = map[DialogType]C.int{
WarningDialogType: NSAlertStyleWarning,
InfoDialogType: NSAlertStyleInformational,
ErrorDialogType: NSAlertStyleCritical,
QuestionDialogType: NSAlertStyleInformational,
}
type dialogResultCallback func(int)
var (
callbacks = make(map[int]dialogResultCallback)
mutex = &sync.Mutex{}
)
func addDialogCallback(callback dialogResultCallback) int {
mutex.Lock()
defer mutex.Unlock()
// Find the first free integer key
var id int
for {
if _, exists := callbacks[id]; !exists {
break
}
id++
}
// Save the function in the map using the integer key
callbacks[id] = callback
// Return the key
return id
}
func removeDialogCallback(id int) {
mutex.Lock()
defer mutex.Unlock()
delete(callbacks, id)
}
//export dialogCallback
func dialogCallback(id C.int, buttonPressed C.int) {
mutex.Lock()
callback, exists := callbacks[int(id)]
mutex.Unlock()
if !exists {
return
}
// Call the function with the button number
callback(int(buttonPressed)) // Replace nil with the actual slice of buttons
}
func (m *macosApp) showAboutDialog(title string, message string, icon []byte) {
var iconData unsafe.Pointer
if icon != nil {
iconData = unsafe.Pointer(&icon[0])
}
InvokeAsync(func() {
C.showAboutBox(C.CString(title), C.CString(message), iconData, C.int(len(icon)))
})
}
type macosDialog struct {
dialog *MessageDialog
nsDialog unsafe.Pointer
}
func (m *macosDialog) show() {
InvokeAsync(func() {
// Mac can only have 4 Buttons on a dialog
if len(m.dialog.Buttons) > 4 {
m.dialog.Buttons = m.dialog.Buttons[:4]
}
if m.nsDialog != nil {
C.releaseDialog(m.nsDialog)
}
var title *C.char
if m.dialog.Title != "" {
title = C.CString(m.dialog.Title)
}
var message *C.char
if m.dialog.Message != "" {
message = C.CString(m.dialog.Message)
}
var iconData unsafe.Pointer
var iconLength C.int
if len(m.dialog.Icon) > 0 {
iconData = unsafe.Pointer(&m.dialog.Icon[0])
iconLength = C.int(len(m.dialog.Icon))
} else {
// if it's an error, use the application Icon
if m.dialog.DialogType == ErrorDialogType {
if len(globalApplication.options.Icon) > 0 {
iconData = unsafe.Pointer(&globalApplication.options.Icon[0])
iconLength = C.int(len(globalApplication.options.Icon))
}
}
}
var parent unsafe.Pointer
if m.dialog.window != nil {
// get NSWindow from window
parent = m.dialog.window.NativeWindow()
}
alertType, ok := alertTypeMap[m.dialog.DialogType]
if !ok {
alertType = C.NSAlertStyleInformational
}
m.nsDialog = C.createAlert(alertType, title, message, iconData, iconLength)
// Reverse the Buttons so that the default is on the right
reversedButtons := make([]*Button, len(m.dialog.Buttons))
var count = 0
for i := len(m.dialog.Buttons) - 1; i >= 0; i-- {
button := m.dialog.Buttons[i]
C.alertAddButton(m.nsDialog, C.CString(button.Label), C.bool(button.IsDefault), C.bool(button.IsCancel))
reversedButtons[count] = m.dialog.Buttons[i]
count++
}
var callBackID int
callBackID = addDialogCallback(func(buttonPressed int) {
if len(m.dialog.Buttons) > buttonPressed {
button := reversedButtons[buttonPressed]
if button.Callback != nil {
button.Callback()
}
}
removeDialogCallback(callBackID)
})
C.dialogRunModal(m.nsDialog, parent, C.int(callBackID))
})
}
func newDialogImpl(d *MessageDialog) *macosDialog {
return &macosDialog{
dialog: d,
}
}
type macosOpenFileDialog struct {
dialog *OpenFileDialogStruct
}
func newOpenFileDialogImpl(d *OpenFileDialogStruct) *macosOpenFileDialog {
return &macosOpenFileDialog{
dialog: d,
}
}
func toCString(s string) *C.char {
if s == "" {
return nil
}
return C.CString(s)
}
func (m *macosOpenFileDialog) show() (chan string, error) {
openFileResponses[m.dialog.id] = make(chan string)
nsWindow := unsafe.Pointer(nil)
if m.dialog.window != nil {
// get NSWindow from window
nsWindow = m.dialog.window.NativeWindow()
}
// Massage filter patterns into macOS format
// We iterate all filter patterns, tidy them up and then join them with a semicolon
// This should produce a single string of extensions like "png;jpg;gif"
var filterPatterns string
if len(m.dialog.filters) > 0 {
var allPatterns []string
for _, filter := range m.dialog.filters {
patternComponents := strings.Split(filter.Pattern, ";")
for i, component := range patternComponents {
filterPattern := strings.TrimSpace(component)
filterPattern = strings.TrimPrefix(filterPattern, "*.")
patternComponents[i] = filterPattern
}
allPatterns = append(allPatterns, strings.Join(patternComponents, ";"))
}
filterPatterns = strings.Join(allPatterns, ";")
}
C.showOpenFileDialog(C.uint(m.dialog.id),
C.bool(m.dialog.canChooseFiles),
C.bool(m.dialog.canChooseDirectories),
C.bool(m.dialog.canCreateDirectories),
C.bool(m.dialog.showHiddenFiles),
C.bool(m.dialog.allowsMultipleSelection),
C.bool(m.dialog.resolvesAliases),
C.bool(m.dialog.hideExtension),
C.bool(m.dialog.treatsFilePackagesAsDirectories),
C.bool(m.dialog.allowsOtherFileTypes),
toCString(filterPatterns),
C.uint(len(filterPatterns)),
toCString(m.dialog.message),
toCString(m.dialog.directory),
toCString(m.dialog.buttonText),
nsWindow)
return openFileResponses[m.dialog.id], nil
}
//export openFileDialogCallback
func openFileDialogCallback(cid C.uint, cpath *C.char) {
path := C.GoString(cpath)
id := uint(cid)
channel, ok := openFileResponses[id]
if ok {
channel <- path
} else {
panic("No channel found for open file dialog")
}
}
//export openFileDialogCallbackEnd
func openFileDialogCallbackEnd(cid C.uint) {
id := uint(cid)
channel, ok := openFileResponses[id]
if ok {
close(channel)
delete(openFileResponses, id)
freeDialogID(id)
} else {
panic("No channel found for open file dialog")
}
}
type macosSaveFileDialog struct {
dialog *SaveFileDialogStruct
}
func newSaveFileDialogImpl(d *SaveFileDialogStruct) *macosSaveFileDialog {
return &macosSaveFileDialog{
dialog: d,
}
}
func (m *macosSaveFileDialog) show() (chan string, error) {
saveFileResponses[m.dialog.id] = make(chan string)
nsWindow := unsafe.Pointer(nil)
if m.dialog.window != nil {
// get NSWindow from window
nsWindow = m.dialog.window.NativeWindow()
}
C.showSaveFileDialog(C.uint(m.dialog.id),
C.bool(m.dialog.canCreateDirectories),
C.bool(m.dialog.showHiddenFiles),
C.bool(m.dialog.canSelectHiddenExtension),
C.bool(m.dialog.hideExtension),
C.bool(m.dialog.treatsFilePackagesAsDirectories),
C.bool(m.dialog.allowOtherFileTypes),
toCString(m.dialog.message),
toCString(m.dialog.directory),
toCString(m.dialog.buttonText),
toCString(m.dialog.filename),
nsWindow)
return saveFileResponses[m.dialog.id], nil
}
//export saveFileDialogCallback
func saveFileDialogCallback(cid C.uint, cpath *C.char) {
// Covert the path to a string
path := C.GoString(cpath)
id := uint(cid)
// put response on channel
channel, ok := saveFileResponses[id]
if ok {
channel <- path
close(channel)
delete(saveFileResponses, id)
freeDialogID(id)
} else {
panic("No channel found for save file dialog")
}
}

View File

@@ -0,0 +1,18 @@
//go:build darwin && !ios
#ifndef _DIALOGS_DELEGATE_H_
#define _DIALOGS_DELEGATE_H_
#import <Cocoa/Cocoa.h>
// Conditionally import UniformTypeIdentifiers based on OS version
#if __MAC_OS_X_VERSION_MAX_ALLOWED >= 110000
#import <UniformTypeIdentifiers/UTType.h>
#endif
// OpenPanel delegate to handle file filtering
@interface OpenPanelDelegate : NSObject <NSOpenSavePanelDelegate>
@property (nonatomic, strong) NSArray *allowedExtensions;
@end
#endif

View File

@@ -0,0 +1,38 @@
//go:build darwin && !ios && !server
#import "dialogs_darwin_delegate.h"
// Override shouldEnableURL
@implementation OpenPanelDelegate
- (BOOL)panel:(id)sender shouldEnableURL:(NSURL *)url {
if (url == nil) {
return NO;
}
NSFileManager *fileManager = [NSFileManager defaultManager];
BOOL isDirectory = NO;
if ([fileManager fileExistsAtPath:url.path isDirectory:&isDirectory] && isDirectory) {
return YES;
}
// If no extensions specified, allow all files
if (self.allowedExtensions == nil || [self.allowedExtensions count] == 0) {
return YES;
}
NSString *extension = [url.pathExtension lowercaseString];
if (extension == nil || [extension isEqualToString:@""]) {
return NO;
}
// Check if the extension is in our allowed list (case insensitive)
for (NSString *allowedExt in self.allowedExtensions) {
if ([[allowedExt lowercaseString] isEqualToString:extension]) {
return YES;
}
}
return NO;
}
@end

View File

@@ -0,0 +1,162 @@
//go:build ios
package application
/*
#include <stdlib.h>
#include "application_ios.h"
*/
import "C"
import (
"encoding/json"
"fmt"
"sync"
"unsafe"
)
// Message dialogs are backed by UIAlertController, file dialogs by
// UIDocumentPickerViewController. Save dialogs have no iOS counterpart
// (apps write into their sandbox and share via the share sheet), so they
// return an explicit error instead of failing silently.
// Pending message dialogs keyed by callback ID
var (
iosDialogsLock sync.Mutex
iosPendingDialogs = make(map[uint]*MessageDialog)
iosNextDialogID uint = 1
)
type iosDialogButton struct {
Label string `json:"label"`
IsCancel bool `json:"isCancel"`
IsDefault bool `json:"isDefault"`
}
type iosDialog struct {
dialog *MessageDialog
}
func newDialogImpl(d *MessageDialog) *iosDialog {
return &iosDialog{dialog: d}
}
func (d *iosDialog) show() {
iosDialogsLock.Lock()
id := iosNextDialogID
iosNextDialogID++
iosPendingDialogs[id] = d.dialog
iosDialogsLock.Unlock()
buttons := make([]iosDialogButton, 0, len(d.dialog.Buttons))
for _, b := range d.dialog.Buttons {
buttons = append(buttons, iosDialogButton{
Label: b.Label,
IsCancel: b.IsCancel,
IsDefault: b.IsDefault,
})
}
buttonsJSON, _ := json.Marshal(buttons)
title := d.dialog.Title
if title == "" {
title = defaultTitles[d.dialog.DialogType]
}
ctitle := C.CString(title)
cmessage := C.CString(d.dialog.Message)
cbuttons := C.CString(string(buttonsJSON))
defer C.free(unsafe.Pointer(ctitle))
defer C.free(unsafe.Pointer(cmessage))
defer C.free(unsafe.Pointer(cbuttons))
C.ios_show_message_dialog(ctitle, cmessage, cbuttons, C.uint(id))
}
//export iosDialogCallback
func iosDialogCallback(callbackID C.uint, buttonIndex C.int) {
iosDialogsLock.Lock()
dialog, ok := iosPendingDialogs[uint(callbackID)]
delete(iosPendingDialogs, uint(callbackID))
iosDialogsLock.Unlock()
if !ok || dialog == nil {
return
}
idx := int(buttonIndex)
if idx < 0 || idx >= len(dialog.Buttons) {
return
}
button := dialog.Buttons[idx]
if button.Callback != nil {
// Run the callback off the main thread, mirroring desktop behaviour
go func() {
defer handlePanic()
button.Callback()
}()
}
}
// File dialogs
// Pending file picker channels keyed by dialog ID
var (
iosFileDialogsLock sync.Mutex
iosFileResponses = make(map[uint]chan string)
)
type iosOpenFileDialog struct {
dialog *OpenFileDialogStruct
}
func newOpenFileDialogImpl(d *OpenFileDialogStruct) openFileDialogImpl {
return &iosOpenFileDialog{dialog: d}
}
func (d *iosOpenFileDialog) show() (chan string, error) {
results := make(chan string, 16)
iosFileDialogsLock.Lock()
id := d.dialog.id
iosFileResponses[id] = results
iosFileDialogsLock.Unlock()
directories := d.dialog.canChooseDirectories && !d.dialog.canChooseFiles
C.ios_show_document_picker(C.uint(id), C.bool(directories), C.bool(d.dialog.allowsMultipleSelection))
return results, nil
}
//export iosOpenFileCallback
func iosOpenFileCallback(callbackID C.uint, cpath *C.char) {
if cpath == nil {
return
}
path := C.GoString(cpath)
iosFileDialogsLock.Lock()
channel, ok := iosFileResponses[uint(callbackID)]
iosFileDialogsLock.Unlock()
if ok {
channel <- path
}
}
//export iosOpenFileCallbackEnd
func iosOpenFileCallbackEnd(callbackID C.uint) {
iosFileDialogsLock.Lock()
channel, ok := iosFileResponses[uint(callbackID)]
delete(iosFileResponses, uint(callbackID))
iosFileDialogsLock.Unlock()
if ok {
close(channel)
}
}
// Save dialogs
type iosSaveFileDialog struct{}
func newSaveFileDialogImpl(_ *SaveFileDialogStruct) saveFileDialogImpl {
return &iosSaveFileDialog{}
}
func (d *iosSaveFileDialog) show() (chan string, error) {
return nil, fmt.Errorf("save file dialogs are not supported on iOS: write the file inside the app sandbox (e.g. the Documents directory) instead")
}

View File

@@ -0,0 +1,87 @@
//go:build linux && !android && !server
package application
func (a *linuxApp) showAboutDialog(title string, message string, icon []byte) {
window, _ := globalApplication.Window.GetByID(a.getCurrentWindowID())
var parent uintptr
if window != nil {
nativeWindow := window.NativeWindow()
if nativeWindow != nil {
parent = uintptr(nativeWindow)
}
}
about := newMessageDialog(InfoDialogType)
about.SetTitle(title).
SetMessage(message).
SetIcon(icon)
gtkDispatch(func() {
runQuestionDialog(
pointer(parent),
about,
)
})
}
type linuxDialog struct {
dialog *MessageDialog
}
func (m *linuxDialog) show() {
windowId := getNativeApplication().getCurrentWindowID()
window, _ := globalApplication.Window.GetByID(windowId)
var parent uintptr
if window != nil {
nativeWindow := window.NativeWindow()
if nativeWindow != nil {
parent = uintptr(nativeWindow)
}
}
gtkDispatch(func() {
response := runQuestionDialog(pointer(parent), m.dialog)
if response >= 0 && response < len(m.dialog.Buttons) {
button := m.dialog.Buttons[response]
if button.Callback != nil {
go func() {
defer handlePanic()
button.Callback()
}()
}
}
})
}
func newDialogImpl(d *MessageDialog) *linuxDialog {
return &linuxDialog{
dialog: d,
}
}
type linuxOpenFileDialog struct {
dialog *OpenFileDialogStruct
}
func newOpenFileDialogImpl(d *OpenFileDialogStruct) *linuxOpenFileDialog {
return &linuxOpenFileDialog{
dialog: d,
}
}
func (m *linuxOpenFileDialog) show() (chan string, error) {
return runOpenFileDialog(m.dialog)
}
type linuxSaveFileDialog struct {
dialog *SaveFileDialogStruct
}
func newSaveFileDialogImpl(d *SaveFileDialogStruct) *linuxSaveFileDialog {
return &linuxSaveFileDialog{
dialog: d,
}
}
func (m *linuxSaveFileDialog) show() (chan string, error) {
return runSaveFileDialog(m.dialog)
}

View File

@@ -0,0 +1,296 @@
//go:build windows && !server
package application
import (
"path/filepath"
"strings"
"github.com/wailsapp/wails/v3/internal/go-common-file-dialog/cfd"
"github.com/wailsapp/wails/v3/pkg/w32"
"golang.org/x/sys/windows"
)
func (m *windowsApp) showAboutDialog(title string, message string, _ []byte) {
about := newDialogImpl(&MessageDialog{
MessageDialogOptions: MessageDialogOptions{
DialogType: InfoDialogType,
Title: title,
Message: message,
},
})
about.UseAppIcon = true
about.show()
}
type windowsDialog struct {
dialog *MessageDialog
//dialogImpl unsafe.Pointer
UseAppIcon bool
}
func (m *windowsDialog) show() {
title := w32.MustStringToUTF16Ptr(m.dialog.Title)
message := w32.MustStringToUTF16Ptr(m.dialog.Message)
flags := calculateMessageDialogFlags(m.dialog.MessageDialogOptions)
var button int32
var err error
var parentWindow uintptr
if m.dialog.window != nil {
nativeWindow := m.dialog.window.NativeWindow()
if nativeWindow != nil {
parentWindow = uintptr(nativeWindow)
}
}
if m.UseAppIcon || m.dialog.Icon != nil {
// Use the application's embedded icon resource (ID 3). MessageBoxIndirect
// cannot render arbitrary icon bytes, so a custom Icon also maps to the
// app icon — the closest supported behaviour (full custom-icon support
// needs a TaskDialog implementation).
//
button, err = w32.MessageBoxWithIcon(parentWindow, message, title, 3, messageDialogUserIconFlags(flags))
if err != nil {
// Dev binaries (`go run`, `wails3 dev`) have no embedded icon
// resource, which makes MessageBoxIndirect fail outright — the
// dialog never appeared and the app aborted via fatal error
// (#4233). Fall back to a standard dialog instead.
button, err = windows.MessageBox(windows.HWND(parentWindow), message, title, flags|windows.MB_SYSTEMMODAL)
}
if err != nil {
globalApplication.handleFatalError(err)
}
} else {
button, err = windows.MessageBox(windows.HWND(parentWindow), message, title, flags|windows.MB_SYSTEMMODAL)
if err != nil {
globalApplication.handleFatalError(err)
}
}
// This maps MessageBox return values to strings
responses := []string{"", "Ok", "Cancel", "Abort", "Retry", "Ignore", "Yes", "No", "", "", "Try Again", "Continue"}
result := "Error"
if int(button) < len(responses) {
result = responses[button]
}
// Check if there's a callback for the button pressed
for _, buttonInDialog := range m.dialog.Buttons {
if buttonInDialog.Label == result {
if buttonInDialog.Callback != nil {
buttonInDialog.Callback()
}
}
}
}
func newDialogImpl(d *MessageDialog) *windowsDialog {
return &windowsDialog{
dialog: d,
}
}
type windowOpenFileDialog struct {
dialog *OpenFileDialogStruct
}
func newOpenFileDialogImpl(d *OpenFileDialogStruct) *windowOpenFileDialog {
return &windowOpenFileDialog{
dialog: d,
}
}
func getDefaultFolder(folder string) (string, error) {
if folder == "" {
return "", nil
}
return filepath.Abs(folder)
}
func (m *windowOpenFileDialog) show() (chan string, error) {
defaultFolder, err := getDefaultFolder(m.dialog.directory)
if err != nil {
return nil, err
}
config := cfd.DialogConfig{
Title: m.dialog.title,
Role: "PickFolder",
FileFilters: convertFilters(m.dialog.filters),
Folder: defaultFolder,
}
var result []string
if m.dialog.allowsMultipleSelection && !m.dialog.canChooseDirectories {
temp, err := showCfdDialog(
func() (cfd.Dialog, error) {
return cfd.NewOpenMultipleFilesDialog(config)
}, true, m.dialog.window)
if err != nil {
return nil, err
}
result = temp.([]string)
} else {
if m.dialog.canChooseDirectories {
temp, err := showCfdDialog(
func() (cfd.Dialog, error) {
return cfd.NewSelectFolderDialog(config)
}, false, m.dialog.window)
if err != nil {
return nil, err
}
result = []string{temp.(string)}
} else {
temp, err := showCfdDialog(
func() (cfd.Dialog, error) {
return cfd.NewOpenFileDialog(config)
}, false, m.dialog.window)
if err != nil {
return nil, err
}
result = []string{temp.(string)}
}
}
files := make(chan string)
go func() {
defer handlePanic()
for _, file := range result {
files <- file
}
close(files)
}()
return files, nil
}
type windowSaveFileDialog struct {
dialog *SaveFileDialogStruct
}
func newSaveFileDialogImpl(d *SaveFileDialogStruct) *windowSaveFileDialog {
return &windowSaveFileDialog{
dialog: d,
}
}
func (m *windowSaveFileDialog) show() (chan string, error) {
files := make(chan string)
defaultFolder, err := getDefaultFolder(m.dialog.directory)
if err != nil {
close(files)
return files, err
}
config := cfd.DialogConfig{
Title: m.dialog.title,
Role: "SaveFile",
FileFilters: convertFilters(m.dialog.filters),
FileName: m.dialog.filename,
Folder: defaultFolder,
}
// Original PR for v2 by @almas1992: https://github.com/wailsapp/wails/pull/3205
if len(m.dialog.filters) > 0 {
config.DefaultExtension = strings.TrimPrefix(strings.Split(m.dialog.filters[0].Pattern, ";")[0], "*")
}
result, err := showCfdDialog(
func() (cfd.Dialog, error) {
return cfd.NewSaveFileDialog(config)
}, false, m.dialog.window)
if err != nil {
close(files)
return files, err
}
go func() {
defer handlePanic()
f, ok := result.(string)
if ok {
files <- f
}
close(files)
}()
return files, err
}
// messageDialogUserIconFlags converts standard message dialog flags for use
// with MB_USERICON. The user icon replaces the standard one, so the MB_ICON*
// bits are stripped — but the button configuration is preserved: forcing
// MB_OK here (the old behaviour) silently destroyed Yes/No buttons on
// question dialogs shown with an icon (#4233).
func messageDialogUserIconFlags(flags uint32) uint32 {
const mbIconMask = w32.MB_ICONHAND | w32.MB_ICONQUESTION | w32.MB_ICONASTERISK | w32.MB_USERICON
return (flags &^ uint32(mbIconMask)) | windows.MB_USERICON | windows.MB_SYSTEMMODAL
}
func calculateMessageDialogFlags(options MessageDialogOptions) uint32 {
var flags uint32
switch options.DialogType {
case InfoDialogType:
flags = windows.MB_OK | windows.MB_ICONINFORMATION
case ErrorDialogType:
flags = windows.MB_ICONERROR | windows.MB_OK
case QuestionDialogType:
flags = windows.MB_YESNO
for _, button := range options.Buttons {
if strings.TrimSpace(strings.ToLower(button.Label)) == "no" && button.IsDefault {
flags |= windows.MB_DEFBUTTON2
}
}
case WarningDialogType:
flags = windows.MB_OK | windows.MB_ICONWARNING
}
return flags
}
func convertFilters(filters []FileFilter) []cfd.FileFilter {
var result []cfd.FileFilter
for _, filter := range filters {
result = append(result, cfd.FileFilter(filter))
}
return result
}
func showCfdDialog(newDlg func() (cfd.Dialog, error), isMultiSelect bool, parentWindow Window) (any, error) {
dlg, err := newDlg()
if err != nil {
return nil, err
}
// Set parent window if provided
if parentWindow != nil {
nativeWindow := parentWindow.NativeWindow()
if nativeWindow != nil {
dlg.SetParentWindowHandle(uintptr(nativeWindow))
}
}
defer func() {
err := dlg.Release()
if err != nil {
globalApplication.error("unable to release dialog: %w", err)
}
}()
if multi, _ := dlg.(cfd.OpenMultipleFilesDialog); multi != nil && isMultiSelect {
paths, err := multi.ShowAndGetResults()
if err != nil {
return nil, err
}
for i, path := range paths {
paths[i] = filepath.Clean(path)
}
return paths, nil
}
path, err := dlg.ShowAndGetResult()
if err != nil {
return nil, err
}
return filepath.Clean(path), nil
}

View File

@@ -0,0 +1,18 @@
package application
import "github.com/wailsapp/wails/v3/internal/operatingsystem"
// EnvironmentInfo represents information about the current environment.
//
// Fields:
// - OS: the operating system that the program is running on.
// - Arch: the architecture of the operating system.
// - Debug: indicates whether debug mode is enabled.
// - OSInfo: information about the operating system.
type EnvironmentInfo struct {
OS string `json:"OS"`
Arch string `json:"Arch"`
Debug bool `json:"Debug"`
OSInfo *operatingsystem.OS `json:"OSInfo"`
PlatformInfo map[string]any `json:"PlatformInfo"`
}

View File

@@ -0,0 +1,210 @@
//go:build linux && !android
package application
import (
"fmt"
"net"
"os"
"path/filepath"
"strconv"
"strings"
)
func detectCompositor() string {
if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "" {
return "hyprland"
}
if os.Getenv("SWAYSOCK") != "" {
return "sway"
}
if os.Getenv("I3SOCK") != "" {
return "i3"
}
if desktop := os.Getenv("XDG_CURRENT_DESKTOP"); desktop != "" {
return strings.ToLower(desktop)
}
return "unknown"
}
func detectFocusFollowsMouse() bool {
compositor := detectCompositor()
switch compositor {
case "hyprland", "sway", "i3":
return true
}
return false
}
func isWayland() bool {
return os.Getenv("XDG_SESSION_TYPE") == "wayland" ||
os.Getenv("WAYLAND_DISPLAY") != ""
}
func isTilingWM() bool {
switch detectCompositor() {
case "hyprland", "sway", "i3":
return true
}
return false
}
func getCursorPositionFromCompositor() (x, y int, ok bool) {
switch detectCompositor() {
case "hyprland":
out, err := hyprlandIPC("cursorpos")
if err != nil {
return 0, 0, false
}
return parseCursorPos(strings.TrimSpace(out))
case "sway":
out, err := swayIPC("get_seats")
if err != nil {
return 0, 0, false
}
return parseSwayCursor(out)
}
return 0, 0, false
}
func parseCursorPos(s string) (x, y int, ok bool) {
parts := strings.Split(s, ", ")
if len(parts) != 2 {
return 0, 0, false
}
var err error
x, err = strconv.Atoi(strings.TrimSpace(parts[0]))
if err != nil {
return 0, 0, false
}
y, err = strconv.Atoi(strings.TrimSpace(parts[1]))
if err != nil {
return 0, 0, false
}
return x, y, true
}
func parseSwayCursor(json string) (x, y int, ok bool) {
cursorIdx := strings.Index(json, `"cursor"`)
if cursorIdx == -1 {
return 0, 0, false
}
xIdx := strings.Index(json[cursorIdx:], `"x"`)
if xIdx == -1 {
return 0, 0, false
}
xStart := cursorIdx + xIdx + 4
xEnd := strings.IndexAny(json[xStart:], ",}")
if xEnd == -1 {
return 0, 0, false
}
x, _ = strconv.Atoi(strings.TrimSpace(json[xStart : xStart+xEnd]))
yIdx := strings.Index(json[cursorIdx:], `"y"`)
if yIdx == -1 {
return 0, 0, false
}
yStart := cursorIdx + yIdx + 4
yEnd := strings.IndexAny(json[yStart:], ",}")
if yEnd == -1 {
return 0, 0, false
}
y, _ = strconv.Atoi(strings.TrimSpace(json[yStart : yStart+yEnd]))
return x, y, true
}
func hyprlandIPC(command string) (string, error) {
sig := os.Getenv("HYPRLAND_INSTANCE_SIGNATURE")
if sig == "" {
return "", fmt.Errorf("HYPRLAND_INSTANCE_SIGNATURE not set")
}
runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
if runtimeDir == "" {
runtimeDir = fmt.Sprintf("/run/user/%d", os.Getuid())
}
socketPath := filepath.Join(runtimeDir, "hypr", sig, ".socket.sock")
conn, err := net.Dial("unix", socketPath)
if err != nil {
return "", err
}
defer conn.Close()
_, err = conn.Write([]byte(command))
if err != nil {
return "", err
}
var result strings.Builder
buf := make([]byte, 4096)
for {
n, err := conn.Read(buf)
if n > 0 {
result.Write(buf[:n])
}
if err != nil {
break
}
if n < len(buf) {
break
}
}
return result.String(), nil
}
func swayIPC(command string) (string, error) {
socketPath := os.Getenv("SWAYSOCK")
if socketPath == "" {
return "", fmt.Errorf("SWAYSOCK not set")
}
conn, err := net.Dial("unix", socketPath)
if err != nil {
return "", err
}
defer conn.Close()
msgType := uint32(0)
if strings.HasPrefix(command, "get_") {
switch command {
case "get_seats":
msgType = 5
}
} else {
msgType = 0
}
payload := []byte(command)
header := make([]byte, 14)
copy(header[0:6], "i3-ipc")
header[6] = byte(len(payload))
header[7] = byte(len(payload) >> 8)
header[8] = byte(len(payload) >> 16)
header[9] = byte(len(payload) >> 24)
header[10] = byte(msgType)
header[11] = byte(msgType >> 8)
header[12] = byte(msgType >> 16)
header[13] = byte(msgType >> 24)
conn.Write(header)
conn.Write(payload)
respHeader := make([]byte, 14)
_, err = conn.Read(respHeader)
if err != nil {
return "", err
}
respLen := uint32(respHeader[6]) | uint32(respHeader[7])<<8 | uint32(respHeader[8])<<16 | uint32(respHeader[9])<<24
respBody := make([]byte, respLen)
_, err = conn.Read(respBody)
if err != nil {
return "", err
}
return string(respBody), nil
}

View File

@@ -0,0 +1,67 @@
package application
import (
"runtime"
"github.com/wailsapp/wails/v3/internal/fileexplorer"
"github.com/wailsapp/wails/v3/internal/operatingsystem"
)
// EnvironmentManager manages environment-related operations
type EnvironmentManager struct {
app *App
}
// newEnvironmentManager creates a new EnvironmentManager instance
func newEnvironmentManager(app *App) *EnvironmentManager {
return &EnvironmentManager{
app: app,
}
}
// Info returns environment information
func (em *EnvironmentManager) Info() EnvironmentInfo {
info, _ := operatingsystem.Info()
result := EnvironmentInfo{
OS: runtime.GOOS,
Arch: runtime.GOARCH,
Debug: em.app.isDebugMode,
OSInfo: info,
}
result.PlatformInfo = em.app.platformEnvironment()
return result
}
// IsDarkMode returns true if the system is in dark mode
func (em *EnvironmentManager) IsDarkMode() bool {
if em.app.impl == nil {
return false
}
return em.app.impl.isDarkMode()
}
// GetAccentColor returns the system accent color
func (em *EnvironmentManager) GetAccentColor() string {
if em.app.impl == nil {
return "rgb(0,122,255)"
}
return em.app.impl.getAccentColor()
}
// OpenFileManager opens the file manager at the specified path, optionally selecting the file
func (em *EnvironmentManager) OpenFileManager(path string, selectFile bool) error {
return InvokeSyncWithError(func() error {
return fileexplorer.OpenFileManager(path, selectFile)
})
}
func (em *EnvironmentManager) HasFocusFollowsMouse() bool {
if runtime.GOOS != "linux" {
return false
}
info := em.app.platformEnvironment()
if ffm, ok := info["focusFollowsMouse"].(bool); ok {
return ffm
}
return false
}

View File

@@ -0,0 +1,55 @@
package application
import (
"fmt"
"os"
"strings"
)
// FatalError instances are passed to the registered error handler
// in case of catastrophic, unrecoverable failures that require immediate termination.
// FatalError wraps the original error value in an informative message.
// The underlying error may be retrieved through the [FatalError.Unwrap] method.
type FatalError struct {
err error
internal bool
}
// Internal returns true when the error was triggered from wails' internal code.
func (e *FatalError) Internal() bool {
return e.internal
}
// Unwrap returns the original cause of the fatal error,
// for easy inspection using the [errors.As] API.
func (e *FatalError) Unwrap() error {
return e.err
}
func (e *FatalError) Error() string {
var buffer strings.Builder
buffer.WriteString("\n\n******************************** FATAL *********************************\n")
buffer.WriteString("* There has been a catastrophic failure in your application. *\n")
if e.internal {
buffer.WriteString("* Please report this error at https://github.com/wailsapp/wails/issues *\n")
}
buffer.WriteString("**************************** Error Details *****************************\n")
buffer.WriteString(e.err.Error())
buffer.WriteString("************************************************************************\n")
return buffer.String()
}
func Fatal(message string, args ...any) {
err := &FatalError{
err: fmt.Errorf(message, args...),
internal: true,
}
if globalApplication != nil {
globalApplication.handleError(err)
} else {
fmt.Println(err)
}
os.Exit(1)
}

View File

@@ -0,0 +1,174 @@
package application
import (
"slices"
"github.com/wailsapp/wails/v3/pkg/events"
)
// EventManager manages event-related operations
type EventManager struct {
app *App
}
// newEventManager creates a new EventManager instance
func newEventManager(app *App) *EventManager {
return &EventManager{
app: app,
}
}
// Emit emits a custom event with the specified name and associated data.
// It returns a boolean indicating whether the event was cancelled by a hook.
//
// If no data argument is provided, Emit emits an event with nil data.
// When there is exactly one data argument, it will be used as the custom event's data field.
// When more than one argument is provided, the event's data field will be set to the argument slice.
//
// If the given event name is registered, Emit validates the data parameter
// against the expected data type. In case of a mismatch, Emit reports an error
// to the registered error handler for the application and cancels the event.
func (em *EventManager) Emit(name string, data ...any) bool {
event := &CustomEvent{Name: name}
if len(data) == 1 {
event.Data = data[0]
} else if len(data) > 1 {
event.Data = data
}
if err := em.app.customEventProcessor.Emit(event); err != nil {
globalApplication.handleError(err)
}
return event.IsCancelled()
}
// EmitEvent emits a custom event object (internal use)
// It returns a boolean indicating whether the event was cancelled by a hook.
//
// If the given event name is registered, emitEvent validates the data parameter
// against the expected data type. In case of a mismatch, emitEvent reports an error
// to the registered error handler for the application and cancels the event.
func (em *EventManager) EmitEvent(event *CustomEvent) bool {
if err := em.app.customEventProcessor.Emit(event); err != nil {
globalApplication.handleError(err)
}
return event.IsCancelled()
}
// On registers a listener for custom events
func (em *EventManager) On(name string, callback func(event *CustomEvent)) func() {
return em.app.customEventProcessor.On(name, callback)
}
// Off removes all listeners for a custom event
func (em *EventManager) Off(name string) {
em.app.customEventProcessor.Off(name)
}
// OnMultiple registers a listener for custom events that will be called N times
func (em *EventManager) OnMultiple(name string, callback func(event *CustomEvent), counter int) {
em.app.customEventProcessor.OnMultiple(name, callback, counter)
}
// Reset removes all custom event listeners
func (em *EventManager) Reset() {
em.app.customEventProcessor.OffAll()
}
// OnApplicationEvent registers a listener for application events
func (em *EventManager) OnApplicationEvent(eventType events.ApplicationEventType, callback func(event *ApplicationEvent)) func() {
eventID := uint(eventType)
em.app.applicationEventListenersLock.Lock()
defer em.app.applicationEventListenersLock.Unlock()
listener := &EventListener{
callback: callback,
}
em.app.applicationEventListeners[eventID] = append(em.app.applicationEventListeners[eventID], listener)
if em.app.impl != nil {
go func() {
defer handlePanic()
em.app.impl.on(eventID)
}()
}
return func() {
// lock the map
em.app.applicationEventListenersLock.Lock()
defer em.app.applicationEventListenersLock.Unlock()
// Remove listener
em.app.applicationEventListeners[eventID] = slices.DeleteFunc(em.app.applicationEventListeners[eventID], func(l *EventListener) bool {
return l == listener
})
}
}
// RegisterApplicationEventHook registers an application event hook
func (em *EventManager) RegisterApplicationEventHook(eventType events.ApplicationEventType, callback func(event *ApplicationEvent)) func() {
eventID := uint(eventType)
em.app.applicationEventHooksLock.Lock()
defer em.app.applicationEventHooksLock.Unlock()
thisHook := &eventHook{
callback: callback,
}
em.app.applicationEventHooks[eventID] = append(em.app.applicationEventHooks[eventID], thisHook)
return func() {
em.app.applicationEventHooksLock.Lock()
em.app.applicationEventHooks[eventID] = slices.DeleteFunc(em.app.applicationEventHooks[eventID], func(h *eventHook) bool {
return h == thisHook
})
em.app.applicationEventHooksLock.Unlock()
}
}
// Dispatch dispatches an event to listeners (internal use)
func (em *EventManager) dispatch(event *CustomEvent) {
// Snapshot listeners under Lock
em.app.wailsEventListenerLock.Lock()
listeners := slices.Clone(em.app.wailsEventListeners)
em.app.wailsEventListenerLock.Unlock()
for _, listener := range listeners {
if event.IsCancelled() {
return
}
listener.DispatchWailsEvent(event)
}
}
// HandleApplicationEvent handles application events (internal use)
func (em *EventManager) handleApplicationEvent(event *ApplicationEvent) {
defer handlePanic()
em.app.applicationEventListenersLock.RLock()
listeners, ok := em.app.applicationEventListeners[event.Id]
em.app.applicationEventListenersLock.RUnlock()
if !ok {
return
}
// Process Hooks
em.app.applicationEventHooksLock.RLock()
hooks, ok := em.app.applicationEventHooks[event.Id]
em.app.applicationEventHooksLock.RUnlock()
if ok {
for _, thisHook := range hooks {
thisHook.callback(event)
if event.IsCancelled() {
return
}
}
}
for _, listener := range listeners {
go func() {
if event.IsCancelled() {
return
}
defer handlePanic()
listener.callback(event)
}()
}
}

View File

@@ -0,0 +1,366 @@
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
}

View File

@@ -0,0 +1,33 @@
//go:build android
package application
import "github.com/wailsapp/wails/v3/pkg/events"
// Map platform events → common events (same pattern as macOS & others).
// setupCommonEvents copies the source event's context, so any data attached on
// the Android side (battery level, theme, …) rides along to the common event.
var commonApplicationEventMap = map[events.ApplicationEventType]events.ApplicationEventType{
events.Android.ActivityCreated: events.Common.ApplicationStarted,
events.Android.ApplicationLowMemory: events.Common.LowMemory,
events.Android.BatteryChanged: events.Common.BatteryChanged,
events.Android.NetworkChanged: events.Common.NetworkChanged,
events.Android.ThemeChanged: events.Common.ThemeChanged,
events.Android.ScreenLocked: events.Common.ScreenLocked,
events.Android.ScreenUnlocked: events.Common.ScreenUnlocked,
}
// setupCommonEvents forwards Android platform events to their common counterparts
func (a *androidApp) setupCommonEvents() {
for sourceEvent, targetEvent := range commonApplicationEventMap {
sourceEvent := sourceEvent
targetEvent := targetEvent
a.parent.Event.OnApplicationEvent(sourceEvent, func(event *ApplicationEvent) {
androidDebugLogf("[events_common_android.go] forwarding android event %d → common %d", sourceEvent, targetEvent)
applicationEvents <- &ApplicationEvent{
Id: uint(targetEvent),
ctx: event.ctx,
}
})
}
}

View File

@@ -0,0 +1,40 @@
//go:build darwin && !ios && !server
package application
import "github.com/wailsapp/wails/v3/pkg/events"
var commonApplicationEventMap = map[events.ApplicationEventType]events.ApplicationEventType{
events.Mac.ApplicationDidFinishLaunching: events.Common.ApplicationStarted,
events.Mac.ApplicationDidChangeTheme: events.Common.ThemeChanged,
events.Mac.ApplicationWillSleep: events.Common.SystemWillSleep,
events.Mac.ApplicationDidWake: events.Common.SystemDidWake,
}
func (m *macosApp) setupCommonEvents() {
for sourceEvent, targetEvent := range commonApplicationEventMap {
sourceEvent := sourceEvent
targetEvent := targetEvent
m.parent.Event.OnApplicationEvent(sourceEvent, func(event *ApplicationEvent) {
applicationEvents <- &ApplicationEvent{
Id: uint(targetEvent),
ctx: event.ctx,
}
})
}
// Handle dock icon click (applicationShouldHandleReopen) to show windows
// when there are no visible windows. This provides the expected macOS UX
// where clicking the dock icon shows a hidden app's window.
// Issue #4583: Apps with StartHidden: true should show when dock icon is clicked.
m.parent.Event.OnApplicationEvent(events.Mac.ApplicationShouldHandleReopen, func(event *ApplicationEvent) {
if !event.Context().HasVisibleWindows() {
// Show all windows that are not visible
for _, window := range m.parent.Window.GetAll() {
if !window.IsVisible() {
window.Show()
}
}
}
})
}

View File

@@ -0,0 +1,34 @@
//go:build ios
package application
import "github.com/wailsapp/wails/v3/pkg/events"
// Map platform events → common events (same pattern as macOS & others).
// setupCommonEvents copies the source event's context, so any data attached on
// the iOS side (battery level, theme, …) rides along to the common event.
var commonApplicationEventMap = map[events.ApplicationEventType]events.ApplicationEventType{
events.IOS.ApplicationDidFinishLaunching: events.Common.ApplicationStarted,
events.IOS.ApplicationDidReceiveMemoryWarning: events.Common.LowMemory,
events.IOS.BatteryChanged: events.Common.BatteryChanged,
events.IOS.NetworkChanged: events.Common.NetworkChanged,
events.IOS.ThemeChanged: events.Common.ThemeChanged,
events.IOS.ScreenLocked: events.Common.ScreenLocked,
events.IOS.ScreenUnlocked: events.Common.ScreenUnlocked,
}
// setupCommonEvents forwards iOS platform events to their common counterparts
func (i *iosApp) setupCommonEvents() {
for sourceEvent, targetEvent := range commonApplicationEventMap {
sourceEvent := sourceEvent
targetEvent := targetEvent
i.parent.Event.OnApplicationEvent(sourceEvent, func(event *ApplicationEvent) {
// Log the forwarding so we can see every emitted event in iOS NSLog
iosConsoleLogf("info", " [events_common_ios.go] Forwarding iOS event %d → common %d", sourceEvent, targetEvent)
applicationEvents <- &ApplicationEvent{
Id: uint(targetEvent),
ctx: event.ctx,
}
})
}
}

View File

@@ -0,0 +1,25 @@
//go:build linux && !android && !server
package application
import "github.com/wailsapp/wails/v3/pkg/events"
var commonApplicationEventMap = map[events.ApplicationEventType]events.ApplicationEventType{
events.Linux.ApplicationStartup: events.Common.ApplicationStarted,
events.Linux.SystemThemeChanged: events.Common.ThemeChanged,
events.Linux.SystemWillSleep: events.Common.SystemWillSleep,
events.Linux.SystemDidWake: events.Common.SystemDidWake,
}
func (a *linuxApp) setupCommonEvents() {
for sourceEvent, targetEvent := range commonApplicationEventMap {
sourceEvent := sourceEvent
targetEvent := targetEvent
a.parent.Event.OnApplicationEvent(sourceEvent, func(event *ApplicationEvent) {
applicationEvents <- &ApplicationEvent{
Id: uint(targetEvent),
ctx: event.ctx,
}
})
}
}

View File

@@ -0,0 +1,10 @@
//go:build server
package application
// setupCommonEvents sets up common application events for server mode.
// In server mode, there are no platform-specific events to map,
// so this is a no-op.
func (h *serverApp) setupCommonEvents() {
// No-op: server mode has no platform-specific events to map
}

View File

@@ -0,0 +1,25 @@
//go:build windows && !server
package application
import "github.com/wailsapp/wails/v3/pkg/events"
var commonApplicationEventMap = map[events.ApplicationEventType]events.ApplicationEventType{
events.Windows.SystemThemeChanged: events.Common.ThemeChanged,
events.Windows.ApplicationStarted: events.Common.ApplicationStarted,
events.Windows.APMSuspend: events.Common.SystemWillSleep,
events.Windows.APMResumeAutomatic: events.Common.SystemDidWake,
}
func (m *windowsApp) setupCommonEvents() {
for sourceEvent, targetEvent := range commonApplicationEventMap {
sourceEvent := sourceEvent
targetEvent := targetEvent
m.parent.Event.OnApplicationEvent(sourceEvent, func(event *ApplicationEvent) {
applicationEvents <- &ApplicationEvent{
Id: uint(targetEvent),
ctx: event.ctx,
}
})
}
}

View File

@@ -0,0 +1,7 @@
//go:build production || !strictevents
package application
func eventRegistered(name string) {}
func warnAboutUnregisteredEvent(name string) {}

View File

@@ -0,0 +1,27 @@
//go:build !production && strictevents
package application
import (
"sync"
)
var knownUnregisteredEvents sync.Map
func eventRegistered(name string) {
knownUnregisteredEvents.Delete(name)
}
func warnAboutUnregisteredEvent(name string) {
// Perform a load first to avoid thrashing the map with unnecessary swaps.
if _, known := knownUnregisteredEvents.Load(name); known {
return
}
// Perform a swap to synchronize with concurrent emissions.
if _, known := knownUnregisteredEvents.Swap(name, true); known {
return
}
globalApplication.warning("unregistered event name '%s'", name)
}

View File

@@ -0,0 +1,195 @@
//go:build darwin && !ios && !server
package application
/*
#cgo CFLAGS: -mmacosx-version-min=10.13
#cgo LDFLAGS: -framework Carbon
#include <Carbon/Carbon.h>
extern void globalShortcutCallback(int id);
static EventHandlerRef gHotKeyHandler = NULL;
// hotKeyHandlerProc is the single Carbon event handler that receives every
// kEventHotKeyPressed event. It extracts the EventHotKeyID we set at
// registration time and forwards the numeric id back into Go.
static OSStatus hotKeyHandlerProc(EventHandlerCallRef nextHandler, EventRef theEvent, void *userData) {
EventHotKeyID hkID;
OSStatus status = GetEventParameter(theEvent, kEventParamDirectObject, typeEventHotKeyID,
NULL, sizeof(hkID), NULL, &hkID);
if (status == noErr) {
globalShortcutCallback((int)hkID.id);
}
return noErr;
}
// installHotKeyHandler installs the shared handler exactly once.
static void installHotKeyHandler(void) {
if (gHotKeyHandler != NULL) {
return;
}
EventTypeSpec evt;
evt.eventClass = kEventClassKeyboard;
evt.eventKind = kEventHotKeyPressed;
InstallApplicationEventHandler(&hotKeyHandlerProc, 1, &evt, NULL, &gHotKeyHandler);
}
// registerHotKey binds (keyCode, modifiers) to id and returns the OSStatus.
// On success *outRef receives the hot key reference used to unregister later.
static int registerHotKey(unsigned int keyCode, unsigned int modifiers, int id, EventHotKeyRef *outRef) {
installHotKeyHandler();
EventHotKeyID hkID;
hkID.signature = 'WLgs'; // "Wails global shortcut"
hkID.id = (unsigned int)id;
OSStatus status = RegisterEventHotKey(keyCode, modifiers, hkID,
GetApplicationEventTarget(), 0, outRef);
return (int)status;
}
static int unregisterHotKey(EventHotKeyRef ref) {
if (ref == NULL) {
return 0;
}
return (int)UnregisterEventHotKey(ref);
}
*/
import "C"
import (
"fmt"
)
// Carbon classic modifier masks (Events.h). RegisterEventHotKey expects these,
// not the Cocoa NSEventModifierFlag values.
const (
carbonCmdKey = 0x0100
carbonShiftKey = 0x0200
carbonOptionKey = 0x0800
carbonControlKey = 0x1000
)
// macosGlobalShortcuts implements globalShortcutImpl using the Carbon Event
// Manager's RegisterEventHotKey API. Carbon's hot key API is the standard,
// still-supported mechanism for system-wide hot keys on macOS and - unlike a
// CGEventTap or an NSEvent global monitor - does not require Accessibility
// permission.
type macosGlobalShortcuts struct {
manager *GlobalShortcutManager
refs map[int]C.EventHotKeyRef
}
func newGlobalShortcutImpl(manager *GlobalShortcutManager) globalShortcutImpl {
return &macosGlobalShortcuts{
manager: manager,
refs: make(map[int]C.EventHotKeyRef),
}
}
func (g *macosGlobalShortcuts) register(id int, accel *accelerator) error {
keyCode, ok := macKeyCodes[accel.Key]
if !ok {
return fmt.Errorf("key %q is not supported as a global shortcut on macOS", accel.Key)
}
var mods C.uint
for _, m := range accel.Modifiers {
switch m {
case CmdOrCtrlKey, SuperKey:
mods |= carbonCmdKey
case ControlKey:
mods |= carbonControlKey
case OptionOrAltKey:
mods |= carbonOptionKey
case ShiftKey:
mods |= carbonShiftKey
}
}
var ref C.EventHotKeyRef
status := C.registerHotKey(C.uint(keyCode), mods, C.int(id), &ref)
if status != 0 {
// -9878 (eventHotKeyExistsErr) means the combination is already taken.
if status == -9878 {
return fmt.Errorf("the shortcut is already registered (possibly by another application) (OSStatus %d)", int(status))
}
return fmt.Errorf("RegisterEventHotKey failed (OSStatus %d)", int(status))
}
g.refs[id] = ref
return nil
}
func (g *macosGlobalShortcuts) unregister(id int) error {
ref, ok := g.refs[id]
if !ok {
return nil
}
delete(g.refs, id)
if status := C.unregisterHotKey(ref); status != 0 {
return fmt.Errorf("UnregisterEventHotKey failed (OSStatus %d)", int(status))
}
return nil
}
func (g *macosGlobalShortcuts) unregisterAll() error {
var firstErr error
for id := range g.refs {
if err := g.unregister(id); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
//export globalShortcutCallback
func globalShortcutCallback(id C.int) {
if globalApplication != nil && globalApplication.GlobalShortcut != nil {
globalApplication.GlobalShortcut.dispatch(int(id))
}
}
// macKeyCodes maps Wails accelerator key names (already lower-cased by
// parseAccelerator) to macOS hardware virtual key codes (kVK_* from Carbon's
// HIToolbox/Events.h).
//
// NOTE: macOS hot keys are bound to *hardware* key codes, not characters, so
// this table assumes a standard ANSI/QWERTY physical layout. On non-QWERTY
// layouts the physical key in the QWERTY position is what triggers the
// shortcut. This matches the behaviour of essentially every macOS global hot
// key implementation; a layout-aware mapping (via UCKeyTranslate) could be
// added later if required.
var macKeyCodes = map[string]int{
// Letters
"a": 0, "s": 1, "d": 2, "f": 3, "h": 4, "g": 5, "z": 6, "x": 7,
"c": 8, "v": 9, "b": 11, "q": 12, "w": 13, "e": 14, "r": 15, "y": 16,
"t": 17, "o": 31, "u": 32, "i": 34, "p": 35, "l": 37, "j": 38, "k": 40,
"n": 45, "m": 46,
// Number row
"1": 18, "2": 19, "3": 20, "4": 21, "6": 22, "5": 23, "9": 25, "7": 26,
"8": 28, "0": 29,
// Punctuation
"=": 24, "-": 27, "]": 30, "[": 33, "'": 39, ";": 41, "\\": 42,
",": 43, "/": 44, ".": 47, "`": 50, "+": 24,
// Named keys
"return": 36,
"enter": 36,
"tab": 48,
"space": 49,
"backspace": 51, // kVK_Delete (labelled "delete" on Mac keyboards)
"delete": 117, // kVK_ForwardDelete
"escape": 53,
"home": 115,
"page up": 116,
"end": 119,
"page down": 121,
"left": 123,
"right": 124,
"down": 125,
"up": 126,
// Function keys
"f1": 122, "f2": 120, "f3": 99, "f4": 118, "f5": 96, "f6": 97,
"f7": 98, "f8": 100, "f9": 101, "f10": 109, "f11": 103, "f12": 111,
"f13": 105, "f14": 107, "f15": 113, "f16": 106, "f17": 64, "f18": 79,
"f19": 80, "f20": 90,
}

View File

@@ -0,0 +1,38 @@
//go:build linux && cgo && !android && !server
package application
import (
"os"
"strings"
)
// newGlobalShortcutImpl selects the appropriate Linux backend.
//
// On X11 sessions the XGrabKey-based backend is used: it is self-contained,
// requires no portal support and grabs the exact accelerator requested.
//
// On Wayland sessions there is, by design, no way for a client to grab keys
// directly. The only sanctioned mechanism is the XDG Desktop Portal's
// org.freedesktop.portal.GlobalShortcuts interface, so the portal backend is
// used there. Note that under the portal the compositor (and ultimately the
// user) decides the final key binding; see portalGlobalShortcuts.
func newGlobalShortcutImpl(manager *GlobalShortcutManager) globalShortcutImpl {
if isWaylandSession() {
return newPortalGlobalShortcuts(manager)
}
return newX11GlobalShortcuts(manager)
}
// isWaylandSession reports whether the process is running under a Wayland
// session. XDG_SESSION_TYPE is authoritative when set; otherwise the presence
// of WAYLAND_DISPLAY is used as a fallback.
func isWaylandSession() bool {
switch strings.ToLower(os.Getenv("XDG_SESSION_TYPE")) {
case "wayland":
return true
case "x11":
return false
}
return os.Getenv("WAYLAND_DISPLAY") != ""
}

View File

@@ -0,0 +1,404 @@
//go:build linux && cgo && !android && !server
package application
import (
"fmt"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/godbus/dbus/v5"
)
const (
portalService = "org.freedesktop.portal.Desktop"
portalPath = "/org/freedesktop/portal/desktop"
portalShortcutIf = "org.freedesktop.portal.GlobalShortcuts"
portalRequestIf = "org.freedesktop.portal.Request"
)
// portalShortcut is one shortcut as known to the portal backend.
type portalShortcut struct {
id int
trigger string // preferred trigger, e.g. "CTRL+SHIFT+a"
desc string
}
// portalGlobalShortcuts implements globalShortcutImpl on Wayland using the XDG
// Desktop Portal's org.freedesktop.portal.GlobalShortcuts interface.
//
// IMPORTANT semantic difference from the X11/macOS/Windows backends: the portal
// only takes a *preferred* trigger. The compositor - and ultimately the user -
// decides the final key combination, and may change or reject it. The callback
// still fires when the (possibly remapped) shortcut is activated, but the exact
// keys are not guaranteed to match what was requested. IsRegistered/GetAll
// therefore report what the application asked for, not what the compositor
// bound.
//
// All D-Bus work runs on a dedicated goroutine so that register/unregister
// never block the UI thread on a (potentially interactive) portal call.
type portalGlobalShortcuts struct {
manager *GlobalShortcutManager
mu sync.Mutex
desired map[int]portalShortcut // current desired set, keyed by numeric id
cmds chan func() // serialized onto the worker goroutine
tokenSeq int
sessionWG sync.Once
}
func newPortalGlobalShortcuts(manager *GlobalShortcutManager) globalShortcutImpl {
return &portalGlobalShortcuts{
manager: manager,
desired: make(map[int]portalShortcut),
cmds: make(chan func(), 32),
}
}
func (p *portalGlobalShortcuts) ensureWorker() {
p.sessionWG.Do(func() {
go p.worker()
})
}
func (p *portalGlobalShortcuts) register(id int, accel *accelerator) error {
p.mu.Lock()
p.desired[id] = portalShortcut{
id: id,
trigger: portalTrigger(accel),
desc: accel.String(),
}
p.mu.Unlock()
p.ensureWorker()
p.requestRebind()
return nil
}
func (p *portalGlobalShortcuts) unregister(id int) error {
p.mu.Lock()
_, ok := p.desired[id]
delete(p.desired, id)
p.mu.Unlock()
if ok {
p.requestRebind()
}
return nil
}
func (p *portalGlobalShortcuts) unregisterAll() error {
p.mu.Lock()
p.desired = make(map[int]portalShortcut)
p.mu.Unlock()
p.requestRebind()
return nil
}
// requestRebind asks the worker to (re)bind the full desired set. Binds are
// debounced so that the burst of register() calls applications make at startup
// collapses into a single portal interaction.
func (p *portalGlobalShortcuts) requestRebind() {
select {
case p.cmds <- p.rebind:
default:
// A rebind is already queued; the worker reads the latest desired set.
}
}
func (p *portalGlobalShortcuts) snapshot() []portalShortcut {
p.mu.Lock()
defer p.mu.Unlock()
out := make([]portalShortcut, 0, len(p.desired))
for _, s := range p.desired {
out = append(out, s)
}
sort.Slice(out, func(i, j int) bool { return out[i].id < out[j].id })
return out
}
// worker owns the D-Bus connection and serializes all portal interaction.
func (p *portalGlobalShortcuts) worker() {
conn, err := dbus.ConnectSessionBus()
if err != nil {
p.manager.app.handleError(fmt.Errorf("global shortcuts: cannot connect to session bus: %w", err))
return
}
defer conn.Close()
w := &portalWorker{
p: p,
conn: conn,
pending: make(map[dbus.ObjectPath]chan portalResponse),
shortcut: make(map[string]int),
}
sender := conn.Names()[0]
w.senderToken = strings.ReplaceAll(strings.TrimPrefix(sender, ":"), ".", "_")
// One signal channel for everything: Request.Response (method results) and
// GlobalShortcuts.Activated (shortcut presses). A dedicated goroutine reads
// it so that synchronous portal calls (which block waiting for their
// Response) do not starve signal delivery.
sigs := make(chan *dbus.Signal, 32)
conn.Signal(sigs)
_ = conn.AddMatchSignal(dbus.WithMatchInterface(portalRequestIf), dbus.WithMatchMember("Response"))
_ = conn.AddMatchSignal(dbus.WithMatchInterface(portalShortcutIf), dbus.WithMatchMember("Activated"))
go func() {
for sig := range sigs {
w.handleSignal(sig)
}
}()
if err := w.createSession(); err != nil {
p.manager.app.handleError(fmt.Errorf("global shortcuts: portal session failed: %w", err))
return
}
// Debounce timer for coalescing the startup burst of register() calls into
// a single BindShortcuts (and thus a single portal interaction).
var debounce *time.Timer
var debounceC <-chan time.Time
rebindPending := false
for {
select {
case _, ok := <-p.cmds:
if !ok {
return
}
rebindPending = true
if debounce == nil {
debounce = time.NewTimer(150 * time.Millisecond)
debounceC = debounce.C
} else {
debounce.Reset(150 * time.Millisecond)
}
case <-debounceC:
if rebindPending {
rebindPending = false
w.bindShortcuts()
}
}
}
}
// rebind is the command pushed onto cmds; the actual work happens in the worker
// loop via the debounce timer.
func (p *portalGlobalShortcuts) rebind() {}
type portalResponse struct {
code uint32
results map[string]dbus.Variant
}
type portalWorker struct {
p *portalGlobalShortcuts
conn *dbus.Conn
senderToken string
sessionPath dbus.ObjectPath
mu sync.Mutex
pending map[dbus.ObjectPath]chan portalResponse
shortcut map[string]int // portal shortcut id string -> numeric id
}
func (w *portalWorker) nextToken(prefix string) string {
w.p.mu.Lock()
w.p.tokenSeq++
seq := w.p.tokenSeq
w.p.mu.Unlock()
return fmt.Sprintf("wails_%s_%d", prefix, seq)
}
func (w *portalWorker) requestPath(token string) dbus.ObjectPath {
return dbus.ObjectPath("/org/freedesktop/portal/desktop/request/" + w.senderToken + "/" + token)
}
// call invokes a portal method that follows the Request/Response pattern and
// blocks (on the worker goroutine) until the Response signal arrives.
func (w *portalWorker) call(method string, options map[string]dbus.Variant, args ...interface{}) (portalResponse, error) {
token := w.nextToken("req")
options["handle_token"] = dbus.MakeVariant(token)
expected := w.requestPath(token)
respCh := make(chan portalResponse, 1)
w.mu.Lock()
w.pending[expected] = respCh
w.mu.Unlock()
defer func() {
w.mu.Lock()
delete(w.pending, expected)
w.mu.Unlock()
}()
// Build the argument list: portal request methods take their own args
// followed by the options dictionary last.
callArgs := append(append([]interface{}{}, args...), options)
obj := w.conn.Object(portalService, portalPath)
var handle dbus.ObjectPath
if err := obj.Call(portalShortcutIf+"."+method, 0, callArgs...).Store(&handle); err != nil {
return portalResponse{}, err
}
// The real request path is the one the portal returns; results arrive there.
if handle != expected {
w.mu.Lock()
w.pending[handle] = respCh
w.mu.Unlock()
defer func() {
w.mu.Lock()
delete(w.pending, handle)
w.mu.Unlock()
}()
}
select {
case resp := <-respCh:
return resp, nil
case <-time.After(30 * time.Second):
return portalResponse{}, fmt.Errorf("timed out waiting for portal response to %s", method)
}
}
func (w *portalWorker) createSession() error {
sessionToken := w.nextToken("session")
opts := map[string]dbus.Variant{
"session_handle_token": dbus.MakeVariant(sessionToken),
}
resp, err := w.call("CreateSession", opts)
if err != nil {
return err
}
if resp.code != 0 {
return fmt.Errorf("CreateSession refused (response %d)", resp.code)
}
if v, ok := resp.results["session_handle"]; ok {
if s, ok := v.Value().(string); ok {
w.sessionPath = dbus.ObjectPath(s)
}
}
if w.sessionPath == "" {
return fmt.Errorf("portal did not return a session handle")
}
return nil
}
func (w *portalWorker) bindShortcuts() {
if w.sessionPath == "" {
return
}
shortcuts := w.p.snapshot()
// Build the a(sa{sv}) shortcuts argument and refresh the id mapping.
newShortcut := make(map[string]int, len(shortcuts))
type entry struct {
ID string
Props map[string]dbus.Variant
}
list := make([]entry, 0, len(shortcuts))
for _, s := range shortcuts {
idStr := strconv.Itoa(s.id)
newShortcut[idStr] = s.id
props := map[string]dbus.Variant{
"description": dbus.MakeVariant(s.desc),
}
if s.trigger != "" {
props["preferred_trigger"] = dbus.MakeVariant(s.trigger)
}
list = append(list, entry{ID: idStr, Props: props})
}
w.mu.Lock()
w.shortcut = newShortcut
w.mu.Unlock()
resp, err := w.call("BindShortcuts", map[string]dbus.Variant{}, w.sessionPath, list, "")
if err != nil {
w.p.manager.app.handleError(fmt.Errorf("global shortcuts: BindShortcuts failed: %w", err))
return
}
if resp.code != 0 {
// response 1 = user cancelled the portal dialog, 2 = ended.
w.p.manager.app.handleError(fmt.Errorf("global shortcuts: portal did not grant shortcuts (response %d); they will not fire", resp.code))
}
}
func (w *portalWorker) handleSignal(sig *dbus.Signal) {
switch {
case strings.HasSuffix(sig.Name, ".Response"):
w.mu.Lock()
ch, ok := w.pending[sig.Path]
w.mu.Unlock()
if ok {
var resp portalResponse
if len(sig.Body) >= 1 {
if code, ok := sig.Body[0].(uint32); ok {
resp.code = code
}
}
if len(sig.Body) >= 2 {
if results, ok := sig.Body[1].(map[string]dbus.Variant); ok {
resp.results = results
}
}
select {
case ch <- resp:
default:
}
}
case strings.HasSuffix(sig.Name, ".Activated"):
// Activated(o session_handle, s shortcut_id, t timestamp, a{sv} options)
if len(sig.Body) < 2 {
return
}
idStr, ok := sig.Body[1].(string)
if !ok {
return
}
w.mu.Lock()
id, ok := w.shortcut[idStr]
w.mu.Unlock()
if ok {
w.p.manager.dispatch(id)
}
}
}
// portalTrigger renders an accelerator as a portal "preferred_trigger" string.
// The portal trigger syntax uses the modifier names CTRL, ALT, SHIFT and LOGO
// joined to the key with "+". This is only a preference; the compositor may
// bind a different combination.
func portalTrigger(accel *accelerator) string {
var parts []string
hasCtrl, hasAlt, hasShift, hasSuper := false, false, false, false
for _, m := range accel.Modifiers {
switch m {
case CmdOrCtrlKey, ControlKey:
hasCtrl = true
case OptionOrAltKey:
hasAlt = true
case ShiftKey:
hasShift = true
case SuperKey:
hasSuper = true
}
}
if hasCtrl {
parts = append(parts, "CTRL")
}
if hasAlt {
parts = append(parts, "ALT")
}
if hasShift {
parts = append(parts, "SHIFT")
}
if hasSuper {
parts = append(parts, "LOGO")
}
key, ok := x11KeysymNames[accel.Key]
if !ok {
key = accel.Key
}
parts = append(parts, key)
return strings.Join(parts, "+")
}

View File

@@ -0,0 +1,364 @@
//go:build linux && cgo && !android && !server
package application
/*
#cgo pkg-config: x11
#include <X11/Xlib.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <sys/select.h>
// A grab error flag, set by the X error handler. Because every Xlib call this
// file makes happens on a single goroutine (the event loop), no locking is
// needed around it.
static int gGrabError = 0;
static int grabErrorHandler(Display *d, XErrorEvent *e) {
gGrabError = 1;
return 0;
}
static Display *gsOpenDisplay(void) {
Display *d = XOpenDisplay(NULL);
if (d != NULL) {
XSetErrorHandler(grabErrorHandler);
}
return d;
}
static void gsCloseDisplay(Display *d) {
if (d != NULL) {
XCloseDisplay(d);
}
}
// gsKeycodeForName resolves an X keysym name (e.g. "a", "F1", "Return") to a
// hardware keycode for this display. Returns 0 if unknown.
static unsigned int gsKeycodeForName(Display *d, const char *name) {
KeySym ks = XStringToKeysym(name);
if (ks == NoSymbol) {
return 0;
}
return (unsigned int)XKeysymToKeycode(d, ks);
}
// The lock modifiers (CapsLock, NumLock) alter the event state, so each shortcut
// must be grabbed for every combination of them or it will not fire while a
// lock is engaged.
static const unsigned int gsLockMasks[4] = {0, LockMask, Mod2Mask, LockMask | Mod2Mask};
// gsGrabKey grabs keycode+modmask (and every lock-mask variant) on the root
// window. Returns 0 on success, -1 if the grab was refused (BadAccess), which
// happens when another client already holds the combination.
static int gsGrabKey(Display *d, unsigned int keycode, unsigned int modmask) {
Window root = DefaultRootWindow(d);
gGrabError = 0;
for (int i = 0; i < 4; i++) {
XGrabKey(d, keycode, modmask | gsLockMasks[i], root, False, GrabModeAsync, GrabModeAsync);
}
XSync(d, False);
return gGrabError ? -1 : 0;
}
static void gsUngrabKey(Display *d, unsigned int keycode, unsigned int modmask) {
Window root = DefaultRootWindow(d);
for (int i = 0; i < 4; i++) {
XUngrabKey(d, keycode, modmask | gsLockMasks[i], root);
}
XSync(d, False);
}
// gsWaitForEvent blocks until either an X KeyPress arrives or wakeFd becomes
// readable. Returns:
// 1 -> a KeyPress; *keycode and *state are filled in
// 0 -> woken via wakeFd (caller should service its request queue)
// -1 -> the connection was lost
static int gsWaitForEvent(Display *d, int wakeFd, unsigned int *keycode, unsigned int *state) {
int xfd = ConnectionNumber(d);
for (;;) {
while (XPending(d) > 0) {
XEvent ev;
XNextEvent(d, &ev);
if (ev.type == KeyPress) {
*keycode = ev.xkey.keycode;
*state = ev.xkey.state;
return 1;
}
}
fd_set fds;
FD_ZERO(&fds);
FD_SET(xfd, &fds);
FD_SET(wakeFd, &fds);
int maxfd = xfd > wakeFd ? xfd : wakeFd;
int r = select(maxfd + 1, &fds, NULL, NULL, NULL);
if (r < 0) {
if (errno == EINTR) {
continue;
}
return -1;
}
if (FD_ISSET(wakeFd, &fds)) {
char buf[64];
while (read(wakeFd, buf, sizeof(buf)) > 0) {
}
return 0;
}
}
}
*/
import "C"
import (
"fmt"
"runtime"
"sync"
"syscall"
"unsafe"
)
// X11 keyboard state mask bits (from X.h) that we treat as significant
// modifiers. LockMask (CapsLock) and Mod2Mask (NumLock) are deliberately
// excluded so that shortcuts fire regardless of those locks.
const (
x11ShiftMask = 1 << 0 // ShiftMask
x11ControlMask = 1 << 2 // ControlMask
x11Mod1Mask = 1 << 3 // Mod1Mask (Alt)
x11Mod4Mask = 1 << 6 // Mod4Mask (Super)
)
const x11SignificantMask = x11ShiftMask | x11ControlMask | x11Mod1Mask | x11Mod4Mask
// x11Binding records what was grabbed so it can be matched against incoming
// events and ungrabbed later.
type x11Binding struct {
keycode uint
modMask uint
}
// x11GlobalShortcuts implements globalShortcutImpl on X11 using XGrabKey. All
// Xlib calls are funnelled onto a single event-loop goroutine; register and
// unregister hand work to it over opCh and wake it through a self-pipe. This
// keeps Xlib single-threaded (no XInitThreads) while still letting the loop
// block in select().
type x11GlobalShortcuts struct {
manager *GlobalShortcutManager
display *C.Display
wakeR int
wakeW int
opCh chan func()
mu sync.RWMutex
bindings map[int]x11Binding // id -> grabbed keycode/mask
match map[x11Binding]int // keycode/mask -> id (for event lookup)
startErr error
}
func newX11GlobalShortcuts(manager *GlobalShortcutManager) globalShortcutImpl {
g := &x11GlobalShortcuts{
manager: manager,
opCh: make(chan func(), 16),
bindings: make(map[int]x11Binding),
match: make(map[x11Binding]int),
}
g.display = C.gsOpenDisplay()
if g.display == nil {
g.startErr = fmt.Errorf("could not open an X11 display (global shortcuts via X require an X11 session)")
return g
}
fds := make([]int, 2)
if err := syscall.Pipe(fds); err != nil {
C.gsCloseDisplay(g.display)
g.display = nil
g.startErr = fmt.Errorf("could not create wake pipe: %w", err)
return g
}
g.wakeR, g.wakeW = fds[0], fds[1]
syscall.SetNonblock(g.wakeR, true)
syscall.SetNonblock(g.wakeW, true)
go g.eventLoop()
return g
}
func (g *x11GlobalShortcuts) wake() {
var b [1]byte
_, _ = syscall.Write(g.wakeW, b[:])
}
// run executes fn on the event-loop goroutine and waits for it to complete.
func (g *x11GlobalShortcuts) run(fn func()) {
done := make(chan struct{})
g.opCh <- func() {
fn()
close(done)
}
g.wake()
<-done
}
func (g *x11GlobalShortcuts) eventLoop() {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
for {
var keycode, state C.uint
r := C.gsWaitForEvent(g.display, C.int(g.wakeR), &keycode, &state)
switch r {
case 0:
g.drainOps()
case 1:
b := x11Binding{keycode: uint(keycode), modMask: uint(state) & x11SignificantMask}
g.mu.RLock()
id, ok := g.match[b]
g.mu.RUnlock()
if ok {
g.manager.dispatch(id)
}
default:
return
}
}
}
func (g *x11GlobalShortcuts) drainOps() {
for {
select {
case op := <-g.opCh:
op()
default:
return
}
}
}
func (g *x11GlobalShortcuts) modMask(accel *accelerator) uint {
var mask uint
for _, m := range accel.Modifiers {
switch m {
case CmdOrCtrlKey, ControlKey:
mask |= x11ControlMask
case OptionOrAltKey:
mask |= x11Mod1Mask
case ShiftKey:
mask |= x11ShiftMask
case SuperKey:
mask |= x11Mod4Mask
}
}
return mask
}
func (g *x11GlobalShortcuts) register(id int, accel *accelerator) error {
if g.startErr != nil {
return g.startErr
}
keysymName, ok := x11KeysymNames[accel.Key]
if !ok {
return fmt.Errorf("key %q is not supported as a global shortcut", accel.Key)
}
modMask := g.modMask(accel)
var regErr error
var binding x11Binding
g.run(func() {
cname := C.CString(keysymName)
defer C.free(unsafe.Pointer(cname))
keycode := uint(C.gsKeycodeForName(g.display, cname))
if keycode == 0 {
regErr = fmt.Errorf("key %q has no keycode on this keyboard", accel.Key)
return
}
if C.gsGrabKey(g.display, C.uint(keycode), C.uint(modMask)) != 0 {
regErr = fmt.Errorf("the shortcut is already registered (possibly by another application)")
return
}
binding = x11Binding{keycode: keycode, modMask: modMask}
})
if regErr != nil {
return regErr
}
g.mu.Lock()
g.bindings[id] = binding
g.match[binding] = id
g.mu.Unlock()
return nil
}
func (g *x11GlobalShortcuts) unregister(id int) error {
g.mu.Lock()
binding, ok := g.bindings[id]
if ok {
delete(g.bindings, id)
delete(g.match, binding)
}
g.mu.Unlock()
if !ok {
return nil
}
g.run(func() {
C.gsUngrabKey(g.display, C.uint(binding.keycode), C.uint(binding.modMask))
})
return nil
}
func (g *x11GlobalShortcuts) unregisterAll() error {
g.mu.Lock()
bindings := g.bindings
g.bindings = make(map[int]x11Binding)
g.match = make(map[x11Binding]int)
g.mu.Unlock()
if len(bindings) == 0 {
return nil
}
g.run(func() {
for _, b := range bindings {
C.gsUngrabKey(g.display, C.uint(b.keycode), C.uint(b.modMask))
}
})
return nil
}
// x11KeysymNames maps Wails accelerator key names (already lower-cased by
// parseAccelerator) to X keysym names accepted by XStringToKeysym. Letters and
// digits map to themselves.
var x11KeysymNames = map[string]string{
"a": "a", "b": "b", "c": "c", "d": "d", "e": "e", "f": "f", "g": "g",
"h": "h", "i": "i", "j": "j", "k": "k", "l": "l", "m": "m", "n": "n",
"o": "o", "p": "p", "q": "q", "r": "r", "s": "s", "t": "t", "u": "u",
"v": "v", "w": "w", "x": "x", "y": "y", "z": "z",
"0": "0", "1": "1", "2": "2", "3": "3", "4": "4",
"5": "5", "6": "6", "7": "7", "8": "8", "9": "9",
// Punctuation
";": "semicolon", "=": "equal", ",": "comma", "-": "minus", ".": "period",
"/": "slash", "`": "grave", "[": "bracketleft", "\\": "backslash",
"]": "bracketright", "'": "apostrophe", "+": "plus",
// Named keys
"backspace": "BackSpace",
"tab": "Tab",
"return": "Return",
"enter": "Return",
"escape": "Escape",
"space": "space",
"page up": "Prior",
"page down": "Next",
"end": "End",
"home": "Home",
"left": "Left",
"up": "Up",
"right": "Right",
"down": "Down",
"delete": "Delete",
"numlock": "Num_Lock",
// Function keys
"f1": "F1", "f2": "F2", "f3": "F3", "f4": "F4", "f5": "F5", "f6": "F6",
"f7": "F7", "f8": "F8", "f9": "F9", "f10": "F10", "f11": "F11", "f12": "F12",
"f13": "F13", "f14": "F14", "f15": "F15", "f16": "F16", "f17": "F17",
"f18": "F18", "f19": "F19", "f20": "F20", "f21": "F21", "f22": "F22",
"f23": "F23", "f24": "F24",
}

View File

@@ -0,0 +1,294 @@
package application
import (
"fmt"
"sort"
"sync"
)
// globalShortcutImpl is the platform-specific implementation of global
// (system-wide) keyboard shortcuts. Each platform registers a shortcut with the
// operating system against an integer id that the native event handler reports
// back when the shortcut fires.
//
// All methods are called on the main thread (see GlobalShortcutManager which
// wraps every call in InvokeSync*). Implementations must not assume otherwise.
type globalShortcutImpl interface {
// register asks the OS to bind the given accelerator to id. It returns an
// error if the OS rejects the registration (for example, because another
// application already owns the shortcut).
register(id int, accel *accelerator) error
// unregister releases the OS binding for id.
unregister(id int) error
// unregisterAll releases every binding owned by this application.
unregisterAll() error
}
// globalShortcut is a single registered shortcut.
type globalShortcut struct {
id int
accelerator string // canonical, normalized accelerator string
parsed *accelerator
callback func()
}
// GlobalShortcutManager manages application-wide (global) keyboard shortcuts.
//
// Unlike menu accelerators or [KeyBindingManager] - which only fire while a
// Wails window has focus - a global shortcut fires regardless of which
// application is currently focused, as long as the Wails application is
// running.
//
// Global shortcuts are owned by the application, not by an individual window.
// Registering the same accelerator twice within the same application is
// reported as an error and the original binding is preserved; see [Register].
//
// Shortcuts may be registered before [App.Run] is called: the binding with the
// operating system is then deferred until the application starts.
type GlobalShortcutManager struct {
app *App
impl globalShortcutImpl
mu sync.Mutex
started bool // set once the app is running and pending shortcuts are flushed
byName map[string]*globalShortcut // keyed by canonical accelerator string
byID map[int]*globalShortcut // keyed by native id
pending []*globalShortcut // registered before the app started; bound on start
nextID int
}
// newGlobalShortcutManager creates a new GlobalShortcutManager instance.
func newGlobalShortcutManager(app *App) *GlobalShortcutManager {
return &GlobalShortcutManager{
app: app,
byName: make(map[string]*globalShortcut),
byID: make(map[int]*globalShortcut),
}
}
// getImpl returns the platform implementation, creating it lazily on first use
// so that platforms which do not support global shortcuts do not pay any cost
// unless the feature is actually used. Callers must hold m.mu.
func (m *GlobalShortcutManager) getImpl() globalShortcutImpl {
if m.impl == nil {
m.impl = newGlobalShortcutImpl(m)
}
return m.impl
}
// Register binds the given accelerator (for example "Ctrl+Shift+P" or
// "Cmd+Option+K") to callback. The callback is invoked - on its own goroutine -
// whenever the shortcut is pressed, even when the application does not have
// focus.
//
// Accelerators use the same syntax as menu accelerators (see SetAccelerator).
// "CmdOrCtrl" resolves to Command on macOS and Control elsewhere.
//
// Register may be called before [App.Run]; the OS binding is then performed
// when the application starts and any OS-level rejection is reported via the
// application's error handler rather than returned here.
//
// Register returns an error when:
// - the accelerator string cannot be parsed;
// - the accelerator is already registered by this application (the existing
// binding is left untouched - this is "error and preserve" semantics, see
// the package documentation on conflicting shortcuts);
// - the application is already running and the operating system rejects the
// registration, typically because another application has already claimed
// the shortcut. Behaviour in this case is platform dependent; see the
// documentation.
func (m *GlobalShortcutManager) Register(accelerator string, callback func()) error {
if callback == nil {
return fmt.Errorf("global shortcut callback must not be nil")
}
parsed, err := parseAccelerator(accelerator)
if err != nil {
return fmt.Errorf("invalid global shortcut %q: %w", accelerator, err)
}
name := parsed.String()
m.mu.Lock()
if _, exists := m.byName[name]; exists {
m.mu.Unlock()
return fmt.Errorf("global shortcut %q is already registered", name)
}
shortcut := &globalShortcut{
id: m.nextID,
accelerator: name,
parsed: parsed,
callback: callback,
}
// Reserve the id and slots before the (blocking) native call so that a
// concurrent Register of the same accelerator loses the race cleanly.
m.nextID++
m.byName[name] = shortcut
m.byID[shortcut.id] = shortcut
if !m.started {
// The application is not running yet: defer the OS binding until start.
m.pending = append(m.pending, shortcut)
m.mu.Unlock()
return nil
}
impl := m.getImpl()
m.mu.Unlock()
if regErr := InvokeSyncWithError(func() error {
return impl.register(shortcut.id, parsed)
}); regErr != nil {
// Roll back the reservation so the accelerator can be retried later.
m.mu.Lock()
delete(m.byName, name)
delete(m.byID, shortcut.id)
m.mu.Unlock()
return fmt.Errorf("failed to register global shortcut %q: %w", name, regErr)
}
return nil
}
// flushPending binds every shortcut that was registered before the application
// started. It is called once, on the main thread, during application startup.
// OS-level rejections are reported through the application error handler since
// the original Register caller has already returned.
func (m *GlobalShortcutManager) flushPending() {
m.mu.Lock()
if m.started {
m.mu.Unlock()
return
}
m.started = true
pending := m.pending
m.pending = nil
var impl globalShortcutImpl
if len(pending) > 0 {
impl = m.getImpl()
}
m.mu.Unlock()
for _, shortcut := range pending {
if err := impl.register(shortcut.id, shortcut.parsed); err != nil {
m.mu.Lock()
// Only roll back if it is still the shortcut we registered (it may
// have been Unregistered in the meantime).
if current, ok := m.byID[shortcut.id]; ok && current == shortcut {
delete(m.byName, shortcut.accelerator)
delete(m.byID, shortcut.id)
}
m.mu.Unlock()
m.app.handleError(fmt.Errorf("failed to register global shortcut %q: %w", shortcut.accelerator, err))
}
}
}
// Unregister releases the given accelerator. It returns an error if the
// accelerator is not currently registered or if the OS rejects the request.
func (m *GlobalShortcutManager) Unregister(accelerator string) error {
parsed, err := parseAccelerator(accelerator)
if err != nil {
return fmt.Errorf("invalid global shortcut %q: %w", accelerator, err)
}
name := parsed.String()
m.mu.Lock()
shortcut, exists := m.byName[name]
if !exists {
m.mu.Unlock()
return fmt.Errorf("global shortcut %q is not registered", name)
}
delete(m.byName, name)
delete(m.byID, shortcut.id)
started := m.started
impl := m.getImpl()
m.mu.Unlock()
if !started {
// Never bound with the OS yet; just drop it from the pending list.
m.removePending(shortcut)
return nil
}
return InvokeSyncWithError(func() error {
return impl.unregister(shortcut.id)
})
}
// removePending drops a shortcut from the pending queue (used when a shortcut is
// unregistered before the application has started).
func (m *GlobalShortcutManager) removePending(shortcut *globalShortcut) {
m.mu.Lock()
defer m.mu.Unlock()
for i, p := range m.pending {
if p == shortcut {
m.pending = append(m.pending[:i], m.pending[i+1:]...)
return
}
}
}
// UnregisterAll releases every global shortcut registered by this application.
// It is called automatically during application shutdown.
func (m *GlobalShortcutManager) UnregisterAll() error {
m.mu.Lock()
hadShortcuts := len(m.byName) > 0
m.byName = make(map[string]*globalShortcut)
m.byID = make(map[int]*globalShortcut)
m.pending = nil
impl := m.impl
started := m.started
m.mu.Unlock()
// Nothing was ever bound with the OS: avoid forcing the platform impl into
// existence just to tear nothing down.
if impl == nil || !started || !hadShortcuts {
return nil
}
return InvokeSyncWithError(impl.unregisterAll)
}
// IsRegistered reports whether the given accelerator is currently registered by
// this application. It returns false for accelerators that cannot be parsed.
//
// On Wayland the returned value reflects what the application requested, not
// necessarily what the compositor ultimately bound; see the package
// documentation on the global shortcuts portal.
func (m *GlobalShortcutManager) IsRegistered(accelerator string) bool {
parsed, err := parseAccelerator(accelerator)
if err != nil {
return false
}
m.mu.Lock()
defer m.mu.Unlock()
_, exists := m.byName[parsed.String()]
return exists
}
// GetAll returns the canonical accelerator strings of all shortcuts currently
// registered by this application, sorted for stable output.
func (m *GlobalShortcutManager) GetAll() []string {
m.mu.Lock()
defer m.mu.Unlock()
result := make([]string, 0, len(m.byName))
for name := range m.byName {
result = append(result, name)
}
sort.Strings(result)
return result
}
// dispatch is called by the platform implementation (from the native event
// handler) when a shortcut with the given id fires. The user callback is run on
// its own goroutine so that it cannot block the platform's main event loop -
// callbacks that need to touch the UI should marshal onto the main thread
// themselves (for example via InvokeSync).
func (m *GlobalShortcutManager) dispatch(id int) {
m.mu.Lock()
shortcut, ok := m.byID[id]
m.mu.Unlock()
if !ok || shortcut.callback == nil {
return
}
go func() {
defer handlePanic()
shortcut.callback()
}()
}

View File

@@ -0,0 +1,23 @@
//go:build ios || android || server || (linux && !cgo)
package application
import "errors"
// errGlobalShortcutsUnsupported is returned on platforms where system-wide
// global shortcuts are not available (mobile, headless/server builds).
var errGlobalShortcutsUnsupported = errors.New("global shortcuts are not supported on this platform")
type unsupportedGlobalShortcuts struct{}
func newGlobalShortcutImpl(_ *GlobalShortcutManager) globalShortcutImpl {
return &unsupportedGlobalShortcuts{}
}
func (unsupportedGlobalShortcuts) register(_ int, _ *accelerator) error {
return errGlobalShortcutsUnsupported
}
func (unsupportedGlobalShortcuts) unregister(_ int) error { return nil }
func (unsupportedGlobalShortcuts) unregisterAll() error { return nil }

View File

@@ -0,0 +1,141 @@
//go:build windows && !server
package application
import (
"fmt"
"github.com/wailsapp/wails/v3/pkg/w32"
)
// windowsGlobalShortcuts implements globalShortcutImpl using the Win32
// RegisterHotKey API. Hot keys are registered against the application's hidden
// main-thread window so that WM_HOTKEY messages are delivered to the same
// message loop the rest of the application already pumps (see wndProc's
// WM_HOTKEY case, which calls back into the manager's dispatch).
//
// RegisterHotKey is thread-affine: the WM_HOTKEY message is posted to the
// thread that owns the window passed in. Registration therefore must happen on
// the main UI thread - the GlobalShortcutManager guarantees this by wrapping
// every call in InvokeSync.
type windowsGlobalShortcuts struct {
manager *GlobalShortcutManager
ids map[int]struct{}
}
func newGlobalShortcutImpl(manager *GlobalShortcutManager) globalShortcutImpl {
return &windowsGlobalShortcuts{
manager: manager,
ids: make(map[int]struct{}),
}
}
func (g *windowsGlobalShortcuts) hwnd() (w32.HWND, error) {
app, ok := globalApplication.impl.(*windowsApp)
if !ok || app == nil || app.mainThreadWindowHWND == 0 {
return 0, fmt.Errorf("global shortcuts require the application to be running")
}
return app.mainThreadWindowHWND, nil
}
func (g *windowsGlobalShortcuts) register(id int, accel *accelerator) error {
vk, ok := winKeyCodes[accel.Key]
if !ok {
return fmt.Errorf("key %q is not supported as a global shortcut on Windows", accel.Key)
}
// MOD_NOREPEAT prevents auto-repeat from spamming the callback while the
// keys are held down.
mods := uint(w32.MOD_NOREPEAT)
for _, m := range accel.Modifiers {
switch m {
case CmdOrCtrlKey, ControlKey:
mods |= w32.MOD_CONTROL
case OptionOrAltKey:
mods |= w32.MOD_ALT
case ShiftKey:
mods |= w32.MOD_SHIFT
case SuperKey:
mods |= w32.MOD_WIN
}
}
hwnd, err := g.hwnd()
if err != nil {
return err
}
if !w32.RegisterHotKey(hwnd, id, mods, vk) {
// RegisterHotKey returns false (ERROR_HOTKEY_ALREADY_REGISTERED) when
// the combination is already owned - either by this process or, more
// commonly, by another application.
return fmt.Errorf("the shortcut is already registered (possibly by another application)")
}
g.ids[id] = struct{}{}
return nil
}
func (g *windowsGlobalShortcuts) unregister(id int) error {
if _, ok := g.ids[id]; !ok {
return nil
}
delete(g.ids, id)
hwnd, err := g.hwnd()
if err != nil {
return err
}
if !w32.UnregisterHotKey(hwnd, id) {
return fmt.Errorf("UnregisterHotKey failed for shortcut id %d", id)
}
return nil
}
func (g *windowsGlobalShortcuts) unregisterAll() error {
var firstErr error
for id := range g.ids {
if err := g.unregister(id); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
// winKeyCodes maps Wails accelerator key names (already lower-cased by
// parseAccelerator) to Windows virtual-key codes. Letters and digits map to
// their ASCII-uppercase value (VK_A == 'A' == 0x41, VK_0 == '0' == 0x30).
var winKeyCodes = map[string]uint{
// Letters
"a": 0x41, "b": 0x42, "c": 0x43, "d": 0x44, "e": 0x45, "f": 0x46,
"g": 0x47, "h": 0x48, "i": 0x49, "j": 0x4A, "k": 0x4B, "l": 0x4C,
"m": 0x4D, "n": 0x4E, "o": 0x4F, "p": 0x50, "q": 0x51, "r": 0x52,
"s": 0x53, "t": 0x54, "u": 0x55, "v": 0x56, "w": 0x57, "x": 0x58,
"y": 0x59, "z": 0x5A,
// Number row
"0": 0x30, "1": 0x31, "2": 0x32, "3": 0x33, "4": 0x34,
"5": 0x35, "6": 0x36, "7": 0x37, "8": 0x38, "9": 0x39,
// Punctuation (OEM keys, US layout)
";": 0xBA, "=": 0xBB, ",": 0xBC, "-": 0xBD, ".": 0xBE, "/": 0xBF,
"`": 0xC0, "[": 0xDB, "\\": 0xDC, "]": 0xDD, "'": 0xDE, "+": 0xBB,
// Named keys
"backspace": 0x08,
"tab": 0x09,
"return": 0x0D,
"enter": 0x0D,
"escape": 0x1B,
"space": 0x20,
"page up": 0x21,
"page down": 0x22,
"end": 0x23,
"home": 0x24,
"left": 0x25,
"up": 0x26,
"right": 0x27,
"down": 0x28,
"delete": 0x2E,
"numlock": 0x90,
// Function keys
"f1": 0x70, "f2": 0x71, "f3": 0x72, "f4": 0x73, "f5": 0x74, "f6": 0x75,
"f7": 0x76, "f8": 0x77, "f9": 0x78, "f10": 0x79, "f11": 0x7A, "f12": 0x7B,
"f13": 0x7C, "f14": 0x7D, "f15": 0x7E, "f16": 0x7F, "f17": 0x80, "f18": 0x81,
"f19": 0x82, "f20": 0x83, "f21": 0x84, "f22": 0x85, "f23": 0x86, "f24": 0x87,
}

View File

@@ -0,0 +1,10 @@
//go:build linux && !gtk3 && !android && !server
package application
func gtkDispatch(fn func()) {
go func() {
defer handlePanic()
fn()
}()
}

View File

@@ -0,0 +1,7 @@
//go:build linux && gtk3 && !android && !server
package application
func gtkDispatch(fn func()) {
InvokeAsync(fn)
}

View File

@@ -0,0 +1,22 @@
//go:build windows
package application
import (
"fmt"
"github.com/wailsapp/wails/v3/pkg/w32"
)
// NewIconFromResource loads an icon from an embedded Windows resource. It is
// available in both desktop and server builds so that services which only need
// the icon helper (e.g. notifications) compile under the `server` tag, matching
// the behaviour on macOS and Linux.
func NewIconFromResource(instance w32.HINSTANCE, resId uint16) (w32.HICON, error) {
var err error
var result w32.HICON
if result = w32.LoadIconWithResourceID(instance, resId); result == 0 {
err = fmt.Errorf("cannot load icon from resource with id %v", resId)
}
return result, err
}

View File

@@ -0,0 +1,37 @@
package application
import (
"bytes"
"image"
"image/draw"
"image/png"
)
func pngToImage(data []byte) (*image.RGBA, error) {
img, err := png.Decode(bytes.NewReader(data))
if err != nil {
return nil, err
}
bounds := img.Bounds()
rgba := image.NewRGBA(bounds)
draw.Draw(rgba, bounds, img, bounds.Min, draw.Src)
return rgba, nil
}
func ToARGB(img *image.RGBA) (int, int, []byte) {
w, h := img.Bounds().Dx(), img.Bounds().Dy()
data := make([]byte, w*h*4)
i := 0
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
r, g, b, a := img.At(x, y).RGBA()
data[i] = byte(a)
data[i+1] = byte(r)
data[i+2] = byte(g)
data[i+3] = byte(b)
i += 4
}
}
return w, h, data
}

View File

@@ -0,0 +1,9 @@
//go:build android
package application
func init() {
// On Android, we don't call runtime.LockOSThread()
// The Android runtime handles thread management via JNI
// and calling LockOSThread can interfere with the JNI environment
}

View File

@@ -0,0 +1,11 @@
//go:build !ios
package application
import "runtime"
func init() {
// Lock the main thread for desktop platforms
// This ensures UI operations happen on the main thread
runtime.LockOSThread()
}

View File

@@ -0,0 +1,9 @@
//go:build ios
package application
func init() {
// On iOS, we don't call runtime.LockOSThread()
// The iOS runtime handles thread management differently
// and calling LockOSThread can interfere with signal handling
}

View File

@@ -0,0 +1,52 @@
package application
import (
_ "embed"
"strings"
)
// inlineEventShimJS is a small ES5 script that installs
// `window.wails.Events.On / Emit` and `window._wails.dispatchWailsEvent`
// for windows whose HTML is loaded directly via WebviewWindowOptions.HTML
// rather than served by the asset server. Those pages can't import
// `/wails/runtime.js` (their origin is the literal string "null"), so
// the framework injects this fallback at construction time when the
// owning code asked for it.
//
// Injection is gated on WebviewWindowOptions.AllowSimpleEventEmit so the
// shim only ships into windows that have opted into the simple postMessage
// emit path in the first place — same security boundary that gates the
// host-side handler. See the field's GoDoc for the threat model.
//
//go:embed inline_event_shim.js
var inlineEventShimJS string
// maybeInjectInlineEventShim prepends the inline-event shim to the
// supplied HTML when the window has opted in via AllowSimpleEventEmit.
// Returns the (possibly modified) HTML unchanged otherwise.
//
// The injected `<script>` is placed at the very top of the document so
// that any inline event handlers registered later in the page can rely
// on `window.wails.Events` being present.
func maybeInjectInlineEventShim(html string, allow bool) string {
if !allow || html == "" {
return html
}
const marker = "data-wails-inline-event-shim"
if strings.Contains(html, marker) {
// Already injected (e.g. caller wrapped manually). Don't duplicate.
return html
}
script := `<script ` + marker + `>` + "\n" + inlineEventShimJS + "\n</script>\n"
// If the body starts with <!doctype …>, keep the doctype on line 1 and
// drop the script immediately after; otherwise just prepend.
lower := strings.ToLower(html)
if idx := strings.Index(lower, "<!doctype"); idx >= 0 {
closing := strings.Index(html[idx:], ">")
if closing >= 0 {
cut := idx + closing + 1
return html[:cut] + "\n" + script + html[cut:]
}
}
return script + html
}

View File

@@ -0,0 +1,67 @@
// Inline Wails events shim for InitialHTML windows.
//
// Pages loaded via WebviewWindowOptions.HTML are served with
// `window.location.origin === "null"`, so the modern HTTP runtime at
// /wails/runtime.js can never be imported (fetch fails). This shim
// installs the minimum subset of the runtime that postMessage-based
// custom-event traffic needs:
//
// * window._wails.dispatchWailsEvent — the framework calls this when
// the host emits a custom event to the page.
// * window.wails.Events.On(name, cb) — the page subscribes.
// * window.wails.Events.Emit(name) — the page fires a bare-name
// event back to the host. Routed through
// `window._wails.invoke("wails:event:emit:" + name)` which the
// framework forwards if the owning window has
// WebviewWindowOptions.AllowSimpleEventEmit set.
//
// Once the platform layer has injected window._wails.invoke the shim
// fires `wails:runtime:ready` so any pending host-side queued events
// flush. If the modern HTTP runtime later loads it will overwrite
// window.wails.Events with its richer implementation — that's fine,
// our subset is the floor not the ceiling.
//
// Kept in ES5-compatible syntax (no const/let/arrow) so older WebView
// engines that may still surface in unusual platform configurations
// don't fail on parse.
(function () {
var w = window._wails = window._wails || {};
if (window.wails && window.wails.Events) {
return; // a full runtime is already in scope
}
var listeners = Object.create(null);
w.dispatchWailsEvent = w.dispatchWailsEvent || function (ev) {
if (!ev || !ev.name) return;
var cbs = listeners[ev.name];
if (!cbs) return;
for (var i = 0; i < cbs.length; i++) {
try { cbs[i](ev); } catch (_) { /* swallow handler errors */ }
}
};
window.wails = window.wails || {};
window.wails.Events = {
On: function (name, cb) {
(listeners[name] = listeners[name] || []).push(cb);
return function () {
var arr = listeners[name];
if (!arr) return;
var i = arr.indexOf(cb);
if (i >= 0) arr.splice(i, 1);
};
},
Emit: function (eventOrName) {
var name = (typeof eventOrName === "string")
? eventOrName
: (eventOrName && eventOrName.name);
if (!name || typeof w.invoke !== "function") return;
w.invoke("wails:event:emit:" + name);
},
};
(function ready() {
if (typeof w.invoke === "function") {
w.invoke("wails:runtime:ready");
} else {
setTimeout(ready, 30);
}
})();
})();

View File

@@ -0,0 +1,8 @@
//go:build ios && !production
package application
// iosVerboseLogging enables the framework's internal iOS diagnostics
// (request tracing, bridge messages, lifecycle markers). Debug builds only;
// production builds compile these call sites away via the constant.
const iosVerboseLogging = true

View File

@@ -0,0 +1,5 @@
//go:build ios && production
package application
const iosVerboseLogging = false

View File

@@ -0,0 +1,16 @@
//go:build ios
package application
// Exported API for use by applications to mutate iOS WKWebView at runtime.
// These call into the internal platform-specific implementations.
func (iosManager) SetScrollEnabled(enabled bool) { iosSetScrollEnabled(enabled) }
func (iosManager) SetBounceEnabled(enabled bool) { iosSetBounceEnabled(enabled) }
func (iosManager) SetScrollIndicatorsEnabled(enabled bool) { iosSetScrollIndicatorsEnabled(enabled) }
func (iosManager) SetBackForwardGesturesEnabled(enabled bool) {
iosSetBackForwardGesturesEnabled(enabled)
}
func (iosManager) SetLinkPreviewEnabled(enabled bool) { iosSetLinkPreviewEnabled(enabled) }
func (iosManager) SetInspectableEnabled(enabled bool) { iosSetInspectableEnabled(enabled) }
func (iosManager) SetCustomUserAgent(ua string) { iosSetCustomUserAgent(ua) }

View File

@@ -0,0 +1,79 @@
//go:build ios
package application
/*
#cgo CFLAGS: -x objective-c -fmodules -fobjc-arc
#cgo LDFLAGS: -framework UIKit
#include <stdlib.h>
#include "application_ios.h"
*/
import "C"
import (
"unsafe"
"encoding/json"
)
// iosHapticsImpact triggers an iOS haptic impact using the provided style.
// The style parameter specifies the impact style name understood by the native haptic engine.
func iosHapticsImpact(style string) {
cstr := C.CString(style)
defer C.free(unsafe.Pointer(cstr))
C.ios_haptics_impact(cstr)
}
type deviceInfo struct {
Model string `json:"model"`
SystemName string `json:"systemName"`
SystemVersion string `json:"systemVersion"`
IsSimulator bool `json:"isSimulator"`
}
func iosDeviceInfo() deviceInfo {
ptr := C.ios_device_info_json()
if ptr == nil {
return deviceInfo{}
}
defer C.free(unsafe.Pointer(ptr))
goStr := C.GoString(ptr)
var out deviceInfo
_ = json.Unmarshal([]byte(goStr), &out)
return out
}
// iosSetScrollEnabled sets whether scrolling is enabled in the iOS runtime.
func iosSetScrollEnabled(enabled bool) { C.ios_runtime_set_scroll_enabled(C.bool(enabled)) }
// iosSetBounceEnabled sets whether scroll bounce (rubber-band) behavior is enabled at runtime.
// If enabled is true, scrollable content will bounce when pulled past its edges; if false, that bounce is disabled.
func iosSetBounceEnabled(enabled bool) { C.ios_runtime_set_bounce_enabled(C.bool(enabled)) }
// iosSetScrollIndicatorsEnabled configures whether the iOS runtime shows scroll indicators.
// The enabled parameter controls visibility: true shows indicators, false hides them.
func iosSetScrollIndicatorsEnabled(enabled bool) {
C.ios_runtime_set_scroll_indicators_enabled(C.bool(enabled))
}
// iosSetBackForwardGesturesEnabled enables back-forward navigation gestures when enabled is true and disables them when enabled is false.
func iosSetBackForwardGesturesEnabled(enabled bool) {
C.ios_runtime_set_back_forward_gestures_enabled(C.bool(enabled))
}
// iosSetLinkPreviewEnabled sets whether link previews are enabled in the iOS runtime.
// Pass true to enable link previews, false to disable them.
func iosSetLinkPreviewEnabled(enabled bool) { C.ios_runtime_set_link_preview_enabled(C.bool(enabled)) }
// iosSetInspectableEnabled sets whether runtime web content inspection is enabled.
// When enabled is true the runtime allows inspection of web content; when false inspection is disabled.
func iosSetInspectableEnabled(enabled bool) { C.ios_runtime_set_inspectable_enabled(C.bool(enabled)) }
// iosSetCustomUserAgent sets the runtime's custom User-Agent string.
// If ua is an empty string, the custom User-Agent is cleared.
func iosSetCustomUserAgent(ua string) {
var cstr *C.char
if ua != "" {
cstr = C.CString(ua)
defer C.free(unsafe.Pointer(cstr))
}
C.ios_runtime_set_custom_user_agent(cstr)
}
// Native tabs
func iosSetNativeTabsEnabled(enabled bool) { C.ios_native_tabs_set_enabled(C.bool(enabled)) }
func iosNativeTabsIsEnabled() bool { return bool(C.ios_native_tabs_is_enabled()) }
func iosSelectNativeTab(index int) { C.ios_native_tabs_select_index(C.int(index)) }

View File

@@ -0,0 +1,27 @@
//go:build !ios
package application
func iosHapticsImpact(style string) {
// no-op on non-iOS
}
type deviceInfo struct {
Model string `json:"model"`
SystemName string `json:"systemName"`
SystemVersion string `json:"systemVersion"`
IsSimulator bool `json:"isSimulator"`
}
func iosDeviceInfo() deviceInfo {
return deviceInfo{}
}
// Live mutation stubs
func iosSetScrollEnabled(enabled bool) {}
func iosSetBounceEnabled(enabled bool) {}
func iosSetScrollIndicatorsEnabled(enabled bool) {}
func iosSetBackForwardGesturesEnabled(enabled bool) {}
func iosSetLinkPreviewEnabled(enabled bool) {}
func iosSetInspectableEnabled(enabled bool) {}
func iosSetCustomUserAgent(ua string) {}

View File

@@ -0,0 +1,128 @@
//go:build ios
#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>
#import <Network/Network.h>
#import "application_ios.h"
#import "../events/events_ios.h"
// processApplicationEvent is a Go //export (application_ios.go). It takes an
// event ID and an optional data pointer; we pass a JSON object string, which Go
// decodes into the ApplicationEvent context. Declared locally (matching the
// other .m files) so we don't pull in cgo's _cgo_export.h.
extern void processApplicationEvent(unsigned int eventID, void* data);
// Emit an application event with an optional JSON payload string.
static void emitEvent(unsigned int eventID, NSString *json) {
processApplicationEvent(eventID, json ? (void *)[json UTF8String] : NULL);
}
// ---- Battery -------------------------------------------------------------
static NSString *batteryStateString(UIDeviceBatteryState s) {
switch (s) {
case UIDeviceBatteryStateCharging: return @"charging";
case UIDeviceBatteryStateFull: return @"full";
case UIDeviceBatteryStateUnplugged: return @"unplugged";
default: return @"unknown";
}
}
static void emitBattery(void) {
UIDevice *dev = [UIDevice currentDevice];
float level = dev.batteryLevel; // 0..1, or -1 when unknown (e.g. Simulator)
NSString *state = batteryStateString(dev.batteryState);
BOOL lowPower = [NSProcessInfo processInfo].lowPowerModeEnabled;
emitEvent(EventBatteryChanged, [NSString stringWithFormat:
@"{\"level\":%.2f,\"state\":\"%@\",\"lowPowerMode\":%@}",
level, state, lowPower ? @"true" : @"false"]);
}
// ---- Theme ---------------------------------------------------------------
// "isDarkMode" matches the key the desktop platforms set (ApplicationEventContext.IsDarkMode()).
static void emitTheme(void) {
emitEvent(EventThemeChanged, ios_is_dark_mode() ? @"{\"isDarkMode\":true}" : @"{\"isDarkMode\":false}");
}
// ---- Network (NWPathMonitor) --------------------------------------------
static nw_path_monitor_t g_pathMonitor = nil;
static void emitNetwork(nw_path_t path) {
BOOL connected = (nw_path_get_status(path) == nw_path_status_satisfied);
NSString *type = @"none";
if (connected) {
if (nw_path_uses_interface_type(path, nw_interface_type_wifi)) {
type = @"wifi";
} else if (nw_path_uses_interface_type(path, nw_interface_type_cellular)) {
type = @"cellular";
} else if (nw_path_uses_interface_type(path, nw_interface_type_wired)) {
type = @"wired";
} else {
type = @"other";
}
}
BOOL expensive = nw_path_is_expensive(path); // cellular / hotspot
BOOL constrained = NO; // Low Data Mode
if (@available(iOS 13.0, *)) {
constrained = nw_path_is_constrained(path);
}
emitEvent(EventNetworkChanged, [NSString stringWithFormat:
@"{\"connected\":%@,\"type\":\"%@\",\"expensive\":%@,\"constrained\":%@}",
connected ? @"true" : @"false", type,
expensive ? @"true" : @"false", constrained ? @"true" : @"false"]);
}
// ---- Setup ---------------------------------------------------------------
void ios_start_system_event_monitors(void) {
static dispatch_once_t once;
dispatch_once(&once, ^{
dispatch_async(dispatch_get_main_queue(), ^{
NSNotificationCenter *nc = [NSNotificationCenter defaultCenter];
// Battery: monitoring must be enabled before level/state are valid.
[UIDevice currentDevice].batteryMonitoringEnabled = YES;
[nc addObserverForName:UIDeviceBatteryLevelDidChangeNotification
object:nil queue:nil
usingBlock:^(NSNotification *n){ emitBattery(); }];
[nc addObserverForName:UIDeviceBatteryStateDidChangeNotification
object:nil queue:nil
usingBlock:^(NSNotification *n){ emitBattery(); }];
[nc addObserverForName:NSProcessInfoPowerStateDidChangeNotification
object:nil queue:nil
usingBlock:^(NSNotification *n){ emitBattery(); }];
// Refresh battery/theme snapshots when the app comes to the front.
// (Lifecycle itself is delivered by the generated UIApplication
// delegate events, so we don't re-emit those here.)
[nc addObserverForName:UIApplicationDidBecomeActiveNotification
object:nil queue:nil
usingBlock:^(NSNotification *n){ emitBattery(); emitTheme(); }];
// Lock / unlock: approximated via data-protection availability.
// Only fires when the device has a passcode set.
[nc addObserverForName:UIApplicationProtectedDataWillBecomeUnavailable
object:nil queue:nil
usingBlock:^(NSNotification *n){ emitEvent(EventScreenLocked, nil); }];
[nc addObserverForName:UIApplicationProtectedDataDidBecomeAvailable
object:nil queue:nil
usingBlock:^(NSNotification *n){ emitEvent(EventScreenUnlocked, nil); }];
// Network reachability / interface type. The update handler fires
// immediately with the current path, giving listeners an initial
// value as well as live changes.
g_pathMonitor = nw_path_monitor_create();
nw_path_monitor_set_queue(g_pathMonitor, dispatch_get_main_queue());
nw_path_monitor_set_update_handler(g_pathMonitor, ^(nw_path_t path){
emitNetwork(path);
});
nw_path_monitor_start(g_pathMonitor);
// Initial snapshot so listeners mounted before any change still get
// current battery/theme.
emitBattery();
emitTheme();
});
});
}

View File

@@ -0,0 +1,66 @@
package application
// KeyBindingManager manages all key binding operations
type KeyBindingManager struct {
app *App
}
// newKeyBindingManager creates a new KeyBindingManager instance
func newKeyBindingManager(app *App) *KeyBindingManager {
return &KeyBindingManager{
app: app,
}
}
// Add adds a key binding
func (kbm *KeyBindingManager) Add(accelerator string, callback func(window Window)) {
kbm.app.keyBindingsLock.Lock()
defer kbm.app.keyBindingsLock.Unlock()
kbm.app.keyBindings[accelerator] = callback
}
// Remove removes a key binding
func (kbm *KeyBindingManager) Remove(accelerator string) {
kbm.app.keyBindingsLock.Lock()
defer kbm.app.keyBindingsLock.Unlock()
delete(kbm.app.keyBindings, accelerator)
}
// Process processes a key binding and returns true if handled
func (kbm *KeyBindingManager) Process(accelerator string, window Window) bool {
kbm.app.keyBindingsLock.RLock()
callback, exists := kbm.app.keyBindings[accelerator]
kbm.app.keyBindingsLock.RUnlock()
if exists && callback != nil {
callback(window)
return true
}
return false
}
// KeyBinding represents a key binding with its accelerator and callback
type KeyBinding struct {
Accelerator string
Callback func(window Window)
}
// GetAll returns all registered key bindings as a slice
func (kbm *KeyBindingManager) GetAll() []*KeyBinding {
kbm.app.keyBindingsLock.RLock()
defer kbm.app.keyBindingsLock.RUnlock()
result := make([]*KeyBinding, 0, len(kbm.app.keyBindings))
for accelerator, callback := range kbm.app.keyBindings {
result = append(result, &KeyBinding{
Accelerator: accelerator,
Callback: callback,
})
}
return result
}
// HandleWindowKeyEvent handles window key events (internal use)
func (kbm *KeyBindingManager) handleWindowKeyEvent(event *windowKeyEvent) {
kbm.app.handleWindowKeyEvent(event)
}

View File

@@ -0,0 +1,220 @@
package application
import (
"fmt"
"runtime"
"slices"
"strconv"
"strings"
)
// modifier is actually a string
type modifier int
const (
// CmdOrCtrlKey represents Command on Mac and Control on other platforms
CmdOrCtrlKey modifier = 0 << iota
// OptionOrAltKey represents Option on Mac and Alt on other platforms
OptionOrAltKey modifier = 1 << iota
// ShiftKey represents the shift key on all systems
ShiftKey modifier = 2 << iota
// SuperKey represents Command on Mac and the Windows key on the other platforms
SuperKey modifier = 3 << iota
// ControlKey represents the control key on all systems
ControlKey modifier = 4 << iota
)
func (m modifier) String() string {
return modifierStringMap[runtime.GOOS][m]
}
var modifierStringMap = map[string]map[modifier]string{
"windows": {
CmdOrCtrlKey: "Ctrl",
ControlKey: "Ctrl",
OptionOrAltKey: "Alt",
ShiftKey: "Shift",
SuperKey: "Win",
},
"darwin": {
CmdOrCtrlKey: "Cmd",
ControlKey: "Ctrl",
OptionOrAltKey: "Option",
ShiftKey: "Shift",
SuperKey: "Cmd",
},
"linux": {
CmdOrCtrlKey: "Ctrl",
ControlKey: "Ctrl",
OptionOrAltKey: "Alt",
ShiftKey: "Shift",
SuperKey: "Super",
},
}
var modifierMap = map[string]modifier{
"cmdorctrl": CmdOrCtrlKey,
"cmd": CmdOrCtrlKey,
"command": CmdOrCtrlKey,
"ctrl": ControlKey,
"optionoralt": OptionOrAltKey,
"alt": OptionOrAltKey,
"option": OptionOrAltKey,
"shift": ShiftKey,
"super": SuperKey,
}
// accelerator holds the keyboard shortcut for a menu item
type accelerator struct {
Key string
Modifiers []modifier
}
func (a *accelerator) clone() *accelerator {
result := *a
return &result
}
func (a *accelerator) String() string {
var result []string
// Sort modifiers
for _, modifier := range a.Modifiers {
result = append(result, modifier.String())
}
slices.Sort(result)
if len(a.Key) > 0 {
result = append(result, strings.ToUpper(a.Key))
}
return strings.Join(result, "+")
}
var namedKeys = map[string]struct{}{
"backspace": {},
"tab": {},
"return": {},
"enter": {},
"escape": {},
"left": {},
"right": {},
"up": {},
"down": {},
"space": {},
"delete": {},
"home": {},
"end": {},
"page up": {},
"page down": {},
"f1": {},
"f2": {},
"f3": {},
"f4": {},
"f5": {},
"f6": {},
"f7": {},
"f8": {},
"f9": {},
"f10": {},
"f11": {},
"f12": {},
"f13": {},
"f14": {},
"f15": {},
"f16": {},
"f17": {},
"f18": {},
"f19": {},
"f20": {},
"f21": {},
"f22": {},
"f23": {},
"f24": {},
"f25": {},
"f26": {},
"f27": {},
"f28": {},
"f29": {},
"f30": {},
"f31": {},
"f32": {},
"f33": {},
"f34": {},
"f35": {},
"numlock": {},
}
func parseKey(key string) (string, bool) {
// Lowercase!
key = strings.ToLower(key)
// Check special case
if key == "plus" {
return "+", true
}
// Handle named keys
_, namedKey := namedKeys[key]
if namedKey {
return key, true
}
// Check we only have a single character
if len(key) != 1 {
return "", false
}
runeKey := rune(key[0])
// This may be too inclusive
if strconv.IsPrint(runeKey) {
return key, true
}
return "", false
}
// parseAccelerator parses a string into an accelerator
func parseAccelerator(shortcut string) (*accelerator, error) {
var result accelerator
// Split the shortcut by +
components := strings.Split(shortcut, "+")
// If we only have one it should be a key
// We require components
if len(components) == 0 {
return nil, fmt.Errorf("no components given to validateComponents")
}
modifiers := map[modifier]struct{}{}
// Check components
for index, component := range components {
// If last component
if index == len(components)-1 {
processedKey, validKey := parseKey(component)
if !validKey {
return nil, fmt.Errorf("'%s' is not a valid key", component)
}
result.Key = strings.ToLower(processedKey)
continue
}
// Not last component - needs to be modifier
lowercaseComponent := strings.ToLower(component)
thisModifier, valid := modifierMap[lowercaseComponent]
if !valid {
return nil, fmt.Errorf("'%s' is not a valid modifier", component)
}
// Save this data
modifiers[thisModifier] = struct{}{}
}
// return the keys as a slice
for thisModifier := range modifiers {
result.Modifiers = append(result.Modifiers, thisModifier)
}
return &result, nil
}

View File

@@ -0,0 +1,9 @@
//go:build android
package application
// Android keyboard handling stub
func acceleratorToString(accelerator *accelerator) string {
return ""
}

View File

@@ -0,0 +1,28 @@
//go:build darwin && !ios && !server
package application
const (
NSEventModifierFlagShift = 1 << 17 // Set if Shift key is pressed.
NSEventModifierFlagControl = 1 << 18 // Set if Control key is pressed.
NSEventModifierFlagOption = 1 << 19 // Set if Option or Alternate key is pressed.
NSEventModifierFlagCommand = 1 << 20 // Set if Command key is pressed.
)
// macModifierMap maps accelerator modifiers to macOS modifiers.
var macModifierMap = map[modifier]int{
CmdOrCtrlKey: NSEventModifierFlagCommand,
ControlKey: NSEventModifierFlagControl,
OptionOrAltKey: NSEventModifierFlagOption,
ShiftKey: NSEventModifierFlagShift,
SuperKey: NSEventModifierFlagCommand,
}
// toMacModifier converts the accelerator to a macOS modifier.
func toMacModifier(modifiers []modifier) int {
result := 0
for _, modifier := range modifiers {
result |= macModifierMap[modifier]
}
return result
}

View File

@@ -0,0 +1,20 @@
//go:build ios
package application
// iOS key codes - these would map to UIKeyCommand
const (
KeyReturn = "Return"
KeyEscape = "Escape"
KeyDelete = "Delete"
KeyTab = "Tab"
KeySpace = "Space"
KeyUp = "Up"
KeyDown = "Down"
KeyLeft = "Left"
KeyRight = "Right"
KeyHome = "Home"
KeyEnd = "End"
KeyPageUp = "PageUp"
KeyPageDown = "PageDown"
)

View File

@@ -0,0 +1,165 @@
//go:build linux && !android && !server
package application
var VirtualKeyCodes = map[uint]string{
0xff08: "backspace",
0xff09: "tab",
0xff0a: "linefeed",
0xff0b: "clear",
0xff0d: "return",
0xff13: "pause",
0xff14: "scrolllock",
0xff15: "sysreq",
0xff1b: "escape",
0xffff: "delete",
0xff50: "home",
0xff51: "left",
0xffe1: "lshift",
0xffe2: "rshift",
0xffe3: "lcontrol",
0xffe4: "rcontrol",
0xffeb: "lmeta",
0xffec: "rmeta",
0xffed: "lalt",
0xffee: "ralt",
// Multi-Lang
0xff21: "kanji",
0xff22: "muhenkan",
0xff24: "henkan",
0xff25: "hiragana",
0xff26: "katakana",
0xff27: "hiragana/katakana",
0xff28: "zenkaku",
0xff29: "hankaku",
0xff2a: "zenkaku/hankaku",
0xff2b: "touroku",
0xff2c: "massyo",
0xff2d: "kana lock",
0xff2e: "kana shift",
0xff2f: "eisu shift",
0xff30: "eisu toggle",
0xff37: "kanji bangou",
// Directions
0xff52: "up",
0xff53: "right",
0xff54: "down",
0xff55: "pageup",
0xff56: "pagedown",
0xff57: "end",
0xff58: "begin",
// Alphabet
0x41: "a",
0x42: "b",
0x43: "c",
0x44: "d",
0x45: "e",
0x46: "f",
0x47: "g",
0x48: "h",
0x49: "i",
0x4a: "j",
0x4b: "k",
0x4c: "l",
0x4d: "m",
0x4e: "n",
0x4f: "o",
0x50: "p",
0x51: "q",
0x52: "r",
0x53: "s",
0x54: "t",
0x55: "u",
0x56: "v",
0x57: "w",
0x58: "x",
0x59: "y",
0x5a: "z",
0x5b: "lbracket",
0x5c: "backslash",
0x5d: "rbracket",
0x5e: "caret",
0x5f: "underscore",
0x60: "grave",
// Capital Alphabet
0x61: "a",
0x62: "b",
0x63: "c",
0x64: "d",
0x65: "e",
0x66: "f",
0x67: "g",
0x68: "h",
0x69: "i",
0x6a: "j",
0x6b: "k",
0x6c: "l",
0x6d: "m",
0x6e: "n",
0x6f: "o",
0x70: "p",
0x71: "q",
0x72: "r",
0x73: "s",
0x74: "t",
0x75: "u",
0x76: "v",
0x77: "w",
0x78: "x",
0x79: "y",
0x7a: "z",
0x7b: "lbrace",
0x7c: "pipe",
0x7d: "rbrace",
0x7e: "tilde",
0xa1: "exclam",
0xa2: "cent",
0xa3: "sterling",
0xa4: "currency",
0xa5: "yen",
0xa6: "brokenbar",
0xa7: "section",
0xa8: "diaeresis",
0xa9: "copyright",
0xaa: "ordfeminine",
0xab: "guillemotleft",
0xad: "hyphen",
0xae: "registered",
0xaf: "macron",
0xb0: "degree",
0xb1: "plusminus",
0xb2: "twosuperior",
// Function Keys
0xffbe: "f1",
0xffbf: "f2",
0xffc0: "f3",
0xffc1: "f4",
0xffc2: "f5",
0xffc3: "f6",
0xffc4: "f7",
0xffc5: "f8",
0xffc6: "f9",
0xffc7: "f10",
0xffc8: "f11",
0xffc9: "f12",
0xffca: "f13",
0xffcb: "f14",
0xffcc: "f15",
0xffcd: "f16",
0xffce: "f17",
0xffcf: "f18",
0xffd0: "f19",
0xffd1: "f20",
0xffd2: "f21",
0xffd3: "f22",
0xffd4: "f23",
0xffd5: "f24",
}

View File

@@ -0,0 +1,230 @@
//go:build windows && !server
package application
var VirtualKeyCodes = map[uint]string{
0x01: "lbutton",
0x02: "rbutton",
0x03: "cancel",
0x04: "mbutton",
0x05: "xbutton1",
0x06: "xbutton2",
0x08: "back",
0x09: "tab",
0x0C: "clear",
0x0D: "return",
0x10: "shift",
0x11: "ctrl",
0x12: "menu",
0x13: "pause",
0x14: "capital",
0x15: "kana",
0x17: "junja",
0x18: "final",
0x19: "hanja",
0x1B: "escape",
0x1C: "convert",
0x1D: "nonconvert",
0x1E: "accept",
0x1F: "modechange",
0x20: "space",
0x21: "prior",
0x22: "next",
0x23: "end",
0x24: "home",
0x25: "left",
0x26: "up",
0x27: "right",
0x28: "down",
0x29: "select",
0x2A: "print",
0x2B: "execute",
0x2C: "snapshot",
0x2D: "insert",
0x2E: "delete",
0x2F: "help",
0x30: "0",
0x31: "1",
0x32: "2",
0x33: "3",
0x34: "4",
0x35: "5",
0x36: "6",
0x37: "7",
0x38: "8",
0x39: "9",
0x41: "a",
0x42: "b",
0x43: "c",
0x44: "d",
0x45: "e",
0x46: "f",
0x47: "g",
0x48: "h",
0x49: "i",
0x4A: "j",
0x4B: "k",
0x4C: "l",
0x4D: "m",
0x4E: "n",
0x4F: "o",
0x50: "p",
0x51: "q",
0x52: "r",
0x53: "s",
0x54: "t",
0x55: "u",
0x56: "v",
0x57: "w",
0x58: "x",
0x59: "y",
0x5A: "z",
0x5B: "lwin",
0x5C: "rwin",
0x5D: "apps",
0x5F: "sleep",
0x60: "numpad0",
0x61: "numpad1",
0x62: "numpad2",
0x63: "numpad3",
0x64: "numpad4",
0x65: "numpad5",
0x66: "numpad6",
0x67: "numpad7",
0x68: "numpad8",
0x69: "numpad9",
0x6A: "multiply",
0x6B: "add",
0x6C: "separator",
0x6D: "subtract",
0x6E: "decimal",
0x6F: "divide",
0x70: "f1",
0x71: "f2",
0x72: "f3",
0x73: "f4",
0x74: "f5",
0x75: "f6",
0x76: "f7",
0x77: "f8",
0x78: "f9",
0x79: "f10",
0x7A: "f11",
0x7B: "f12",
0x7C: "f13",
0x7D: "f14",
0x7E: "f15",
0x7F: "f16",
0x80: "f17",
0x81: "f18",
0x82: "f19",
0x83: "f20",
0x84: "f21",
0x85: "f22",
0x86: "f23",
0x87: "f24",
0x88: "navigation_view",
0x89: "navigation_menu",
0x8A: "navigation_up",
0x8B: "navigation_down",
0x8C: "navigation_left",
0x8D: "navigation_right",
0x8E: "navigation_accept",
0x8F: "navigation_cancel",
0x90: "numlock",
0x91: "scroll",
0x92: "oem_nec_equal",
0x93: "oem_fj_masshou",
0x94: "oem_fj_touroku",
0x95: "oem_fj_loya",
0x96: "oem_fj_roya",
0xA0: "lshift",
0xA1: "rshift",
0xA2: "lcontrol",
0xA3: "rcontrol",
0xA4: "lmenu",
0xA5: "rmenu",
0xA6: "browser_back",
0xA7: "browser_forward",
0xA8: "browser_refresh",
0xA9: "browser_stop",
0xAA: "browser_search",
0xAB: "browser_favorites",
0xAC: "browser_home",
0xAD: "volume_mute",
0xAE: "volume_down",
0xAF: "volume_up",
0xB0: "media_next_track",
0xB1: "media_prev_track",
0xB2: "media_stop",
0xB3: "media_play_pause",
0xB4: "launch_mail",
0xB5: "launch_media_select",
0xB6: "launch_app1",
0xB7: "launch_app2",
0xBA: "oem_1",
0xBB: "oem_plus",
0xBC: "oem_comma",
0xBD: "oem_minus",
0xBE: "oem_period",
0xBF: "oem_2",
0xC0: "oem_3",
0xC3: "gamepad_a",
0xC4: "gamepad_b",
0xC5: "gamepad_x",
0xC6: "gamepad_y",
0xC7: "gamepad_right_shoulder",
0xC8: "gamepad_left_shoulder",
0xC9: "gamepad_left_trigger",
0xCA: "gamepad_right_trigger",
0xCB: "gamepad_dpad_up",
0xCC: "gamepad_dpad_down",
0xCD: "gamepad_dpad_left",
0xCE: "gamepad_dpad_right",
0xCF: "gamepad_menu",
0xD0: "gamepad_view",
0xD1: "gamepad_left_thumbstick_button",
0xD2: "gamepad_right_thumbstick_button",
0xD3: "gamepad_left_thumbstick_up",
0xD4: "gamepad_left_thumbstick_down",
0xD5: "gamepad_left_thumbstick_right",
0xD6: "gamepad_left_thumbstick_left",
0xD7: "gamepad_right_thumbstick_up",
0xD8: "gamepad_right_thumbstick_down",
0xD9: "gamepad_right_thumbstick_right",
0xDA: "gamepad_right_thumbstick_left",
0xDB: "oem_4",
0xDC: "oem_5",
0xDD: "oem_6",
0xDE: "oem_7",
0xDF: "oem_8",
0xE1: "oem_ax",
0xE2: "oem_102",
0xE3: "ico_help",
0xE4: "ico_00",
0xE5: "processkey",
0xE6: "ico_clear",
0xE7: "packet",
0xE9: "oem_reset",
0xEA: "oem_jump",
0xEB: "oem_pa1",
0xEC: "oem_pa2",
0xED: "oem_pa3",
0xEE: "oem_wsctrl",
0xEF: "oem_cusel",
0xF0: "oem_attn",
0xF1: "oem_finish",
0xF2: "oem_copy",
0xF3: "oem_auto",
0xF4: "oem_enlw",
0xF5: "oem_backtab",
0xF6: "attn",
0xF7: "crsel",
0xF8: "exsel",
0xF9: "ereof",
0xFA: "play",
0xFB: "zoom",
0xFC: "noname",
0xFD: "pa1",
0xFE: "oem_clear",
}

Some files were not shown because too many files have changed in this diff Show More