diff --git a/v3/pkg/application/application_linux.go b/v3/pkg/application/application_linux.go index db707e9e834..e986fe979de 100644 --- a/v3/pkg/application/application_linux.go +++ b/v3/pkg/application/application_linux.go @@ -21,7 +21,6 @@ import ( "strings" "sync" - "github.com/godbus/dbus/v5" "github.com/wailsapp/wails/v3/internal/operatingsystem" "github.com/wailsapp/wails/v3/pkg/events" ) @@ -70,8 +69,6 @@ type linuxApp struct { windowMap map[windowPointer]uint windowMapLock sync.Mutex - theme string - icon pointer } @@ -93,8 +90,9 @@ func (a *linuxApp) run() error { } }) a.setupCommonEvents() - // Theme changes are already monitored by listenForSystemThemeChanges via init(); - // it uses the portal-standard org.freedesktop.appearance namespace. + // Started here, not from init(): init() is not part of the platformApp + // interface and nothing calls it, so a monitor started there never runs. + a.monitorThemeChanges() a.monitorPowerEvents() return appRun(a.application) } @@ -154,41 +152,6 @@ func (a *linuxApp) init(_ *App, options Options) { 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) { @@ -279,35 +242,6 @@ 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)" } diff --git a/v3/pkg/application/application_linux_dbus.go b/v3/pkg/application/application_linux_dbus.go index 04f02d671f0..bba2c82169f 100644 --- a/v3/pkg/application/application_linux_dbus.go +++ b/v3/pkg/application/application_linux_dbus.go @@ -7,6 +7,84 @@ import ( "github.com/wailsapp/wails/v3/pkg/events" ) +const ( + portalBusName = "org.freedesktop.portal.Desktop" + portalObjectPath = "/org/freedesktop/portal/desktop" + portalSettingsIface = "org.freedesktop.portal.Settings" + + // appearanceNamespace is the standardised namespace every portal + // implementation publishes; it is the only one read or watched. GNOME also + // mirrors the preference under org.gnome.desktop.interface as a string, but + // honouring that in the signal filter alone would be inert: the handler + // resolves through portalColorScheme, which speaks only this namespace. + appearanceNamespace = "org.freedesktop.appearance" + colorSchemeKey = "color-scheme" + + colorSchemePreferDark = 1 +) + +// isDarkMode reports the desktop colour-scheme preference, read from the +// freedesktop Settings portal on every call. +// +// Read on demand rather than served from state maintained by +// monitorThemeChanges: that monitor is started per backend, so any cache it +// owns is only as correct as its startup wiring, and a cache fed from +// SettingChanged payloads reports light on every desktop until the first signal +// arrives. An on-demand read is right whether or not the monitor is running. +// Callers that need this on a hot path should cache it themselves. +func (a *linuxApp) isDarkMode() bool { + scheme, ok := portalColorScheme() + return ok && scheme == colorSchemePreferDark +} + +// portalColorScheme reads org.freedesktop.appearance color-scheme: 0 is no +// preference, 1 prefers dark, 2 prefers light. ok is false when the portal is +// unreachable or the value is not the documented type. +func portalColorScheme() (uint32, bool) { + conn, err := dbus.SessionBus() + if err != nil { + return 0, false + } + + obj := conn.Object(portalBusName, portalObjectPath) + call := obj.Call(portalSettingsIface+".Read", 0, appearanceNamespace, colorSchemeKey) + if call.Err != nil { + return 0, false + } + + var outer dbus.Variant + if err := call.Store(&outer); err != nil { + return 0, false + } + // Portal v1 Read double-wraps the value; other implementations, and ReadOne, + // return it singly wrapped. Accept both, because rejecting one shape here + // silently reports light -- the failure this whole path exists to remove. + if inner, ok := outer.Value().(dbus.Variant); ok { + scheme, ok := inner.Value().(uint32) + return scheme, ok + } + scheme, ok := outer.Value().(uint32) + return scheme, ok +} + +// isColorSchemeChange reports whether a signal is a colour-scheme +// SettingChanged in the standardised appearance namespace. +func isColorSchemeChange(sig *dbus.Signal) bool { + if sig.Name != portalSettingsIface+".SettingChanged" { + return false + } + if len(sig.Body) < 2 { + return false + } + namespace, _ := sig.Body[0].(string) + key, _ := sig.Body[1].(string) + return namespace == appearanceNamespace && key == colorSchemeKey +} + +// monitorThemeChanges emits Linux.SystemThemeChanged when the desktop colour +// scheme changes. The portal is re-read rather than the signal payload trusted, +// so the emitted value always agrees with isDarkMode regardless of which +// namespace fired and what type it carried. func (a *linuxApp) monitorThemeChanges() { go func() { defer handlePanic() @@ -21,7 +99,10 @@ func (a *linuxApp) monitorThemeChanges() { defer conn.Close() if err = conn.AddMatchSignal( - dbus.WithMatchObjectPath("/org/freedesktop/portal/desktop"), + dbus.WithMatchSender(portalBusName), + dbus.WithMatchObjectPath(portalObjectPath), + dbus.WithMatchInterface(portalSettingsIface), + dbus.WithMatchMember("SettingChanged"), ); err != nil { a.parent.warning( "[WARNING] Failed to subscribe to portal SettingChanged; theme changes will not fire: %v", @@ -33,40 +114,20 @@ func (a *linuxApp) monitorThemeChanges() { 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 - } - + last := a.isDarkMode() for v := range c { - theme, ok := getTheme(v.Body) - if !ok { + if !isColorSchemeChange(v) { continue } - - if theme != a.theme { - a.theme = theme - event := newApplicationEvent(events.Linux.SystemThemeChanged) - event.Context().setIsDarkMode(a.isDarkMode()) - applicationEvents <- event + dark := a.isDarkMode() + if dark == last { + continue } + last = dark + event := newApplicationEvent(events.Linux.SystemThemeChanged) + event.Context().setIsDarkMode(dark) + applicationEvents <- event } }() } diff --git a/v3/pkg/application/application_linux_gtk3.go b/v3/pkg/application/application_linux_gtk3.go index e61fd53261f..db9b91b1791 100644 --- a/v3/pkg/application/application_linux_gtk3.go +++ b/v3/pkg/application/application_linux_gtk3.go @@ -87,8 +87,6 @@ type linuxApp struct { windowMap map[windowPointer]uint windowMapLock sync.Mutex - theme string - icon pointer } @@ -216,10 +214,6 @@ func (a *linuxApp) registerWindow(window pointer, id uint) { 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 diff --git a/v3/pkg/application/linux_cgo.go b/v3/pkg/application/linux_cgo.go index 9270d549c32..e7db4fc6c39 100644 --- a/v3/pkg/application/linux_cgo.go +++ b/v3/pkg/application/linux_cgo.go @@ -118,12 +118,6 @@ func activateLinux(data pointer) { //export processApplicationEvent func processApplicationEvent(eventID C.uint, data pointer) { event := newApplicationEvent(events.ApplicationEventType(eventID)) - - switch event.Id { - case uint(events.Linux.SystemThemeChanged): - isDark := globalApplication.Env.IsDarkMode() - event.Context().setIsDarkMode(isDark) - } applicationEvents <- event } diff --git a/v3/pkg/application/linux_cgo_gtk3.go b/v3/pkg/application/linux_cgo_gtk3.go index f71f6fe1a72..a40ed6f7b76 100644 --- a/v3/pkg/application/linux_cgo_gtk3.go +++ b/v3/pkg/application/linux_cgo_gtk3.go @@ -598,11 +598,6 @@ func processApplicationEvent(eventID C.uint, data pointer) { // } //} - switch event.Id { - case uint(events.Linux.SystemThemeChanged): - isDark := globalApplication.Env.IsDarkMode() - event.Context().setIsDarkMode(isDark) - } applicationEvents <- event }