summaryrefslogtreecommitdiff
path: root/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server
diff options
context:
space:
mode:
authornippy <you@example.com>2026-04-18 13:49:56 +0200
committernippy <you@example.com>2026-04-18 13:49:56 +0200
commit5d94c0a7d44a2255b81815a52a7056a94a39842d (patch)
tree759bdea9645c6a62f9e1e4c001f7d81cccd120d2 /raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server
parente79cdf210b267f21a186255ce1a4d50029439d54 (diff)
downloadRaveOS-PKGBUILD-5d94c0a7d44a2255b81815a52a7056a94a39842d.tar.gz
RaveOS-PKGBUILD-5d94c0a7d44a2255b81815a52a7056a94a39842d.zip
update Raveos themes
Diffstat (limited to 'raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server')
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/apppicker/handlers.go51
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/apppicker/manager.go48
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/apppicker/models.go8
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/bluez/agent.go341
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/bluez/broker.go21
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/bluez/broker_test.go220
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/bluez/handlers.go237
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/bluez/handlers_test.go41
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/bluez/manager.go588
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/bluez/subscription_broker.go86
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/bluez/types.go78
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/bluez/types_test.go210
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/ddc.go510
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/ddc_filter.go135
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/ddc_filter_test.go122
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/ddc_test.go135
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/handlers.go124
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/logind.go66
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/logind_test.go95
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/manager.go389
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/manager_test.go11
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/sysfs.go267
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/sysfs_logind_test.go278
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/sysfs_test.go180
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/types.go184
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/udev.go237
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/udev_test.go260
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/browser/handlers.go22
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/browser/manager.go49
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/browser/models.go5
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/clipboard/handlers.go325
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/clipboard/manager.go1911
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/clipboard/manager_test.go531
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/clipboard/types.go196
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/actions.go479
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/actions_pkhelper_test.go235
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/actions_test.go450
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/handlers.go487
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/handlers_test.go711
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/manager.go390
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/manager_test.go363
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/pkhelper.go184
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/pkhelper_test.go95
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/subscription.go245
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/subscription_dbus.go295
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/test_connection.go176
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/test_connection_test.go397
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/testing.go13
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/types.go132
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/dbus/handlers.go237
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/dbus/manager.go392
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/dbus/types.go52
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/dwl/handlers.go138
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/dwl/manager.go522
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/dwl/manager_test.go366
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/dwl/types.go176
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/evdev/handlers.go20
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/evdev/handlers_test.go130
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/evdev/manager.go413
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/evdev/manager_test.go344
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/evdev/models.go6
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/extworkspace/handlers.go134
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/extworkspace/manager.go598
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/extworkspace/manager_test.go392
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/extworkspace/types.go169
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/actions.go112
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/actions_test.go145
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/constants.go23
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/handlers.go154
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/handlers_test.go581
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/manager.go206
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/manager_test.go143
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/screensaver.go349
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/screensaver_test.go102
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/types.go66
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/types_test.go70
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/location/handlers.go61
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/location/manager.go175
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/location/types.go28
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/actions.go88
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/constants.go10
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/handlers.go156
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/handlers_test.go500
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/manager.go723
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/manager_test.go296
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/monitor.go155
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/monitor_test.go298
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/types.go76
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/types_test.go50
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/matugen_handler.go79
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/models/types.go47
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/models/types_test.go52
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/API.md552
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/agent_iwd.go306
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/agent_networkmanager.go897
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/agent_networkmanager_test.go355
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend.go80
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_hybrid_iwd_networkd.go245
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_hybrid_test.go135
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_iwd.go238
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_iwd_signals.go355
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_iwd_test.go212
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_iwd_unimplemented.go133
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_iwd_wifi.go662
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkd.go301
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkd_ethernet.go120
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkd_signals.go68
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkd_test.go147
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkd_unimplemented.go99
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager.go405
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_ethernet.go402
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_ethernet_test.go134
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_gp_saml.go203
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_gp_saml_test.go169
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_signals.go530
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_signals_test.go255
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_state.go277
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_state_test.go82
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_test.go154
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_vpn.go1394
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_vpn_test.go138
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_wifi.go1173
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_wifi_test.go202
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/broker.go22
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/connection_test.go109
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/detect.go89
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/detect_test.go41
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/handlers.go630
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/handlers_test.go269
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/helpers.go53
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/manager.go626
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/manager_test.go191
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/monitor.go1
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/priority.go187
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/priority_test.go50
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/subscription_broker.go125
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/testing.go14
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/types.go267
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/types_test.go178
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/wifi_qrcode.go59
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/wifi_test.go148
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/wired_test.go51
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/params/params.go113
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/params/params_test.go154
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/handlers.go27
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/handlers_test.go196
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/install.go69
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/list.go53
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/list_installed.go77
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/search.go74
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/types.go25
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/uninstall.go66
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/update.go64
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/utils.go17
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/router.go250
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/server.go1758
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/server_test.go180
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/thememode/handlers.go154
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/thememode/manager.go462
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/thememode/types.go23
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/themes/handlers.go27
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/themes/install.go52
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/themes/list.go54
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/themes/list_installed.go149
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/themes/search.go53
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/themes/types.go47
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/themes/uninstall.go63
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/themes/update.go57
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/trayrecovery/manager.go93
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/trayrecovery/recovery.go262
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/gamma.go167
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/gamma_test.go120
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/geolocation.go50
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/handlers.go182
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/manager.go1256
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/manager_test.go414
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/suncalc.go122
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/suncalc_test.go387
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/types.go216
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/types_test.go330
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wlcontext/context.go200
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wlcontext/context_test.go128
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wlroutput/handlers.go282
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wlroutput/manager.go492
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wlroutput/manager_test.go414
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wlroutput/types.go192
186 files changed, 44551 insertions, 0 deletions
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/apppicker/handlers.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/apppicker/handlers.go
new file mode 100644
index 0000000..007ab3e
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/apppicker/handlers.go
@@ -0,0 +1,51 @@
+package apppicker
+
+import (
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+)
+
+func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
+ switch req.Method {
+ case "apppicker.open", "browser.open":
+ handleOpen(conn, req, manager)
+ default:
+ models.RespondError(conn, req.ID, "unknown method")
+ }
+}
+
+func handleOpen(conn net.Conn, req models.Request, manager *Manager) {
+ log.Infof("AppPicker: Received %s request with params: %+v", req.Method, req.Params)
+
+ target, ok := models.Get[string](req, "target")
+ if !ok {
+ target, ok = models.Get[string](req, "url")
+ if !ok {
+ log.Warnf("AppPicker: Invalid target parameter in request")
+ models.RespondError(conn, req.ID, "invalid target parameter")
+ return
+ }
+ }
+
+ event := OpenEvent{
+ Target: target,
+ RequestType: models.GetOr(req, "requestType", "url"),
+ MimeType: models.GetOr(req, "mimeType", ""),
+ }
+
+ if categories, ok := models.Get[[]any](req, "categories"); ok {
+ event.Categories = make([]string, 0, len(categories))
+ for _, cat := range categories {
+ if catStr, ok := cat.(string); ok {
+ event.Categories = append(event.Categories, catStr)
+ }
+ }
+ }
+
+ log.Infof("AppPicker: Broadcasting event: %+v", event)
+ manager.RequestOpen(event)
+ models.Respond(conn, req.ID, "ok")
+ log.Infof("AppPicker: Request handled successfully")
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/apppicker/manager.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/apppicker/manager.go
new file mode 100644
index 0000000..bde0846
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/apppicker/manager.go
@@ -0,0 +1,48 @@
+package apppicker
+
+import (
+ "sync"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
+)
+
+type Manager struct {
+ subscribers syncmap.Map[string, chan OpenEvent]
+ closeOnce sync.Once
+}
+
+func NewManager() *Manager {
+ return &Manager{}
+}
+
+func (m *Manager) Subscribe(id string) chan OpenEvent {
+ ch := make(chan OpenEvent, 16)
+ m.subscribers.Store(id, ch)
+ return ch
+}
+
+func (m *Manager) Unsubscribe(id string) {
+ if val, ok := m.subscribers.LoadAndDelete(id); ok {
+ close(val)
+ }
+}
+
+func (m *Manager) RequestOpen(event OpenEvent) {
+ m.subscribers.Range(func(key string, ch chan OpenEvent) bool {
+ select {
+ case ch <- event:
+ default:
+ }
+ return true
+ })
+}
+
+func (m *Manager) Close() {
+ m.closeOnce.Do(func() {
+ m.subscribers.Range(func(key string, ch chan OpenEvent) bool {
+ close(ch)
+ m.subscribers.Delete(key)
+ return true
+ })
+ })
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/apppicker/models.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/apppicker/models.go
new file mode 100644
index 0000000..8861461
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/apppicker/models.go
@@ -0,0 +1,8 @@
+package apppicker
+
+type OpenEvent struct {
+ Target string `json:"target"`
+ MimeType string `json:"mimeType,omitempty"`
+ Categories []string `json:"categories,omitempty"`
+ RequestType string `json:"requestType"`
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/bluez/agent.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/bluez/agent.go
new file mode 100644
index 0000000..b630a19
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/bluez/agent.go
@@ -0,0 +1,341 @@
+package bluez
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strconv"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "github.com/godbus/dbus/v5"
+)
+
+const (
+ bluezService = "org.bluez"
+ agentManagerPath = "/org/bluez"
+ agentManagerIface = "org.bluez.AgentManager1"
+ agent1Iface = "org.bluez.Agent1"
+ device1Iface = "org.bluez.Device1"
+ agentPath = "/com/danklinux/bluez/agent"
+ agentCapability = "KeyboardDisplay"
+)
+
+const introspectXML = `
+<node>
+ <interface name="org.bluez.Agent1">
+ <method name="Release"/>
+ <method name="RequestPinCode">
+ <arg direction="in" type="o" name="device"/>
+ <arg direction="out" type="s" name="pincode"/>
+ </method>
+ <method name="RequestPasskey">
+ <arg direction="in" type="o" name="device"/>
+ <arg direction="out" type="u" name="passkey"/>
+ </method>
+ <method name="DisplayPinCode">
+ <arg direction="in" type="o" name="device"/>
+ <arg direction="in" type="s" name="pincode"/>
+ </method>
+ <method name="DisplayPasskey">
+ <arg direction="in" type="o" name="device"/>
+ <arg direction="in" type="u" name="passkey"/>
+ <arg direction="in" type="q" name="entered"/>
+ </method>
+ <method name="RequestConfirmation">
+ <arg direction="in" type="o" name="device"/>
+ <arg direction="in" type="u" name="passkey"/>
+ </method>
+ <method name="RequestAuthorization">
+ <arg direction="in" type="o" name="device"/>
+ </method>
+ <method name="AuthorizeService">
+ <arg direction="in" type="o" name="device"/>
+ <arg direction="in" type="s" name="uuid"/>
+ </method>
+ <method name="Cancel"/>
+ </interface>
+ <interface name="org.freedesktop.DBus.Introspectable">
+ <method name="Introspect">
+ <arg direction="out" type="s" name="data"/>
+ </method>
+ </interface>
+</node>`
+
+type BluezAgent struct {
+ conn *dbus.Conn
+ broker PromptBroker
+}
+
+func NewBluezAgent(broker PromptBroker) (*BluezAgent, error) {
+ conn, err := dbus.ConnectSystemBus()
+ if err != nil {
+ return nil, fmt.Errorf("system bus connection failed: %w", err)
+ }
+
+ agent := &BluezAgent{
+ conn: conn,
+ broker: broker,
+ }
+
+ if err := conn.Export(agent, dbus.ObjectPath(agentPath), agent1Iface); err != nil {
+ conn.Close()
+ return nil, fmt.Errorf("agent export failed: %w", err)
+ }
+
+ if err := conn.Export(agent, dbus.ObjectPath(agentPath), "org.freedesktop.DBus.Introspectable"); err != nil {
+ conn.Close()
+ return nil, fmt.Errorf("introspection export failed: %w", err)
+ }
+
+ mgr := conn.Object(bluezService, dbus.ObjectPath(agentManagerPath))
+ if err := mgr.Call(agentManagerIface+".RegisterAgent", 0, dbus.ObjectPath(agentPath), agentCapability).Err; err != nil {
+ conn.Close()
+ return nil, fmt.Errorf("agent registration failed: %w", err)
+ }
+
+ if err := mgr.Call(agentManagerIface+".RequestDefaultAgent", 0, dbus.ObjectPath(agentPath)).Err; err != nil {
+ log.Debugf("[BluezAgent] not default agent: %v", err)
+ }
+
+ log.Infof("[BluezAgent] registered at %s with capability %s", agentPath, agentCapability)
+ return agent, nil
+}
+
+func (a *BluezAgent) Close() {
+ if a.conn == nil {
+ return
+ }
+ mgr := a.conn.Object(bluezService, dbus.ObjectPath(agentManagerPath))
+ mgr.Call(agentManagerIface+".UnregisterAgent", 0, dbus.ObjectPath(agentPath))
+ a.conn.Close()
+}
+
+func (a *BluezAgent) Release() *dbus.Error {
+ log.Infof("[BluezAgent] Release called")
+ return nil
+}
+
+func (a *BluezAgent) RequestPinCode(device dbus.ObjectPath) (string, *dbus.Error) {
+ log.Infof("[BluezAgent] RequestPinCode: device=%s", device)
+
+ secrets, err := a.promptFor(device, "pin", []string{"pin"}, nil)
+ if err != nil {
+ log.Warnf("[BluezAgent] RequestPinCode failed: %v", err)
+ return "", a.errorFrom(err)
+ }
+
+ pin := secrets["pin"]
+ log.Infof("[BluezAgent] RequestPinCode returning PIN (len=%d)", len(pin))
+ return pin, nil
+}
+
+func (a *BluezAgent) RequestPasskey(device dbus.ObjectPath) (uint32, *dbus.Error) {
+ log.Infof("[BluezAgent] RequestPasskey: device=%s", device)
+
+ secrets, err := a.promptFor(device, "passkey", []string{"passkey"}, nil)
+ if err != nil {
+ log.Warnf("[BluezAgent] RequestPasskey failed: %v", err)
+ return 0, a.errorFrom(err)
+ }
+
+ passkey, err := strconv.ParseUint(secrets["passkey"], 10, 32)
+ if err != nil {
+ log.Warnf("[BluezAgent] invalid passkey format: %v", err)
+ return 0, dbus.MakeFailedError(fmt.Errorf("invalid passkey: %w", err))
+ }
+
+ log.Infof("[BluezAgent] RequestPasskey returning: %d", passkey)
+ return uint32(passkey), nil
+}
+
+func (a *BluezAgent) DisplayPinCode(device dbus.ObjectPath, pincode string) *dbus.Error {
+ log.Infof("[BluezAgent] DisplayPinCode: device=%s, pin=%s", device, pincode)
+
+ _, err := a.promptFor(device, "display-pin", []string{}, &pincode)
+ if err != nil {
+ log.Warnf("[BluezAgent] DisplayPinCode acknowledgment failed: %v", err)
+ }
+
+ return nil
+}
+
+func (a *BluezAgent) DisplayPasskey(device dbus.ObjectPath, passkey uint32, entered uint16) *dbus.Error {
+ log.Infof("[BluezAgent] DisplayPasskey: device=%s, passkey=%06d, entered=%d", device, passkey, entered)
+
+ if entered == 0 {
+ passkeyStr := strconv.FormatUint(uint64(passkey), 10)
+ _, err := a.promptFor(device, "display-passkey", []string{}, &passkeyStr)
+ if err != nil {
+ log.Warnf("[BluezAgent] DisplayPasskey acknowledgment failed: %v", err)
+ }
+ }
+
+ return nil
+}
+
+func (a *BluezAgent) RequestConfirmation(device dbus.ObjectPath, passkey uint32) *dbus.Error {
+ log.Infof("[BluezAgent] RequestConfirmation: device=%s, passkey=%06d", device, passkey)
+
+ passkeyStr := strconv.FormatUint(uint64(passkey), 10)
+ secrets, err := a.promptFor(device, "confirm", []string{"decision"}, &passkeyStr)
+ if err != nil {
+ log.Warnf("[BluezAgent] RequestConfirmation failed: %v", err)
+ return a.errorFrom(err)
+ }
+
+ if secrets["decision"] != "yes" && secrets["decision"] != "accept" {
+ log.Debugf("[BluezAgent] RequestConfirmation rejected by user")
+ return dbus.NewError("org.bluez.Error.Rejected", nil)
+ }
+
+ log.Infof("[BluezAgent] RequestConfirmation accepted")
+ return nil
+}
+
+func (a *BluezAgent) RequestAuthorization(device dbus.ObjectPath) *dbus.Error {
+ log.Infof("[BluezAgent] RequestAuthorization: device=%s", device)
+
+ secrets, err := a.promptFor(device, "authorize", []string{"decision"}, nil)
+ if err != nil {
+ log.Warnf("[BluezAgent] RequestAuthorization failed: %v", err)
+ return a.errorFrom(err)
+ }
+
+ if secrets["decision"] != "yes" && secrets["decision"] != "accept" {
+ log.Debugf("[BluezAgent] RequestAuthorization rejected by user")
+ return dbus.NewError("org.bluez.Error.Rejected", nil)
+ }
+
+ log.Infof("[BluezAgent] RequestAuthorization accepted")
+ return nil
+}
+
+func (a *BluezAgent) AuthorizeService(device dbus.ObjectPath, uuid string) *dbus.Error {
+ log.Infof("[BluezAgent] AuthorizeService: device=%s, uuid=%s", device, uuid)
+
+ secrets, err := a.promptFor(device, "authorize-service:"+uuid, []string{"decision"}, nil)
+ if err != nil {
+ log.Warnf("[BluezAgent] AuthorizeService failed: %v", err)
+ return a.errorFrom(err)
+ }
+
+ if secrets["decision"] != "yes" && secrets["decision"] != "accept" {
+ log.Debugf("[BluezAgent] AuthorizeService rejected by user")
+ return dbus.NewError("org.bluez.Error.Rejected", nil)
+ }
+
+ log.Infof("[BluezAgent] AuthorizeService accepted")
+ return nil
+}
+
+func (a *BluezAgent) Cancel() *dbus.Error {
+ log.Infof("[BluezAgent] Cancel called")
+ return nil
+}
+
+func (a *BluezAgent) Introspect() (string, *dbus.Error) {
+ return introspectXML, nil
+}
+
+func (a *BluezAgent) promptFor(device dbus.ObjectPath, requestType string, fields []string, displayValue *string) (map[string]string, error) {
+ if a.broker == nil {
+ return nil, fmt.Errorf("broker not initialized")
+ }
+
+ deviceName, deviceAddr := a.getDeviceInfo(device)
+ hints := []string{}
+ if displayValue != nil {
+ hints = append(hints, *displayValue)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+ defer cancel()
+
+ var passkey *uint32
+ if requestType == "confirm" || requestType == "display-passkey" {
+ if displayValue != nil {
+ if pk, err := strconv.ParseUint(*displayValue, 10, 32); err == nil {
+ pk32 := uint32(pk)
+ passkey = &pk32
+ }
+ }
+ }
+
+ token, err := a.broker.Ask(ctx, PromptRequest{
+ DevicePath: string(device),
+ DeviceName: deviceName,
+ DeviceAddr: deviceAddr,
+ RequestType: requestType,
+ Fields: fields,
+ Hints: hints,
+ Passkey: passkey,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("prompt creation failed: %w", err)
+ }
+
+ log.Infof("[BluezAgent] waiting for user response (token=%s)", token)
+ reply, err := a.broker.Wait(ctx, token)
+ if err != nil {
+ if errors.Is(err, errdefs.ErrSecretPromptTimeout) {
+ return nil, err
+ }
+ if reply.Cancel || errors.Is(err, errdefs.ErrSecretPromptCancelled) {
+ return nil, errdefs.ErrSecretPromptCancelled
+ }
+ return nil, err
+ }
+
+ if !reply.Accept && len(fields) > 0 {
+ return nil, errdefs.ErrSecretPromptCancelled
+ }
+
+ return reply.Secrets, nil
+}
+
+func (a *BluezAgent) getDeviceInfo(device dbus.ObjectPath) (string, string) {
+ obj := a.conn.Object(bluezService, device)
+
+ var name, alias, addr string
+
+ nameVar, err := obj.GetProperty(device1Iface + ".Name")
+ if err == nil {
+ if n, ok := nameVar.Value().(string); ok {
+ name = n
+ }
+ }
+
+ aliasVar, err := obj.GetProperty(device1Iface + ".Alias")
+ if err == nil {
+ if a, ok := aliasVar.Value().(string); ok {
+ alias = a
+ }
+ }
+
+ addrVar, err := obj.GetProperty(device1Iface + ".Address")
+ if err == nil {
+ if a, ok := addrVar.Value().(string); ok {
+ addr = a
+ }
+ }
+
+ if alias != "" {
+ return alias, addr
+ }
+ if name != "" {
+ return name, addr
+ }
+ return addr, addr
+}
+
+func (a *BluezAgent) errorFrom(err error) *dbus.Error {
+ if errors.Is(err, errdefs.ErrSecretPromptTimeout) {
+ return dbus.NewError("org.bluez.Error.Canceled", nil)
+ }
+ if errors.Is(err, errdefs.ErrSecretPromptCancelled) {
+ return dbus.NewError("org.bluez.Error.Canceled", nil)
+ }
+ return dbus.MakeFailedError(err)
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/bluez/broker.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/bluez/broker.go
new file mode 100644
index 0000000..f817707
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/bluez/broker.go
@@ -0,0 +1,21 @@
+package bluez
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+)
+
+type PromptBroker interface {
+ Ask(ctx context.Context, req PromptRequest) (token string, err error)
+ Wait(ctx context.Context, token string) (PromptReply, error)
+ Resolve(token string, reply PromptReply) error
+}
+
+func generateToken() (string, error) {
+ bytes := make([]byte, 16)
+ if _, err := rand.Read(bytes); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(bytes), nil
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/bluez/broker_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/bluez/broker_test.go
new file mode 100644
index 0000000..9eebe61
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/bluez/broker_test.go
@@ -0,0 +1,220 @@
+package bluez
+
+import (
+ "context"
+ "testing"
+ "time"
+)
+
+func TestSubscriptionBrokerAskWait(t *testing.T) {
+ promptReceived := false
+ broker := NewSubscriptionBroker(func(p PairingPrompt) {
+ promptReceived = true
+ if p.Token == "" {
+ t.Error("expected token to be non-empty")
+ }
+ if p.DeviceName != "TestDevice" {
+ t.Errorf("expected DeviceName=TestDevice, got %s", p.DeviceName)
+ }
+ })
+
+ ctx := context.Background()
+ req := PromptRequest{
+ DevicePath: "/org/bluez/test",
+ DeviceName: "TestDevice",
+ DeviceAddr: "AA:BB:CC:DD:EE:FF",
+ RequestType: "pin",
+ Fields: []string{"pin"},
+ }
+
+ token, err := broker.Ask(ctx, req)
+ if err != nil {
+ t.Fatalf("Ask failed: %v", err)
+ }
+
+ if token == "" {
+ t.Fatal("expected non-empty token")
+ }
+
+ if !promptReceived {
+ t.Fatal("expected prompt broadcast to be called")
+ }
+
+ go func() {
+ time.Sleep(50 * time.Millisecond)
+ broker.Resolve(token, PromptReply{
+ Secrets: map[string]string{"pin": "1234"},
+ Accept: true,
+ })
+ }()
+
+ reply, err := broker.Wait(ctx, token)
+ if err != nil {
+ t.Fatalf("Wait failed: %v", err)
+ }
+
+ if reply.Secrets["pin"] != "1234" {
+ t.Errorf("expected pin=1234, got %s", reply.Secrets["pin"])
+ }
+
+ if !reply.Accept {
+ t.Error("expected Accept=true")
+ }
+}
+
+func TestSubscriptionBrokerTimeout(t *testing.T) {
+ broker := NewSubscriptionBroker(nil)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
+ defer cancel()
+
+ req := PromptRequest{
+ DevicePath: "/org/bluez/test",
+ DeviceName: "TestDevice",
+ RequestType: "passkey",
+ Fields: []string{"passkey"},
+ }
+
+ token, err := broker.Ask(ctx, req)
+ if err != nil {
+ t.Fatalf("Ask failed: %v", err)
+ }
+
+ _, err = broker.Wait(ctx, token)
+ if err == nil {
+ t.Fatal("expected timeout error")
+ }
+}
+
+func TestSubscriptionBrokerCancel(t *testing.T) {
+ broker := NewSubscriptionBroker(nil)
+
+ ctx := context.Background()
+ req := PromptRequest{
+ DevicePath: "/org/bluez/test",
+ DeviceName: "TestDevice",
+ RequestType: "confirm",
+ Fields: []string{"decision"},
+ }
+
+ token, err := broker.Ask(ctx, req)
+ if err != nil {
+ t.Fatalf("Ask failed: %v", err)
+ }
+
+ go func() {
+ time.Sleep(50 * time.Millisecond)
+ broker.Resolve(token, PromptReply{
+ Cancel: true,
+ })
+ }()
+
+ _, err = broker.Wait(ctx, token)
+ if err == nil {
+ t.Fatal("expected cancelled error")
+ }
+}
+
+func TestSubscriptionBrokerUnknownToken(t *testing.T) {
+ broker := NewSubscriptionBroker(nil)
+
+ ctx := context.Background()
+ _, err := broker.Wait(ctx, "invalid-token")
+ if err == nil {
+ t.Fatal("expected error for unknown token")
+ }
+}
+
+func TestGenerateToken(t *testing.T) {
+ token1, err := generateToken()
+ if err != nil {
+ t.Fatalf("generateToken failed: %v", err)
+ }
+
+ token2, err := generateToken()
+ if err != nil {
+ t.Fatalf("generateToken failed: %v", err)
+ }
+
+ if token1 == token2 {
+ t.Error("expected unique tokens")
+ }
+
+ if len(token1) != 32 {
+ t.Errorf("expected token length 32, got %d", len(token1))
+ }
+}
+
+func TestSubscriptionBrokerResolveUnknownToken(t *testing.T) {
+ broker := NewSubscriptionBroker(nil)
+
+ err := broker.Resolve("unknown-token", PromptReply{
+ Secrets: map[string]string{"test": "value"},
+ })
+ if err == nil {
+ t.Fatal("expected error for unknown token")
+ }
+}
+
+func TestSubscriptionBrokerMultipleRequests(t *testing.T) {
+ broker := NewSubscriptionBroker(nil)
+ ctx := context.Background()
+
+ req1 := PromptRequest{
+ DevicePath: "/org/bluez/test1",
+ DeviceName: "Device1",
+ RequestType: "pin",
+ Fields: []string{"pin"},
+ }
+
+ req2 := PromptRequest{
+ DevicePath: "/org/bluez/test2",
+ DeviceName: "Device2",
+ RequestType: "passkey",
+ Fields: []string{"passkey"},
+ }
+
+ token1, err := broker.Ask(ctx, req1)
+ if err != nil {
+ t.Fatalf("Ask1 failed: %v", err)
+ }
+
+ token2, err := broker.Ask(ctx, req2)
+ if err != nil {
+ t.Fatalf("Ask2 failed: %v", err)
+ }
+
+ if token1 == token2 {
+ t.Error("expected different tokens")
+ }
+
+ go func() {
+ time.Sleep(50 * time.Millisecond)
+ broker.Resolve(token1, PromptReply{
+ Secrets: map[string]string{"pin": "1234"},
+ Accept: true,
+ })
+ broker.Resolve(token2, PromptReply{
+ Secrets: map[string]string{"passkey": "567890"},
+ Accept: true,
+ })
+ }()
+
+ reply1, err := broker.Wait(ctx, token1)
+ if err != nil {
+ t.Fatalf("Wait1 failed: %v", err)
+ }
+
+ reply2, err := broker.Wait(ctx, token2)
+ if err != nil {
+ t.Fatalf("Wait2 failed: %v", err)
+ }
+
+ if reply1.Secrets["pin"] != "1234" {
+ t.Errorf("expected pin=1234, got %s", reply1.Secrets["pin"])
+ }
+
+ if reply2.Secrets["passkey"] != "567890" {
+ t.Errorf("expected passkey=567890, got %s", reply2.Secrets["passkey"])
+ }
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/bluez/handlers.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/bluez/handlers.go
new file mode 100644
index 0000000..be5a1c4
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/bluez/handlers.go
@@ -0,0 +1,237 @@
+package bluez
+
+import (
+ "encoding/json"
+ "fmt"
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/params"
+)
+
+type BluetoothEvent struct {
+ Type string `json:"type"`
+ Data BluetoothState `json:"data"`
+}
+
+func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
+ switch req.Method {
+ case "bluetooth.getState":
+ handleGetState(conn, req, manager)
+ case "bluetooth.startDiscovery":
+ handleStartDiscovery(conn, req, manager)
+ case "bluetooth.stopDiscovery":
+ handleStopDiscovery(conn, req, manager)
+ case "bluetooth.setPowered":
+ handleSetPowered(conn, req, manager)
+ case "bluetooth.pair":
+ handlePairDevice(conn, req, manager)
+ case "bluetooth.connect":
+ handleConnectDevice(conn, req, manager)
+ case "bluetooth.disconnect":
+ handleDisconnectDevice(conn, req, manager)
+ case "bluetooth.remove":
+ handleRemoveDevice(conn, req, manager)
+ case "bluetooth.trust":
+ handleTrustDevice(conn, req, manager)
+ case "bluetooth.untrust":
+ handleUntrustDevice(conn, req, manager)
+ case "bluetooth.subscribe":
+ handleSubscribe(conn, req, manager)
+ case "bluetooth.pairing.submit":
+ handlePairingSubmit(conn, req, manager)
+ case "bluetooth.pairing.cancel":
+ handlePairingCancel(conn, req, manager)
+ default:
+ models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
+ }
+}
+
+func handleGetState(conn net.Conn, req models.Request, manager *Manager) {
+ models.Respond(conn, req.ID, manager.GetState())
+}
+
+func handleStartDiscovery(conn net.Conn, req models.Request, manager *Manager) {
+ if err := manager.StartDiscovery(); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "discovery started"})
+}
+
+func handleStopDiscovery(conn net.Conn, req models.Request, manager *Manager) {
+ if err := manager.StopDiscovery(); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "discovery stopped"})
+}
+
+func handleSetPowered(conn net.Conn, req models.Request, manager *Manager) {
+ powered, err := params.Bool(req.Params, "powered")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.SetPowered(powered); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "powered state updated"})
+}
+
+func handlePairDevice(conn net.Conn, req models.Request, manager *Manager) {
+ devicePath, err := params.String(req.Params, "device")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.PairDevice(devicePath); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "pairing initiated"})
+}
+
+func handleConnectDevice(conn net.Conn, req models.Request, manager *Manager) {
+ devicePath, err := params.String(req.Params, "device")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.ConnectDevice(devicePath); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "connecting"})
+}
+
+func handleDisconnectDevice(conn net.Conn, req models.Request, manager *Manager) {
+ devicePath, err := params.String(req.Params, "device")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.DisconnectDevice(devicePath); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "disconnected"})
+}
+
+func handleRemoveDevice(conn net.Conn, req models.Request, manager *Manager) {
+ devicePath, err := params.String(req.Params, "device")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.RemoveDevice(devicePath); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "device removed"})
+}
+
+func handleTrustDevice(conn net.Conn, req models.Request, manager *Manager) {
+ devicePath, err := params.String(req.Params, "device")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.TrustDevice(devicePath, true); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "device trusted"})
+}
+
+func handleUntrustDevice(conn net.Conn, req models.Request, manager *Manager) {
+ devicePath, err := params.String(req.Params, "device")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.TrustDevice(devicePath, false); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "device untrusted"})
+}
+
+func handlePairingSubmit(conn net.Conn, req models.Request, manager *Manager) {
+ token, err := params.String(req.Params, "token")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ secrets := params.StringMapOpt(req.Params, "secrets")
+ accept := params.BoolOpt(req.Params, "accept", false)
+
+ if err := manager.SubmitPairing(token, secrets, accept); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "pairing response submitted"})
+}
+
+func handlePairingCancel(conn net.Conn, req models.Request, manager *Manager) {
+ token, err := params.String(req.Params, "token")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.CancelPairing(token); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "pairing cancelled"})
+}
+
+func handleSubscribe(conn net.Conn, req models.Request, manager *Manager) {
+ clientID := fmt.Sprintf("client-%p", conn)
+ stateChan := manager.Subscribe(clientID)
+ defer manager.Unsubscribe(clientID)
+
+ initialState := manager.GetState()
+ event := BluetoothEvent{
+ Type: "state_changed",
+ Data: initialState,
+ }
+
+ if err := json.NewEncoder(conn).Encode(models.Response[BluetoothEvent]{
+ ID: req.ID,
+ Result: &event,
+ }); err != nil {
+ return
+ }
+
+ for state := range stateChan {
+ event := BluetoothEvent{
+ Type: "state_changed",
+ Data: state,
+ }
+ if err := json.NewEncoder(conn).Encode(models.Response[BluetoothEvent]{
+ Result: &event,
+ }); err != nil {
+ return
+ }
+ }
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/bluez/handlers_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/bluez/handlers_test.go
new file mode 100644
index 0000000..1846eb0
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/bluez/handlers_test.go
@@ -0,0 +1,41 @@
+package bluez
+
+import (
+ "context"
+ "testing"
+ "time"
+)
+
+func TestBrokerIntegration(t *testing.T) {
+ broker := NewSubscriptionBroker(nil)
+ ctx := context.Background()
+
+ req := PromptRequest{
+ DevicePath: "/org/bluez/test",
+ DeviceName: "TestDevice",
+ RequestType: "pin",
+ Fields: []string{"pin"},
+ }
+
+ token, err := broker.Ask(ctx, req)
+ if err != nil {
+ t.Fatalf("Ask failed: %v", err)
+ }
+
+ go func() {
+ time.Sleep(50 * time.Millisecond)
+ broker.Resolve(token, PromptReply{
+ Secrets: map[string]string{"pin": "1234"},
+ Accept: true,
+ })
+ }()
+
+ reply, err := broker.Wait(ctx, token)
+ if err != nil {
+ t.Fatalf("Wait failed: %v", err)
+ }
+
+ if reply.Secrets["pin"] != "1234" {
+ t.Errorf("expected pin=1234, got %s", reply.Secrets["pin"])
+ }
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/bluez/manager.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/bluez/manager.go
new file mode 100644
index 0000000..4408d25
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/bluez/manager.go
@@ -0,0 +1,588 @@
+package bluez
+
+import (
+ "fmt"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/dbusutil"
+ "github.com/godbus/dbus/v5"
+)
+
+const (
+ adapter1Iface = "org.bluez.Adapter1"
+ objectMgrIface = "org.freedesktop.DBus.ObjectManager"
+ propertiesIface = "org.freedesktop.DBus.Properties"
+)
+
+func NewManager() (*Manager, error) {
+ conn, err := dbus.ConnectSystemBus()
+ if err != nil {
+ return nil, fmt.Errorf("system bus connection failed: %w", err)
+ }
+
+ m := &Manager{
+ state: &BluetoothState{
+ Powered: false,
+ Discovering: false,
+ Devices: []Device{},
+ PairedDevices: []Device{},
+ ConnectedDevices: []Device{},
+ },
+ stateMutex: sync.RWMutex{},
+
+ stopChan: make(chan struct{}),
+ dbusConn: conn,
+ signals: make(chan *dbus.Signal, 256),
+ dirty: make(chan struct{}, 1),
+ eventQueue: make(chan func(), 32),
+ }
+
+ broker := NewSubscriptionBroker(m.broadcastPairingPrompt)
+ m.promptBroker = broker
+
+ adapter, err := m.findAdapter()
+ if err != nil {
+ conn.Close()
+ return nil, fmt.Errorf("no bluetooth adapter found: %w", err)
+ }
+ m.adapterPath = adapter
+
+ if err := m.initialize(); err != nil {
+ conn.Close()
+ return nil, err
+ }
+
+ if err := m.startAgent(); err != nil {
+ conn.Close()
+ return nil, fmt.Errorf("agent start failed: %w", err)
+ }
+
+ if err := m.startSignalPump(); err != nil {
+ m.Close()
+ return nil, err
+ }
+
+ m.notifierWg.Add(1)
+ go m.notifier()
+
+ m.eventWg.Add(1)
+ go m.eventWorker()
+
+ return m, nil
+}
+
+func (m *Manager) findAdapter() (dbus.ObjectPath, error) {
+ obj := m.dbusConn.Object(bluezService, dbus.ObjectPath("/"))
+ var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
+
+ if err := obj.Call(objectMgrIface+".GetManagedObjects", 0).Store(&objects); err != nil {
+ return "", err
+ }
+
+ for path, interfaces := range objects {
+ if _, ok := interfaces[adapter1Iface]; ok {
+ log.Infof("[BluezManager] found adapter: %s", path)
+ return path, nil
+ }
+ }
+
+ return "", fmt.Errorf("no adapter found")
+}
+
+func (m *Manager) initialize() error {
+ if err := m.updateAdapterState(); err != nil {
+ return err
+ }
+
+ if err := m.updateDevices(); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (m *Manager) updateAdapterState() error {
+ obj := m.dbusConn.Object(bluezService, m.adapterPath)
+
+ poweredVar, err := obj.GetProperty(adapter1Iface + ".Powered")
+ if err != nil {
+ return err
+ }
+
+ discoveringVar, err := obj.GetProperty(adapter1Iface + ".Discovering")
+ if err != nil {
+ return err
+ }
+
+ m.stateMutex.Lock()
+ m.state.Powered = dbusutil.AsOr(poweredVar, false)
+ m.state.Discovering = dbusutil.AsOr(discoveringVar, false)
+ m.stateMutex.Unlock()
+
+ return nil
+}
+
+func (m *Manager) updateDevices() error {
+ obj := m.dbusConn.Object(bluezService, dbus.ObjectPath("/"))
+ var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
+
+ if err := obj.Call(objectMgrIface+".GetManagedObjects", 0).Store(&objects); err != nil {
+ return err
+ }
+
+ devices := []Device{}
+ paired := []Device{}
+ connected := []Device{}
+
+ for path, interfaces := range objects {
+ devProps, ok := interfaces[device1Iface]
+ if !ok {
+ continue
+ }
+
+ if !strings.HasPrefix(string(path), string(m.adapterPath)+"/") {
+ continue
+ }
+
+ dev := m.deviceFromProps(string(path), devProps)
+ devices = append(devices, dev)
+
+ if dev.Paired {
+ paired = append(paired, dev)
+ }
+ if dev.Connected {
+ connected = append(connected, dev)
+ }
+ }
+
+ m.stateMutex.Lock()
+ m.state.Devices = devices
+ m.state.PairedDevices = paired
+ m.state.ConnectedDevices = connected
+ m.stateMutex.Unlock()
+
+ return nil
+}
+
+func (m *Manager) deviceFromProps(path string, props map[string]dbus.Variant) Device {
+ return Device{
+ Path: path,
+ Address: dbusutil.GetOr(props, "Address", ""),
+ Name: dbusutil.GetOr(props, "Name", ""),
+ Alias: dbusutil.GetOr(props, "Alias", ""),
+ Paired: dbusutil.GetOr(props, "Paired", false),
+ Trusted: dbusutil.GetOr(props, "Trusted", false),
+ Blocked: dbusutil.GetOr(props, "Blocked", false),
+ Connected: dbusutil.GetOr(props, "Connected", false),
+ Class: dbusutil.GetOr(props, "Class", uint32(0)),
+ Icon: dbusutil.GetOr(props, "Icon", ""),
+ RSSI: dbusutil.GetOr(props, "RSSI", int16(0)),
+ LegacyPairing: dbusutil.GetOr(props, "LegacyPairing", false),
+ }
+}
+
+func (m *Manager) startAgent() error {
+ if m.promptBroker == nil {
+ return fmt.Errorf("prompt broker not initialized")
+ }
+
+ agent, err := NewBluezAgent(m.promptBroker)
+ if err != nil {
+ return err
+ }
+
+ m.agent = agent
+ return nil
+}
+
+func (m *Manager) startSignalPump() error {
+ m.dbusConn.Signal(m.signals)
+
+ if err := m.dbusConn.AddMatchSignal(
+ dbus.WithMatchInterface(propertiesIface),
+ dbus.WithMatchMember("PropertiesChanged"),
+ ); err != nil {
+ return err
+ }
+
+ if err := m.dbusConn.AddMatchSignal(
+ dbus.WithMatchInterface(objectMgrIface),
+ dbus.WithMatchMember("InterfacesAdded"),
+ ); err != nil {
+ return err
+ }
+
+ if err := m.dbusConn.AddMatchSignal(
+ dbus.WithMatchInterface(objectMgrIface),
+ dbus.WithMatchMember("InterfacesRemoved"),
+ ); err != nil {
+ return err
+ }
+
+ m.sigWG.Add(1)
+ go func() {
+ defer m.sigWG.Done()
+ for {
+ select {
+ case <-m.stopChan:
+ return
+ case sig, ok := <-m.signals:
+ if !ok {
+ return
+ }
+ if sig == nil {
+ continue
+ }
+ m.handleSignal(sig)
+ }
+ }
+ }()
+
+ return nil
+}
+
+func (m *Manager) handleSignal(sig *dbus.Signal) {
+ switch sig.Name {
+ case propertiesIface + ".PropertiesChanged":
+ if len(sig.Body) < 2 {
+ return
+ }
+
+ iface, ok := sig.Body[0].(string)
+ if !ok {
+ return
+ }
+
+ changed, ok := sig.Body[1].(map[string]dbus.Variant)
+ if !ok {
+ return
+ }
+
+ switch iface {
+ case adapter1Iface:
+ if strings.HasPrefix(string(sig.Path), string(m.adapterPath)) {
+ m.handleAdapterPropertiesChanged(changed)
+ }
+ case device1Iface:
+ m.handleDevicePropertiesChanged(sig.Path, changed)
+ }
+
+ case objectMgrIface + ".InterfacesAdded":
+ m.notifySubscribers()
+
+ case objectMgrIface + ".InterfacesRemoved":
+ m.notifySubscribers()
+ }
+}
+
+func (m *Manager) handleAdapterPropertiesChanged(changed map[string]dbus.Variant) {
+ m.stateMutex.Lock()
+ dirty := false
+
+ if powered, ok := dbusutil.Get[bool](changed, "Powered"); ok {
+ m.state.Powered = powered
+ dirty = true
+ }
+ if discovering, ok := dbusutil.Get[bool](changed, "Discovering"); ok {
+ m.state.Discovering = discovering
+ dirty = true
+ }
+
+ m.stateMutex.Unlock()
+
+ if dirty {
+ m.notifySubscribers()
+ }
+}
+
+func (m *Manager) handleDevicePropertiesChanged(path dbus.ObjectPath, changed map[string]dbus.Variant) {
+ paired, hasPaired := dbusutil.Get[bool](changed, "Paired")
+ _, hasConnected := changed["Connected"]
+ _, hasTrusted := changed["Trusted"]
+
+ if hasPaired {
+ devicePath := string(path)
+ if paired {
+ _, wasPending := m.pendingPairings.LoadAndDelete(devicePath)
+ if wasPending {
+ select {
+ case m.eventQueue <- func() {
+ time.Sleep(300 * time.Millisecond)
+ log.Infof("[Bluetooth] Auto-trusting newly paired device: %s", devicePath)
+ if err := m.TrustDevice(devicePath, true); err != nil {
+ log.Warnf("[Bluetooth] Auto-trust failed: %v", err)
+ }
+ log.Infof("[Bluetooth] Auto-connecting newly paired device: %s", devicePath)
+ if err := m.ConnectDevice(devicePath); err != nil {
+ log.Warnf("[Bluetooth] Auto-connect failed: %v", err)
+ }
+ }:
+ default:
+ }
+ }
+ } else {
+ m.pendingPairings.Delete(devicePath)
+ }
+ }
+
+ if hasPaired || hasConnected || hasTrusted {
+ select {
+ case m.eventQueue <- func() {
+ time.Sleep(100 * time.Millisecond)
+ m.updateDevices()
+ m.notifySubscribers()
+ }:
+ default:
+ }
+ }
+}
+
+func (m *Manager) eventWorker() {
+ defer m.eventWg.Done()
+ for {
+ select {
+ case <-m.stopChan:
+ return
+ case event := <-m.eventQueue:
+ event()
+ }
+ }
+}
+
+func (m *Manager) notifier() {
+ defer m.notifierWg.Done()
+ const minGap = 200 * time.Millisecond
+ timer := time.NewTimer(minGap)
+ timer.Stop()
+ var pending bool
+
+ for {
+ select {
+ case <-m.stopChan:
+ timer.Stop()
+ return
+ case <-m.dirty:
+ if pending {
+ continue
+ }
+ pending = true
+ timer.Reset(minGap)
+ case <-timer.C:
+ if !pending {
+ continue
+ }
+ m.updateDevices()
+
+ currentState := m.snapshotState()
+
+ if m.lastNotifiedState != nil && !stateChanged(m.lastNotifiedState, &currentState) {
+ pending = false
+ continue
+ }
+
+ m.subscribers.Range(func(key string, ch chan BluetoothState) bool {
+ select {
+ case ch <- currentState:
+ default:
+ }
+ return true
+ })
+
+ stateCopy := currentState
+ m.lastNotifiedState = &stateCopy
+ pending = false
+ }
+ }
+}
+
+func (m *Manager) notifySubscribers() {
+ select {
+ case m.dirty <- struct{}{}:
+ default:
+ }
+}
+
+func (m *Manager) GetState() BluetoothState {
+ return m.snapshotState()
+}
+
+func (m *Manager) snapshotState() BluetoothState {
+ m.stateMutex.RLock()
+ defer m.stateMutex.RUnlock()
+
+ s := *m.state
+ s.Devices = append([]Device(nil), m.state.Devices...)
+ s.PairedDevices = append([]Device(nil), m.state.PairedDevices...)
+ s.ConnectedDevices = append([]Device(nil), m.state.ConnectedDevices...)
+ return s
+}
+
+func (m *Manager) Subscribe(id string) chan BluetoothState {
+ ch := make(chan BluetoothState, 64)
+ m.subscribers.Store(id, ch)
+ return ch
+}
+
+func (m *Manager) Unsubscribe(id string) {
+ if ch, ok := m.subscribers.LoadAndDelete(id); ok {
+ close(ch)
+ }
+}
+
+func (m *Manager) SubscribePairing(id string) chan PairingPrompt {
+ ch := make(chan PairingPrompt, 16)
+ m.pairingSubscribers.Store(id, ch)
+ return ch
+}
+
+func (m *Manager) UnsubscribePairing(id string) {
+ if ch, ok := m.pairingSubscribers.LoadAndDelete(id); ok {
+ close(ch)
+ }
+}
+
+func (m *Manager) broadcastPairingPrompt(prompt PairingPrompt) {
+ m.pairingSubscribers.Range(func(key string, ch chan PairingPrompt) bool {
+ select {
+ case ch <- prompt:
+ default:
+ }
+ return true
+ })
+}
+
+func (m *Manager) SubmitPairing(token string, secrets map[string]string, accept bool) error {
+ if m.promptBroker == nil {
+ return fmt.Errorf("prompt broker not initialized")
+ }
+
+ return m.promptBroker.Resolve(token, PromptReply{
+ Secrets: secrets,
+ Accept: accept,
+ Cancel: false,
+ })
+}
+
+func (m *Manager) CancelPairing(token string) error {
+ if m.promptBroker == nil {
+ return fmt.Errorf("prompt broker not initialized")
+ }
+
+ return m.promptBroker.Resolve(token, PromptReply{
+ Cancel: true,
+ })
+}
+
+func (m *Manager) StartDiscovery() error {
+ obj := m.dbusConn.Object(bluezService, m.adapterPath)
+ return obj.Call(adapter1Iface+".StartDiscovery", 0).Err
+}
+
+func (m *Manager) StopDiscovery() error {
+ obj := m.dbusConn.Object(bluezService, m.adapterPath)
+ return obj.Call(adapter1Iface+".StopDiscovery", 0).Err
+}
+
+func (m *Manager) SetPowered(powered bool) error {
+ obj := m.dbusConn.Object(bluezService, m.adapterPath)
+ return obj.Call(propertiesIface+".Set", 0, adapter1Iface, "Powered", dbus.MakeVariant(powered)).Err
+}
+
+func (m *Manager) PairDevice(devicePath string) error {
+ m.pendingPairings.Store(devicePath, true)
+
+ obj := m.dbusConn.Object(bluezService, dbus.ObjectPath(devicePath))
+ err := obj.Call(device1Iface+".Pair", 0).Err
+
+ if err != nil {
+ m.pendingPairings.Delete(devicePath)
+ }
+
+ return err
+}
+
+func (m *Manager) ConnectDevice(devicePath string) error {
+ obj := m.dbusConn.Object(bluezService, dbus.ObjectPath(devicePath))
+ return obj.Call(device1Iface+".Connect", 0).Err
+}
+
+func (m *Manager) DisconnectDevice(devicePath string) error {
+ obj := m.dbusConn.Object(bluezService, dbus.ObjectPath(devicePath))
+ return obj.Call(device1Iface+".Disconnect", 0).Err
+}
+
+func (m *Manager) RemoveDevice(devicePath string) error {
+ obj := m.dbusConn.Object(bluezService, m.adapterPath)
+ return obj.Call(adapter1Iface+".RemoveDevice", 0, dbus.ObjectPath(devicePath)).Err
+}
+
+func (m *Manager) TrustDevice(devicePath string, trusted bool) error {
+ obj := m.dbusConn.Object(bluezService, dbus.ObjectPath(devicePath))
+ return obj.Call(propertiesIface+".Set", 0, device1Iface, "Trusted", dbus.MakeVariant(trusted)).Err
+}
+
+func (m *Manager) Close() {
+ close(m.stopChan)
+ m.notifierWg.Wait()
+ m.eventWg.Wait()
+
+ m.sigWG.Wait()
+
+ if m.signals != nil {
+ m.dbusConn.RemoveSignal(m.signals)
+ close(m.signals)
+ }
+
+ if m.agent != nil {
+ m.agent.Close()
+ }
+
+ m.subscribers.Range(func(key string, ch chan BluetoothState) bool {
+ close(ch)
+ m.subscribers.Delete(key)
+ return true
+ })
+
+ m.pairingSubscribers.Range(func(key string, ch chan PairingPrompt) bool {
+ close(ch)
+ m.pairingSubscribers.Delete(key)
+ return true
+ })
+
+ if m.dbusConn != nil {
+ m.dbusConn.Close()
+ }
+}
+
+func stateChanged(old, new *BluetoothState) bool {
+ if old.Powered != new.Powered {
+ return true
+ }
+ if old.Discovering != new.Discovering {
+ return true
+ }
+ if len(old.Devices) != len(new.Devices) {
+ return true
+ }
+ if len(old.PairedDevices) != len(new.PairedDevices) {
+ return true
+ }
+ if len(old.ConnectedDevices) != len(new.ConnectedDevices) {
+ return true
+ }
+ for i := range old.Devices {
+ if old.Devices[i].Path != new.Devices[i].Path {
+ return true
+ }
+ if old.Devices[i].Paired != new.Devices[i].Paired {
+ return true
+ }
+ if old.Devices[i].Connected != new.Devices[i].Connected {
+ return true
+ }
+ }
+ return false
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/bluez/subscription_broker.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/bluez/subscription_broker.go
new file mode 100644
index 0000000..ea04a19
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/bluez/subscription_broker.go
@@ -0,0 +1,86 @@
+package bluez
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
+)
+
+type SubscriptionBroker struct {
+ pending syncmap.Map[string, chan PromptReply]
+ requests syncmap.Map[string, PromptRequest]
+ broadcastPrompt func(PairingPrompt)
+}
+
+func NewSubscriptionBroker(broadcastPrompt func(PairingPrompt)) PromptBroker {
+ return &SubscriptionBroker{
+ broadcastPrompt: broadcastPrompt,
+ }
+}
+
+func (b *SubscriptionBroker) Ask(ctx context.Context, req PromptRequest) (string, error) {
+ token, err := generateToken()
+ if err != nil {
+ return "", err
+ }
+
+ replyChan := make(chan PromptReply, 1)
+ b.pending.Store(token, replyChan)
+ b.requests.Store(token, req)
+
+ if b.broadcastPrompt != nil {
+ prompt := PairingPrompt{
+ Token: token,
+ DevicePath: req.DevicePath,
+ DeviceName: req.DeviceName,
+ DeviceAddr: req.DeviceAddr,
+ RequestType: req.RequestType,
+ Fields: req.Fields,
+ Hints: req.Hints,
+ Passkey: req.Passkey,
+ }
+ b.broadcastPrompt(prompt)
+ }
+
+ return token, nil
+}
+
+func (b *SubscriptionBroker) Wait(ctx context.Context, token string) (PromptReply, error) {
+ replyChan, exists := b.pending.Load(token)
+ if !exists {
+ return PromptReply{}, fmt.Errorf("unknown token: %s", token)
+ }
+
+ select {
+ case <-ctx.Done():
+ b.cleanup(token)
+ return PromptReply{}, errdefs.ErrSecretPromptTimeout
+ case reply := <-replyChan:
+ b.cleanup(token)
+ if reply.Cancel {
+ return reply, errdefs.ErrSecretPromptCancelled
+ }
+ return reply, nil
+ }
+}
+
+func (b *SubscriptionBroker) Resolve(token string, reply PromptReply) error {
+ replyChan, exists := b.pending.Load(token)
+ if !exists {
+ return fmt.Errorf("unknown or expired token: %s", token)
+ }
+
+ select {
+ case replyChan <- reply:
+ return nil
+ default:
+ return fmt.Errorf("failed to deliver reply for token: %s", token)
+ }
+}
+
+func (b *SubscriptionBroker) cleanup(token string) {
+ b.pending.Delete(token)
+ b.requests.Delete(token)
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/bluez/types.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/bluez/types.go
new file mode 100644
index 0000000..663acca
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/bluez/types.go
@@ -0,0 +1,78 @@
+package bluez
+
+import (
+ "sync"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
+ "github.com/godbus/dbus/v5"
+)
+
+type BluetoothState struct {
+ Powered bool `json:"powered"`
+ Discovering bool `json:"discovering"`
+ Devices []Device `json:"devices"`
+ PairedDevices []Device `json:"pairedDevices"`
+ ConnectedDevices []Device `json:"connectedDevices"`
+}
+
+type Device struct {
+ Path string `json:"path"`
+ Address string `json:"address"`
+ Name string `json:"name"`
+ Alias string `json:"alias"`
+ Paired bool `json:"paired"`
+ Trusted bool `json:"trusted"`
+ Blocked bool `json:"blocked"`
+ Connected bool `json:"connected"`
+ Class uint32 `json:"class"`
+ Icon string `json:"icon"`
+ RSSI int16 `json:"rssi"`
+ LegacyPairing bool `json:"legacyPairing"`
+}
+
+type PromptRequest struct {
+ DevicePath string `json:"devicePath"`
+ DeviceName string `json:"deviceName"`
+ DeviceAddr string `json:"deviceAddr"`
+ RequestType string `json:"requestType"`
+ Fields []string `json:"fields"`
+ Hints []string `json:"hints"`
+ Passkey *uint32 `json:"passkey,omitempty"`
+}
+
+type PromptReply struct {
+ Secrets map[string]string `json:"secrets"`
+ Accept bool `json:"accept"`
+ Cancel bool `json:"cancel"`
+}
+
+type PairingPrompt struct {
+ Token string `json:"token"`
+ DevicePath string `json:"devicePath"`
+ DeviceName string `json:"deviceName"`
+ DeviceAddr string `json:"deviceAddr"`
+ RequestType string `json:"requestType"`
+ Fields []string `json:"fields"`
+ Hints []string `json:"hints"`
+ Passkey *uint32 `json:"passkey,omitempty"`
+}
+
+type Manager struct {
+ state *BluetoothState
+ stateMutex sync.RWMutex
+ subscribers syncmap.Map[string, chan BluetoothState]
+ stopChan chan struct{}
+ dbusConn *dbus.Conn
+ signals chan *dbus.Signal
+ sigWG sync.WaitGroup
+ agent *BluezAgent
+ promptBroker PromptBroker
+ pairingSubscribers syncmap.Map[string, chan PairingPrompt]
+ dirty chan struct{}
+ notifierWg sync.WaitGroup
+ lastNotifiedState *BluetoothState
+ adapterPath dbus.ObjectPath
+ pendingPairings syncmap.Map[string, bool]
+ eventQueue chan func()
+ eventWg sync.WaitGroup
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/bluez/types_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/bluez/types_test.go
new file mode 100644
index 0000000..ab5b857
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/bluez/types_test.go
@@ -0,0 +1,210 @@
+package bluez
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+func TestBluetoothStateJSON(t *testing.T) {
+ state := BluetoothState{
+ Powered: true,
+ Discovering: false,
+ Devices: []Device{
+ {
+ Path: "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
+ Address: "AA:BB:CC:DD:EE:FF",
+ Name: "TestDevice",
+ Alias: "My Device",
+ Paired: true,
+ Trusted: false,
+ Connected: true,
+ Class: 0x240418,
+ Icon: "audio-headset",
+ RSSI: -50,
+ },
+ },
+ PairedDevices: []Device{
+ {
+ Path: "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
+ Address: "AA:BB:CC:DD:EE:FF",
+ Paired: true,
+ },
+ },
+ ConnectedDevices: []Device{
+ {
+ Path: "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
+ Address: "AA:BB:CC:DD:EE:FF",
+ Connected: true,
+ },
+ },
+ }
+
+ data, err := json.Marshal(state)
+ if err != nil {
+ t.Fatalf("failed to marshal state: %v", err)
+ }
+
+ var decoded BluetoothState
+ if err := json.Unmarshal(data, &decoded); err != nil {
+ t.Fatalf("failed to unmarshal state: %v", err)
+ }
+
+ if decoded.Powered != state.Powered {
+ t.Errorf("expected Powered=%v, got %v", state.Powered, decoded.Powered)
+ }
+
+ if len(decoded.Devices) != 1 {
+ t.Fatalf("expected 1 device, got %d", len(decoded.Devices))
+ }
+
+ if decoded.Devices[0].Address != "AA:BB:CC:DD:EE:FF" {
+ t.Errorf("expected address AA:BB:CC:DD:EE:FF, got %s", decoded.Devices[0].Address)
+ }
+}
+
+func TestDeviceJSON(t *testing.T) {
+ device := Device{
+ Path: "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
+ Address: "AA:BB:CC:DD:EE:FF",
+ Name: "TestDevice",
+ Alias: "My Device",
+ Paired: true,
+ Trusted: true,
+ Blocked: false,
+ Connected: true,
+ Class: 0x240418,
+ Icon: "audio-headset",
+ RSSI: -50,
+ LegacyPairing: false,
+ }
+
+ data, err := json.Marshal(device)
+ if err != nil {
+ t.Fatalf("failed to marshal device: %v", err)
+ }
+
+ var decoded Device
+ if err := json.Unmarshal(data, &decoded); err != nil {
+ t.Fatalf("failed to unmarshal device: %v", err)
+ }
+
+ if decoded.Address != device.Address {
+ t.Errorf("expected Address=%s, got %s", device.Address, decoded.Address)
+ }
+
+ if decoded.Name != device.Name {
+ t.Errorf("expected Name=%s, got %s", device.Name, decoded.Name)
+ }
+
+ if decoded.Paired != device.Paired {
+ t.Errorf("expected Paired=%v, got %v", device.Paired, decoded.Paired)
+ }
+
+ if decoded.RSSI != device.RSSI {
+ t.Errorf("expected RSSI=%d, got %d", device.RSSI, decoded.RSSI)
+ }
+}
+
+func TestPairingPromptJSON(t *testing.T) {
+ passkey := uint32(123456)
+ prompt := PairingPrompt{
+ Token: "test-token",
+ DevicePath: "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
+ DeviceName: "TestDevice",
+ DeviceAddr: "AA:BB:CC:DD:EE:FF",
+ RequestType: "confirm",
+ Fields: []string{"decision"},
+ Hints: []string{},
+ Passkey: &passkey,
+ }
+
+ data, err := json.Marshal(prompt)
+ if err != nil {
+ t.Fatalf("failed to marshal prompt: %v", err)
+ }
+
+ var decoded PairingPrompt
+ if err := json.Unmarshal(data, &decoded); err != nil {
+ t.Fatalf("failed to unmarshal prompt: %v", err)
+ }
+
+ if decoded.Token != prompt.Token {
+ t.Errorf("expected Token=%s, got %s", prompt.Token, decoded.Token)
+ }
+
+ if decoded.DeviceName != prompt.DeviceName {
+ t.Errorf("expected DeviceName=%s, got %s", prompt.DeviceName, decoded.DeviceName)
+ }
+
+ if decoded.Passkey == nil {
+ t.Fatal("expected non-nil Passkey")
+ }
+
+ if *decoded.Passkey != *prompt.Passkey {
+ t.Errorf("expected Passkey=%d, got %d", *prompt.Passkey, *decoded.Passkey)
+ }
+}
+
+func TestPromptReplyJSON(t *testing.T) {
+ reply := PromptReply{
+ Secrets: map[string]string{
+ "pin": "1234",
+ "passkey": "567890",
+ },
+ Accept: true,
+ Cancel: false,
+ }
+
+ data, err := json.Marshal(reply)
+ if err != nil {
+ t.Fatalf("failed to marshal reply: %v", err)
+ }
+
+ var decoded PromptReply
+ if err := json.Unmarshal(data, &decoded); err != nil {
+ t.Fatalf("failed to unmarshal reply: %v", err)
+ }
+
+ if decoded.Secrets["pin"] != reply.Secrets["pin"] {
+ t.Errorf("expected pin=%s, got %s", reply.Secrets["pin"], decoded.Secrets["pin"])
+ }
+
+ if decoded.Accept != reply.Accept {
+ t.Errorf("expected Accept=%v, got %v", reply.Accept, decoded.Accept)
+ }
+}
+
+func TestPromptRequestJSON(t *testing.T) {
+ passkey := uint32(123456)
+ req := PromptRequest{
+ DevicePath: "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
+ DeviceName: "TestDevice",
+ DeviceAddr: "AA:BB:CC:DD:EE:FF",
+ RequestType: "confirm",
+ Fields: []string{"decision"},
+ Hints: []string{"hint1", "hint2"},
+ Passkey: &passkey,
+ }
+
+ data, err := json.Marshal(req)
+ if err != nil {
+ t.Fatalf("failed to marshal request: %v", err)
+ }
+
+ var decoded PromptRequest
+ if err := json.Unmarshal(data, &decoded); err != nil {
+ t.Fatalf("failed to unmarshal request: %v", err)
+ }
+
+ if decoded.DevicePath != req.DevicePath {
+ t.Errorf("expected DevicePath=%s, got %s", req.DevicePath, decoded.DevicePath)
+ }
+
+ if decoded.RequestType != req.RequestType {
+ t.Errorf("expected RequestType=%s, got %s", req.RequestType, decoded.RequestType)
+ }
+
+ if len(decoded.Fields) != len(req.Fields) {
+ t.Errorf("expected %d fields, got %d", len(req.Fields), len(decoded.Fields))
+ }
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/ddc.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/ddc.go
new file mode 100644
index 0000000..00cf2d9
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/ddc.go
@@ -0,0 +1,510 @@
+package brightness
+
+import (
+ "encoding/binary"
+ "fmt"
+ "math"
+ "os"
+ "strings"
+ "syscall"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "golang.org/x/sys/unix"
+)
+
+const (
+ I2C_SLAVE = 0x0703
+ DDCCI_ADDR = 0x37
+ DDCCI_VCP_GET = 0x01
+ DDCCI_VCP_SET = 0x03
+ VCP_BRIGHTNESS = 0x10
+ DDC_SOURCE_ADDR = 0x51
+)
+
+func NewDDCBackend() (*DDCBackend, error) {
+ b := &DDCBackend{
+ scanInterval: 30 * time.Second,
+ debounceTimers: make(map[string]*time.Timer),
+ debouncePending: make(map[string]ddcPendingSet),
+ }
+
+ if err := b.scanI2CDevices(); err != nil {
+ return nil, err
+ }
+
+ return b, nil
+}
+
+func (b *DDCBackend) scanI2CDevices() error {
+ return b.scanI2CDevicesInternal(false)
+}
+
+func (b *DDCBackend) ForceRescan() error {
+ return b.scanI2CDevicesInternal(true)
+}
+
+func (b *DDCBackend) scanI2CDevicesInternal(force bool) error {
+ b.scanMutex.Lock()
+ defer b.scanMutex.Unlock()
+
+ if !force && time.Since(b.lastScan) < b.scanInterval {
+ return nil
+ }
+
+ activeBuses := make(map[int]bool)
+
+ for i := 0; i < 32; i++ {
+ busPath := fmt.Sprintf("/dev/i2c-%d", i)
+ if _, err := os.Stat(busPath); os.IsNotExist(err) {
+ continue
+ }
+
+ if isIgnorableI2CBus(i) {
+ log.Debugf("Skipping ignorable i2c-%d", i)
+ continue
+ }
+
+ activeBuses[i] = true
+ id := fmt.Sprintf("ddc:i2c-%d", i)
+
+ dev, err := b.probeDDCDevice(i)
+ if err != nil || dev == nil {
+ continue
+ }
+
+ dev.id = id
+ b.devices.Store(id, dev)
+ log.Debugf("found DDC device on i2c-%d", i)
+ }
+
+ b.devices.Range(func(id string, dev *ddcDevice) bool {
+ if !activeBuses[dev.bus] {
+ b.devices.Delete(id)
+ log.Debugf("removed DDC device %s (bus no longer exists)", id)
+ }
+ return true
+ })
+
+ b.lastScan = time.Now()
+
+ return nil
+}
+
+func (b *DDCBackend) probeDDCDevice(bus int) (*ddcDevice, error) {
+ busPath := fmt.Sprintf("/dev/i2c-%d", bus)
+
+ fd, err := syscall.Open(busPath, syscall.O_RDWR, 0)
+ if err != nil {
+ return nil, err
+ }
+ defer syscall.Close(fd)
+
+ if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), I2C_SLAVE, uintptr(DDCCI_ADDR)); errno != 0 {
+ return nil, errno
+ }
+
+ dummy := make([]byte, 32)
+ syscall.Read(fd, dummy) //nolint:errcheck
+
+ writebuf := []byte{0x00}
+ n, err := syscall.Write(fd, writebuf)
+ if err == nil && n == len(writebuf) {
+ name := b.getDDCName(bus)
+ dev := &ddcDevice{
+ bus: bus,
+ addr: DDCCI_ADDR,
+ name: name,
+ }
+ b.readInitialBrightness(fd, dev)
+ return dev, nil
+ }
+
+ readbuf := make([]byte, 4)
+ n, err = syscall.Read(fd, readbuf)
+ if err != nil || n == 0 {
+ return nil, fmt.Errorf("x37 unresponsive")
+ }
+
+ name := b.getDDCName(bus)
+
+ dev := &ddcDevice{
+ bus: bus,
+ addr: DDCCI_ADDR,
+ name: name,
+ }
+ b.readInitialBrightness(fd, dev)
+ return dev, nil
+}
+
+func (b *DDCBackend) getDDCName(bus int) string {
+ sysfsPath := fmt.Sprintf("/sys/class/i2c-adapter/i2c-%d/name", bus)
+ data, err := os.ReadFile(sysfsPath)
+ if err != nil {
+ return fmt.Sprintf("I2C-%d", bus)
+ }
+
+ name := strings.TrimSpace(string(data))
+ if name == "" {
+ name = fmt.Sprintf("I2C-%d", bus)
+ }
+
+ return name
+}
+
+func (b *DDCBackend) readInitialBrightness(fd int, dev *ddcDevice) {
+ cap, err := b.getVCPFeature(fd, VCP_BRIGHTNESS)
+ if err != nil {
+ log.Debugf("failed to read initial brightness for %s: %v", dev.name, err)
+ return
+ }
+
+ dev.max = cap.max
+ dev.lastBrightness = cap.current
+ log.Debugf("initialized %s with brightness %d/%d", dev.name, cap.current, cap.max)
+}
+
+func (b *DDCBackend) GetDevices() ([]Device, error) {
+ if err := b.scanI2CDevices(); err != nil {
+ log.Debugf("DDC scan error: %v", err)
+ }
+
+ devices := make([]Device, 0)
+
+ b.devices.Range(func(id string, dev *ddcDevice) bool {
+ devices = append(devices, Device{
+ Class: ClassDDC,
+ ID: id,
+ Name: dev.name,
+ Current: dev.lastBrightness,
+ Max: dev.max,
+ CurrentPercent: dev.lastBrightness,
+ Backend: "ddc",
+ })
+ return true
+ })
+
+ return devices, nil
+}
+
+func (b *DDCBackend) SetBrightness(id string, value int, exponential bool, callback func()) error {
+ return b.SetBrightnessWithExponent(id, value, exponential, 1.2, callback)
+}
+
+func (b *DDCBackend) SetBrightnessWithExponent(id string, value int, exponential bool, exponent float64, callback func()) error {
+ _, ok := b.devices.Load(id)
+
+ if !ok {
+ if err := b.scanI2CDevicesInternal(true); err != nil {
+ log.Debugf("rescan failed for %s: %v", id, err)
+ }
+ _, ok = b.devices.Load(id)
+ }
+
+ if !ok {
+ return fmt.Errorf("device not found: %s", id)
+ }
+
+ if value < 0 {
+ return fmt.Errorf("value out of range: %d", value)
+ }
+
+ b.debounceMutex.Lock()
+ b.debouncePending[id] = ddcPendingSet{
+ percent: value,
+ callback: callback,
+ }
+
+ if timer, exists := b.debounceTimers[id]; exists {
+ timer.Reset(200 * time.Millisecond)
+ } else {
+ b.debounceWg.Add(1)
+ b.debounceTimers[id] = time.AfterFunc(200*time.Millisecond, func() {
+ defer b.debounceWg.Done()
+ b.debounceMutex.Lock()
+ pending, exists := b.debouncePending[id]
+ if exists {
+ delete(b.debouncePending, id)
+ }
+ b.debounceMutex.Unlock()
+
+ if !exists {
+ return
+ }
+
+ err := b.setBrightnessImmediateWithExponent(id, pending.percent)
+ if err != nil {
+ log.Debugf("Failed to set brightness for %s: %v", id, err)
+ }
+
+ if pending.callback != nil {
+ pending.callback()
+ }
+ })
+ }
+ b.debounceMutex.Unlock()
+
+ return nil
+}
+
+func (b *DDCBackend) setBrightnessImmediateWithExponent(id string, value int) error {
+ dev, ok := b.devices.Load(id)
+
+ if !ok {
+ if err := b.scanI2CDevicesInternal(true); err != nil {
+ log.Debugf("rescan failed for %s: %v", id, err)
+ }
+ dev, ok = b.devices.Load(id)
+ }
+
+ if !ok {
+ return fmt.Errorf("device not found: %s", id)
+ }
+
+ busPath := fmt.Sprintf("/dev/i2c-%d", dev.bus)
+
+ if _, err := os.Stat(busPath); os.IsNotExist(err) {
+ b.devices.Delete(id)
+ log.Debugf("removed stale DDC device %s (bus no longer exists)", id)
+ return fmt.Errorf("device disconnected: %s", id)
+ }
+
+ fd, err := syscall.Open(busPath, syscall.O_RDWR, 0)
+ if err != nil {
+ b.devices.Delete(id)
+ log.Debugf("removed DDC device %s (open failed: %v)", id, err)
+ return fmt.Errorf("open i2c device: %w", err)
+ }
+ defer syscall.Close(fd)
+
+ if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), I2C_SLAVE, uintptr(dev.addr)); errno != 0 {
+ return fmt.Errorf("set i2c slave addr: %w", errno)
+ }
+
+ max := dev.max
+ if max == 0 {
+ cap, err := b.getVCPFeature(fd, VCP_BRIGHTNESS)
+ if err != nil {
+ return fmt.Errorf("get current capability: %w", err)
+ }
+ max = cap.max
+ dev.max = max
+ b.devices.Store(id, dev)
+ }
+
+ if err := b.setVCPFeature(fd, VCP_BRIGHTNESS, value); err != nil {
+ return fmt.Errorf("set vcp feature: %w", err)
+ }
+
+ log.Debugf("set %s to %d/%d", id, value, max)
+
+ dev.max = max
+ dev.lastBrightness = value
+ b.devices.Store(id, dev)
+
+ return nil
+}
+
+func (b *DDCBackend) getVCPFeature(fd int, vcp byte) (*ddcCapability, error) {
+ for flushTry := 0; flushTry < 3; flushTry++ {
+ dummy := make([]byte, 32)
+ n, _ := syscall.Read(fd, dummy)
+ if n == 0 {
+ break
+ }
+ time.Sleep(20 * time.Millisecond)
+ }
+
+ data := []byte{
+ DDCCI_VCP_GET,
+ vcp,
+ }
+
+ payload := []byte{
+ DDC_SOURCE_ADDR,
+ byte(len(data)) | 0x80,
+ }
+ payload = append(payload, data...)
+ payload = append(payload, ddcciChecksum(payload))
+
+ n, err := syscall.Write(fd, payload)
+ if err != nil || n != len(payload) {
+ return nil, fmt.Errorf("write i2c: %w", err)
+ }
+
+ time.Sleep(50 * time.Millisecond)
+
+ pollFds := []unix.PollFd{
+ {
+ Fd: int32(fd),
+ Events: unix.POLLIN,
+ },
+ }
+
+ pollTimeout := 200
+ pollResult, err := unix.Poll(pollFds, pollTimeout)
+ if err != nil {
+ return nil, fmt.Errorf("poll i2c: %w", err)
+ }
+ if pollResult == 0 {
+ return nil, fmt.Errorf("poll timeout after %dms", pollTimeout)
+ }
+ if pollFds[0].Revents&unix.POLLIN == 0 {
+ return nil, fmt.Errorf("poll returned but POLLIN not set")
+ }
+
+ response := make([]byte, 12)
+ n, err = syscall.Read(fd, response)
+ if err != nil || n < 8 {
+ return nil, fmt.Errorf("read i2c: %w", err)
+ }
+
+ if response[0] != 0x6E || response[2] != 0x02 {
+ return nil, fmt.Errorf("invalid ddc response")
+ }
+
+ resultCode := response[3]
+ if resultCode != 0x00 {
+ return nil, fmt.Errorf("vcp feature not supported")
+ }
+
+ responseVCP := response[4]
+ if responseVCP != vcp {
+ return nil, fmt.Errorf("vcp mismatch: wanted 0x%02x, got 0x%02x", vcp, responseVCP)
+ }
+
+ maxHigh := response[6]
+ maxLow := response[7]
+ currentHigh := response[8]
+ currentLow := response[9]
+
+ max := int(binary.BigEndian.Uint16([]byte{maxHigh, maxLow}))
+ current := int(binary.BigEndian.Uint16([]byte{currentHigh, currentLow}))
+
+ return &ddcCapability{
+ vcp: vcp,
+ max: max,
+ current: current,
+ }, nil
+}
+
+func ddcciChecksum(payload []byte) byte {
+ sum := byte(0x6E)
+ for _, b := range payload {
+ sum ^= b
+ }
+ return sum
+}
+
+func (b *DDCBackend) setVCPFeature(fd int, vcp byte, value int) error {
+ data := []byte{
+ DDCCI_VCP_SET,
+ vcp,
+ byte(value >> 8),
+ byte(value & 0xFF),
+ }
+
+ payload := []byte{
+ DDC_SOURCE_ADDR,
+ byte(len(data)) | 0x80,
+ }
+ payload = append(payload, data...)
+ payload = append(payload, ddcciChecksum(payload))
+
+ if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), I2C_SLAVE, uintptr(DDCCI_ADDR)); errno != 0 {
+ return fmt.Errorf("set i2c slave for write: %w", errno)
+ }
+
+ n, err := syscall.Write(fd, payload)
+ if err != nil || n != len(payload) {
+ return fmt.Errorf("write i2c: wrote %d/%d: %w", n, len(payload), err)
+ }
+
+ time.Sleep(50 * time.Millisecond)
+
+ return nil
+}
+
+func (b *DDCBackend) percentToValue(percent int, max int, exponential bool) int {
+ const minValue = 1
+
+ if percent == 0 {
+ return minValue
+ }
+
+ usableRange := max - minValue
+ var value int
+
+ if exponential {
+ const exponent = 2.0
+ normalizedPercent := float64(percent) / 100.0
+ hardwarePercent := math.Pow(normalizedPercent, 1.0/exponent)
+ value = minValue + int(math.Round(hardwarePercent*float64(usableRange)))
+ } else {
+ value = minValue + ((percent - 1) * usableRange / 99)
+ }
+
+ if value < minValue {
+ value = minValue
+ }
+ if value > max {
+ value = max
+ }
+
+ return value
+}
+
+func (b *DDCBackend) valueToPercent(value int, max int, exponential bool) int {
+ const minValue = 1
+
+ if max == 0 {
+ return 0
+ }
+
+ if value <= minValue {
+ return 1
+ }
+
+ usableRange := max - minValue
+ if usableRange == 0 {
+ return 100
+ }
+
+ var percent int
+
+ if exponential {
+ const exponent = 2.0
+ linearPercent := 1 + ((value - minValue) * 99 / usableRange)
+ normalizedLinear := float64(linearPercent) / 100.0
+ expPercent := math.Pow(normalizedLinear, exponent)
+ percent = int(math.Round(expPercent * 100.0))
+ } else {
+ percent = 1 + ((value - minValue) * 99 / usableRange)
+ }
+
+ if percent > 100 {
+ percent = 100
+ }
+ if percent < 1 {
+ percent = 1
+ }
+
+ return percent
+}
+
+func (b *DDCBackend) WaitPending() {
+ done := make(chan struct{})
+ go func() {
+ b.debounceWg.Wait()
+ close(done)
+ }()
+
+ select {
+ case <-done:
+ case <-time.After(5 * time.Second):
+ log.Debug("WaitPending timed out waiting for DDC writes")
+ }
+}
+
+func (b *DDCBackend) Close() {
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/ddc_filter.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/ddc_filter.go
new file mode 100644
index 0000000..12fc535
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/ddc_filter.go
@@ -0,0 +1,135 @@
+package brightness
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+)
+
+// isIgnorableI2CBus checks if an I2C bus should be skipped during DDC probing.
+// Based on ddcutil's sysfs_is_ignorable_i2c_device() (sysfs_base.c:1441)
+func isIgnorableI2CBus(busno int) bool {
+ name := getI2CDeviceSysfsName(busno)
+ driver := getI2CSysfsDriver(busno)
+
+ if name != "" && isIgnorableI2CDeviceName(name, driver) {
+ log.Debugf("i2c-%d: ignoring '%s' (driver: %s)", busno, name, driver)
+ return true
+ }
+
+ // Only probe display adapters (0x03xxxx) and docking stations (0x0axxxx)
+ class := getI2CDeviceSysfsClass(busno)
+ if class != 0 {
+ classHigh := class & 0xFFFF0000
+ ignorable := (classHigh != 0x030000 && classHigh != 0x0A0000)
+ if ignorable {
+ log.Debugf("i2c-%d: ignoring class 0x%08x", busno, class)
+ }
+ return ignorable
+ }
+
+ return false
+}
+
+// Based on ddcutil's ignorable_i2c_device_sysfs_name() (sysfs_base.c:1408)
+func isIgnorableI2CDeviceName(name, driver string) bool {
+ ignorablePrefixes := []string{
+ "SMBus",
+ "Synopsys DesignWare",
+ "soc:i2cdsi",
+ "smu",
+ "mac-io",
+ "u4",
+ "AMDGPU SMU", // AMD Navi2+ - probing hangs GPU
+ }
+
+ for _, prefix := range ignorablePrefixes {
+ if strings.HasPrefix(name, prefix) {
+ return true
+ }
+ }
+
+ // nouveau driver: only nvkm-* buses are valid
+ if driver == "nouveau" && !strings.HasPrefix(name, "nvkm-") {
+ return true
+ }
+
+ return false
+}
+
+// Based on ddcutil's get_i2c_device_sysfs_name() (sysfs_base.c:1175)
+func getI2CDeviceSysfsName(busno int) string {
+ path := fmt.Sprintf("/sys/bus/i2c/devices/i2c-%d/name", busno)
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return ""
+ }
+ return strings.TrimSpace(string(data))
+}
+
+// Based on ddcutil's get_i2c_device_sysfs_class() (sysfs_base.c:1380)
+func getI2CDeviceSysfsClass(busno int) uint32 {
+ classPath := fmt.Sprintf("/sys/bus/i2c/devices/i2c-%d/device/class", busno)
+ data, err := os.ReadFile(classPath)
+ if err != nil {
+ classPath = fmt.Sprintf("/sys/bus/i2c/devices/i2c-%d/device/device/device/class", busno)
+ data, err = os.ReadFile(classPath)
+ if err != nil {
+ return 0
+ }
+ }
+
+ classStr := strings.TrimSpace(string(data))
+ classStr = strings.TrimPrefix(classStr, "0x")
+
+ class, err := strconv.ParseUint(classStr, 16, 32)
+ if err != nil {
+ return 0
+ }
+
+ return uint32(class)
+}
+
+// Based on ddcutil's get_i2c_sysfs_driver_by_busno() (sysfs_base.c:1284)
+func getI2CSysfsDriver(busno int) string {
+ devicePath := fmt.Sprintf("/sys/bus/i2c/devices/i2c-%d", busno)
+ adapterPath, err := findI2CAdapter(devicePath)
+ if err != nil {
+ return ""
+ }
+
+ driverLink := filepath.Join(adapterPath, "driver")
+ target, err := os.Readlink(driverLink)
+ if err != nil {
+ return ""
+ }
+
+ return filepath.Base(target)
+}
+
+func findI2CAdapter(devicePath string) (string, error) {
+ currentPath := devicePath
+
+ for depth := 0; depth < 10; depth++ {
+ if _, err := os.Stat(filepath.Join(currentPath, "name")); err == nil {
+ return currentPath, nil
+ }
+
+ deviceLink := filepath.Join(currentPath, "device")
+ target, err := os.Readlink(deviceLink)
+ if err != nil {
+ break
+ }
+
+ if !filepath.IsAbs(target) {
+ target = filepath.Join(filepath.Dir(currentPath), target)
+ }
+ currentPath = filepath.Clean(target)
+ }
+
+ return "", fmt.Errorf("could not find adapter for %s", devicePath)
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/ddc_filter_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/ddc_filter_test.go
new file mode 100644
index 0000000..dc3751f
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/ddc_filter_test.go
@@ -0,0 +1,122 @@
+package brightness
+
+import (
+ "testing"
+)
+
+func TestIsIgnorableI2CDeviceName(t *testing.T) {
+ tests := []struct {
+ name string
+ deviceName string
+ driver string
+ want bool
+ }{
+ {
+ name: "AMDGPU SMU should be ignored",
+ deviceName: "AMDGPU SMU",
+ driver: "amdgpu",
+ want: true,
+ },
+ {
+ name: "SMBus should be ignored",
+ deviceName: "SMBus I801 adapter",
+ driver: "",
+ want: true,
+ },
+ {
+ name: "Synopsys DesignWare should be ignored",
+ deviceName: "Synopsys DesignWare I2C adapter",
+ driver: "",
+ want: true,
+ },
+ {
+ name: "smu prefix should be ignored (Mac G5)",
+ deviceName: "smu-i2c-controller",
+ driver: "",
+ want: true,
+ },
+ {
+ name: "Regular NVIDIA DDC should not be ignored",
+ deviceName: "NVIDIA i2c adapter 1",
+ driver: "nvidia",
+ want: false,
+ },
+ {
+ name: "nouveau nvkm bus should not be ignored",
+ deviceName: "nvkm-0000:01:00.0-bus-0000",
+ driver: "nouveau",
+ want: false,
+ },
+ {
+ name: "nouveau non-nvkm bus should be ignored",
+ deviceName: "nouveau-other-bus",
+ driver: "nouveau",
+ want: true,
+ },
+ {
+ name: "Regular AMD display adapter should not be ignored",
+ deviceName: "AMDGPU DM i2c hw bus 0",
+ driver: "amdgpu",
+ want: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := isIgnorableI2CDeviceName(tt.deviceName, tt.driver)
+ if got != tt.want {
+ t.Errorf("isIgnorableI2CDeviceName(%q, %q) = %v, want %v",
+ tt.deviceName, tt.driver, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestClassFiltering(t *testing.T) {
+ tests := []struct {
+ name string
+ class uint32
+ want bool
+ }{
+ {
+ name: "Display adapter class should not be ignored",
+ class: 0x030000,
+ want: false,
+ },
+ {
+ name: "Docking station class should not be ignored",
+ class: 0x0a0000,
+ want: false,
+ },
+ {
+ name: "Display adapter with subclass should not be ignored",
+ class: 0x030001,
+ want: false,
+ },
+ {
+ name: "SMBus class should be ignored",
+ class: 0x0c0500,
+ want: true,
+ },
+ {
+ name: "Bridge class should be ignored",
+ class: 0x060400,
+ want: true,
+ },
+ {
+ name: "Generic system peripheral should be ignored",
+ class: 0x088000,
+ want: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ classHigh := tt.class & 0xFFFF0000
+ ignorable := (classHigh != 0x030000 && classHigh != 0x0A0000)
+ if ignorable != tt.want {
+ t.Errorf("class 0x%08x: ignorable = %v, want %v", tt.class, ignorable, tt.want)
+ }
+ })
+ }
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/ddc_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/ddc_test.go
new file mode 100644
index 0000000..7f85510
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/ddc_test.go
@@ -0,0 +1,135 @@
+package brightness
+
+import (
+ "testing"
+)
+
+func TestDDCBackend_PercentConversions(t *testing.T) {
+ tests := []struct {
+ name string
+ max int
+ percent int
+ wantValue int
+ }{
+ {
+ name: "0% should map to minValue=1",
+ max: 100,
+ percent: 0,
+ wantValue: 1,
+ },
+ {
+ name: "1% should be 1",
+ max: 100,
+ percent: 1,
+ wantValue: 1,
+ },
+ {
+ name: "50% should be ~50",
+ max: 100,
+ percent: 50,
+ wantValue: 50,
+ },
+ {
+ name: "100% should be max",
+ max: 100,
+ percent: 100,
+ wantValue: 100,
+ },
+ }
+
+ b := &DDCBackend{}
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := b.percentToValue(tt.percent, tt.max, false)
+ diff := got - tt.wantValue
+ if diff < 0 {
+ diff = -diff
+ }
+ if diff > 1 {
+ t.Errorf("percentToValue() = %v, want %v (±1)", got, tt.wantValue)
+ }
+ })
+ }
+}
+
+func TestDDCBackend_ValueToPercent(t *testing.T) {
+ tests := []struct {
+ name string
+ max int
+ value int
+ wantPercent int
+ tolerance int
+ }{
+ {
+ name: "zero value should be 1%",
+ max: 100,
+ value: 0,
+ wantPercent: 1,
+ tolerance: 0,
+ },
+ {
+ name: "min value should be 1%",
+ max: 100,
+ value: 1,
+ wantPercent: 1,
+ tolerance: 0,
+ },
+ {
+ name: "mid value should be ~50%",
+ max: 100,
+ value: 50,
+ wantPercent: 50,
+ tolerance: 2,
+ },
+ {
+ name: "max value should be 100%",
+ max: 100,
+ value: 100,
+ wantPercent: 100,
+ tolerance: 0,
+ },
+ }
+
+ b := &DDCBackend{}
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := b.valueToPercent(tt.value, tt.max, false)
+ diff := got - tt.wantPercent
+ if diff < 0 {
+ diff = -diff
+ }
+ if diff > tt.tolerance {
+ t.Errorf("valueToPercent() = %v, want %v (±%d)", got, tt.wantPercent, tt.tolerance)
+ }
+ })
+ }
+}
+
+func TestDDCBackend_RoundTrip(t *testing.T) {
+ b := &DDCBackend{}
+
+ tests := []struct {
+ name string
+ max int
+ percent int
+ }{
+ {"1%", 100, 1},
+ {"25%", 100, 25},
+ {"50%", 100, 50},
+ {"75%", 100, 75},
+ {"100%", 100, 100},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ value := b.percentToValue(tt.percent, tt.max, false)
+ gotPercent := b.valueToPercent(value, tt.max, false)
+
+ if diff := tt.percent - gotPercent; diff < -1 || diff > 1 {
+ t.Errorf("round trip failed: wanted %d%%, got %d%% (value=%d)", tt.percent, gotPercent, value)
+ }
+ })
+ }
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/handlers.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/handlers.go
new file mode 100644
index 0000000..c71163e
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/handlers.go
@@ -0,0 +1,124 @@
+package brightness
+
+import (
+ "encoding/json"
+ "fmt"
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/params"
+)
+
+func HandleRequest(conn net.Conn, req models.Request, m *Manager) {
+ switch req.Method {
+ case "brightness.getState":
+ handleGetState(conn, req, m)
+ case "brightness.setBrightness":
+ handleSetBrightness(conn, req, m)
+ case "brightness.increment":
+ handleIncrement(conn, req, m)
+ case "brightness.decrement":
+ handleDecrement(conn, req, m)
+ case "brightness.rescan":
+ handleRescan(conn, req, m)
+ case "brightness.subscribe":
+ handleSubscribe(conn, req, m)
+ default:
+ models.RespondError(conn, req.ID, "unknown method: "+req.Method)
+ }
+}
+
+func handleGetState(conn net.Conn, req models.Request, m *Manager) {
+ models.Respond(conn, req.ID, m.GetState())
+}
+
+func handleSetBrightness(conn net.Conn, req models.Request, m *Manager) {
+ device, err := params.String(req.Params, "device")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ percent, err := params.Int(req.Params, "percent")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ exponential := params.BoolOpt(req.Params, "exponential", false)
+ exponent := params.FloatOpt(req.Params, "exponent", 1.2)
+
+ if err := m.SetBrightnessWithExponent(device, percent, exponential, exponent); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, m.GetState())
+}
+
+func handleIncrement(conn net.Conn, req models.Request, m *Manager) {
+ device, err := params.String(req.Params, "device")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ step := params.IntOpt(req.Params, "step", 10)
+ exponential := params.BoolOpt(req.Params, "exponential", false)
+ exponent := params.FloatOpt(req.Params, "exponent", 1.2)
+
+ if err := m.IncrementBrightnessWithExponent(device, step, exponential, exponent); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, m.GetState())
+}
+
+func handleDecrement(conn net.Conn, req models.Request, m *Manager) {
+ device, err := params.String(req.Params, "device")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ step := params.IntOpt(req.Params, "step", 10)
+ exponential := params.BoolOpt(req.Params, "exponential", false)
+ exponent := params.FloatOpt(req.Params, "exponent", 1.2)
+
+ if err := m.IncrementBrightnessWithExponent(device, -step, exponential, exponent); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, m.GetState())
+}
+
+func handleRescan(conn net.Conn, req models.Request, m *Manager) {
+ m.Rescan()
+ models.Respond(conn, req.ID, m.GetState())
+}
+
+func handleSubscribe(conn net.Conn, req models.Request, m *Manager) {
+ clientID := fmt.Sprintf("brightness-%d", req.ID)
+
+ ch := m.Subscribe(clientID)
+ defer m.Unsubscribe(clientID)
+
+ initialState := m.GetState()
+ if err := json.NewEncoder(conn).Encode(models.Response[State]{
+ ID: req.ID,
+ Result: &initialState,
+ }); err != nil {
+ return
+ }
+
+ for state := range ch {
+ if err := json.NewEncoder(conn).Encode(models.Response[State]{
+ ID: req.ID,
+ Result: &state,
+ }); err != nil {
+ return
+ }
+ }
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/logind.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/logind.go
new file mode 100644
index 0000000..8449ab5
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/logind.go
@@ -0,0 +1,66 @@
+package brightness
+
+import (
+ "fmt"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "github.com/godbus/dbus/v5"
+)
+
+type DBusConn interface {
+ Object(dest string, path dbus.ObjectPath) dbus.BusObject
+ Close() error
+}
+
+type LogindBackend struct {
+ conn DBusConn
+}
+
+func NewLogindBackend() (*LogindBackend, error) {
+ conn, err := dbus.ConnectSystemBus()
+ if err != nil {
+ return nil, fmt.Errorf("connect to system bus: %w", err)
+ }
+
+ obj := conn.Object("org.freedesktop.login1", "/org/freedesktop/login1/session/auto")
+ call := obj.Call("org.freedesktop.DBus.Peer.Ping", 0)
+ if call.Err != nil {
+ conn.Close()
+ return nil, fmt.Errorf("logind not available: %w", call.Err)
+ }
+
+ conn.Close()
+
+ return &LogindBackend{}, nil
+}
+
+func NewLogindBackendWithConn(conn DBusConn) *LogindBackend {
+ return &LogindBackend{
+ conn: conn,
+ }
+}
+
+func (b *LogindBackend) SetBrightness(subsystem, name string, brightness uint32) error {
+ if b.conn == nil {
+ conn, err := dbus.ConnectSystemBus()
+ if err != nil {
+ return fmt.Errorf("connect to system bus: %w", err)
+ }
+ b.conn = conn
+ }
+
+ obj := b.conn.Object("org.freedesktop.login1", "/org/freedesktop/login1/session/auto")
+ call := obj.Call("org.freedesktop.login1.Session.SetBrightness", 0, subsystem, name, brightness)
+ if call.Err != nil {
+ return fmt.Errorf("dbus call failed: %w", call.Err)
+ }
+
+ log.Debugf("logind: set %s/%s to %d", subsystem, name, brightness)
+ return nil
+}
+
+func (b *LogindBackend) Close() {
+ if b.conn != nil {
+ b.conn.Close()
+ }
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/logind_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/logind_test.go
new file mode 100644
index 0000000..3d4c2ee
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/logind_test.go
@@ -0,0 +1,95 @@
+package brightness
+
+import (
+ "errors"
+ "testing"
+
+ mocks_brightness "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/brightness"
+ mock_dbus "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/github.com/godbus/dbus/v5"
+ "github.com/godbus/dbus/v5"
+ "github.com/stretchr/testify/mock"
+)
+
+func TestLogindBackend_SetBrightness_Success(t *testing.T) {
+ mockConn := mocks_brightness.NewMockDBusConn(t)
+ mockObj := mock_dbus.NewMockBusObject(t)
+
+ backend := NewLogindBackendWithConn(mockConn)
+
+ mockConn.EXPECT().
+ Object("org.freedesktop.login1", dbus.ObjectPath("/org/freedesktop/login1/session/auto")).
+ Return(mockObj).
+ Once()
+
+ mockObj.EXPECT().
+ Call("org.freedesktop.login1.Session.SetBrightness", dbus.Flags(0), "backlight", "nvidia_0", uint32(75)).
+ Return(&dbus.Call{Err: nil}).
+ Once()
+
+ err := backend.SetBrightness("backlight", "nvidia_0", 75)
+ if err != nil {
+ t.Errorf("SetBrightness() error = %v, want nil", err)
+ }
+}
+
+func TestLogindBackend_SetBrightness_DBusError(t *testing.T) {
+ mockConn := mocks_brightness.NewMockDBusConn(t)
+ mockObj := mock_dbus.NewMockBusObject(t)
+
+ backend := NewLogindBackendWithConn(mockConn)
+
+ mockConn.EXPECT().
+ Object("org.freedesktop.login1", dbus.ObjectPath("/org/freedesktop/login1/session/auto")).
+ Return(mockObj).
+ Once()
+
+ dbusErr := errors.New("permission denied")
+ mockObj.EXPECT().
+ Call("org.freedesktop.login1.Session.SetBrightness", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+ Return(&dbus.Call{Err: dbusErr}).
+ Once()
+
+ err := backend.SetBrightness("backlight", "test_device", 50)
+ if err == nil {
+ t.Error("SetBrightness() error = nil, want error")
+ }
+}
+
+func TestLogindBackend_SetBrightness_LEDDevice(t *testing.T) {
+ mockConn := mocks_brightness.NewMockDBusConn(t)
+ mockObj := mock_dbus.NewMockBusObject(t)
+
+ backend := NewLogindBackendWithConn(mockConn)
+
+ mockConn.EXPECT().
+ Object("org.freedesktop.login1", dbus.ObjectPath("/org/freedesktop/login1/session/auto")).
+ Return(mockObj).
+ Once()
+
+ mockObj.EXPECT().
+ Call("org.freedesktop.login1.Session.SetBrightness", dbus.Flags(0), "leds", "test_led", uint32(128)).
+ Return(&dbus.Call{Err: nil}).
+ Once()
+
+ err := backend.SetBrightness("leds", "test_led", 128)
+ if err != nil {
+ t.Errorf("SetBrightness() error = %v, want nil", err)
+ }
+}
+
+func TestLogindBackend_Close(t *testing.T) {
+ mockConn := mocks_brightness.NewMockDBusConn(t)
+ backend := NewLogindBackendWithConn(mockConn)
+
+ mockConn.EXPECT().
+ Close().
+ Return(nil).
+ Once()
+
+ backend.Close()
+}
+
+func TestLogindBackend_Close_NilConn(t *testing.T) {
+ backend := &LogindBackend{conn: nil}
+ backend.Close()
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/manager.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/manager.go
new file mode 100644
index 0000000..4cf7dd4
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/manager.go
@@ -0,0 +1,389 @@
+package brightness
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+)
+
+func NewManager() (*Manager, error) {
+ return NewManagerWithOptions(false)
+}
+
+func NewManagerWithOptions(exponential bool) (*Manager, error) {
+ m := &Manager{
+ stopChan: make(chan struct{}),
+ exponential: exponential,
+ }
+
+ go m.initLogind()
+ go m.initSysfs()
+ go m.initDDC()
+
+ return m, nil
+}
+
+func (m *Manager) initLogind() {
+ log.Debug("Initializing logind backend...")
+ logind, err := NewLogindBackend()
+ if err != nil {
+ log.Infof("Logind backend not available: %v", err)
+ log.Info("Will use direct sysfs access for brightness control")
+ return
+ }
+
+ m.logindBackend = logind
+ m.logindReady = true
+ log.Info("Logind backend initialized - will use for brightness control")
+}
+
+func (m *Manager) initSysfs() {
+ log.Debug("Initializing sysfs backend...")
+ sysfs, err := NewSysfsBackend()
+ if err != nil {
+ log.Warnf("Failed to initialize sysfs backend: %v", err)
+ return
+ }
+
+ devices, err := sysfs.GetDevices()
+ if err != nil {
+ log.Warnf("Failed to get initial sysfs devices: %v", err)
+ m.sysfsBackend = sysfs
+ m.sysfsReady = true
+ m.updateState()
+ m.initUdev()
+ return
+ }
+
+ log.Infof("Sysfs backend initialized with %d devices", len(devices))
+ for _, d := range devices {
+ log.Debugf(" - %s: %s (%d%%)", d.ID, d.Name, d.CurrentPercent)
+ }
+
+ m.sysfsBackend = sysfs
+ m.sysfsReady = true
+ m.updateState()
+ m.initUdev()
+}
+
+func (m *Manager) initUdev() {
+ m.udevMonitor = NewUdevMonitor(m)
+}
+
+func (m *Manager) initDDC() {
+ ddc, err := NewDDCBackend()
+ if err != nil {
+ log.Debugf("Failed to initialize DDC backend: %v", err)
+ return
+ }
+
+ m.ddcBackend = ddc
+ m.ddcReady = true
+ log.Info("DDC backend initialized")
+
+ m.updateState()
+}
+
+func (m *Manager) Rescan() {
+ log.Debug("Rescanning brightness devices...")
+
+ if m.ddcReady && m.ddcBackend != nil {
+ if err := m.ddcBackend.ForceRescan(); err != nil {
+ log.Debugf("DDC force rescan failed: %v", err)
+ }
+ }
+
+ if m.sysfsReady && m.sysfsBackend != nil {
+ if err := m.sysfsBackend.Rescan(); err != nil {
+ log.Debugf("Sysfs rescan failed: %v", err)
+ }
+ }
+
+ m.updateState()
+}
+
+func sortDevices(devices []Device) {
+ sort.Slice(devices, func(i, j int) bool {
+ classOrder := map[DeviceClass]int{
+ ClassBacklight: 0,
+ ClassDDC: 1,
+ ClassLED: 2,
+ }
+
+ orderI := classOrder[devices[i].Class]
+ orderJ := classOrder[devices[j].Class]
+
+ if orderI != orderJ {
+ return orderI < orderJ
+ }
+
+ return devices[i].Name < devices[j].Name
+ })
+}
+
+func stateChanged(old, new State) bool {
+ if len(old.Devices) != len(new.Devices) {
+ return true
+ }
+
+ oldMap := make(map[string]Device)
+ for _, d := range old.Devices {
+ oldMap[d.ID] = d
+ }
+
+ for _, newDev := range new.Devices {
+ oldDev, exists := oldMap[newDev.ID]
+ if !exists {
+ return true
+ }
+ if oldDev.Current != newDev.Current || oldDev.Max != newDev.Max {
+ return true
+ }
+ }
+
+ return false
+}
+
+func (m *Manager) updateState() {
+ allDevices := make([]Device, 0)
+
+ if m.sysfsReady && m.sysfsBackend != nil {
+ devices, err := m.sysfsBackend.GetDevices()
+ if err != nil {
+ log.Debugf("Failed to get sysfs devices: %v", err)
+ }
+ if err == nil {
+ allDevices = append(allDevices, devices...)
+ }
+ }
+
+ if m.ddcReady && m.ddcBackend != nil {
+ devices, err := m.ddcBackend.GetDevices()
+ if err != nil {
+ log.Debugf("Failed to get DDC devices: %v", err)
+ }
+ if err == nil {
+ allDevices = append(allDevices, devices...)
+ }
+ }
+
+ sortDevices(allDevices)
+
+ m.stateMutex.Lock()
+ oldState := m.state
+ newState := State{Devices: allDevices}
+
+ if !stateChanged(oldState, newState) {
+ m.stateMutex.Unlock()
+ return
+ }
+
+ m.state = newState
+ m.stateMutex.Unlock()
+ log.Debugf("State changed, notifying subscribers")
+ m.NotifySubscribers()
+}
+
+func (m *Manager) SetBrightness(deviceID string, percent int) error {
+ return m.SetBrightnessWithMode(deviceID, percent, m.exponential)
+}
+
+func (m *Manager) SetBrightnessWithMode(deviceID string, percent int, exponential bool) error {
+ return m.SetBrightnessWithExponent(deviceID, percent, exponential, 1.2)
+}
+
+func (m *Manager) SetBrightnessWithExponent(deviceID string, percent int, exponential bool, exponent float64) error {
+ if percent < 0 {
+ return fmt.Errorf("percent out of range: %d", percent)
+ }
+
+ log.Debugf("SetBrightness: %s to %d%%", deviceID, percent)
+
+ m.stateMutex.Lock()
+ currentState := m.state
+ var found bool
+ var deviceClass DeviceClass
+ var deviceIndex int
+
+ log.Debugf("Current state has %d devices", len(currentState.Devices))
+
+ for i, dev := range currentState.Devices {
+ if dev.ID == deviceID {
+ found = true
+ deviceClass = dev.Class
+ deviceIndex = i
+ break
+ }
+ }
+
+ if !found {
+ m.stateMutex.Unlock()
+ log.Debugf("Device not found in state: %s", deviceID)
+ return fmt.Errorf("device not found: %s", deviceID)
+ }
+
+ newDevices := make([]Device, len(currentState.Devices))
+ copy(newDevices, currentState.Devices)
+ newDevices[deviceIndex].CurrentPercent = percent
+ m.state = State{Devices: newDevices}
+ m.stateMutex.Unlock()
+
+ var err error
+ if deviceClass == ClassDDC {
+ log.Debugf("Calling DDC backend for %s", deviceID)
+ err = m.ddcBackend.SetBrightnessWithExponent(deviceID, percent, exponential, exponent, func() {
+ m.updateState()
+ m.debouncedBroadcast(deviceID)
+ })
+ } else if m.logindReady && m.logindBackend != nil {
+ log.Debugf("Calling logind backend for %s", deviceID)
+ err = m.setViaSysfsWithLogindWithExponent(deviceID, percent, exponential, exponent)
+ } else {
+ log.Debugf("Calling sysfs backend for %s", deviceID)
+ err = m.sysfsBackend.SetBrightnessWithExponent(deviceID, percent, exponential, exponent)
+ }
+
+ if err != nil {
+ m.updateState()
+ return fmt.Errorf("failed to set brightness: %w", err)
+ }
+
+ if deviceClass != ClassDDC {
+ log.Debugf("Queueing broadcast for %s", deviceID)
+ m.debouncedBroadcast(deviceID)
+ }
+ return nil
+}
+
+func (m *Manager) IncrementBrightness(deviceID string, step int) error {
+ return m.IncrementBrightnessWithMode(deviceID, step, m.exponential)
+}
+
+func (m *Manager) IncrementBrightnessWithMode(deviceID string, step int, exponential bool) error {
+ return m.IncrementBrightnessWithExponent(deviceID, step, exponential, 1.2)
+}
+
+func (m *Manager) IncrementBrightnessWithExponent(deviceID string, step int, exponential bool, exponent float64) error {
+ m.stateMutex.RLock()
+ currentState := m.state
+ m.stateMutex.RUnlock()
+
+ var currentPercent int
+ var found bool
+
+ for _, dev := range currentState.Devices {
+ if dev.ID == deviceID {
+ currentPercent = dev.CurrentPercent
+ found = true
+ break
+ }
+ }
+
+ if !found {
+ return fmt.Errorf("device not found: %s", deviceID)
+ }
+
+ newPercent := currentPercent + step
+ if newPercent > 100 {
+ newPercent = 100
+ }
+ if newPercent < 0 {
+ newPercent = 0
+ }
+
+ return m.SetBrightnessWithExponent(deviceID, newPercent, exponential, exponent)
+}
+
+func (m *Manager) DecrementBrightness(deviceID string, step int) error {
+ return m.IncrementBrightness(deviceID, -step)
+}
+
+func (m *Manager) setViaSysfsWithLogindWithExponent(deviceID string, percent int, exponential bool, exponent float64) error {
+ parts := strings.SplitN(deviceID, ":", 2)
+ if len(parts) != 2 {
+ return fmt.Errorf("invalid device id: %s", deviceID)
+ }
+
+ subsystem := parts[0]
+ name := parts[1]
+
+ dev, err := m.sysfsBackend.GetDevice(deviceID)
+ if err != nil {
+ return err
+ }
+
+ value := m.sysfsBackend.PercentToValueWithExponent(percent, dev, exponential, exponent)
+
+ if m.logindBackend == nil {
+ return m.sysfsBackend.SetBrightnessWithExponent(deviceID, percent, exponential, exponent)
+ }
+
+ err = m.logindBackend.SetBrightness(subsystem, name, uint32(value))
+ if err != nil {
+ log.Debugf("logind SetBrightness failed, falling back to direct sysfs: %v", err)
+ return m.sysfsBackend.SetBrightnessWithExponent(deviceID, percent, exponential, exponent)
+ }
+
+ log.Debugf("set %s to %d%% (%d/%d) via logind", deviceID, percent, value, dev.maxBrightness)
+ return nil
+}
+
+func (m *Manager) debouncedBroadcast(deviceID string) {
+ m.broadcastMutex.Lock()
+ defer m.broadcastMutex.Unlock()
+
+ m.broadcastPending = true
+ m.pendingDeviceID = deviceID
+
+ if m.broadcastTimer == nil {
+ m.broadcastTimer = time.AfterFunc(150*time.Millisecond, func() {
+ m.broadcastMutex.Lock()
+ pending := m.broadcastPending
+ deviceID := m.pendingDeviceID
+ m.broadcastPending = false
+ m.pendingDeviceID = ""
+ m.broadcastMutex.Unlock()
+
+ if !pending || deviceID == "" {
+ return
+ }
+
+ m.broadcastDeviceUpdate(deviceID)
+ })
+ } else {
+ m.broadcastTimer.Reset(150 * time.Millisecond)
+ }
+}
+
+func (m *Manager) broadcastDeviceUpdate(deviceID string) {
+ m.stateMutex.RLock()
+ var targetDevice *Device
+ for _, dev := range m.state.Devices {
+ if dev.ID == deviceID {
+ devCopy := dev
+ targetDevice = &devCopy
+ break
+ }
+ }
+ m.stateMutex.RUnlock()
+
+ if targetDevice == nil {
+ log.Debugf("Device not found for broadcast: %s", deviceID)
+ return
+ }
+
+ update := DeviceUpdate{Device: *targetDevice}
+
+ log.Debugf("Broadcasting device update: %s at %d%%", deviceID, targetDevice.CurrentPercent)
+
+ m.updateSubscribers.Range(func(key string, ch chan DeviceUpdate) bool {
+ select {
+ case ch <- update:
+ default:
+ }
+ return true
+ })
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/manager_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/manager_test.go
new file mode 100644
index 0000000..a8ae0f8
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/manager_test.go
@@ -0,0 +1,11 @@
+package brightness
+
+import (
+ "testing"
+)
+
+// Manager tests can be added here as needed
+func TestManager_Placeholder(t *testing.T) {
+ // Placeholder test to keep the test file valid
+ t.Skip("No tests implemented yet")
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/sysfs.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/sysfs.go
new file mode 100644
index 0000000..1ff38f9
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/sysfs.go
@@ -0,0 +1,267 @@
+package brightness
+
+import (
+ "fmt"
+ "math"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+)
+
+func NewSysfsBackend() (*SysfsBackend, error) {
+ b := &SysfsBackend{
+ basePath: "/sys/class",
+ classes: []string{"backlight", "leds"},
+ }
+
+ if err := b.scanDevices(); err != nil {
+ return nil, err
+ }
+
+ return b, nil
+}
+
+func (b *SysfsBackend) scanDevices() error {
+ for _, class := range b.classes {
+ classPath := filepath.Join(b.basePath, class)
+ entries, err := os.ReadDir(classPath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ continue
+ }
+ return fmt.Errorf("read %s: %w", classPath, err)
+ }
+
+ for _, entry := range entries {
+ devicePath := filepath.Join(classPath, entry.Name())
+
+ stat, err := os.Stat(devicePath)
+ if err != nil || !stat.IsDir() {
+ continue
+ }
+ maxPath := filepath.Join(devicePath, "max_brightness")
+
+ maxData, err := os.ReadFile(maxPath)
+ if err != nil {
+ log.Debugf("skip %s/%s: no max_brightness", class, entry.Name())
+ continue
+ }
+
+ maxBrightness, err := strconv.Atoi(strings.TrimSpace(string(maxData)))
+ if err != nil || maxBrightness <= 0 {
+ log.Debugf("skip %s/%s: invalid max_brightness", class, entry.Name())
+ continue
+ }
+
+ deviceClass := ClassBacklight
+ minValue := 1
+ if class == "leds" {
+ deviceClass = ClassLED
+ minValue = 0
+ }
+
+ deviceID := fmt.Sprintf("%s:%s", class, entry.Name())
+ b.deviceCache.Store(deviceID, &sysfsDevice{
+ class: deviceClass,
+ id: deviceID,
+ name: entry.Name(),
+ maxBrightness: maxBrightness,
+ minValue: minValue,
+ })
+
+ log.Debugf("found %s device: %s (max=%d)", class, entry.Name(), maxBrightness)
+ }
+ }
+
+ return nil
+}
+
+func shouldSuppressDevice(name string) bool {
+ if strings.HasSuffix(name, "::lan") {
+ return true
+ }
+
+ keyboardLEDs := []string{
+ "::scrolllock",
+ "::capslock",
+ "::numlock",
+ "::kana",
+ "::compose",
+ }
+
+ for _, suffix := range keyboardLEDs {
+ if strings.HasSuffix(name, suffix) {
+ return true
+ }
+ }
+
+ return false
+}
+
+func (b *SysfsBackend) Rescan() error {
+ return b.scanDevices()
+}
+
+func (b *SysfsBackend) GetDevices() ([]Device, error) {
+ devices := make([]Device, 0)
+
+ b.deviceCache.Range(func(key string, dev *sysfsDevice) bool {
+ if shouldSuppressDevice(dev.name) {
+ return true
+ }
+
+ parts := strings.SplitN(dev.id, ":", 2)
+ if len(parts) != 2 {
+ return true
+ }
+
+ class := parts[0]
+ name := parts[1]
+
+ devicePath := filepath.Join(b.basePath, class, name)
+ brightnessPath := filepath.Join(devicePath, "brightness")
+
+ brightnessData, err := os.ReadFile(brightnessPath)
+ if err != nil {
+ log.Debugf("failed to read brightness for %s: %v", dev.id, err)
+ return true
+ }
+
+ current, err := strconv.Atoi(strings.TrimSpace(string(brightnessData)))
+ if err != nil {
+ log.Debugf("failed to parse brightness for %s: %v", dev.id, err)
+ return true
+ }
+
+ percent := b.ValueToPercent(current, dev, false)
+
+ devices = append(devices, Device{
+ Class: dev.class,
+ ID: dev.id,
+ Name: dev.name,
+ Current: current,
+ Max: dev.maxBrightness,
+ CurrentPercent: percent,
+ Backend: "sysfs",
+ })
+ return true
+ })
+
+ return devices, nil
+}
+
+func (b *SysfsBackend) GetDevice(id string) (*sysfsDevice, error) {
+ dev, ok := b.deviceCache.Load(id)
+ if !ok {
+ return nil, fmt.Errorf("device not found: %s", id)
+ }
+
+ return dev, nil
+}
+
+func (b *SysfsBackend) SetBrightness(id string, percent int, exponential bool) error {
+ return b.SetBrightnessWithExponent(id, percent, exponential, 1.2)
+}
+
+func (b *SysfsBackend) SetBrightnessWithExponent(id string, percent int, exponential bool, exponent float64) error {
+ dev, err := b.GetDevice(id)
+ if err != nil {
+ return err
+ }
+
+ if percent < 0 {
+ return fmt.Errorf("percent out of range: %d", percent)
+ }
+
+ value := b.PercentToValueWithExponent(percent, dev, exponential, exponent)
+
+ parts := strings.SplitN(id, ":", 2)
+ if len(parts) != 2 {
+ return fmt.Errorf("invalid device id: %s", id)
+ }
+
+ class := parts[0]
+ name := parts[1]
+
+ devicePath := filepath.Join(b.basePath, class, name)
+ brightnessPath := filepath.Join(devicePath, "brightness")
+
+ data := []byte(fmt.Sprintf("%d", value))
+ if err := os.WriteFile(brightnessPath, data, 0o644); err != nil {
+ return fmt.Errorf("write brightness: %w", err)
+ }
+
+ log.Debugf("set %s to %d%% (%d/%d) via direct sysfs", id, percent, value, dev.maxBrightness)
+
+ return nil
+}
+
+func (b *SysfsBackend) PercentToValue(percent int, dev *sysfsDevice, exponential bool) int {
+ return b.PercentToValueWithExponent(percent, dev, exponential, 1.2)
+}
+
+func (b *SysfsBackend) PercentToValueWithExponent(percent int, dev *sysfsDevice, exponential bool, exponent float64) int {
+ if percent == 0 {
+ return dev.minValue
+ }
+
+ usableRange := dev.maxBrightness - dev.minValue
+ var value int
+
+ if exponential {
+ normalizedPercent := float64(percent-1) / 99.0
+ hardwarePercent := math.Pow(normalizedPercent, exponent)
+ value = dev.minValue + int(math.Round(hardwarePercent*float64(usableRange)))
+ } else {
+ value = dev.minValue + ((percent - 1) * usableRange / 99)
+ }
+
+ if value < dev.minValue {
+ value = dev.minValue
+ }
+ if value > dev.maxBrightness {
+ value = dev.maxBrightness
+ }
+
+ return value
+}
+
+func (b *SysfsBackend) ValueToPercent(value int, dev *sysfsDevice, exponential bool) int {
+ return b.ValueToPercentWithExponent(value, dev, exponential, 1.2)
+}
+
+func (b *SysfsBackend) ValueToPercentWithExponent(value int, dev *sysfsDevice, exponential bool, exponent float64) int {
+ if value <= dev.minValue {
+ if dev.minValue == 0 && value == 0 {
+ return 0
+ }
+ return 1
+ }
+
+ usableRange := dev.maxBrightness - dev.minValue
+ if usableRange == 0 {
+ return 100
+ }
+
+ var percent int
+
+ if exponential {
+ hardwarePercent := float64(value-dev.minValue) / float64(usableRange)
+ normalizedPercent := math.Pow(hardwarePercent, 1.0/exponent)
+ percent = 1 + int(math.Round(normalizedPercent*99.0))
+ } else {
+ percent = 1 + int(math.Round(float64(value-dev.minValue)*99.0/float64(usableRange)))
+ }
+
+ if percent > 100 {
+ percent = 100
+ }
+ if percent < 1 {
+ percent = 1
+ }
+
+ return percent
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/sysfs_logind_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/sysfs_logind_test.go
new file mode 100644
index 0000000..6ba84b8
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/sysfs_logind_test.go
@@ -0,0 +1,278 @@
+package brightness
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ mocks_brightness "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/brightness"
+ mock_dbus "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/github.com/godbus/dbus/v5"
+ "github.com/godbus/dbus/v5"
+ "github.com/stretchr/testify/mock"
+)
+
+func TestManager_SetBrightness_LogindSuccess(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ backlightDir := filepath.Join(tmpDir, "backlight", "test_backlight")
+ if err := os.MkdirAll(backlightDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(backlightDir, "max_brightness"), []byte("100\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(backlightDir, "brightness"), []byte("50\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ mockConn := mocks_brightness.NewMockDBusConn(t)
+ mockObj := mock_dbus.NewMockBusObject(t)
+
+ mockLogind := NewLogindBackendWithConn(mockConn)
+
+ sysfs := &SysfsBackend{
+ basePath: tmpDir,
+ classes: []string{"backlight"},
+ }
+
+ if err := sysfs.scanDevices(); err != nil {
+ t.Fatal(err)
+ }
+
+ m := &Manager{
+ logindBackend: mockLogind,
+ sysfsBackend: sysfs,
+ logindReady: true,
+ sysfsReady: true,
+ stopChan: make(chan struct{}),
+ }
+
+ m.state = State{
+ Devices: []Device{
+ {
+ Class: ClassBacklight,
+ ID: "backlight:test_backlight",
+ Name: "test_backlight",
+ Current: 50,
+ Max: 100,
+ CurrentPercent: 50,
+ Backend: "sysfs",
+ },
+ },
+ }
+
+ mockConn.EXPECT().
+ Object("org.freedesktop.login1", dbus.ObjectPath("/org/freedesktop/login1/session/auto")).
+ Return(mockObj).
+ Once()
+
+ mockObj.EXPECT().
+ Call("org.freedesktop.login1.Session.SetBrightness", mock.Anything, "backlight", "test_backlight", uint32(75)).
+ Return(&dbus.Call{Err: nil}).
+ Once()
+
+ err := m.SetBrightness("backlight:test_backlight", 75)
+ if err != nil {
+ t.Errorf("SetBrightness() with logind error = %v, want nil", err)
+ }
+
+ data, _ := os.ReadFile(filepath.Join(backlightDir, "brightness"))
+ if string(data) == "75\n" {
+ t.Error("Direct sysfs write occurred when logind should have been used")
+ }
+}
+
+func TestManager_SetBrightness_LogindFailsFallbackToSysfs(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ backlightDir := filepath.Join(tmpDir, "backlight", "test_backlight")
+ if err := os.MkdirAll(backlightDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(backlightDir, "max_brightness"), []byte("100\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(backlightDir, "brightness"), []byte("50\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ mockConn := mocks_brightness.NewMockDBusConn(t)
+ mockObj := mock_dbus.NewMockBusObject(t)
+
+ mockLogind := NewLogindBackendWithConn(mockConn)
+
+ sysfs := &SysfsBackend{
+ basePath: tmpDir,
+ classes: []string{"backlight"},
+ }
+
+ if err := sysfs.scanDevices(); err != nil {
+ t.Fatal(err)
+ }
+
+ m := &Manager{
+ logindBackend: mockLogind,
+ sysfsBackend: sysfs,
+ logindReady: true,
+ sysfsReady: true,
+ stopChan: make(chan struct{}),
+ }
+
+ m.state = State{
+ Devices: []Device{
+ {
+ Class: ClassBacklight,
+ ID: "backlight:test_backlight",
+ Name: "test_backlight",
+ Current: 50,
+ Max: 100,
+ CurrentPercent: 50,
+ Backend: "sysfs",
+ },
+ },
+ }
+
+ mockConn.EXPECT().
+ Object("org.freedesktop.login1", dbus.ObjectPath("/org/freedesktop/login1/session/auto")).
+ Return(mockObj).
+ Once()
+
+ mockObj.EXPECT().
+ Call("org.freedesktop.login1.Session.SetBrightness", mock.Anything, "backlight", "test_backlight", mock.Anything).
+ Return(&dbus.Call{Err: dbus.ErrMsgNoObject}).
+ Once()
+
+ err := m.SetBrightness("backlight:test_backlight", 75)
+ if err != nil {
+ t.Errorf("SetBrightness() with fallback error = %v, want nil", err)
+ }
+
+ data, _ := os.ReadFile(filepath.Join(backlightDir, "brightness"))
+ brightness := string(data)
+ if brightness != "75" {
+ t.Errorf("Fallback sysfs write did not occur, got brightness = %q, want %q", brightness, "75")
+ }
+}
+
+func TestManager_SetBrightness_NoLogind(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ backlightDir := filepath.Join(tmpDir, "backlight", "test_backlight")
+ if err := os.MkdirAll(backlightDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(backlightDir, "max_brightness"), []byte("100\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(backlightDir, "brightness"), []byte("50\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ sysfs := &SysfsBackend{
+ basePath: tmpDir,
+ classes: []string{"backlight"},
+ }
+
+ if err := sysfs.scanDevices(); err != nil {
+ t.Fatal(err)
+ }
+
+ m := &Manager{
+ logindBackend: nil,
+ sysfsBackend: sysfs,
+ logindReady: false,
+ sysfsReady: true,
+ stopChan: make(chan struct{}),
+ }
+
+ m.state = State{
+ Devices: []Device{
+ {
+ Class: ClassBacklight,
+ ID: "backlight:test_backlight",
+ Name: "test_backlight",
+ Current: 50,
+ Max: 100,
+ CurrentPercent: 50,
+ Backend: "sysfs",
+ },
+ },
+ }
+
+ err := m.SetBrightness("backlight:test_backlight", 75)
+ if err != nil {
+ t.Errorf("SetBrightness() without logind error = %v, want nil", err)
+ }
+
+ data, _ := os.ReadFile(filepath.Join(backlightDir, "brightness"))
+ brightness := string(data)
+ if brightness != "75" {
+ t.Errorf("Direct sysfs write = %q, want %q", brightness, "75")
+ }
+}
+
+func TestManager_SetBrightness_LEDWithLogind(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ ledsDir := filepath.Join(tmpDir, "leds", "test_led")
+ if err := os.MkdirAll(ledsDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(ledsDir, "max_brightness"), []byte("255\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(ledsDir, "brightness"), []byte("128\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ mockConn := mocks_brightness.NewMockDBusConn(t)
+ mockObj := mock_dbus.NewMockBusObject(t)
+
+ mockLogind := NewLogindBackendWithConn(mockConn)
+
+ sysfs := &SysfsBackend{
+ basePath: tmpDir,
+ classes: []string{"leds"},
+ }
+
+ if err := sysfs.scanDevices(); err != nil {
+ t.Fatal(err)
+ }
+
+ m := &Manager{
+ logindBackend: mockLogind,
+ sysfsBackend: sysfs,
+ logindReady: true,
+ sysfsReady: true,
+ stopChan: make(chan struct{}),
+ }
+
+ m.state = State{
+ Devices: []Device{
+ {
+ Class: ClassLED,
+ ID: "leds:test_led",
+ Name: "test_led",
+ Current: 128,
+ Max: 255,
+ CurrentPercent: 50,
+ Backend: "sysfs",
+ },
+ },
+ }
+
+ mockConn.EXPECT().
+ Object("org.freedesktop.login1", dbus.ObjectPath("/org/freedesktop/login1/session/auto")).
+ Return(mockObj).
+ Once()
+
+ mockObj.EXPECT().
+ Call("org.freedesktop.login1.Session.SetBrightness", mock.Anything, "leds", "test_led", uint32(0)).
+ Return(&dbus.Call{Err: nil}).
+ Once()
+
+ err := m.SetBrightness("leds:test_led", 0)
+ if err != nil {
+ t.Errorf("SetBrightness() LED with logind error = %v, want nil", err)
+ }
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/sysfs_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/sysfs_test.go
new file mode 100644
index 0000000..8061513
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/sysfs_test.go
@@ -0,0 +1,180 @@
+package brightness
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestSysfsBackend_PercentConversions(t *testing.T) {
+ tests := []struct {
+ name string
+ device *sysfsDevice
+ percent int
+ wantValue int
+ tolerance int
+ }{
+ {
+ name: "backlight 0% should be minValue=1",
+ device: &sysfsDevice{maxBrightness: 100, minValue: 1, class: ClassBacklight},
+ percent: 0,
+ wantValue: 1,
+ tolerance: 0,
+ },
+ {
+ name: "backlight 1% should be minValue=1",
+ device: &sysfsDevice{maxBrightness: 100, minValue: 1, class: ClassBacklight},
+ percent: 1,
+ wantValue: 1,
+ tolerance: 0,
+ },
+ {
+ name: "backlight 50% should be ~50",
+ device: &sysfsDevice{maxBrightness: 100, minValue: 1, class: ClassBacklight},
+ percent: 50,
+ wantValue: 50,
+ tolerance: 1,
+ },
+ {
+ name: "backlight 100% should be max",
+ device: &sysfsDevice{maxBrightness: 100, minValue: 1, class: ClassBacklight},
+ percent: 100,
+ wantValue: 100,
+ tolerance: 0,
+ },
+ {
+ name: "led 0% should be 0",
+ device: &sysfsDevice{maxBrightness: 255, minValue: 0, class: ClassLED},
+ percent: 0,
+ wantValue: 0,
+ tolerance: 0,
+ },
+ {
+ name: "led 1% should be ~2-3",
+ device: &sysfsDevice{maxBrightness: 255, minValue: 0, class: ClassLED},
+ percent: 1,
+ wantValue: 2,
+ tolerance: 3,
+ },
+ {
+ name: "led 50% should be ~127",
+ device: &sysfsDevice{maxBrightness: 255, minValue: 0, class: ClassLED},
+ percent: 50,
+ wantValue: 127,
+ tolerance: 2,
+ },
+ {
+ name: "led 100% should be max",
+ device: &sysfsDevice{maxBrightness: 255, minValue: 0, class: ClassLED},
+ percent: 100,
+ wantValue: 255,
+ tolerance: 0,
+ },
+ }
+
+ b := &SysfsBackend{}
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := b.PercentToValue(tt.percent, tt.device, false)
+ diff := got - tt.wantValue
+ if diff < 0 {
+ diff = -diff
+ }
+ if diff > tt.tolerance {
+ t.Errorf("percentToValue() = %v, want %v (±%d)", got, tt.wantValue, tt.tolerance)
+ }
+
+ gotPercent := b.ValueToPercent(got, tt.device, false)
+ if tt.percent > 1 && gotPercent == 0 {
+ t.Errorf("valueToPercent() returned 0 for non-zero input (percent=%d, got value=%d)", tt.percent, got)
+ }
+ })
+ }
+}
+
+func TestSysfsBackend_ValueToPercent(t *testing.T) {
+ tests := []struct {
+ name string
+ device *sysfsDevice
+ value int
+ wantPercent int
+ }{
+ {
+ name: "backlight min value",
+ device: &sysfsDevice{maxBrightness: 100, minValue: 1, class: ClassBacklight},
+ value: 1,
+ wantPercent: 1,
+ },
+ {
+ name: "backlight max value",
+ device: &sysfsDevice{maxBrightness: 100, minValue: 1, class: ClassBacklight},
+ value: 100,
+ wantPercent: 100,
+ },
+ {
+ name: "led zero",
+ device: &sysfsDevice{maxBrightness: 255, minValue: 0, class: ClassLED},
+ value: 0,
+ wantPercent: 0,
+ },
+ }
+
+ b := &SysfsBackend{}
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := b.ValueToPercent(tt.value, tt.device, false)
+ if got != tt.wantPercent {
+ t.Errorf("valueToPercent() = %v, want %v", got, tt.wantPercent)
+ }
+ })
+ }
+}
+
+func TestSysfsBackend_ScanDevices(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ backlightDir := filepath.Join(tmpDir, "backlight", "test_backlight")
+ if err := os.MkdirAll(backlightDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := os.WriteFile(filepath.Join(backlightDir, "max_brightness"), []byte("100\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(backlightDir, "brightness"), []byte("50\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ ledsDir := filepath.Join(tmpDir, "leds", "test_led")
+ if err := os.MkdirAll(ledsDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := os.WriteFile(filepath.Join(ledsDir, "max_brightness"), []byte("255\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(ledsDir, "brightness"), []byte("128\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ b := &SysfsBackend{
+ basePath: tmpDir,
+ classes: []string{"backlight", "leds"},
+ }
+
+ if err := b.scanDevices(); err != nil {
+ t.Fatalf("scanDevices() error = %v", err)
+ }
+
+ backlightID := "backlight:test_backlight"
+ if _, ok := b.deviceCache.Load(backlightID); !ok {
+ t.Errorf("backlight device not found")
+ }
+
+ ledID := "leds:test_led"
+ if _, ok := b.deviceCache.Load(ledID); !ok {
+ t.Errorf("LED device not found")
+ }
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/types.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/types.go
new file mode 100644
index 0000000..81e00cf
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/types.go
@@ -0,0 +1,184 @@
+package brightness
+
+import (
+ "sync"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
+)
+
+type DeviceClass string
+
+const (
+ ClassBacklight DeviceClass = "backlight"
+ ClassLED DeviceClass = "leds"
+ ClassDDC DeviceClass = "ddc"
+)
+
+type Device struct {
+ Class DeviceClass `json:"class"`
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Current int `json:"current"`
+ Max int `json:"max"`
+ CurrentPercent int `json:"currentPercent"`
+ Backend string `json:"backend"`
+}
+
+type State struct {
+ Devices []Device `json:"devices"`
+}
+
+type DeviceUpdate struct {
+ Device Device `json:"device"`
+}
+
+type Manager struct {
+ logindBackend *LogindBackend
+ sysfsBackend *SysfsBackend
+ ddcBackend *DDCBackend
+ udevMonitor *UdevMonitor
+
+ logindReady bool
+ sysfsReady bool
+ ddcReady bool
+
+ exponential bool
+
+ stateMutex sync.RWMutex
+ state State
+
+ subscribers syncmap.Map[string, chan State]
+ updateSubscribers syncmap.Map[string, chan DeviceUpdate]
+
+ broadcastMutex sync.Mutex
+ broadcastTimer *time.Timer
+ broadcastPending bool
+ pendingDeviceID string
+
+ stopChan chan struct{}
+}
+
+type SysfsBackend struct {
+ basePath string
+ classes []string
+
+ deviceCache syncmap.Map[string, *sysfsDevice]
+}
+
+type sysfsDevice struct {
+ class DeviceClass
+ id string
+ name string
+ maxBrightness int
+ minValue int
+}
+
+type DDCBackend struct {
+ devices syncmap.Map[string, *ddcDevice]
+
+ scanMutex sync.Mutex
+ lastScan time.Time
+ scanInterval time.Duration
+
+ debounceMutex sync.Mutex
+ debounceTimers map[string]*time.Timer
+ debouncePending map[string]ddcPendingSet
+ debounceWg sync.WaitGroup
+}
+
+type ddcPendingSet struct {
+ percent int
+ callback func()
+}
+
+type ddcDevice struct {
+ bus int
+ addr int
+ id string
+ name string
+ max int
+ lastBrightness int
+}
+
+type ddcCapability struct {
+ vcp byte
+ max int
+ current int
+}
+
+func (m *Manager) Subscribe(id string) chan State {
+ ch := make(chan State, 16)
+
+ m.subscribers.Store(id, ch)
+
+ return ch
+}
+
+func (m *Manager) Unsubscribe(id string) {
+
+ if val, ok := m.subscribers.LoadAndDelete(id); ok {
+ close(val)
+
+ }
+
+}
+
+func (m *Manager) SubscribeUpdates(id string) chan DeviceUpdate {
+ ch := make(chan DeviceUpdate, 16)
+ m.updateSubscribers.Store(id, ch)
+ return ch
+}
+
+func (m *Manager) UnsubscribeUpdates(id string) {
+ if val, ok := m.updateSubscribers.LoadAndDelete(id); ok {
+ close(val)
+ }
+}
+
+func (m *Manager) NotifySubscribers() {
+ m.stateMutex.RLock()
+ state := m.state
+ m.stateMutex.RUnlock()
+
+ m.subscribers.Range(func(key string, ch chan State) bool {
+ select {
+ case ch <- state:
+ default:
+ }
+ return true
+ })
+}
+
+func (m *Manager) GetState() State {
+ m.stateMutex.RLock()
+ defer m.stateMutex.RUnlock()
+ return m.state
+}
+
+func (m *Manager) Close() {
+ close(m.stopChan)
+
+ m.subscribers.Range(func(key string, ch chan State) bool {
+ close(ch)
+ m.subscribers.Delete(key)
+ return true
+ })
+ m.updateSubscribers.Range(func(key string, ch chan DeviceUpdate) bool {
+ close(ch)
+ m.updateSubscribers.Delete(key)
+ return true
+ })
+
+ if m.udevMonitor != nil {
+ m.udevMonitor.Close()
+ }
+
+ if m.logindBackend != nil {
+ m.logindBackend.Close()
+ }
+
+ if m.ddcBackend != nil {
+ m.ddcBackend.Close()
+ }
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/udev.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/udev.go
new file mode 100644
index 0000000..b7f02a8
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/udev.go
@@ -0,0 +1,237 @@
+package brightness
+
+import (
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "sync"
+ "syscall"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "github.com/pilebones/go-udev/netlink"
+)
+
+const (
+ udevRecvBufSize = 8 * 1024 * 1024
+ udevMaxRetries = 5
+ udevBaseDelay = 2 * time.Second
+ udevMaxDelay = 60 * time.Second
+)
+
+type UdevMonitor struct {
+ stop chan struct{}
+ rescanMutex sync.Mutex
+ rescanTimer *time.Timer
+ rescanPending bool
+}
+
+func NewUdevMonitor(manager *Manager) *UdevMonitor {
+ m := &UdevMonitor{
+ stop: make(chan struct{}),
+ }
+
+ go m.run(manager)
+ return m
+}
+
+func (m *UdevMonitor) run(manager *Manager) {
+ matcher := &netlink.RuleDefinitions{
+ Rules: []netlink.RuleDefinition{
+ {Env: map[string]string{"SUBSYSTEM": "backlight"}},
+ {Env: map[string]string{"SUBSYSTEM": "drm"}},
+ {Env: map[string]string{"SUBSYSTEM": "i2c"}},
+ },
+ }
+ if err := matcher.Compile(); err != nil {
+ log.Errorf("Failed to compile udev matcher: %v", err)
+ return
+ }
+
+ failures := 0
+ for {
+ if err := m.monitorLoop(manager, matcher); err != nil {
+ log.Errorf("Udev monitor error: %v", err)
+ }
+
+ select {
+ case <-m.stop:
+ return
+ default:
+ }
+
+ failures++
+ if failures > udevMaxRetries {
+ log.Errorf("Udev monitor exceeded %d retries, giving up", udevMaxRetries)
+ return
+ }
+
+ delay := min(udevBaseDelay*time.Duration(1<<(failures-1)), udevMaxDelay)
+ log.Infof("Udev monitor reconnecting in %v (attempt %d/%d)", delay, failures, udevMaxRetries)
+
+ select {
+ case <-m.stop:
+ return
+ case <-time.After(delay):
+ }
+ }
+}
+
+func (m *UdevMonitor) monitorLoop(manager *Manager, matcher *netlink.RuleDefinitions) error {
+ conn := &netlink.UEventConn{}
+ if err := conn.Connect(netlink.UdevEvent); err != nil {
+ return err
+ }
+ defer conn.Close()
+
+ if err := syscall.SetsockoptInt(conn.Fd, syscall.SOL_SOCKET, syscall.SO_RCVBUF, udevRecvBufSize); err != nil {
+ log.Warnf("Failed to set udev socket receive buffer: %v", err)
+ }
+
+ events := make(chan netlink.UEvent)
+ errs := make(chan error)
+ conn.Monitor(events, errs, matcher)
+
+ log.Info("Udev monitor started for backlight/drm/i2c events")
+
+ for {
+ select {
+ case <-m.stop:
+ return nil
+ case err := <-errs:
+ return err
+ case event := <-events:
+ m.handleEvent(manager, event)
+ }
+ }
+}
+
+func (m *UdevMonitor) handleEvent(manager *Manager, event netlink.UEvent) {
+ subsystem := event.Env["SUBSYSTEM"]
+ devpath := event.Env["DEVPATH"]
+
+ if subsystem == "" || devpath == "" {
+ return
+ }
+
+ sysname := filepath.Base(devpath)
+ action := string(event.Action)
+
+ switch subsystem {
+ case "drm", "i2c":
+ m.handleDisplayEvent(manager, action, subsystem, sysname)
+ case "backlight":
+ m.handleBacklightEvent(manager, action, sysname)
+ }
+}
+
+func (m *UdevMonitor) handleDisplayEvent(manager *Manager, action, subsystem, sysname string) {
+ switch action {
+ case "add", "remove", "change":
+ log.Debugf("Udev %s event: %s:%s - queueing DDC rescan", action, subsystem, sysname)
+ m.debouncedRescan(manager)
+ }
+}
+
+func (m *UdevMonitor) debouncedRescan(manager *Manager) {
+ m.rescanMutex.Lock()
+ defer m.rescanMutex.Unlock()
+
+ m.rescanPending = true
+
+ if m.rescanTimer != nil {
+ m.rescanTimer.Reset(2 * time.Second)
+ return
+ }
+
+ m.rescanTimer = time.AfterFunc(2*time.Second, func() {
+ m.rescanMutex.Lock()
+ pending := m.rescanPending
+ m.rescanPending = false
+ m.rescanMutex.Unlock()
+
+ if !pending {
+ return
+ }
+
+ log.Debug("Executing debounced DDC rescan")
+ manager.Rescan()
+ })
+}
+
+func (m *UdevMonitor) handleBacklightEvent(manager *Manager, action, sysname string) {
+ switch action {
+ case "change":
+ m.handleChange(manager, "backlight", sysname)
+ case "add", "remove":
+ log.Debugf("Udev %s event: backlight:%s - triggering rescan", action, sysname)
+ manager.Rescan()
+ }
+}
+
+func (m *UdevMonitor) handleChange(manager *Manager, subsystem, sysname string) {
+ deviceID := subsystem + ":" + sysname
+
+ if manager.sysfsBackend == nil {
+ return
+ }
+
+ brightnessPath := filepath.Join(manager.sysfsBackend.basePath, subsystem, sysname, "brightness")
+ data, err := os.ReadFile(brightnessPath)
+ if err != nil {
+ log.Debugf("Udev change event for %s but failed to read brightness: %v", deviceID, err)
+ return
+ }
+
+ brightness, err := strconv.Atoi(strings.TrimSpace(string(data)))
+ if err != nil {
+ log.Debugf("Failed to parse brightness for %s: %v", deviceID, err)
+ return
+ }
+
+ manager.handleUdevBrightnessChange(deviceID, brightness)
+}
+
+func (m *UdevMonitor) Close() {
+ close(m.stop)
+}
+
+func (m *Manager) handleUdevBrightnessChange(deviceID string, rawBrightness int) {
+ if m.sysfsBackend == nil {
+ return
+ }
+
+ dev, err := m.sysfsBackend.GetDevice(deviceID)
+ if err != nil {
+ log.Debugf("Udev event for unknown device %s: %v", deviceID, err)
+ return
+ }
+
+ percent := m.sysfsBackend.ValueToPercent(rawBrightness, dev, false)
+
+ m.stateMutex.Lock()
+ var found bool
+ for i, d := range m.state.Devices {
+ if d.ID != deviceID {
+ continue
+ }
+ found = true
+ if d.Current == rawBrightness {
+ m.stateMutex.Unlock()
+ return
+ }
+ m.state.Devices[i].Current = rawBrightness
+ m.state.Devices[i].CurrentPercent = percent
+ break
+ }
+ m.stateMutex.Unlock()
+
+ if !found {
+ log.Debugf("Udev event for device not in state: %s", deviceID)
+ return
+ }
+
+ log.Debugf("Udev brightness change: %s -> %d (%d%%)", deviceID, rawBrightness, percent)
+ m.broadcastDeviceUpdate(deviceID)
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/udev_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/udev_test.go
new file mode 100644
index 0000000..a5c406b
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/udev_test.go
@@ -0,0 +1,260 @@
+package brightness
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/pilebones/go-udev/netlink"
+)
+
+func setupTestManager(t *testing.T) (*Manager, string) {
+ tmpDir := t.TempDir()
+
+ backlightDir := filepath.Join(tmpDir, "backlight", "intel_backlight")
+ if err := os.MkdirAll(backlightDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(backlightDir, "max_brightness"), []byte("1000\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(backlightDir, "brightness"), []byte("500\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ sysfs := &SysfsBackend{
+ basePath: tmpDir,
+ classes: []string{"backlight"},
+ }
+ if err := sysfs.scanDevices(); err != nil {
+ t.Fatal(err)
+ }
+
+ m := &Manager{
+ sysfsBackend: sysfs,
+ sysfsReady: true,
+ stopChan: make(chan struct{}),
+ }
+
+ m.state = State{
+ Devices: []Device{
+ {
+ Class: ClassBacklight,
+ ID: "backlight:intel_backlight",
+ Name: "intel_backlight",
+ Current: 500,
+ Max: 1000,
+ CurrentPercent: 50,
+ Backend: "sysfs",
+ },
+ },
+ }
+
+ return m, tmpDir
+}
+
+func TestHandleUdevBrightnessChange_UpdatesState(t *testing.T) {
+ m, _ := setupTestManager(t)
+
+ m.handleUdevBrightnessChange("backlight:intel_backlight", 750)
+
+ state := m.GetState()
+ if len(state.Devices) != 1 {
+ t.Fatalf("expected 1 device, got %d", len(state.Devices))
+ }
+
+ dev := state.Devices[0]
+ if dev.Current != 750 {
+ t.Errorf("expected Current=750, got %d", dev.Current)
+ }
+ if dev.CurrentPercent != 75 {
+ t.Errorf("expected CurrentPercent=75, got %d", dev.CurrentPercent)
+ }
+}
+
+func TestHandleUdevBrightnessChange_NoChangeWhenSameValue(t *testing.T) {
+ m, _ := setupTestManager(t)
+
+ updateCh := m.SubscribeUpdates("test")
+ defer m.UnsubscribeUpdates("test")
+
+ m.handleUdevBrightnessChange("backlight:intel_backlight", 500)
+
+ select {
+ case <-updateCh:
+ t.Error("should not broadcast when brightness unchanged")
+ case <-time.After(50 * time.Millisecond):
+ }
+}
+
+func TestHandleUdevBrightnessChange_BroadcastsOnChange(t *testing.T) {
+ m, _ := setupTestManager(t)
+
+ updateCh := m.SubscribeUpdates("test")
+ defer m.UnsubscribeUpdates("test")
+
+ m.handleUdevBrightnessChange("backlight:intel_backlight", 750)
+
+ select {
+ case update := <-updateCh:
+ if update.Device.Current != 750 {
+ t.Errorf("broadcast had wrong Current: got %d, want 750", update.Device.Current)
+ }
+ case <-time.After(100 * time.Millisecond):
+ t.Error("expected broadcast on brightness change")
+ }
+}
+
+func TestHandleUdevBrightnessChange_UnknownDevice(t *testing.T) {
+ m, _ := setupTestManager(t)
+
+ m.handleUdevBrightnessChange("backlight:unknown_device", 500)
+
+ state := m.GetState()
+ if len(state.Devices) != 1 {
+ t.Errorf("state should be unchanged, got %d devices", len(state.Devices))
+ }
+}
+
+func TestHandleUdevBrightnessChange_NilSysfsBackend(t *testing.T) {
+ m := &Manager{
+ sysfsBackend: nil,
+ stopChan: make(chan struct{}),
+ }
+
+ m.handleUdevBrightnessChange("backlight:test", 500)
+}
+
+func TestHandleUdevBrightnessChange_DeviceNotInState(t *testing.T) {
+ m, _ := setupTestManager(t)
+
+ m.sysfsBackend.deviceCache.Store("backlight:other_device", &sysfsDevice{
+ class: ClassBacklight,
+ id: "backlight:other_device",
+ name: "other_device",
+ maxBrightness: 100,
+ minValue: 1,
+ })
+
+ m.handleUdevBrightnessChange("backlight:other_device", 50)
+
+ state := m.GetState()
+ for _, d := range state.Devices {
+ if d.ID == "backlight:other_device" {
+ t.Error("device should not be added to state via udev change event")
+ }
+ }
+}
+
+func TestHandleEvent_ChangeAction(t *testing.T) {
+ m, tmpDir := setupTestManager(t)
+ um := &UdevMonitor{stop: make(chan struct{})}
+
+ brightnessPath := filepath.Join(tmpDir, "backlight", "intel_backlight", "brightness")
+ if err := os.WriteFile(brightnessPath, []byte("800\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ event := netlink.UEvent{
+ Action: netlink.CHANGE,
+ Env: map[string]string{
+ "SUBSYSTEM": "backlight",
+ "DEVPATH": "/devices/pci0000:00/0000:00:02.0/drm/card0/card0-eDP-1/intel_backlight",
+ },
+ }
+
+ um.handleEvent(m, event)
+
+ state := m.GetState()
+ if state.Devices[0].Current != 800 {
+ t.Errorf("expected Current=800 after change event, got %d", state.Devices[0].Current)
+ }
+}
+
+func TestHandleEvent_MissingEnvVars(t *testing.T) {
+ m, _ := setupTestManager(t)
+ um := &UdevMonitor{stop: make(chan struct{})}
+
+ event := netlink.UEvent{
+ Action: netlink.CHANGE,
+ Env: map[string]string{},
+ }
+
+ um.handleEvent(m, event)
+
+ state := m.GetState()
+ if state.Devices[0].Current != 500 {
+ t.Error("state should be unchanged with missing env vars")
+ }
+}
+
+func TestHandleEvent_MissingSubsystem(t *testing.T) {
+ m, _ := setupTestManager(t)
+ um := &UdevMonitor{stop: make(chan struct{})}
+
+ event := netlink.UEvent{
+ Action: netlink.CHANGE,
+ Env: map[string]string{
+ "DEVPATH": "/devices/foo/bar",
+ },
+ }
+
+ um.handleEvent(m, event)
+
+ state := m.GetState()
+ if state.Devices[0].Current != 500 {
+ t.Error("state should be unchanged with missing SUBSYSTEM")
+ }
+}
+
+func TestHandleChange_BrightnessFileNotFound(t *testing.T) {
+ m, _ := setupTestManager(t)
+ um := &UdevMonitor{stop: make(chan struct{})}
+
+ um.handleChange(m, "backlight", "nonexistent_device")
+
+ state := m.GetState()
+ if state.Devices[0].Current != 500 {
+ t.Error("state should be unchanged when brightness file not found")
+ }
+}
+
+func TestHandleChange_InvalidBrightnessValue(t *testing.T) {
+ m, tmpDir := setupTestManager(t)
+ um := &UdevMonitor{stop: make(chan struct{})}
+
+ brightnessPath := filepath.Join(tmpDir, "backlight", "intel_backlight", "brightness")
+ if err := os.WriteFile(brightnessPath, []byte("not_a_number\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ um.handleChange(m, "backlight", "intel_backlight")
+
+ state := m.GetState()
+ if state.Devices[0].Current != 500 {
+ t.Error("state should be unchanged with invalid brightness value")
+ }
+}
+
+func TestUdevMonitor_Close(t *testing.T) {
+ um := &UdevMonitor{stop: make(chan struct{})}
+
+ um.Close()
+
+ select {
+ case <-um.stop:
+ default:
+ t.Error("stop channel should be closed")
+ }
+}
+
+func TestHandleChange_NilSysfsBackend(t *testing.T) {
+ m := &Manager{
+ sysfsBackend: nil,
+ stopChan: make(chan struct{}),
+ }
+ um := &UdevMonitor{stop: make(chan struct{})}
+
+ um.handleChange(m, "backlight", "test_device")
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/browser/handlers.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/browser/handlers.go
new file mode 100644
index 0000000..0b4159a
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/browser/handlers.go
@@ -0,0 +1,22 @@
+package browser
+
+import (
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+)
+
+func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
+ switch req.Method {
+ case "browser.open":
+ url, ok := models.Get[string](req, "url")
+ if !ok {
+ models.RespondError(conn, req.ID, "invalid url parameter")
+ return
+ }
+ manager.RequestOpen(url)
+ models.Respond(conn, req.ID, "ok")
+ default:
+ models.RespondError(conn, req.ID, "unknown method")
+ }
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/browser/manager.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/browser/manager.go
new file mode 100644
index 0000000..5306759
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/browser/manager.go
@@ -0,0 +1,49 @@
+package browser
+
+import (
+ "sync"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
+)
+
+type Manager struct {
+ subscribers syncmap.Map[string, chan OpenEvent]
+ closeOnce sync.Once
+}
+
+func NewManager() *Manager {
+ return &Manager{}
+}
+
+func (m *Manager) Subscribe(id string) chan OpenEvent {
+ ch := make(chan OpenEvent, 16)
+ m.subscribers.Store(id, ch)
+ return ch
+}
+
+func (m *Manager) Unsubscribe(id string) {
+ if val, ok := m.subscribers.LoadAndDelete(id); ok {
+ close(val)
+ }
+}
+
+func (m *Manager) RequestOpen(url string) {
+ event := OpenEvent{URL: url}
+ m.subscribers.Range(func(key string, ch chan OpenEvent) bool {
+ select {
+ case ch <- event:
+ default:
+ }
+ return true
+ })
+}
+
+func (m *Manager) Close() {
+ m.closeOnce.Do(func() {
+ m.subscribers.Range(func(key string, ch chan OpenEvent) bool {
+ close(ch)
+ m.subscribers.Delete(key)
+ return true
+ })
+ })
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/browser/models.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/browser/models.go
new file mode 100644
index 0000000..fc63519
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/browser/models.go
@@ -0,0 +1,5 @@
+package browser
+
+type OpenEvent struct {
+ URL string `json:"url"`
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/clipboard/handlers.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/clipboard/handlers.go
new file mode 100644
index 0000000..1ccc710
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/clipboard/handlers.go
@@ -0,0 +1,325 @@
+package clipboard
+
+import (
+ "encoding/json"
+ "fmt"
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/params"
+)
+
+func HandleRequest(conn net.Conn, req models.Request, m *Manager) {
+ switch req.Method {
+ case "clipboard.getState":
+ handleGetState(conn, req, m)
+ case "clipboard.getHistory":
+ handleGetHistory(conn, req, m)
+ case "clipboard.getEntry":
+ handleGetEntry(conn, req, m)
+ case "clipboard.deleteEntry":
+ handleDeleteEntry(conn, req, m)
+ case "clipboard.clearHistory":
+ handleClearHistory(conn, req, m)
+ case "clipboard.copy":
+ handleCopy(conn, req, m)
+ case "clipboard.copyEntry":
+ handleCopyEntry(conn, req, m)
+ case "clipboard.paste":
+ handlePaste(conn, req, m)
+ case "clipboard.subscribe":
+ handleSubscribe(conn, req, m)
+ case "clipboard.search":
+ handleSearch(conn, req, m)
+ case "clipboard.getConfig":
+ handleGetConfig(conn, req, m)
+ case "clipboard.setConfig":
+ handleSetConfig(conn, req, m)
+ case "clipboard.store":
+ handleStore(conn, req, m)
+ case "clipboard.pinEntry":
+ handlePinEntry(conn, req, m)
+ case "clipboard.unpinEntry":
+ handleUnpinEntry(conn, req, m)
+ case "clipboard.getPinnedEntries":
+ handleGetPinnedEntries(conn, req, m)
+ case "clipboard.getPinnedCount":
+ handleGetPinnedCount(conn, req, m)
+ case "clipboard.copyFile":
+ handleCopyFile(conn, req, m)
+ default:
+ models.RespondError(conn, req.ID, "unknown method: "+req.Method)
+ }
+}
+
+func handleGetState(conn net.Conn, req models.Request, m *Manager) {
+ models.Respond(conn, req.ID, m.GetState())
+}
+
+func handleGetHistory(conn net.Conn, req models.Request, m *Manager) {
+ history := m.GetHistory()
+ for i := range history {
+ history[i].Data = nil
+ }
+ models.Respond(conn, req.ID, history)
+}
+
+func handleGetEntry(conn net.Conn, req models.Request, m *Manager) {
+ id, err := params.Int(req.Params, "id")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ entry, err := m.GetEntry(uint64(id))
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, entry)
+}
+
+func handleDeleteEntry(conn net.Conn, req models.Request, m *Manager) {
+ id, err := params.Int(req.Params, "id")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := m.DeleteEntry(uint64(id)); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "entry deleted"})
+}
+
+func handleClearHistory(conn net.Conn, req models.Request, m *Manager) {
+ m.ClearHistory()
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "history cleared"})
+}
+
+func handleCopy(conn net.Conn, req models.Request, m *Manager) {
+ text, err := params.String(req.Params, "text")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := m.CopyText(text); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "copied to clipboard"})
+}
+
+func handleCopyEntry(conn net.Conn, req models.Request, m *Manager) {
+ id, err := params.Int(req.Params, "id")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ entry, err := m.GetEntry(uint64(id))
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ filePath := m.EntryToFile(entry)
+ if filePath != "" {
+ if err := m.CopyFile(filePath); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, map[string]any{
+ "success": true,
+ "filePath": filePath,
+ })
+ return
+ }
+
+ if err := m.SetClipboard(entry.Data, entry.MimeType); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if entry.Pinned {
+ if err := m.CreateHistoryEntryFromPinned(entry); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ } else {
+ if err := m.TouchEntry(uint64(id)); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "copied to clipboard"})
+}
+
+func handlePaste(conn net.Conn, req models.Request, m *Manager) {
+ text, err := m.PasteText()
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, map[string]string{"text": text})
+}
+
+func handleSubscribe(conn net.Conn, req models.Request, m *Manager) {
+ clientID := fmt.Sprintf("clipboard-%d", req.ID)
+
+ ch := m.Subscribe(clientID)
+ defer m.Unsubscribe(clientID)
+
+ initialState := m.GetState()
+ if err := json.NewEncoder(conn).Encode(models.Response[State]{
+ ID: req.ID,
+ Result: &initialState,
+ }); err != nil {
+ return
+ }
+
+ for state := range ch {
+ if err := json.NewEncoder(conn).Encode(models.Response[State]{
+ ID: req.ID,
+ Result: &state,
+ }); err != nil {
+ return
+ }
+ }
+}
+
+func handleSearch(conn net.Conn, req models.Request, m *Manager) {
+ p := SearchParams{
+ Query: params.StringOpt(req.Params, "query", ""),
+ MimeType: params.StringOpt(req.Params, "mimeType", ""),
+ Limit: params.IntOpt(req.Params, "limit", 50),
+ Offset: params.IntOpt(req.Params, "offset", 0),
+ }
+
+ if img, ok := models.Get[bool](req, "isImage"); ok {
+ p.IsImage = &img
+ }
+ if b, ok := models.Get[float64](req, "before"); ok {
+ v := int64(b)
+ p.Before = &v
+ }
+ if a, ok := models.Get[float64](req, "after"); ok {
+ v := int64(a)
+ p.After = &v
+ }
+
+ models.Respond(conn, req.ID, m.Search(p))
+}
+
+func handleGetConfig(conn net.Conn, req models.Request, m *Manager) {
+ models.Respond(conn, req.ID, m.GetConfig())
+}
+
+func handleSetConfig(conn net.Conn, req models.Request, m *Manager) {
+ cfg := m.GetConfig()
+
+ if v, ok := models.Get[float64](req, "maxHistory"); ok {
+ cfg.MaxHistory = int(v)
+ }
+ if v, ok := models.Get[float64](req, "maxEntrySize"); ok {
+ cfg.MaxEntrySize = int64(v)
+ }
+ if v, ok := models.Get[float64](req, "autoClearDays"); ok {
+ cfg.AutoClearDays = int(v)
+ }
+ if v, ok := models.Get[bool](req, "clearAtStartup"); ok {
+ cfg.ClearAtStartup = v
+ }
+ if v, ok := models.Get[bool](req, "disabled"); ok {
+ cfg.Disabled = v
+ }
+ if v, ok := models.Get[float64](req, "maxPinned"); ok {
+ cfg.MaxPinned = int(v)
+ }
+
+ if err := m.SetConfig(cfg); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "config updated"})
+}
+
+func handleStore(conn net.Conn, req models.Request, m *Manager) {
+ data, err := params.String(req.Params, "data")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ mimeType := params.StringOpt(req.Params, "mimeType", "text/plain;charset=utf-8")
+
+ if err := m.StoreData([]byte(data), mimeType); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "stored"})
+}
+
+func handlePinEntry(conn net.Conn, req models.Request, m *Manager) {
+ id, err := params.Int(req.Params, "id")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := m.PinEntry(uint64(id)); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "entry pinned"})
+}
+
+func handleUnpinEntry(conn net.Conn, req models.Request, m *Manager) {
+ id, err := params.Int(req.Params, "id")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := m.UnpinEntry(uint64(id)); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "entry unpinned"})
+}
+
+func handleGetPinnedEntries(conn net.Conn, req models.Request, m *Manager) {
+ pinned := m.GetPinnedEntries()
+ models.Respond(conn, req.ID, pinned)
+}
+
+func handleGetPinnedCount(conn net.Conn, req models.Request, m *Manager) {
+ count := m.GetPinnedCount()
+ models.Respond(conn, req.ID, map[string]int{"count": count})
+}
+
+func handleCopyFile(conn net.Conn, req models.Request, m *Manager) {
+ filePath, err := params.String(req.Params, "filePath")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := m.CopyFile(filePath); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "copied"})
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/clipboard/manager.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/clipboard/manager.go
new file mode 100644
index 0000000..f5f528b
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/clipboard/manager.go
@@ -0,0 +1,1911 @@
+package clipboard
+
+import (
+ "bytes"
+ "encoding/binary"
+ "fmt"
+ "image"
+ _ "image/gif"
+ _ "image/jpeg"
+ _ "image/png"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "slices"
+ "strings"
+ "syscall"
+ "time"
+
+ "hash/fnv"
+
+ "github.com/fsnotify/fsnotify"
+ "github.com/godbus/dbus/v5"
+ _ "golang.org/x/image/bmp"
+ _ "golang.org/x/image/tiff"
+ _ "golang.org/x/image/webp"
+
+ bolt "go.etcd.io/bbolt"
+
+ clipboardstore "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext"
+ wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
+)
+
+// These mime types won't be stored in history
+var sensitiveMimeTypes = []string{
+ "x-kde-passwordManagerHint",
+}
+
+func NewManager(wlCtx wlcontext.WaylandContext, config Config) (*Manager, error) {
+ display := wlCtx.Display()
+ dbPath, err := clipboardstore.GetDBPath()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get db path: %w", err)
+ }
+
+ configPath, _ := getConfigPath()
+
+ m := &Manager{
+ config: config,
+ configPath: configPath,
+ display: display,
+ wlCtx: wlCtx,
+ stopChan: make(chan struct{}),
+ subscribers: make(map[string]chan State),
+ dirty: make(chan struct{}, 1),
+ offerMimeTypes: make(map[any][]string),
+ offerRegistry: make(map[uint32]any),
+ dbPath: dbPath,
+ }
+
+ if !config.Disabled {
+ if err := m.setupRegistry(); err != nil {
+ return nil, err
+ }
+ }
+
+ m.notifierWg.Add(1)
+ go m.notifier()
+
+ go m.watchConfig()
+
+ db, err := openDB(dbPath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to open db: %w", err)
+ }
+ m.db = db
+
+ if err := m.migrateHashes(); err != nil {
+ log.Errorf("Failed to migrate hashes: %v", err)
+ }
+
+ if !config.Disabled {
+ if config.ClearAtStartup {
+ if err := m.clearHistoryInternal(); err != nil {
+ log.Errorf("Failed to clear history at startup: %v", err)
+ }
+ }
+
+ if config.AutoClearDays > 0 {
+ if err := m.clearOldEntries(config.AutoClearDays); err != nil {
+ log.Errorf("Failed to clear old entries: %v", err)
+ }
+ }
+ }
+
+ m.alive = true
+ m.updateState()
+
+ if !config.Disabled && m.dataControlMgr != nil && m.seat != nil {
+ m.setupDataDeviceSync()
+ }
+
+ return m, nil
+}
+
+func openDB(path string) (*bolt.DB, error) {
+ db, err := bolt.Open(path, 0o644, &bolt.Options{
+ Timeout: 1 * time.Second,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ err = db.Update(func(tx *bolt.Tx) error {
+ _, err := tx.CreateBucketIfNotExists([]byte("clipboard"))
+ return err
+ })
+ if err != nil {
+ db.Close()
+ return nil, err
+ }
+
+ return db, nil
+}
+
+func (m *Manager) post(fn func()) {
+ m.wlCtx.Post(fn)
+}
+
+func (m *Manager) setupRegistry() error {
+ ctx := m.display.Context()
+
+ registry, err := m.display.GetRegistry()
+ if err != nil {
+ return fmt.Errorf("failed to get registry: %w", err)
+ }
+ m.registry = registry
+
+ registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
+ switch e.Interface {
+ case "ext_data_control_manager_v1":
+ if e.Version < 1 {
+ return
+ }
+ dataControlMgr := ext_data_control.NewExtDataControlManagerV1(ctx)
+ if err := registry.Bind(e.Name, e.Interface, e.Version, dataControlMgr); err != nil {
+ log.Errorf("Failed to bind ext_data_control_manager_v1: %v", err)
+ return
+ }
+ m.dataControlMgr = dataControlMgr
+ log.Info("Bound ext_data_control_manager_v1")
+ case "wl_seat":
+ seat := wlclient.NewSeat(ctx)
+ if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil {
+ log.Errorf("Failed to bind wl_seat: %v", err)
+ return
+ }
+ m.seat = seat
+ m.seatName = e.Name
+ log.Info("Bound wl_seat")
+ }
+ })
+
+ m.display.Roundtrip()
+ m.display.Roundtrip()
+
+ if m.dataControlMgr == nil {
+ return fmt.Errorf("compositor does not support ext_data_control_manager_v1")
+ }
+
+ if m.seat == nil {
+ return fmt.Errorf("no seat available")
+ }
+
+ return nil
+}
+
+func (m *Manager) setupDataDeviceSync() {
+ if m.dataControlMgr == nil || m.seat == nil {
+ return
+ }
+
+ ctx := m.display.Context()
+ dataMgr := m.dataControlMgr.(*ext_data_control.ExtDataControlManagerV1)
+
+ dataDevice := ext_data_control.NewExtDataControlDeviceV1(ctx)
+
+ dataDevice.SetDataOfferHandler(func(e ext_data_control.ExtDataControlDeviceV1DataOfferEvent) {
+ if e.Id == nil {
+ return
+ }
+
+ m.offerMutex.Lock()
+ m.offerRegistry[e.Id.ID()] = e.Id
+ m.offerMimeTypes[e.Id] = make([]string, 0)
+ m.offerMutex.Unlock()
+
+ e.Id.SetOfferHandler(func(me ext_data_control.ExtDataControlOfferV1OfferEvent) {
+ m.offerMutex.Lock()
+ m.offerMimeTypes[e.Id] = append(m.offerMimeTypes[e.Id], me.MimeType)
+ m.offerMutex.Unlock()
+ })
+ })
+
+ dataDevice.SetSelectionHandler(func(e ext_data_control.ExtDataControlDeviceV1SelectionEvent) {
+ if !m.initialized {
+ m.initialized = true
+ return
+ }
+
+ var offer any
+ if e.Id != nil {
+ offer = e.Id
+ } else if e.OfferId != 0 {
+ m.offerMutex.RLock()
+ offer = m.offerRegistry[e.OfferId]
+ m.offerMutex.RUnlock()
+ }
+
+ m.ownerLock.Lock()
+ wasOwner := m.isOwner
+ m.ownerLock.Unlock()
+
+ if offer == nil {
+ return
+ }
+
+ if wasOwner {
+ return
+ }
+
+ prevOffer := m.currentOffer
+ m.currentOffer = offer
+
+ if prevOffer != nil && prevOffer != offer {
+ m.offerMutex.Lock()
+ delete(m.offerMimeTypes, prevOffer)
+ m.offerMutex.Unlock()
+ }
+
+ m.offerMutex.RLock()
+ mimes := m.offerMimeTypes[offer]
+ m.offerMutex.RUnlock()
+
+ m.mimeTypes = mimes
+
+ if len(mimes) == 0 {
+ return
+ }
+
+ if m.hasSensitiveMimeType(mimes) {
+ return
+ }
+
+ preferredMime := m.selectMimeType(mimes)
+ if preferredMime == "" {
+ return
+ }
+
+ typedOffer := offer.(*ext_data_control.ExtDataControlOfferV1)
+
+ r, w, err := os.Pipe()
+ if err != nil {
+ return
+ }
+
+ if err := typedOffer.Receive(preferredMime, int(w.Fd())); err != nil {
+ r.Close()
+ w.Close()
+ return
+ }
+ w.Close()
+
+ go m.readAndStore(r, preferredMime)
+ })
+
+ if err := dataMgr.GetDataDeviceWithProxy(dataDevice, m.seat); err != nil {
+ log.Errorf("Failed to send get_data_device request: %v", err)
+ return
+ }
+
+ m.dataDevice = dataDevice
+
+ if err := ctx.Dispatch(); err != nil {
+ log.Errorf("Failed to dispatch initial events: %v", err)
+ return
+ }
+
+ log.Info("Data device setup complete")
+}
+
+func (m *Manager) readAndStore(r *os.File, mimeType string) {
+ defer r.Close()
+
+ cfg := m.getConfig()
+
+ done := make(chan []byte, 1)
+ go func() {
+ data, _ := io.ReadAll(r)
+ done <- data
+ }()
+
+ var data []byte
+ select {
+ case data = <-done:
+ case <-time.After(500 * time.Millisecond):
+ return
+ }
+
+ if len(data) == 0 || int64(len(data)) > cfg.MaxEntrySize {
+ return
+ }
+ if len(bytes.TrimSpace(data)) == 0 {
+ return
+ }
+
+ if !cfg.Disabled && m.db != nil {
+ m.storeClipboardEntry(data, mimeType)
+ }
+
+ m.updateState()
+ m.notifySubscribers()
+}
+
+func (m *Manager) storeClipboardEntry(data []byte, mimeType string) {
+ if mimeType == "text/uri-list" {
+ if imgData, imgMime, ok := m.tryReadImageFromURI(data); ok {
+ data = imgData
+ mimeType = imgMime
+ }
+ }
+
+ entry := Entry{
+ Data: data,
+ MimeType: mimeType,
+ Size: len(data),
+ Timestamp: time.Now(),
+ IsImage: m.isImageMimeType(mimeType),
+ }
+
+ switch {
+ case entry.IsImage:
+ entry.Preview = m.imagePreview(data, mimeType)
+ case mimeType == "text/uri-list":
+ entry.Preview, entry.IsImage = m.uriListPreview(data)
+ default:
+ entry.Preview = m.textPreview(data)
+ }
+
+ if err := m.storeEntry(entry); err != nil {
+ log.Errorf("Failed to store clipboard entry: %v", err)
+ }
+}
+
+func (m *Manager) storeEntry(entry Entry) error {
+ if m.db == nil {
+ return fmt.Errorf("database not available")
+ }
+
+ entry.Hash = computeHash(entry.Data)
+
+ return m.db.Update(func(tx *bolt.Tx) error {
+ b := tx.Bucket([]byte("clipboard"))
+
+ if err := m.deduplicateInTx(b, entry.Hash); err != nil {
+ return err
+ }
+
+ id, err := b.NextSequence()
+ if err != nil {
+ return err
+ }
+
+ entry.ID = id
+
+ encoded, err := encodeEntry(entry)
+ if err != nil {
+ return err
+ }
+
+ if err := b.Put(itob(id), encoded); err != nil {
+ return err
+ }
+
+ return m.trimLengthInTx(b)
+ })
+}
+
+func (m *Manager) deduplicateInTx(b *bolt.Bucket, hash uint64) error {
+ c := b.Cursor()
+ for k, v := c.Last(); k != nil; k, v = c.Prev() {
+ if extractHash(v) != hash {
+ continue
+ }
+ entry, err := decodeEntry(v)
+ if err == nil && entry.Pinned {
+ continue
+ }
+ if err := b.Delete(k); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (m *Manager) trimLengthInTx(b *bolt.Bucket) error {
+ if m.config.MaxHistory < 0 {
+ return nil
+ }
+ c := b.Cursor()
+ var count int
+ for k, v := c.Last(); k != nil; k, v = c.Prev() {
+ entry, err := decodeEntry(v)
+ if err == nil && entry.Pinned {
+ continue
+ }
+ if count < m.config.MaxHistory {
+ count++
+ continue
+ }
+ if err := b.Delete(k); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func encodeEntry(e Entry) ([]byte, error) {
+ buf := new(bytes.Buffer)
+
+ binary.Write(buf, binary.BigEndian, e.ID)
+ binary.Write(buf, binary.BigEndian, uint32(len(e.Data)))
+ buf.Write(e.Data)
+ binary.Write(buf, binary.BigEndian, uint32(len(e.MimeType)))
+ buf.WriteString(e.MimeType)
+ binary.Write(buf, binary.BigEndian, uint32(len(e.Preview)))
+ buf.WriteString(e.Preview)
+ binary.Write(buf, binary.BigEndian, int32(e.Size))
+ binary.Write(buf, binary.BigEndian, e.Timestamp.Unix())
+ if e.IsImage {
+ buf.WriteByte(1)
+ } else {
+ buf.WriteByte(0)
+ }
+ binary.Write(buf, binary.BigEndian, e.Hash)
+ if e.Pinned {
+ buf.WriteByte(1)
+ } else {
+ buf.WriteByte(0)
+ }
+
+ return buf.Bytes(), nil
+}
+
+func decodeEntry(data []byte) (Entry, error) {
+ buf := bytes.NewReader(data)
+ var e Entry
+
+ binary.Read(buf, binary.BigEndian, &e.ID)
+
+ var dataLen uint32
+ binary.Read(buf, binary.BigEndian, &dataLen)
+ e.Data = make([]byte, dataLen)
+ buf.Read(e.Data)
+
+ var mimeLen uint32
+ binary.Read(buf, binary.BigEndian, &mimeLen)
+ mimeBytes := make([]byte, mimeLen)
+ buf.Read(mimeBytes)
+ e.MimeType = string(mimeBytes)
+
+ var prevLen uint32
+ binary.Read(buf, binary.BigEndian, &prevLen)
+ prevBytes := make([]byte, prevLen)
+ buf.Read(prevBytes)
+ e.Preview = string(prevBytes)
+
+ var size int32
+ binary.Read(buf, binary.BigEndian, &size)
+ e.Size = int(size)
+
+ var timestamp int64
+ binary.Read(buf, binary.BigEndian, &timestamp)
+ e.Timestamp = time.Unix(timestamp, 0)
+
+ var isImage byte
+ binary.Read(buf, binary.BigEndian, &isImage)
+ e.IsImage = isImage == 1
+
+ if buf.Len() >= 8 {
+ binary.Read(buf, binary.BigEndian, &e.Hash)
+ }
+
+ if buf.Len() >= 1 {
+ var pinnedByte byte
+ binary.Read(buf, binary.BigEndian, &pinnedByte)
+ e.Pinned = pinnedByte == 1
+ }
+
+ return e, nil
+}
+
+func itob(v uint64) []byte {
+ b := make([]byte, 8)
+ binary.BigEndian.PutUint64(b, v)
+ return b
+}
+
+func computeHash(data []byte) uint64 {
+ h := fnv.New64a()
+ h.Write(data)
+ return h.Sum64()
+}
+
+func extractHash(data []byte) uint64 {
+ if len(data) < 9 {
+ return 0
+ }
+ return binary.BigEndian.Uint64(data[len(data)-9 : len(data)-1])
+}
+
+func (m *Manager) hasSensitiveMimeType(mimes []string) bool {
+ return slices.ContainsFunc(mimes, func(mime string) bool {
+ return slices.Contains(sensitiveMimeTypes, mime)
+ })
+}
+
+func (m *Manager) selectMimeType(mimes []string) string {
+ preferredTypes := []string{
+ "text/uri-list",
+ "text/plain;charset=utf-8",
+ "text/plain",
+ "UTF8_STRING",
+ "STRING",
+ "TEXT",
+ "image/png",
+ "image/jpeg",
+ "image/gif",
+ "image/bmp",
+ "image/tiff",
+ }
+
+ for _, pref := range preferredTypes {
+ for _, mime := range mimes {
+ if mime == pref {
+ return mime
+ }
+ }
+ }
+
+ // Skip useless MIME types when falling back
+ for _, mime := range mimes {
+ switch mime {
+ case "application/vnd.portal.filetransfer":
+ continue
+ default:
+ return mime
+ }
+ }
+
+ return ""
+}
+
+func (m *Manager) isImageMimeType(mime string) bool {
+ return strings.HasPrefix(mime, "image/")
+}
+
+func (m *Manager) textPreview(data []byte) string {
+ text := string(data)
+ text = strings.TrimSpace(text)
+ text = strings.Join(strings.Fields(text), " ")
+
+ if len(text) > 100 {
+ return text[:100] + "…"
+ }
+ return text
+}
+
+func (m *Manager) imagePreview(data []byte, format string) string {
+ config, imgFmt, err := image.DecodeConfig(bytes.NewReader(data))
+ if err != nil {
+ return fmt.Sprintf("[[ image %s %s ]]", sizeStr(len(data)), format)
+ }
+ return fmt.Sprintf("[[ image %s %s %dx%d ]]", sizeStr(len(data)), imgFmt, config.Width, config.Height)
+}
+
+func (m *Manager) uriListPreview(data []byte) (string, bool) {
+ text := strings.TrimSpace(string(data))
+ uris := strings.Split(text, "\r\n")
+ if len(uris) == 0 {
+ uris = strings.Split(text, "\n")
+ }
+
+ if len(uris) > 1 {
+ return fmt.Sprintf("[[ %d files ]]", len(uris)), false
+ }
+
+ if len(uris) == 1 && strings.HasPrefix(uris[0], "file://") {
+ filePath := strings.TrimPrefix(uris[0], "file://")
+ info, err := os.Stat(filePath)
+ if err != nil || info.IsDir() {
+ return m.textPreview(data), false
+ }
+
+ cfg := m.getConfig()
+ if info.Size() <= cfg.MaxEntrySize {
+ if imgData, err := os.ReadFile(filePath); err == nil {
+ if config, imgFmt, err := image.DecodeConfig(bytes.NewReader(imgData)); err == nil {
+ return fmt.Sprintf("[[ file %s %s %dx%d ]]", filepath.Base(filePath), imgFmt, config.Width, config.Height), true
+ }
+ }
+ }
+ return fmt.Sprintf("[[ file %s ]]", filepath.Base(filePath)), false
+ }
+
+ return m.textPreview(data), false
+}
+
+func (m *Manager) tryReadImageFromURI(data []byte) ([]byte, string, bool) {
+ text := strings.TrimSpace(string(data))
+ uris := strings.Split(text, "\r\n")
+ if len(uris) == 0 {
+ uris = strings.Split(text, "\n")
+ }
+
+ if len(uris) != 1 || !strings.HasPrefix(uris[0], "file://") {
+ return nil, "", false
+ }
+
+ filePath := strings.TrimPrefix(uris[0], "file://")
+ info, err := os.Stat(filePath)
+ if err != nil || info.IsDir() {
+ return nil, "", false
+ }
+
+ cfg := m.getConfig()
+ if info.Size() > cfg.MaxEntrySize {
+ return nil, "", false
+ }
+
+ imgData, err := os.ReadFile(filePath)
+ if err != nil {
+ return nil, "", false
+ }
+
+ _, imgFmt, err := image.DecodeConfig(bytes.NewReader(imgData))
+ if err != nil {
+ return nil, "", false
+ }
+
+ return imgData, "image/" + imgFmt, true
+}
+
+func sizeStr(size int) string {
+ units := []string{"B", "KiB", "MiB"}
+ var i int
+ fsize := float64(size)
+ for fsize >= 1024 && i < len(units)-1 {
+ fsize /= 1024
+ i++
+ }
+ return fmt.Sprintf("%.0f %s", fsize, units[i])
+}
+
+func (m *Manager) updateState() {
+ history := m.GetHistory()
+
+ for i := range history {
+ history[i].Data = nil
+ }
+
+ var current *Entry
+ if len(history) > 0 {
+ c := history[0]
+ c.Data = nil
+ current = &c
+ }
+
+ newState := &State{
+ Enabled: m.alive,
+ History: history,
+ Current: current,
+ }
+
+ m.stateMutex.Lock()
+ m.state = newState
+ m.stateMutex.Unlock()
+}
+
+func (m *Manager) notifier() {
+ defer m.notifierWg.Done()
+
+ for range m.dirty {
+ state := m.GetState()
+
+ if m.lastState != nil && stateEqual(m.lastState, &state) {
+ continue
+ }
+
+ m.lastState = &state
+
+ m.subMutex.RLock()
+ subs := make([]chan State, 0, len(m.subscribers))
+ for _, ch := range m.subscribers {
+ subs = append(subs, ch)
+ }
+ m.subMutex.RUnlock()
+
+ for _, ch := range subs {
+ select {
+ case ch <- state:
+ default:
+ }
+ }
+ }
+}
+
+func stateEqual(a, b *State) bool {
+ if a == nil || b == nil {
+ return false
+ }
+ if a.Enabled != b.Enabled {
+ return false
+ }
+ if len(a.History) != len(b.History) {
+ return false
+ }
+ return true
+}
+
+func (m *Manager) GetHistory() []Entry {
+ if m.db == nil {
+ return nil
+ }
+
+ cfg := m.getConfig()
+ var cutoff time.Time
+ if cfg.AutoClearDays > 0 {
+ cutoff = time.Now().AddDate(0, 0, -cfg.AutoClearDays)
+ }
+
+ var history []Entry
+ var stale []uint64
+
+ if err := m.db.View(func(tx *bolt.Tx) error {
+ b := tx.Bucket([]byte("clipboard"))
+ c := b.Cursor()
+
+ for k, v := c.Last(); k != nil; k, v = c.Prev() {
+ entry, err := decodeEntry(v)
+ if err != nil {
+ continue
+ }
+ if !cutoff.IsZero() && entry.Timestamp.Before(cutoff) {
+ stale = append(stale, entry.ID)
+ continue
+ }
+ history = append(history, entry)
+ }
+ return nil
+ }); err != nil {
+ log.Errorf("Failed to read clipboard history: %v", err)
+ }
+
+ if len(stale) > 0 {
+ go m.deleteStaleEntries(stale)
+ }
+
+ return history
+}
+
+func (m *Manager) deleteStaleEntries(ids []uint64) {
+ if m.db == nil {
+ return
+ }
+
+ if err := m.db.Update(func(tx *bolt.Tx) error {
+ b := tx.Bucket([]byte("clipboard"))
+ for _, id := range ids {
+ if err := b.Delete(itob(id)); err != nil {
+ log.Errorf("Failed to delete stale entry %d: %v", id, err)
+ }
+ }
+ return nil
+ }); err != nil {
+ log.Errorf("Failed to delete stale entries: %v", err)
+ }
+}
+
+func (m *Manager) GetEntry(id uint64) (*Entry, error) {
+ if m.db == nil {
+ return nil, fmt.Errorf("database not available")
+ }
+
+ var entry Entry
+ var found bool
+
+ err := m.db.View(func(tx *bolt.Tx) error {
+ b := tx.Bucket([]byte("clipboard"))
+ v := b.Get(itob(id))
+ if v == nil {
+ return nil
+ }
+
+ var err error
+ entry, err = decodeEntry(v)
+ if err != nil {
+ return err
+ }
+ found = true
+ return nil
+ })
+
+ if err != nil {
+ return nil, err
+ }
+ if !found {
+ return nil, fmt.Errorf("entry not found")
+ }
+
+ return &entry, nil
+}
+
+func (m *Manager) DeleteEntry(id uint64) error {
+ if m.db == nil {
+ return fmt.Errorf("database not available")
+ }
+
+ err := m.db.Update(func(tx *bolt.Tx) error {
+ b := tx.Bucket([]byte("clipboard"))
+ return b.Delete(itob(id))
+ })
+
+ if err == nil {
+ m.updateState()
+ m.notifySubscribers()
+ }
+
+ return err
+}
+
+func (m *Manager) TouchEntry(id uint64) error {
+ if m.db == nil {
+ return fmt.Errorf("database not available")
+ }
+
+ entry, err := m.GetEntry(id)
+ if err != nil {
+ return err
+ }
+
+ entry.Timestamp = time.Now()
+
+ if err := m.storeEntry(*entry); err != nil {
+ return err
+ }
+
+ m.updateState()
+ m.notifySubscribers()
+
+ return nil
+}
+
+func (m *Manager) CreateHistoryEntryFromPinned(pinnedEntry *Entry) error {
+ if m.db == nil {
+ return fmt.Errorf("database not available")
+ }
+
+ // Create a new unpinned entry with the same data
+ newEntry := Entry{
+ Data: pinnedEntry.Data,
+ MimeType: pinnedEntry.MimeType,
+ Size: pinnedEntry.Size,
+ Timestamp: time.Now(),
+ IsImage: pinnedEntry.IsImage,
+ Preview: pinnedEntry.Preview,
+ Pinned: false,
+ }
+
+ if err := m.storeEntryWithoutDedup(newEntry); err != nil {
+ return err
+ }
+
+ m.updateState()
+ m.notifySubscribers()
+
+ return nil
+}
+
+func (m *Manager) storeEntryWithoutDedup(entry Entry) error {
+ if m.db == nil {
+ return fmt.Errorf("database not available")
+ }
+
+ entry.Hash = computeHash(entry.Data)
+
+ return m.db.Update(func(tx *bolt.Tx) error {
+ b := tx.Bucket([]byte("clipboard"))
+
+ id, err := b.NextSequence()
+ if err != nil {
+ return err
+ }
+
+ entry.ID = id
+
+ encoded, err := encodeEntry(entry)
+ if err != nil {
+ return err
+ }
+
+ if err := b.Put(itob(id), encoded); err != nil {
+ return err
+ }
+
+ return m.trimLengthInTx(b)
+ })
+}
+
+func (m *Manager) ClearHistory() {
+ if m.db == nil {
+ return
+ }
+
+ // Delete only non-pinned entries
+ if err := m.db.Update(func(tx *bolt.Tx) error {
+ b := tx.Bucket([]byte("clipboard"))
+ if b == nil {
+ return nil
+ }
+
+ var toDelete [][]byte
+ c := b.Cursor()
+ for k, v := c.First(); k != nil; k, v = c.Next() {
+ entry, err := decodeEntry(v)
+ if err != nil || !entry.Pinned {
+ toDelete = append(toDelete, k)
+ }
+ }
+
+ for _, k := range toDelete {
+ if err := b.Delete(k); err != nil {
+ return err
+ }
+ }
+ return nil
+ }); err != nil {
+ log.Errorf("Failed to clear clipboard history: %v", err)
+ return
+ }
+
+ pinnedCount := 0
+ if err := m.db.View(func(tx *bolt.Tx) error {
+ b := tx.Bucket([]byte("clipboard"))
+ if b != nil {
+ c := b.Cursor()
+ for k, v := c.First(); k != nil; k, v = c.Next() {
+ entry, _ := decodeEntry(v)
+ if entry.Pinned {
+ pinnedCount++
+ }
+ }
+ }
+ return nil
+ }); err != nil {
+ log.Errorf("Failed to count pinned entries: %v", err)
+ }
+
+ if pinnedCount == 0 {
+ if err := m.compactDB(); err != nil {
+ log.Errorf("Failed to compact database: %v", err)
+ }
+ }
+
+ m.updateState()
+ m.notifySubscribers()
+}
+
+func (m *Manager) compactDB() error {
+ m.db.Close()
+
+ tmpPath := m.dbPath + ".compact"
+ defer os.Remove(tmpPath)
+
+ srcDB, err := bolt.Open(m.dbPath, 0o644, &bolt.Options{ReadOnly: true, Timeout: time.Second})
+ if err != nil {
+ m.db, _ = bolt.Open(m.dbPath, 0o644, &bolt.Options{Timeout: time.Second})
+ return fmt.Errorf("open source: %w", err)
+ }
+
+ dstDB, err := bolt.Open(tmpPath, 0o644, &bolt.Options{Timeout: time.Second})
+ if err != nil {
+ srcDB.Close()
+ m.db, _ = bolt.Open(m.dbPath, 0o644, &bolt.Options{Timeout: time.Second})
+ return fmt.Errorf("open destination: %w", err)
+ }
+
+ if err := bolt.Compact(dstDB, srcDB, 0); err != nil {
+ srcDB.Close()
+ dstDB.Close()
+ m.db, _ = bolt.Open(m.dbPath, 0o644, &bolt.Options{Timeout: time.Second})
+ return fmt.Errorf("compact: %w", err)
+ }
+
+ srcDB.Close()
+ dstDB.Close()
+
+ if err := os.Rename(tmpPath, m.dbPath); err != nil {
+ m.db, _ = bolt.Open(m.dbPath, 0o644, &bolt.Options{Timeout: time.Second})
+ return fmt.Errorf("rename: %w", err)
+ }
+
+ m.db, err = bolt.Open(m.dbPath, 0o644, &bolt.Options{Timeout: time.Second})
+ if err != nil {
+ return fmt.Errorf("reopen: %w", err)
+ }
+
+ return nil
+}
+
+func (m *Manager) SetClipboard(data []byte, mimeType string) error {
+ if int64(len(data)) > m.config.MaxEntrySize {
+ return fmt.Errorf("data too large")
+ }
+
+ dataCopy := make([]byte, len(data))
+ copy(dataCopy, data)
+
+ m.post(func() {
+ if m.dataControlMgr == nil || m.dataDevice == nil {
+ log.Error("Data control manager or device not initialized")
+ return
+ }
+
+ dataMgr := m.dataControlMgr.(*ext_data_control.ExtDataControlManagerV1)
+
+ source, err := dataMgr.CreateDataSource()
+ if err != nil {
+ log.Errorf("Failed to create data source: %v", err)
+ return
+ }
+
+ if err := source.Offer(mimeType); err != nil {
+ log.Errorf("Failed to offer mime type: %v", err)
+ return
+ }
+
+ source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) {
+ fd := e.Fd
+ defer syscall.Close(fd)
+
+ file := os.NewFile(uintptr(fd), "clipboard-pipe")
+ defer file.Close()
+
+ if _, err := file.Write(dataCopy); err != nil {
+ log.Errorf("Failed to write clipboard data: %v", err)
+ }
+ })
+
+ source.SetCancelledHandler(func(e ext_data_control.ExtDataControlSourceV1CancelledEvent) {
+ m.ownerLock.Lock()
+ m.isOwner = false
+ m.ownerLock.Unlock()
+ })
+
+ m.currentSource = source
+ m.sourceMutex.Lock()
+ m.sourceMimeTypes = []string{mimeType}
+ m.sourceMutex.Unlock()
+
+ m.ownerLock.Lock()
+ m.isOwner = true
+ m.ownerLock.Unlock()
+
+ device := m.dataDevice.(*ext_data_control.ExtDataControlDeviceV1)
+ if err := device.SetSelection(source); err != nil {
+ log.Errorf("Failed to set selection: %v", err)
+ }
+ })
+
+ return nil
+}
+
+func (m *Manager) CopyText(text string) error {
+ if err := m.SetClipboard([]byte(text), "text/plain;charset=utf-8"); err != nil {
+ return err
+ }
+
+ entry := Entry{
+ Data: []byte(text),
+ MimeType: "text/plain;charset=utf-8",
+ Size: len(text),
+ Timestamp: time.Now(),
+ IsImage: false,
+ Preview: m.textPreview([]byte(text)),
+ }
+
+ if err := m.storeEntry(entry); err != nil {
+ log.Errorf("Failed to store clipboard entry: %v", err)
+ }
+
+ m.updateState()
+ m.notifySubscribers()
+
+ return nil
+}
+
+func (m *Manager) PasteText() (string, error) {
+ history := m.GetHistory()
+ if len(history) == 0 {
+ return "", fmt.Errorf("no clipboard data available")
+ }
+
+ entry := history[0]
+ if entry.IsImage {
+ return "", fmt.Errorf("clipboard contains image, not text")
+ }
+
+ fullEntry, err := m.GetEntry(entry.ID)
+ if err != nil {
+ return "", err
+ }
+
+ return string(fullEntry.Data), nil
+}
+
+func (m *Manager) Close() {
+ if !m.alive {
+ return
+ }
+
+ m.alive = false
+ close(m.stopChan)
+
+ close(m.dirty)
+ m.notifierWg.Wait()
+
+ m.subMutex.Lock()
+ for _, ch := range m.subscribers {
+ close(ch)
+ }
+ m.subscribers = make(map[string]chan State)
+ m.subMutex.Unlock()
+
+ if m.currentSource != nil {
+ source := m.currentSource.(*ext_data_control.ExtDataControlSourceV1)
+ source.Destroy()
+ }
+
+ if m.dataDevice != nil {
+ device := m.dataDevice.(*ext_data_control.ExtDataControlDeviceV1)
+ device.Destroy()
+ }
+
+ if m.dataControlMgr != nil {
+ mgr := m.dataControlMgr.(*ext_data_control.ExtDataControlManagerV1)
+ mgr.Destroy()
+ }
+
+ if m.registry != nil {
+ m.registry.Destroy()
+ }
+
+ if m.db != nil {
+ m.db.Close()
+ }
+}
+
+func (m *Manager) clearHistoryInternal() error {
+ return m.db.Update(func(tx *bolt.Tx) error {
+ if err := tx.DeleteBucket([]byte("clipboard")); err != nil {
+ return err
+ }
+ _, err := tx.CreateBucket([]byte("clipboard"))
+ return err
+ })
+}
+
+func (m *Manager) clearOldEntries(days int) error {
+ cutoff := time.Now().AddDate(0, 0, -days)
+
+ return m.db.Update(func(tx *bolt.Tx) error {
+ b := tx.Bucket([]byte("clipboard"))
+ if b == nil {
+ return nil
+ }
+
+ var toDelete [][]byte
+ c := b.Cursor()
+ for k, v := c.First(); k != nil; k, v = c.Next() {
+ entry, err := decodeEntry(v)
+ if err != nil {
+ continue
+ }
+ // Skip pinned entries
+ if entry.Pinned {
+ continue
+ }
+ if entry.Timestamp.Before(cutoff) {
+ toDelete = append(toDelete, k)
+ }
+ }
+
+ for _, k := range toDelete {
+ if err := b.Delete(k); err != nil {
+ return err
+ }
+ }
+ return nil
+ })
+}
+
+func (m *Manager) migrateHashes() error {
+ if m.db == nil {
+ return nil
+ }
+
+ var needsMigration bool
+ if err := m.db.View(func(tx *bolt.Tx) error {
+ b := tx.Bucket([]byte("clipboard"))
+ if b == nil {
+ return nil
+ }
+ c := b.Cursor()
+ for k, v := c.First(); k != nil; k, v = c.Next() {
+ if extractHash(v) == 0 {
+ needsMigration = true
+ return nil
+ }
+ }
+ return nil
+ }); err != nil {
+ return err
+ }
+
+ if !needsMigration {
+ return nil
+ }
+
+ log.Info("Migrating clipboard entries to add hashes...")
+
+ return m.db.Update(func(tx *bolt.Tx) error {
+ b := tx.Bucket([]byte("clipboard"))
+ if b == nil {
+ return nil
+ }
+
+ var updates []struct {
+ key []byte
+ entry Entry
+ }
+
+ c := b.Cursor()
+ for k, v := c.First(); k != nil; k, v = c.Next() {
+ entry, err := decodeEntry(v)
+ if err != nil {
+ continue
+ }
+ if entry.Hash != 0 {
+ continue
+ }
+ entry.Hash = computeHash(entry.Data)
+ keyCopy := make([]byte, len(k))
+ copy(keyCopy, k)
+ updates = append(updates, struct {
+ key []byte
+ entry Entry
+ }{keyCopy, entry})
+ }
+
+ for _, u := range updates {
+ encoded, err := encodeEntry(u.entry)
+ if err != nil {
+ continue
+ }
+ if err := b.Put(u.key, encoded); err != nil {
+ return err
+ }
+ }
+
+ log.Infof("Migrated %d clipboard entries", len(updates))
+ return nil
+ })
+}
+
+func (m *Manager) Search(params SearchParams) SearchResult {
+ if m.db == nil {
+ return SearchResult{}
+ }
+
+ if params.Limit <= 0 {
+ params.Limit = 50
+ }
+ if params.Limit > 500 {
+ params.Limit = 500
+ }
+
+ query := strings.ToLower(params.Query)
+ mimeFilter := strings.ToLower(params.MimeType)
+
+ var all []Entry
+ if err := m.db.View(func(tx *bolt.Tx) error {
+ b := tx.Bucket([]byte("clipboard"))
+ if b == nil {
+ return nil
+ }
+
+ c := b.Cursor()
+ for k, v := c.Last(); k != nil; k, v = c.Prev() {
+ entry, err := decodeEntry(v)
+ if err != nil {
+ continue
+ }
+
+ if params.IsImage != nil && entry.IsImage != *params.IsImage {
+ continue
+ }
+
+ if mimeFilter != "" && !strings.Contains(strings.ToLower(entry.MimeType), mimeFilter) {
+ continue
+ }
+
+ if params.Before != nil && entry.Timestamp.Unix() >= *params.Before {
+ continue
+ }
+
+ if params.After != nil && entry.Timestamp.Unix() <= *params.After {
+ continue
+ }
+
+ if query != "" && !strings.Contains(strings.ToLower(entry.Preview), query) {
+ continue
+ }
+
+ entry.Data = nil
+ all = append(all, entry)
+ }
+ return nil
+ }); err != nil {
+ log.Errorf("Search failed: %v", err)
+ }
+
+ total := len(all)
+
+ start := params.Offset
+ if start > total {
+ start = total
+ }
+ end := start + params.Limit
+ if end > total {
+ end = total
+ }
+
+ return SearchResult{
+ Entries: all[start:end],
+ Total: total,
+ HasMore: end < total,
+ }
+}
+
+func (m *Manager) GetConfig() Config {
+ return m.config
+}
+
+func (m *Manager) SetConfig(cfg Config) error {
+ m.configMutex.Lock()
+ m.config = cfg
+ m.configMutex.Unlock()
+
+ m.updateState()
+ m.notifySubscribers()
+
+ return SaveConfig(cfg)
+}
+
+func (m *Manager) getConfig() Config {
+ m.configMutex.RLock()
+ defer m.configMutex.RUnlock()
+ return m.config
+}
+
+func (m *Manager) watchConfig() {
+ if m.configPath == "" {
+ return
+ }
+
+ watcher, err := fsnotify.NewWatcher()
+ if err != nil {
+ log.Warnf("Failed to create config watcher: %v", err)
+ return
+ }
+ defer watcher.Close()
+
+ configDir := filepath.Dir(m.configPath)
+ if err := watcher.Add(configDir); err != nil {
+ log.Warnf("Failed to watch config directory: %v", err)
+ return
+ }
+
+ for {
+ select {
+ case <-m.stopChan:
+ return
+ case event, ok := <-watcher.Events:
+ if !ok {
+ return
+ }
+ if event.Name != m.configPath {
+ continue
+ }
+ if event.Op&(fsnotify.Write|fsnotify.Create) == 0 {
+ continue
+ }
+ newCfg := LoadConfig()
+ m.applyConfigChange(newCfg)
+ case err, ok := <-watcher.Errors:
+ if !ok {
+ return
+ }
+ log.Warnf("Config watcher error: %v", err)
+ }
+ }
+}
+
+func (m *Manager) applyConfigChange(newCfg Config) {
+ m.configMutex.Lock()
+ oldCfg := m.config
+ m.config = newCfg
+ m.configMutex.Unlock()
+
+ switch {
+ case newCfg.Disabled && !oldCfg.Disabled:
+ log.Info("Clipboard tracking disabled")
+ case !newCfg.Disabled && oldCfg.Disabled:
+ log.Info("Clipboard tracking enabled")
+ }
+
+ m.updateState()
+ m.notifySubscribers()
+}
+
+func (m *Manager) StoreData(data []byte, mimeType string) error {
+ cfg := m.getConfig()
+
+ if cfg.Disabled {
+ return fmt.Errorf("clipboard tracking disabled")
+ }
+
+ if m.db == nil {
+ return fmt.Errorf("database not available")
+ }
+
+ if len(data) == 0 {
+ return nil
+ }
+
+ if int64(len(data)) > cfg.MaxEntrySize {
+ return fmt.Errorf("data too large")
+ }
+
+ if len(bytes.TrimSpace(data)) == 0 {
+ return nil
+ }
+
+ entry := Entry{
+ Data: data,
+ MimeType: mimeType,
+ Size: len(data),
+ Timestamp: time.Now(),
+ IsImage: m.isImageMimeType(mimeType),
+ }
+
+ switch {
+ case entry.IsImage:
+ entry.Preview = m.imagePreview(data, mimeType)
+ case mimeType == "text/uri-list":
+ entry.Preview, entry.IsImage = m.uriListPreview(data)
+ default:
+ entry.Preview = m.textPreview(data)
+ }
+
+ if err := m.storeEntry(entry); err != nil {
+ return err
+ }
+
+ m.updateState()
+ m.notifySubscribers()
+
+ return nil
+}
+
+func (m *Manager) PinEntry(id uint64) error {
+ if m.db == nil {
+ return fmt.Errorf("database not available")
+ }
+
+ entryToPin, err := m.GetEntry(id)
+ if err != nil {
+ return err
+ }
+
+ var hashExists bool
+ if err := m.db.View(func(tx *bolt.Tx) error {
+ b := tx.Bucket([]byte("clipboard"))
+ if b == nil {
+ return nil
+ }
+ c := b.Cursor()
+ for k, v := c.First(); k != nil; k, v = c.Next() {
+ entry, err := decodeEntry(v)
+ if err != nil || !entry.Pinned {
+ continue
+ }
+ if entry.Hash == entryToPin.Hash {
+ hashExists = true
+ return nil
+ }
+ }
+ return nil
+ }); err != nil {
+ return err
+ }
+
+ if hashExists {
+ return nil
+ }
+
+ // Check pinned count
+ cfg := m.getConfig()
+ pinnedCount := 0
+ if err := m.db.View(func(tx *bolt.Tx) error {
+ b := tx.Bucket([]byte("clipboard"))
+ if b == nil {
+ return nil
+ }
+ c := b.Cursor()
+ for k, v := c.First(); k != nil; k, v = c.Next() {
+ entry, err := decodeEntry(v)
+ if err == nil && entry.Pinned {
+ pinnedCount++
+ }
+ }
+ return nil
+ }); err != nil {
+ log.Errorf("Failed to count pinned entries: %v", err)
+ }
+
+ if pinnedCount >= cfg.MaxPinned {
+ return fmt.Errorf("maximum pinned entries reached (%d)", cfg.MaxPinned)
+ }
+
+ err = m.db.Update(func(tx *bolt.Tx) error {
+ b := tx.Bucket([]byte("clipboard"))
+ v := b.Get(itob(id))
+ if v == nil {
+ return fmt.Errorf("entry not found")
+ }
+
+ entry, err := decodeEntry(v)
+ if err != nil {
+ return err
+ }
+
+ entry.Pinned = true
+ encoded, err := encodeEntry(entry)
+ if err != nil {
+ return err
+ }
+
+ return b.Put(itob(id), encoded)
+ })
+
+ if err == nil {
+ m.updateState()
+ m.notifySubscribers()
+ }
+
+ return err
+}
+
+func (m *Manager) UnpinEntry(id uint64) error {
+ if m.db == nil {
+ return fmt.Errorf("database not available")
+ }
+
+ err := m.db.Update(func(tx *bolt.Tx) error {
+ b := tx.Bucket([]byte("clipboard"))
+ v := b.Get(itob(id))
+ if v == nil {
+ return fmt.Errorf("entry not found")
+ }
+
+ entry, err := decodeEntry(v)
+ if err != nil {
+ return err
+ }
+
+ entry.Pinned = false
+ encoded, err := encodeEntry(entry)
+ if err != nil {
+ return err
+ }
+
+ return b.Put(itob(id), encoded)
+ })
+
+ if err == nil {
+ m.updateState()
+ m.notifySubscribers()
+ }
+
+ return err
+}
+
+func (m *Manager) GetPinnedEntries() []Entry {
+ if m.db == nil {
+ return nil
+ }
+
+ var pinned []Entry
+ if err := m.db.View(func(tx *bolt.Tx) error {
+ b := tx.Bucket([]byte("clipboard"))
+ if b == nil {
+ return nil
+ }
+
+ c := b.Cursor()
+ for k, v := c.Last(); k != nil; k, v = c.Prev() {
+ entry, err := decodeEntry(v)
+ if err != nil {
+ continue
+ }
+ if entry.Pinned {
+ entry.Data = nil
+ pinned = append(pinned, entry)
+ }
+ }
+ return nil
+ }); err != nil {
+ log.Errorf("Failed to get pinned entries: %v", err)
+ }
+
+ return pinned
+}
+
+func (m *Manager) GetPinnedCount() int {
+ if m.db == nil {
+ return 0
+ }
+
+ count := 0
+ if err := m.db.View(func(tx *bolt.Tx) error {
+ b := tx.Bucket([]byte("clipboard"))
+ if b == nil {
+ return nil
+ }
+
+ c := b.Cursor()
+ for k, v := c.First(); k != nil; k, v = c.Next() {
+ entry, err := decodeEntry(v)
+ if err == nil && entry.Pinned {
+ count++
+ }
+ }
+ return nil
+ }); err != nil {
+ log.Errorf("Failed to count pinned entries: %v", err)
+ }
+
+ return count
+}
+
+func (m *Manager) CopyFile(filePath string) error {
+ fileInfo, err := os.Stat(filePath)
+ if err != nil {
+ return fmt.Errorf("file not found: %w", err)
+ }
+
+ cfg := m.getConfig()
+ if fileInfo.Size() > cfg.MaxEntrySize {
+ return fmt.Errorf("file too large: %d > %d", fileInfo.Size(), cfg.MaxEntrySize)
+ }
+
+ fileData, err := os.ReadFile(filePath)
+ if err != nil {
+ return fmt.Errorf("read file: %w", err)
+ }
+
+ exportedPath, err := m.ExportFileForFlatpak(filePath)
+ if err != nil {
+ exportedPath = filePath
+ }
+ fileURI := "file://" + exportedPath
+
+ if imgData, imgMime, ok := m.tryReadImageFromURI([]byte("file://" + filePath)); ok {
+ entry := Entry{
+ Data: imgData,
+ MimeType: imgMime,
+ Size: len(imgData),
+ Timestamp: time.Now(),
+ IsImage: true,
+ Preview: m.imagePreview(imgData, imgMime),
+ }
+ if err := m.storeEntry(entry); err != nil {
+ log.Errorf("Failed to store file entry: %v", err)
+ }
+ } else {
+ entry := Entry{
+ Data: fileData,
+ MimeType: "text/uri-list",
+ Size: len(fileData),
+ Timestamp: time.Now(),
+ IsImage: false,
+ Preview: fmt.Sprintf("[[ file %s ]]", filepath.Base(filePath)),
+ }
+ if err := m.storeEntry(entry); err != nil {
+ log.Errorf("Failed to store file entry: %v", err)
+ }
+ }
+
+ m.updateState()
+ m.notifySubscribers()
+
+ _, imgMime, imgErr := image.DecodeConfig(bytes.NewReader(fileData))
+
+ m.post(func() {
+ if m.dataControlMgr == nil || m.dataDevice == nil {
+ log.Error("Data control manager or device not initialized")
+ return
+ }
+
+ dataMgr := m.dataControlMgr.(*ext_data_control.ExtDataControlManagerV1)
+ source, err := dataMgr.CreateDataSource()
+ if err != nil {
+ log.Errorf("Failed to create data source: %v", err)
+ return
+ }
+
+ type offer struct {
+ mime string
+ data []byte
+ }
+ offers := []offer{
+ {"x-special/gnome-copied-files", []byte("copy\n" + fileURI)},
+ {"text/uri-list", []byte(fileURI + "\r\n")},
+ {"text/plain", []byte(filePath)},
+ }
+
+ if imgErr == nil {
+ imgMimeType := "image/" + imgMime
+ offers = append(offers, offer{imgMimeType, fileData})
+ }
+
+ offerData := make(map[string][]byte)
+ for _, o := range offers {
+ if err := source.Offer(o.mime); err != nil {
+ log.Errorf("Failed to offer %s: %v", o.mime, err)
+ return
+ }
+ offerData[o.mime] = o.data
+ }
+
+ source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) {
+ fd := e.Fd
+ defer syscall.Close(fd)
+ file := os.NewFile(uintptr(fd), "clipboard-pipe")
+ defer file.Close()
+ if data, ok := offerData[e.MimeType]; ok {
+ file.Write(data)
+ }
+ })
+
+ source.SetCancelledHandler(func(e ext_data_control.ExtDataControlSourceV1CancelledEvent) {
+ m.ownerLock.Lock()
+ m.isOwner = false
+ m.ownerLock.Unlock()
+ })
+
+ m.currentSource = source
+
+ m.ownerLock.Lock()
+ m.isOwner = true
+ m.ownerLock.Unlock()
+
+ device := m.dataDevice.(*ext_data_control.ExtDataControlDeviceV1)
+ if err := device.SetSelection(source); err != nil {
+ log.Errorf("Failed to set selection: %v", err)
+ }
+ })
+
+ return nil
+}
+
+func (m *Manager) EntryToFile(entry *Entry) string {
+ switch {
+ case entry.MimeType == "text/uri-list":
+ data := strings.TrimSpace(string(entry.Data))
+ lines := strings.Split(data, "\n")
+ if len(lines) == 0 {
+ return ""
+ }
+ uri := strings.TrimSuffix(strings.TrimSpace(lines[0]), "\r")
+ if path, ok := strings.CutPrefix(uri, "file://"); ok {
+ return path
+ }
+ case entry.IsImage:
+ ext := ".png"
+ if suffix, ok := strings.CutPrefix(entry.MimeType, "image/"); ok {
+ ext = "." + suffix
+ }
+ cacheDir, err := os.UserCacheDir()
+ if err != nil {
+ return ""
+ }
+ clipDir := filepath.Join(cacheDir, "dms", "clipboard")
+ if err := os.MkdirAll(clipDir, 0o755); err != nil {
+ return ""
+ }
+ filePath := filepath.Join(clipDir, fmt.Sprintf("%d%s", time.Now().UnixNano(), ext))
+ if os.WriteFile(filePath, entry.Data, 0o644) != nil {
+ return ""
+ }
+ return filePath
+ }
+ return ""
+}
+
+func (m *Manager) ExportFileForFlatpak(filePath string) (string, error) {
+ if _, err := os.Stat(filePath); err != nil {
+ return "", fmt.Errorf("file not found: %w", err)
+ }
+
+ if m.dbusConn == nil {
+ conn, err := dbus.ConnectSessionBus()
+ if err != nil {
+ return "", fmt.Errorf("connect session bus: %w", err)
+ }
+ if !conn.SupportsUnixFDs() {
+ conn.Close()
+ return "", fmt.Errorf("D-Bus connection does not support Unix FD passing")
+ }
+ m.dbusConn = conn
+ }
+
+ file, err := os.Open(filePath)
+ if err != nil {
+ return "", fmt.Errorf("open file: %w", err)
+ }
+ fd := int(file.Fd())
+
+ portal := m.dbusConn.Object("org.freedesktop.portal.Documents", "/org/freedesktop/portal/documents")
+
+ var docIds []string
+ var extra map[string]dbus.Variant
+ flags := uint32(0)
+
+ err = portal.Call(
+ "org.freedesktop.portal.Documents.AddFull",
+ 0,
+ []dbus.UnixFD{dbus.UnixFD(fd)},
+ flags,
+ "",
+ []string{},
+ ).Store(&docIds, &extra)
+
+ file.Close()
+
+ if err != nil {
+ return "", fmt.Errorf("AddFull: %w", err)
+ }
+
+ if len(docIds) == 0 {
+ return "", fmt.Errorf("no doc IDs returned")
+ }
+
+ docId := docIds[0]
+
+ for _, app := range getInstalledFlatpaks() {
+ _ = portal.Call(
+ "org.freedesktop.portal.Documents.GrantPermissions",
+ 0,
+ docId,
+ app,
+ []string{"read"},
+ ).Err
+ }
+
+ uid := os.Getuid()
+ basename := filepath.Base(filePath)
+ exportedPath := fmt.Sprintf("/run/user/%d/doc/%s/%s", uid, docId, basename)
+
+ return exportedPath, nil
+}
+
+func getInstalledFlatpaks() []string {
+ out, err := exec.Command("flatpak", "list", "--app", "--columns=application").Output()
+ if err != nil {
+ return nil
+ }
+
+ var apps []string
+ for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
+ if app := strings.TrimSpace(line); app != "" {
+ apps = append(apps, app)
+ }
+ }
+ return apps
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/clipboard/manager_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/clipboard/manager_test.go
new file mode 100644
index 0000000..db45bca
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/clipboard/manager_test.go
@@ -0,0 +1,531 @@
+package clipboard
+
+import (
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+
+ mocks_wlcontext "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/wlcontext"
+)
+
+func TestEncodeDecodeEntry_Roundtrip(t *testing.T) {
+ original := Entry{
+ ID: 12345,
+ Data: []byte("hello world"),
+ MimeType: "text/plain;charset=utf-8",
+ Preview: "hello world",
+ Size: 11,
+ Timestamp: time.Now().Truncate(time.Second),
+ IsImage: false,
+ }
+
+ encoded, err := encodeEntry(original)
+ assert.NoError(t, err)
+
+ decoded, err := decodeEntry(encoded)
+ assert.NoError(t, err)
+
+ assert.Equal(t, original.ID, decoded.ID)
+ assert.Equal(t, original.Data, decoded.Data)
+ assert.Equal(t, original.MimeType, decoded.MimeType)
+ assert.Equal(t, original.Preview, decoded.Preview)
+ assert.Equal(t, original.Size, decoded.Size)
+ assert.Equal(t, original.Timestamp.Unix(), decoded.Timestamp.Unix())
+ assert.Equal(t, original.IsImage, decoded.IsImage)
+}
+
+func TestEncodeDecodeEntry_Image(t *testing.T) {
+ original := Entry{
+ ID: 99999,
+ Data: []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A},
+ MimeType: "image/png",
+ Preview: "[[ image 8 B png 100x100 ]]",
+ Size: 8,
+ Timestamp: time.Now().Truncate(time.Second),
+ IsImage: true,
+ }
+
+ encoded, err := encodeEntry(original)
+ assert.NoError(t, err)
+
+ decoded, err := decodeEntry(encoded)
+ assert.NoError(t, err)
+
+ assert.Equal(t, original.ID, decoded.ID)
+ assert.Equal(t, original.Data, decoded.Data)
+ assert.True(t, decoded.IsImage)
+ assert.Equal(t, original.Preview, decoded.Preview)
+}
+
+func TestEncodeDecodeEntry_EmptyData(t *testing.T) {
+ original := Entry{
+ ID: 1,
+ Data: []byte{},
+ MimeType: "text/plain",
+ Preview: "",
+ Size: 0,
+ Timestamp: time.Now().Truncate(time.Second),
+ IsImage: false,
+ }
+
+ encoded, err := encodeEntry(original)
+ assert.NoError(t, err)
+
+ decoded, err := decodeEntry(encoded)
+ assert.NoError(t, err)
+
+ assert.Equal(t, original.ID, decoded.ID)
+ assert.Empty(t, decoded.Data)
+}
+
+func TestEncodeDecodeEntry_LargeData(t *testing.T) {
+ largeData := make([]byte, 100000)
+ for i := range largeData {
+ largeData[i] = byte(i % 256)
+ }
+
+ original := Entry{
+ ID: 777,
+ Data: largeData,
+ MimeType: "application/octet-stream",
+ Preview: "binary data...",
+ Size: len(largeData),
+ Timestamp: time.Now().Truncate(time.Second),
+ IsImage: false,
+ }
+
+ encoded, err := encodeEntry(original)
+ assert.NoError(t, err)
+
+ decoded, err := decodeEntry(encoded)
+ assert.NoError(t, err)
+
+ assert.Equal(t, original.Data, decoded.Data)
+ assert.Equal(t, original.Size, decoded.Size)
+}
+
+func TestStateEqual_BothNil(t *testing.T) {
+ assert.False(t, stateEqual(nil, nil))
+}
+
+func TestStateEqual_OneNil(t *testing.T) {
+ s := &State{Enabled: true}
+ assert.False(t, stateEqual(s, nil))
+ assert.False(t, stateEqual(nil, s))
+}
+
+func TestStateEqual_EnabledDiffers(t *testing.T) {
+ a := &State{Enabled: true, History: []Entry{}}
+ b := &State{Enabled: false, History: []Entry{}}
+ assert.False(t, stateEqual(a, b))
+}
+
+func TestStateEqual_HistoryLengthDiffers(t *testing.T) {
+ a := &State{Enabled: true, History: []Entry{{ID: 1}}}
+ b := &State{Enabled: true, History: []Entry{}}
+ assert.False(t, stateEqual(a, b))
+}
+
+func TestStateEqual_BothEqual(t *testing.T) {
+ a := &State{Enabled: true, History: []Entry{{ID: 1}, {ID: 2}}}
+ b := &State{Enabled: true, History: []Entry{{ID: 3}, {ID: 4}}}
+ assert.True(t, stateEqual(a, b))
+}
+
+func TestManager_ConcurrentSubscriberAccess(t *testing.T) {
+ m := &Manager{
+ subscribers: make(map[string]chan State),
+ dirty: make(chan struct{}, 1),
+ }
+
+ var wg sync.WaitGroup
+ const goroutines = 20
+
+ for i := 0; i < goroutines; i++ {
+ wg.Add(1)
+ go func(id int) {
+ defer wg.Done()
+ subID := string(rune('a' + id))
+ ch := m.Subscribe(subID)
+ assert.NotNil(t, ch)
+ time.Sleep(time.Millisecond)
+ m.Unsubscribe(subID)
+ }(i)
+ }
+
+ wg.Wait()
+}
+
+func TestManager_ConcurrentGetState(t *testing.T) {
+ m := &Manager{
+ state: &State{
+ Enabled: true,
+ History: []Entry{{ID: 1}, {ID: 2}},
+ },
+ }
+
+ var wg sync.WaitGroup
+ const goroutines = 50
+ const iterations = 100
+
+ for i := 0; i < goroutines/2; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for j := 0; j < iterations; j++ {
+ s := m.GetState()
+ _ = s.Enabled
+ _ = len(s.History)
+ }
+ }()
+ }
+
+ for i := 0; i < goroutines/2; i++ {
+ wg.Add(1)
+ go func(i int) {
+ defer wg.Done()
+ for j := 0; j < iterations; j++ {
+ m.stateMutex.Lock()
+ m.state = &State{
+ Enabled: j%2 == 0,
+ History: []Entry{{ID: uint64(j)}},
+ }
+ m.stateMutex.Unlock()
+ }
+ }(i)
+ }
+
+ wg.Wait()
+}
+
+func TestManager_ConcurrentConfigAccess(t *testing.T) {
+ m := &Manager{
+ config: DefaultConfig(),
+ }
+
+ var wg sync.WaitGroup
+ const goroutines = 30
+ const iterations = 100
+
+ for i := 0; i < goroutines/2; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for j := 0; j < iterations; j++ {
+ cfg := m.getConfig()
+ _ = cfg.MaxHistory
+ _ = cfg.MaxEntrySize
+ }
+ }()
+ }
+
+ for i := 0; i < goroutines/2; i++ {
+ wg.Add(1)
+ go func(i int) {
+ defer wg.Done()
+ for j := 0; j < iterations; j++ {
+ m.configMutex.Lock()
+ m.config.MaxHistory = 50 + j
+ m.config.MaxEntrySize = int64(1024 * j)
+ m.configMutex.Unlock()
+ }
+ }(i)
+ }
+
+ wg.Wait()
+}
+
+func TestManager_NotifySubscribersNonBlocking(t *testing.T) {
+ m := &Manager{
+ dirty: make(chan struct{}, 1),
+ }
+
+ for i := 0; i < 10; i++ {
+ m.notifySubscribers()
+ }
+
+ assert.Len(t, m.dirty, 1)
+}
+
+func TestManager_ConcurrentOfferAccess(t *testing.T) {
+ m := &Manager{
+ offerMimeTypes: make(map[any][]string),
+ offerRegistry: make(map[uint32]any),
+ }
+
+ var wg sync.WaitGroup
+ const goroutines = 20
+ const iterations = 50
+
+ for i := 0; i < goroutines; i++ {
+ wg.Add(1)
+ go func(id int) {
+ defer wg.Done()
+ key := uint32(id)
+
+ for j := 0; j < iterations; j++ {
+ m.offerMutex.Lock()
+ m.offerRegistry[key] = struct{}{}
+ m.offerMimeTypes[key] = []string{"text/plain"}
+ m.offerMutex.Unlock()
+
+ m.offerMutex.RLock()
+ _ = m.offerRegistry[key]
+ _ = m.offerMimeTypes[key]
+ m.offerMutex.RUnlock()
+
+ m.offerMutex.Lock()
+ delete(m.offerRegistry, key)
+ delete(m.offerMimeTypes, key)
+ m.offerMutex.Unlock()
+ }
+ }(i)
+ }
+
+ wg.Wait()
+}
+
+func TestManager_ConcurrentPersistAccess(t *testing.T) {
+ m := &Manager{
+ persistData: make(map[string][]byte),
+ persistMimeTypes: []string{},
+ }
+
+ var wg sync.WaitGroup
+ const goroutines = 20
+ const iterations = 50
+
+ for i := 0; i < goroutines/2; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for j := 0; j < iterations; j++ {
+ m.persistMutex.RLock()
+ _ = m.persistData
+ _ = m.persistMimeTypes
+ m.persistMutex.RUnlock()
+ }
+ }()
+ }
+
+ for i := 0; i < goroutines/2; i++ {
+ wg.Add(1)
+ go func(id int) {
+ defer wg.Done()
+ for j := 0; j < iterations; j++ {
+ m.persistMutex.Lock()
+ m.persistMimeTypes = []string{"text/plain", "text/html"}
+ m.persistData = map[string][]byte{
+ "text/plain": []byte("test"),
+ }
+ m.persistMutex.Unlock()
+ }
+ }(i)
+ }
+
+ wg.Wait()
+}
+
+func TestManager_ConcurrentOwnerAccess(t *testing.T) {
+ m := &Manager{}
+
+ var wg sync.WaitGroup
+ const goroutines = 30
+ const iterations = 100
+
+ for i := 0; i < goroutines/2; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for j := 0; j < iterations; j++ {
+ m.ownerLock.Lock()
+ _ = m.isOwner
+ m.ownerLock.Unlock()
+ }
+ }()
+ }
+
+ for i := 0; i < goroutines/2; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for j := 0; j < iterations; j++ {
+ m.ownerLock.Lock()
+ m.isOwner = j%2 == 0
+ m.ownerLock.Unlock()
+ }
+ }()
+ }
+
+ wg.Wait()
+}
+
+func TestItob(t *testing.T) {
+ tests := []struct {
+ input uint64
+ expected []byte
+ }{
+ {0, []byte{0, 0, 0, 0, 0, 0, 0, 0}},
+ {1, []byte{0, 0, 0, 0, 0, 0, 0, 1}},
+ {256, []byte{0, 0, 0, 0, 0, 0, 1, 0}},
+ {0xFFFFFFFFFFFFFFFF, []byte{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}},
+ }
+
+ for _, tt := range tests {
+ result := itob(tt.input)
+ assert.Equal(t, tt.expected, result)
+ }
+}
+
+func TestSizeStr(t *testing.T) {
+ tests := []struct {
+ input int
+ expected string
+ }{
+ {0, "0 B"},
+ {100, "100 B"},
+ {1024, "1 KiB"},
+ {2048, "2 KiB"},
+ {1048576, "1 MiB"},
+ {5242880, "5 MiB"},
+ }
+
+ for _, tt := range tests {
+ result := sizeStr(tt.input)
+ assert.Equal(t, tt.expected, result)
+ }
+}
+
+func TestSelectMimeType(t *testing.T) {
+ m := &Manager{}
+
+ tests := []struct {
+ mimes []string
+ expected string
+ }{
+ {[]string{"text/plain;charset=utf-8", "text/html"}, "text/plain;charset=utf-8"},
+ {[]string{"text/html", "text/plain"}, "text/plain"},
+ {[]string{"text/html", "image/png"}, "image/png"},
+ {[]string{"image/png", "image/jpeg"}, "image/png"},
+ {[]string{"image/png"}, "image/png"},
+ {[]string{"application/octet-stream"}, "application/octet-stream"},
+ {[]string{}, ""},
+ }
+
+ for _, tt := range tests {
+ result := m.selectMimeType(tt.mimes)
+ assert.Equal(t, tt.expected, result)
+ }
+}
+
+func TestIsImageMimeType(t *testing.T) {
+ m := &Manager{}
+
+ assert.True(t, m.isImageMimeType("image/png"))
+ assert.True(t, m.isImageMimeType("image/jpeg"))
+ assert.True(t, m.isImageMimeType("image/gif"))
+ assert.False(t, m.isImageMimeType("text/plain"))
+ assert.False(t, m.isImageMimeType("application/json"))
+}
+
+func TestTextPreview(t *testing.T) {
+ m := &Manager{}
+
+ short := m.textPreview([]byte("hello world"))
+ assert.Equal(t, "hello world", short)
+
+ withWhitespace := m.textPreview([]byte(" hello world "))
+ assert.Equal(t, "hello world", withWhitespace)
+
+ longText := make([]byte, 200)
+ for i := range longText {
+ longText[i] = 'a'
+ }
+ preview := m.textPreview(longText)
+ assert.True(t, len(preview) > 100)
+ assert.Contains(t, preview, "…")
+}
+
+func TestDefaultConfig(t *testing.T) {
+ cfg := DefaultConfig()
+ assert.Equal(t, 100, cfg.MaxHistory)
+ assert.Equal(t, int64(5*1024*1024), cfg.MaxEntrySize)
+ assert.Equal(t, 0, cfg.AutoClearDays)
+ assert.False(t, cfg.ClearAtStartup)
+ assert.False(t, cfg.Disabled)
+}
+
+func TestManager_PostDelegatesToWlContext(t *testing.T) {
+ mockCtx := mocks_wlcontext.NewMockWaylandContext(t)
+
+ var called atomic.Bool
+ mockCtx.EXPECT().Post(mock.AnythingOfType("func()")).Run(func(fn func()) {
+ called.Store(true)
+ fn()
+ }).Once()
+
+ m := &Manager{
+ wlCtx: mockCtx,
+ }
+
+ executed := false
+ m.post(func() {
+ executed = true
+ })
+
+ assert.True(t, called.Load())
+ assert.True(t, executed)
+}
+
+func TestManager_PostExecutesFunctionViaContext(t *testing.T) {
+ mockCtx := mocks_wlcontext.NewMockWaylandContext(t)
+
+ var capturedFn func()
+ mockCtx.EXPECT().Post(mock.AnythingOfType("func()")).Run(func(fn func()) {
+ capturedFn = fn
+ }).Times(3)
+
+ m := &Manager{
+ wlCtx: mockCtx,
+ }
+
+ counter := 0
+ m.post(func() { counter++ })
+ m.post(func() { counter += 10 })
+ m.post(func() { counter += 100 })
+
+ assert.NotNil(t, capturedFn)
+ capturedFn()
+ assert.Equal(t, 100, counter)
+}
+
+func TestManager_ConcurrentPostWithMock(t *testing.T) {
+ mockCtx := mocks_wlcontext.NewMockWaylandContext(t)
+
+ var postCount atomic.Int32
+ mockCtx.EXPECT().Post(mock.AnythingOfType("func()")).Run(func(fn func()) {
+ postCount.Add(1)
+ }).Times(100)
+
+ m := &Manager{
+ wlCtx: mockCtx,
+ }
+
+ var wg sync.WaitGroup
+ for i := 0; i < 10; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for j := 0; j < 10; j++ {
+ m.post(func() {})
+ }
+ }()
+ }
+
+ wg.Wait()
+ assert.Equal(t, int32(100), postCount.Load())
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/clipboard/types.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/clipboard/types.go
new file mode 100644
index 0000000..c754561
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/clipboard/types.go
@@ -0,0 +1,196 @@
+package clipboard
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "sync"
+ "time"
+
+ "github.com/godbus/dbus/v5"
+ bolt "go.etcd.io/bbolt"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext"
+ wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
+)
+
+type Config struct {
+ MaxHistory int `json:"maxHistory"`
+ MaxEntrySize int64 `json:"maxEntrySize"`
+ AutoClearDays int `json:"autoClearDays"`
+ ClearAtStartup bool `json:"clearAtStartup"`
+ Disabled bool `json:"disabled"`
+ MaxPinned int `json:"maxPinned"`
+}
+
+func DefaultConfig() Config {
+ return Config{
+ MaxHistory: 100,
+ MaxEntrySize: 5 * 1024 * 1024,
+ AutoClearDays: 0,
+ ClearAtStartup: false,
+ MaxPinned: 25,
+ }
+}
+
+func getConfigPath() (string, error) {
+ configDir, err := os.UserConfigDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(configDir, "DankMaterialShell", "clsettings.json"), nil
+}
+
+func LoadConfig() Config {
+ cfg := DefaultConfig()
+
+ path, err := getConfigPath()
+ if err != nil {
+ return cfg
+ }
+
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return cfg
+ }
+
+ if err := json.Unmarshal(data, &cfg); err != nil {
+ return DefaultConfig()
+ }
+ return cfg
+}
+
+func SaveConfig(cfg Config) error {
+ path, err := getConfigPath()
+ if err != nil {
+ return err
+ }
+
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return err
+ }
+
+ data, err := json.MarshalIndent(cfg, "", " ")
+ if err != nil {
+ return err
+ }
+
+ return os.WriteFile(path, data, 0o644)
+}
+
+type SearchParams struct {
+ Query string `json:"query"`
+ MimeType string `json:"mimeType"`
+ IsImage *bool `json:"isImage"`
+ Limit int `json:"limit"`
+ Offset int `json:"offset"`
+ Before *int64 `json:"before"`
+ After *int64 `json:"after"`
+}
+
+type SearchResult struct {
+ Entries []Entry `json:"entries"`
+ Total int `json:"total"`
+ HasMore bool `json:"hasMore"`
+}
+
+type Entry struct {
+ ID uint64 `json:"id"`
+ Data []byte `json:"data,omitempty"`
+ MimeType string `json:"mimeType"`
+ Preview string `json:"preview"`
+ Size int `json:"size"`
+ Timestamp time.Time `json:"timestamp"`
+ IsImage bool `json:"isImage"`
+ Hash uint64 `json:"hash,omitempty"`
+ Pinned bool `json:"pinned"`
+}
+
+type State struct {
+ Enabled bool `json:"enabled"`
+ History []Entry `json:"history"`
+ Current *Entry `json:"current,omitempty"`
+}
+
+type Manager struct {
+ config Config
+ configMutex sync.RWMutex
+ configPath string
+
+ display wlclient.WaylandDisplay
+ wlCtx wlcontext.WaylandContext
+
+ registry *wlclient.Registry
+ dataControlMgr any
+ seat *wlclient.Seat
+ dataDevice any
+ currentOffer any
+ currentSource any
+ seatName uint32
+ mimeTypes []string
+ offerMimeTypes map[any][]string
+ offerMutex sync.RWMutex
+ offerRegistry map[uint32]any
+
+ sourceMimeTypes []string
+ sourceMutex sync.RWMutex
+
+ persistData map[string][]byte
+ persistMimeTypes []string
+ persistMutex sync.RWMutex
+
+ isOwner bool
+ ownerLock sync.Mutex
+
+ initialized bool
+
+ alive bool
+ stopChan chan struct{}
+
+ db *bolt.DB
+ dbPath string
+
+ state *State
+ stateMutex sync.RWMutex
+
+ subscribers map[string]chan State
+ subMutex sync.RWMutex
+ dirty chan struct{}
+ notifierWg sync.WaitGroup
+ lastState *State
+
+ dbusConn *dbus.Conn
+}
+
+func (m *Manager) GetState() State {
+ m.stateMutex.RLock()
+ defer m.stateMutex.RUnlock()
+ if m.state == nil {
+ return State{}
+ }
+ return *m.state
+}
+
+func (m *Manager) Subscribe(id string) chan State {
+ ch := make(chan State, 64)
+ m.subMutex.Lock()
+ m.subscribers[id] = ch
+ m.subMutex.Unlock()
+ return ch
+}
+
+func (m *Manager) Unsubscribe(id string) {
+ m.subMutex.Lock()
+ if ch, ok := m.subscribers[id]; ok {
+ close(ch)
+ delete(m.subscribers, id)
+ }
+ m.subMutex.Unlock()
+}
+
+func (m *Manager) notifySubscribers() {
+ select {
+ case m.dirty <- struct{}{}:
+ default:
+ }
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/actions.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/actions.go
new file mode 100644
index 0000000..2f9b98b
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/actions.go
@@ -0,0 +1,479 @@
+package cups
+
+import (
+ "errors"
+ "fmt"
+ "net"
+ "net/url"
+ "os/exec"
+ "strings"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/config"
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/ipp"
+)
+
+func isAuthError(err error) bool {
+ if err == nil {
+ return false
+ }
+
+ var httpErr ipp.HTTPError
+ if errors.As(err, &httpErr) {
+ return httpErr.Code == 401 || httpErr.Code == 403
+ }
+
+ var ippErr ipp.IPPError
+ if errors.As(err, &ippErr) {
+ return ippErr.Status == ipp.StatusErrorForbidden ||
+ ippErr.Status == ipp.StatusErrorNotAuthenticated ||
+ ippErr.Status == ipp.StatusErrorNotAuthorized
+ }
+
+ return false
+}
+
+func (m *Manager) GetPrinters() ([]Printer, error) {
+ attributes := []string{
+ ipp.AttributePrinterName,
+ ipp.AttributePrinterUriSupported,
+ ipp.AttributePrinterState,
+ ipp.AttributePrinterStateReasons,
+ ipp.AttributePrinterLocation,
+ ipp.AttributePrinterInfo,
+ ipp.AttributePrinterMakeAndModel,
+ ipp.AttributePrinterIsAcceptingJobs,
+ }
+
+ printerAttrs, err := m.client.GetPrinters(attributes)
+ if err != nil {
+ if isNoPrintersError(err) {
+ return []Printer{}, nil
+ }
+ return nil, err
+ }
+
+ printers := make([]Printer, 0, len(printerAttrs))
+ for _, attrs := range printerAttrs {
+ printer := Printer{
+ Name: getStringAttr(attrs, ipp.AttributePrinterName),
+ URI: getStringAttr(attrs, ipp.AttributePrinterUriSupported),
+ State: parsePrinterState(attrs),
+ StateReason: getStringAttr(attrs, ipp.AttributePrinterStateReasons),
+ Location: getStringAttr(attrs, ipp.AttributePrinterLocation),
+ Info: getStringAttr(attrs, ipp.AttributePrinterInfo),
+ MakeModel: getStringAttr(attrs, ipp.AttributePrinterMakeAndModel),
+ Accepting: getBoolAttr(attrs, ipp.AttributePrinterIsAcceptingJobs),
+ }
+
+ if printer.Name != "" {
+ printers = append(printers, printer)
+ }
+ }
+
+ return printers, nil
+}
+
+func (m *Manager) GetJobs(printerName string, whichJobs string) ([]Job, error) {
+ attributes := []string{
+ ipp.AttributeJobID,
+ ipp.AttributeJobName,
+ ipp.AttributeJobState,
+ ipp.AttributeJobPrinterURI,
+ ipp.AttributeJobOriginatingUserName,
+ ipp.AttributeJobKilobyteOctets,
+ "time-at-creation",
+ }
+
+ jobAttrs, err := m.client.GetJobs(printerName, "", whichJobs, false, 0, 0, attributes)
+ if err != nil {
+ return nil, err
+ }
+
+ jobs := make([]Job, 0, len(jobAttrs))
+ for _, attrs := range jobAttrs {
+ job := Job{
+ ID: getIntAttr(attrs, ipp.AttributeJobID),
+ Name: getStringAttr(attrs, ipp.AttributeJobName),
+ State: parseJobState(attrs),
+ User: getStringAttr(attrs, ipp.AttributeJobOriginatingUserName),
+ Size: getIntAttr(attrs, ipp.AttributeJobKilobyteOctets) * 1024,
+ }
+
+ if uri := getStringAttr(attrs, ipp.AttributeJobPrinterURI); uri != "" {
+ parts := strings.Split(uri, "/")
+ if len(parts) > 0 {
+ job.Printer = parts[len(parts)-1]
+ }
+ }
+
+ if ts := getIntAttr(attrs, "time-at-creation"); ts > 0 {
+ job.TimeCreated = time.Unix(int64(ts), 0)
+ }
+
+ if job.ID != 0 {
+ jobs = append(jobs, job)
+ }
+ }
+
+ return jobs, nil
+}
+
+func (m *Manager) CancelJob(jobID int) error {
+ err := m.client.CancelJob(jobID, false)
+ if isAuthError(err) && m.pkHelper != nil {
+ err = m.pkHelper.JobCancelPurge(jobID, false)
+ }
+ if err == nil {
+ m.RefreshState()
+ }
+ return err
+}
+
+func (m *Manager) PausePrinter(printerName string) error {
+ err := m.client.PausePrinter(printerName)
+ if isAuthError(err) && m.pkHelper != nil {
+ err = m.pkHelper.PrinterSetEnabled(printerName, false)
+ }
+ if err == nil {
+ m.RefreshState()
+ }
+ return err
+}
+
+func (m *Manager) ResumePrinter(printerName string) error {
+ err := m.client.ResumePrinter(printerName)
+ if isAuthError(err) && m.pkHelper != nil {
+ err = m.pkHelper.PrinterSetEnabled(printerName, true)
+ }
+ if err == nil {
+ m.RefreshState()
+ }
+ return err
+}
+
+func (m *Manager) PurgeJobs(printerName string) error {
+ err := m.client.CancelAllJob(printerName, true)
+ if err == nil {
+ m.RefreshState()
+ }
+ return err
+}
+
+func resolveIPFromURI(uri string) string {
+ parsed, err := url.Parse(uri)
+ if err != nil {
+ return ""
+ }
+ host := parsed.Hostname()
+ if host == "" {
+ return ""
+ }
+ if ip := net.ParseIP(host); ip != nil {
+ return ip.String()
+ }
+ addrs, err := net.LookupIP(host)
+ if err != nil || len(addrs) == 0 {
+ return ""
+ }
+ for _, addr := range addrs {
+ if v4 := addr.To4(); v4 != nil {
+ return v4.String()
+ }
+ }
+ return addrs[0].String()
+}
+
+func (m *Manager) GetDevices() ([]Device, error) {
+ if m.pkHelper != nil {
+ devices, err := m.pkHelper.DevicesGet(10, 0, nil, nil)
+ if err != nil {
+ return nil, err
+ }
+ for i := range devices {
+ if devices[i].Class == "network" {
+ devices[i].IP = resolveIPFromURI(devices[i].URI)
+ }
+ }
+ return devices, nil
+ }
+
+ deviceAttrs, err := m.client.GetDevices()
+ if err != nil {
+ return nil, err
+ }
+
+ devices := make([]Device, 0, len(deviceAttrs))
+ for uri, attrs := range deviceAttrs {
+ device := Device{
+ URI: uri,
+ Class: getStringAttr(attrs, "device-class"),
+ Info: getStringAttr(attrs, "device-info"),
+ MakeModel: getStringAttr(attrs, "device-make-and-model"),
+ ID: getStringAttr(attrs, "device-id"),
+ Location: getStringAttr(attrs, "device-location"),
+ }
+ if device.Class == "network" {
+ device.IP = resolveIPFromURI(uri)
+ }
+ devices = append(devices, device)
+ }
+
+ return devices, nil
+}
+
+func (m *Manager) GetPPDs() ([]PPD, error) {
+ ppdAttrs, err := m.client.GetPPDs()
+ if err != nil {
+ return nil, err
+ }
+
+ ppds := make([]PPD, 0, len(ppdAttrs))
+ for name, attrs := range ppdAttrs {
+ ppd := PPD{
+ Name: name,
+ NaturalLanguage: getStringAttr(attrs, "ppd-natural-language"),
+ MakeModel: getStringAttr(attrs, ipp.AttributePPDMakeAndModel),
+ DeviceID: getStringAttr(attrs, "ppd-device-id"),
+ Product: getStringAttr(attrs, "ppd-product"),
+ PSVersion: getStringAttr(attrs, "ppd-psversion"),
+ Type: getStringAttr(attrs, "ppd-type"),
+ }
+ ppds = append(ppds, ppd)
+ }
+
+ return ppds, nil
+}
+
+func (m *Manager) GetClasses() ([]PrinterClass, error) {
+ attributes := []string{
+ ipp.AttributePrinterName,
+ ipp.AttributePrinterUriSupported,
+ ipp.AttributePrinterState,
+ ipp.AttributeMemberURIs,
+ ipp.AttributeMemberNames,
+ ipp.AttributePrinterLocation,
+ ipp.AttributePrinterInfo,
+ }
+
+ classAttrs, err := m.client.GetClasses(attributes)
+ if err != nil {
+ return nil, err
+ }
+
+ classes := make([]PrinterClass, 0, len(classAttrs))
+ for _, attrs := range classAttrs {
+ class := PrinterClass{
+ Name: getStringAttr(attrs, ipp.AttributePrinterName),
+ URI: getStringAttr(attrs, ipp.AttributePrinterUriSupported),
+ State: parsePrinterState(attrs),
+ Location: getStringAttr(attrs, ipp.AttributePrinterLocation),
+ Info: getStringAttr(attrs, ipp.AttributePrinterInfo),
+ Members: getStringSliceAttr(attrs, ipp.AttributeMemberNames),
+ }
+ classes = append(classes, class)
+ }
+
+ return classes, nil
+}
+
+func createPrinterViaLpadmin(name, deviceURI, ppd, information, location string) error {
+ args := []string{"-p", name, "-E", "-v", deviceURI, "-m", ppd}
+ if information != "" {
+ args = append(args, "-D", information)
+ }
+ if location != "" {
+ args = append(args, "-L", location)
+ }
+ out, err := exec.Command("lpadmin", args...).CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("lpadmin failed: %s: %w", strings.TrimSpace(string(out)), err)
+ }
+ return nil
+}
+
+func deletePrinterViaLpadmin(name string) error {
+ out, err := exec.Command("lpadmin", "-x", name).CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("lpadmin failed: %s: %w", strings.TrimSpace(string(out)), err)
+ }
+ return nil
+}
+
+func (m *Manager) CreatePrinter(name, deviceURI, ppd string, shared bool, errorPolicy, information, location string) error {
+ usedPkHelper := false
+
+ err := m.client.CreatePrinter(name, deviceURI, ppd, shared, errorPolicy, information, location)
+ if isAuthError(err) && m.pkHelper != nil {
+ if err = m.pkHelper.PrinterAdd(name, deviceURI, ppd, information, location); err != nil {
+ // pkHelper failed (e.g., no polkit agent), try lpadmin as last resort.
+ // lpadmin -E enables the printer, so no further setup needed.
+ if lpadminErr := createPrinterViaLpadmin(name, deviceURI, ppd, information, location); lpadminErr != nil {
+ return err
+ }
+ m.RefreshState()
+ return nil
+ }
+ usedPkHelper = true
+ } else if err != nil {
+ return err
+ }
+
+ if usedPkHelper {
+ m.pkHelper.PrinterSetEnabled(name, true) //nolint:errcheck
+ m.pkHelper.PrinterSetAcceptJobs(name, true, "") //nolint:errcheck
+ } else {
+ if err := m.client.ResumePrinter(name); isAuthError(err) && m.pkHelper != nil {
+ m.pkHelper.PrinterSetEnabled(name, true) //nolint:errcheck
+ }
+ if err := m.client.AcceptJobs(name); isAuthError(err) && m.pkHelper != nil {
+ m.pkHelper.PrinterSetAcceptJobs(name, true, "") //nolint:errcheck
+ }
+ }
+
+ m.RefreshState()
+ return nil
+}
+
+func (m *Manager) DeletePrinter(printerName string) error {
+ err := m.client.DeletePrinter(printerName)
+ if isAuthError(err) && m.pkHelper != nil {
+ err = m.pkHelper.PrinterDelete(printerName)
+ if err != nil {
+ // pkHelper failed, try lpadmin as last resort
+ if lpadminErr := deletePrinterViaLpadmin(printerName); lpadminErr == nil {
+ err = nil
+ }
+ }
+ }
+ if err == nil {
+ m.RefreshState()
+ }
+ return err
+}
+
+func (m *Manager) AcceptJobs(printerName string) error {
+ err := m.client.AcceptJobs(printerName)
+ if isAuthError(err) && m.pkHelper != nil {
+ err = m.pkHelper.PrinterSetAcceptJobs(printerName, true, "")
+ }
+ if err == nil {
+ m.RefreshState()
+ }
+ return err
+}
+
+func (m *Manager) RejectJobs(printerName string) error {
+ err := m.client.RejectJobs(printerName)
+ if isAuthError(err) && m.pkHelper != nil {
+ err = m.pkHelper.PrinterSetAcceptJobs(printerName, false, "")
+ }
+ if err == nil {
+ m.RefreshState()
+ }
+ return err
+}
+
+func (m *Manager) SetPrinterShared(printerName string, shared bool) error {
+ err := m.client.SetPrinterIsShared(printerName, shared)
+ if isAuthError(err) && m.pkHelper != nil {
+ err = m.pkHelper.PrinterSetShared(printerName, shared)
+ }
+ if err == nil {
+ m.RefreshState()
+ }
+ return err
+}
+
+func (m *Manager) SetPrinterLocation(printerName, location string) error {
+ err := m.client.SetPrinterLocation(printerName, location)
+ if isAuthError(err) && m.pkHelper != nil {
+ err = m.pkHelper.PrinterSetLocation(printerName, location)
+ }
+ if err == nil {
+ m.RefreshState()
+ }
+ return err
+}
+
+func (m *Manager) SetPrinterInfo(printerName, info string) error {
+ err := m.client.SetPrinterInformation(printerName, info)
+ if isAuthError(err) && m.pkHelper != nil {
+ err = m.pkHelper.PrinterSetInfo(printerName, info)
+ }
+ if err == nil {
+ m.RefreshState()
+ }
+ return err
+}
+
+func (m *Manager) MoveJob(jobID int, destPrinter string) error {
+ err := m.client.MoveJob(jobID, destPrinter)
+ if err == nil {
+ m.RefreshState()
+ }
+ return err
+}
+
+func (m *Manager) PrintTestPage(printerName string) (int, error) {
+ jobID, err := m.client.PrintTestPage(printerName, strings.NewReader(config.TestPage), len(config.TestPage))
+ if err == nil {
+ m.RefreshState()
+ }
+ return jobID, err
+}
+
+func (m *Manager) AddPrinterToClass(className, printerName string) error {
+ err := m.client.AddPrinterToClass(className, printerName)
+ if isAuthError(err) && m.pkHelper != nil {
+ err = m.pkHelper.ClassAddPrinter(className, printerName)
+ }
+ if err == nil {
+ m.RefreshState()
+ }
+ return err
+}
+
+func (m *Manager) RemovePrinterFromClass(className, printerName string) error {
+ err := m.client.DeletePrinterFromClass(className, printerName)
+ if isAuthError(err) && m.pkHelper != nil {
+ err = m.pkHelper.ClassDeletePrinter(className, printerName)
+ }
+ if err == nil {
+ m.RefreshState()
+ }
+ return err
+}
+
+func (m *Manager) DeleteClass(className string) error {
+ err := m.client.DeleteClass(className)
+ if isAuthError(err) && m.pkHelper != nil {
+ err = m.pkHelper.ClassDelete(className)
+ }
+ if err == nil {
+ m.RefreshState()
+ }
+ return err
+}
+
+func (m *Manager) RestartJob(jobID int) error {
+ err := m.client.RestartJob(jobID)
+ if isAuthError(err) && m.pkHelper != nil {
+ err = m.pkHelper.JobRestart(jobID)
+ }
+ if err == nil {
+ m.RefreshState()
+ }
+ return err
+}
+
+func (m *Manager) HoldJob(jobID int, holdUntil string) error {
+ err := m.client.HoldJobUntil(jobID, holdUntil)
+ if isAuthError(err) && m.pkHelper != nil {
+ err = m.pkHelper.JobSetHoldUntil(jobID, holdUntil)
+ }
+ if err == nil {
+ m.RefreshState()
+ }
+ return err
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/actions_pkhelper_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/actions_pkhelper_test.go
new file mode 100644
index 0000000..ad16a8a
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/actions_pkhelper_test.go
@@ -0,0 +1,235 @@
+package cups_test
+
+import (
+ "testing"
+
+ mocks_cups "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/cups"
+ mocks_pkhelper "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/cups_pkhelper"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups"
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/ipp"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+)
+
+func authErr() error {
+ return ipp.IPPError{Status: ipp.StatusErrorForbidden}
+}
+
+func TestManager_CancelJob_WithPkHelper(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().CancelJob(1, false).Return(authErr())
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ mockPk := mocks_pkhelper.NewMockPkHelper(t)
+ mockPk.EXPECT().JobCancelPurge(1, false).Return(nil)
+
+ m := cups.NewTestManager(mockClient, mockPk)
+ assert.NoError(t, m.CancelJob(1))
+}
+
+func TestManager_CancelJob_PkHelperError(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().CancelJob(1, false).Return(authErr())
+
+ mockPk := mocks_pkhelper.NewMockPkHelper(t)
+ mockPk.EXPECT().JobCancelPurge(1, false).Return(assert.AnError)
+
+ m := cups.NewTestManager(mockClient, mockPk)
+ assert.Error(t, m.CancelJob(1))
+}
+
+func TestManager_PausePrinter_WithPkHelper(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().PausePrinter("printer1").Return(authErr())
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ mockPk := mocks_pkhelper.NewMockPkHelper(t)
+ mockPk.EXPECT().PrinterSetEnabled("printer1", false).Return(nil)
+
+ m := cups.NewTestManager(mockClient, mockPk)
+ assert.NoError(t, m.PausePrinter("printer1"))
+}
+
+func TestManager_ResumePrinter_WithPkHelper(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().ResumePrinter("printer1").Return(authErr())
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ mockPk := mocks_pkhelper.NewMockPkHelper(t)
+ mockPk.EXPECT().PrinterSetEnabled("printer1", true).Return(nil)
+
+ m := cups.NewTestManager(mockClient, mockPk)
+ assert.NoError(t, m.ResumePrinter("printer1"))
+}
+
+func TestManager_GetDevices_WithPkHelper(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+
+ mockPk := mocks_pkhelper.NewMockPkHelper(t)
+ mockPk.EXPECT().DevicesGet(10, 0, []string(nil), []string(nil)).Return([]cups.Device{
+ {URI: "usb://HP/LaserJet", Class: "direct"},
+ }, nil)
+
+ m := cups.NewTestManager(mockClient, mockPk)
+ got, err := m.GetDevices()
+ assert.NoError(t, err)
+ assert.Len(t, got, 1)
+ assert.Equal(t, "usb://HP/LaserJet", got[0].URI)
+}
+
+func TestManager_GetDevices_PkHelperError(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+
+ mockPk := mocks_pkhelper.NewMockPkHelper(t)
+ mockPk.EXPECT().DevicesGet(10, 0, []string(nil), []string(nil)).Return(nil, assert.AnError)
+
+ m := cups.NewTestManager(mockClient, mockPk)
+ _, err := m.GetDevices()
+ assert.Error(t, err)
+}
+
+func TestManager_CreatePrinter_WithPkHelper(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().CreatePrinter("newprinter", "usb://HP", "generic.ppd", true, "stop-printer", "info", "location").Return(authErr())
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ mockPk := mocks_pkhelper.NewMockPkHelper(t)
+ mockPk.EXPECT().PrinterAdd("newprinter", "usb://HP", "generic.ppd", "info", "location").Return(nil)
+ mockPk.EXPECT().PrinterSetEnabled("newprinter", true).Return(nil)
+ mockPk.EXPECT().PrinterSetAcceptJobs("newprinter", true, "").Return(nil)
+
+ m := cups.NewTestManager(mockClient, mockPk)
+ assert.NoError(t, m.CreatePrinter("newprinter", "usb://HP", "generic.ppd", true, "stop-printer", "info", "location"))
+}
+
+func TestManager_DeletePrinter_WithPkHelper(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().DeletePrinter("printer1").Return(authErr())
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ mockPk := mocks_pkhelper.NewMockPkHelper(t)
+ mockPk.EXPECT().PrinterDelete("printer1").Return(nil)
+
+ m := cups.NewTestManager(mockClient, mockPk)
+ assert.NoError(t, m.DeletePrinter("printer1"))
+}
+
+func TestManager_AcceptJobs_WithPkHelper(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().AcceptJobs("printer1").Return(authErr())
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ mockPk := mocks_pkhelper.NewMockPkHelper(t)
+ mockPk.EXPECT().PrinterSetAcceptJobs("printer1", true, "").Return(nil)
+
+ m := cups.NewTestManager(mockClient, mockPk)
+ assert.NoError(t, m.AcceptJobs("printer1"))
+}
+
+func TestManager_RejectJobs_WithPkHelper(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().RejectJobs("printer1").Return(authErr())
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ mockPk := mocks_pkhelper.NewMockPkHelper(t)
+ mockPk.EXPECT().PrinterSetAcceptJobs("printer1", false, "").Return(nil)
+
+ m := cups.NewTestManager(mockClient, mockPk)
+ assert.NoError(t, m.RejectJobs("printer1"))
+}
+
+func TestManager_SetPrinterShared_WithPkHelper(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().SetPrinterIsShared("printer1", true).Return(authErr())
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ mockPk := mocks_pkhelper.NewMockPkHelper(t)
+ mockPk.EXPECT().PrinterSetShared("printer1", true).Return(nil)
+
+ m := cups.NewTestManager(mockClient, mockPk)
+ assert.NoError(t, m.SetPrinterShared("printer1", true))
+}
+
+func TestManager_SetPrinterLocation_WithPkHelper(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().SetPrinterLocation("printer1", "Office").Return(authErr())
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ mockPk := mocks_pkhelper.NewMockPkHelper(t)
+ mockPk.EXPECT().PrinterSetLocation("printer1", "Office").Return(nil)
+
+ m := cups.NewTestManager(mockClient, mockPk)
+ assert.NoError(t, m.SetPrinterLocation("printer1", "Office"))
+}
+
+func TestManager_SetPrinterInfo_WithPkHelper(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().SetPrinterInformation("printer1", "Main Printer").Return(authErr())
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ mockPk := mocks_pkhelper.NewMockPkHelper(t)
+ mockPk.EXPECT().PrinterSetInfo("printer1", "Main Printer").Return(nil)
+
+ m := cups.NewTestManager(mockClient, mockPk)
+ assert.NoError(t, m.SetPrinterInfo("printer1", "Main Printer"))
+}
+
+func TestManager_AddPrinterToClass_WithPkHelper(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().AddPrinterToClass("office", "printer1").Return(authErr())
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ mockPk := mocks_pkhelper.NewMockPkHelper(t)
+ mockPk.EXPECT().ClassAddPrinter("office", "printer1").Return(nil)
+
+ m := cups.NewTestManager(mockClient, mockPk)
+ assert.NoError(t, m.AddPrinterToClass("office", "printer1"))
+}
+
+func TestManager_RemovePrinterFromClass_WithPkHelper(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().DeletePrinterFromClass("office", "printer1").Return(authErr())
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ mockPk := mocks_pkhelper.NewMockPkHelper(t)
+ mockPk.EXPECT().ClassDeletePrinter("office", "printer1").Return(nil)
+
+ m := cups.NewTestManager(mockClient, mockPk)
+ assert.NoError(t, m.RemovePrinterFromClass("office", "printer1"))
+}
+
+func TestManager_DeleteClass_WithPkHelper(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().DeleteClass("office").Return(authErr())
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ mockPk := mocks_pkhelper.NewMockPkHelper(t)
+ mockPk.EXPECT().ClassDelete("office").Return(nil)
+
+ m := cups.NewTestManager(mockClient, mockPk)
+ assert.NoError(t, m.DeleteClass("office"))
+}
+
+func TestManager_RestartJob_WithPkHelper(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().RestartJob(1).Return(authErr())
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ mockPk := mocks_pkhelper.NewMockPkHelper(t)
+ mockPk.EXPECT().JobRestart(1).Return(nil)
+
+ m := cups.NewTestManager(mockClient, mockPk)
+ assert.NoError(t, m.RestartJob(1))
+}
+
+func TestManager_HoldJob_WithPkHelper(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().HoldJobUntil(1, "indefinite").Return(authErr())
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ mockPk := mocks_pkhelper.NewMockPkHelper(t)
+ mockPk.EXPECT().JobSetHoldUntil(1, "indefinite").Return(nil)
+
+ m := cups.NewTestManager(mockClient, mockPk)
+ assert.NoError(t, m.HoldJob(1, "indefinite"))
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/actions_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/actions_test.go
new file mode 100644
index 0000000..70f4f21
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/actions_test.go
@@ -0,0 +1,450 @@
+package cups
+
+import (
+ "errors"
+ "testing"
+ "time"
+
+ mocks_cups "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/cups"
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/ipp"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+)
+
+func TestManager_GetPrinters(t *testing.T) {
+ tests := []struct {
+ name string
+ mockRet map[string]ipp.Attributes
+ mockErr error
+ want int
+ wantErr bool
+ }{
+ {
+ name: "success",
+ mockRet: map[string]ipp.Attributes{
+ "printer1": {
+ ipp.AttributePrinterName: []ipp.Attribute{{Value: "printer1"}},
+ ipp.AttributePrinterUriSupported: []ipp.Attribute{{Value: "ipp://localhost/printers/printer1"}},
+ ipp.AttributePrinterState: []ipp.Attribute{{Value: 3}},
+ ipp.AttributePrinterStateReasons: []ipp.Attribute{{Value: "none"}},
+ ipp.AttributePrinterLocation: []ipp.Attribute{{Value: "Office"}},
+ ipp.AttributePrinterInfo: []ipp.Attribute{{Value: "Test Printer"}},
+ ipp.AttributePrinterMakeAndModel: []ipp.Attribute{{Value: "Generic"}},
+ ipp.AttributePrinterIsAcceptingJobs: []ipp.Attribute{{Value: true}},
+ },
+ },
+ mockErr: nil,
+ want: 1,
+ wantErr: false,
+ },
+ {
+ name: "error",
+ mockRet: nil,
+ mockErr: errors.New("test error"),
+ want: 0,
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(tt.mockRet, tt.mockErr)
+
+ m := &Manager{
+ client: mockClient,
+ }
+
+ got, err := m.GetPrinters()
+ if tt.wantErr {
+ assert.Error(t, err)
+ } else {
+ assert.NoError(t, err)
+ assert.Equal(t, tt.want, len(got))
+ if len(got) > 0 {
+ assert.Equal(t, "printer1", got[0].Name)
+ assert.Equal(t, "idle", got[0].State)
+ assert.Equal(t, "Office", got[0].Location)
+ assert.True(t, got[0].Accepting)
+ }
+ }
+ })
+ }
+}
+
+func TestManager_GetJobs(t *testing.T) {
+ tests := []struct {
+ name string
+ mockRet map[int]ipp.Attributes
+ mockErr error
+ want int
+ wantErr bool
+ }{
+ {
+ name: "success",
+ mockRet: map[int]ipp.Attributes{
+ 1: {
+ ipp.AttributeJobID: []ipp.Attribute{{Value: 1}},
+ ipp.AttributeJobName: []ipp.Attribute{{Value: "test-job"}},
+ ipp.AttributeJobState: []ipp.Attribute{{Value: 5}},
+ ipp.AttributeJobPrinterURI: []ipp.Attribute{{Value: "ipp://localhost/printers/printer1"}},
+ ipp.AttributeJobOriginatingUserName: []ipp.Attribute{{Value: "testuser"}},
+ ipp.AttributeJobKilobyteOctets: []ipp.Attribute{{Value: 10}},
+ "time-at-creation": []ipp.Attribute{{Value: 1609459200}},
+ },
+ },
+ mockErr: nil,
+ want: 1,
+ wantErr: false,
+ },
+ {
+ name: "error",
+ mockRet: nil,
+ mockErr: errors.New("test error"),
+ want: 0,
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().GetJobs("printer1", "", "not-completed", false, 0, 0, mock.Anything).
+ Return(tt.mockRet, tt.mockErr)
+
+ m := &Manager{
+ client: mockClient,
+ }
+
+ got, err := m.GetJobs("printer1", "not-completed")
+ if tt.wantErr {
+ assert.Error(t, err)
+ } else {
+ assert.NoError(t, err)
+ assert.Equal(t, tt.want, len(got))
+ if len(got) > 0 {
+ assert.Equal(t, 1, got[0].ID)
+ assert.Equal(t, "test-job", got[0].Name)
+ assert.Equal(t, "processing", got[0].State)
+ assert.Equal(t, "testuser", got[0].User)
+ assert.Equal(t, "printer1", got[0].Printer)
+ assert.Equal(t, 10240, got[0].Size)
+ assert.Equal(t, time.Unix(1609459200, 0), got[0].TimeCreated)
+ }
+ }
+ })
+ }
+}
+
+func TestManager_CancelJob(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().CancelJob(1, false).Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ assert.NoError(t, m.CancelJob(1))
+}
+
+func TestManager_PausePrinter(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().PausePrinter("printer1").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ assert.NoError(t, m.PausePrinter("printer1"))
+}
+
+func TestManager_ResumePrinter(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().ResumePrinter("printer1").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ assert.NoError(t, m.ResumePrinter("printer1"))
+}
+
+func TestManager_PurgeJobs(t *testing.T) {
+ tests := []struct {
+ name string
+ mockErr error
+ wantErr bool
+ }{
+ {
+ name: "success",
+ mockErr: nil,
+ wantErr: false,
+ },
+ {
+ name: "error",
+ mockErr: errors.New("test error"),
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().CancelAllJob("printer1", true).Return(tt.mockErr)
+ if !tt.wantErr {
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+ }
+
+ m := NewTestManager(mockClient, nil)
+
+ err := m.PurgeJobs("printer1")
+ if tt.wantErr {
+ assert.Error(t, err)
+ } else {
+ assert.NoError(t, err)
+ }
+ })
+ }
+}
+
+func TestManager_GetDevices(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().GetDevices().Return(map[string]ipp.Attributes{
+ "usb://HP/LaserJet": {
+ "device-class": []ipp.Attribute{{Value: "direct"}},
+ "device-info": []ipp.Attribute{{Value: "HP LaserJet"}},
+ "device-make-and-model": []ipp.Attribute{{Value: "HP LaserJet 1020"}},
+ },
+ }, nil)
+
+ m := &Manager{client: mockClient}
+ got, err := m.GetDevices()
+ assert.NoError(t, err)
+ assert.Len(t, got, 1)
+ assert.Equal(t, "usb://HP/LaserJet", got[0].URI)
+ assert.Equal(t, "direct", got[0].Class)
+}
+
+func TestManager_GetPPDs(t *testing.T) {
+ tests := []struct {
+ name string
+ mockRet map[string]ipp.Attributes
+ mockErr error
+ want int
+ wantErr bool
+ }{
+ {
+ name: "success",
+ mockRet: map[string]ipp.Attributes{
+ "drv:///sample.drv/generic.ppd": {
+ "ppd-make-and-model": []ipp.Attribute{{Value: "Generic PostScript"}},
+ "ppd-type": []ipp.Attribute{{Value: "ppd"}},
+ },
+ },
+ mockErr: nil,
+ want: 1,
+ wantErr: false,
+ },
+ {
+ name: "error",
+ mockRet: nil,
+ mockErr: errors.New("test error"),
+ want: 0,
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().GetPPDs().Return(tt.mockRet, tt.mockErr)
+
+ m := &Manager{client: mockClient}
+
+ got, err := m.GetPPDs()
+ if tt.wantErr {
+ assert.Error(t, err)
+ return
+ }
+ assert.NoError(t, err)
+ assert.Equal(t, tt.want, len(got))
+ })
+ }
+}
+
+func TestManager_GetClasses(t *testing.T) {
+ tests := []struct {
+ name string
+ mockRet map[string]ipp.Attributes
+ mockErr error
+ want int
+ wantErr bool
+ }{
+ {
+ name: "success",
+ mockRet: map[string]ipp.Attributes{
+ "office": {
+ ipp.AttributePrinterName: []ipp.Attribute{{Value: "office"}},
+ ipp.AttributePrinterState: []ipp.Attribute{{Value: 3}},
+ ipp.AttributeMemberNames: []ipp.Attribute{{Value: "printer1"}, {Value: "printer2"}},
+ },
+ },
+ mockErr: nil,
+ want: 1,
+ wantErr: false,
+ },
+ {
+ name: "error",
+ mockRet: nil,
+ mockErr: errors.New("test error"),
+ want: 0,
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().GetClasses(mock.Anything).Return(tt.mockRet, tt.mockErr)
+
+ m := &Manager{client: mockClient}
+
+ got, err := m.GetClasses()
+ if tt.wantErr {
+ assert.Error(t, err)
+ return
+ }
+ assert.NoError(t, err)
+ assert.Equal(t, tt.want, len(got))
+ if len(got) > 0 {
+ assert.Equal(t, "office", got[0].Name)
+ assert.Equal(t, 2, len(got[0].Members))
+ }
+ })
+ }
+}
+
+func TestManager_CreatePrinter(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().CreatePrinter("newprinter", "usb://HP", "generic.ppd", true, "stop-printer", "info", "location").Return(nil)
+ mockClient.EXPECT().ResumePrinter("newprinter").Return(nil)
+ mockClient.EXPECT().AcceptJobs("newprinter").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ assert.NoError(t, m.CreatePrinter("newprinter", "usb://HP", "generic.ppd", true, "stop-printer", "info", "location"))
+}
+
+func TestManager_DeletePrinter(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().DeletePrinter("printer1").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ assert.NoError(t, m.DeletePrinter("printer1"))
+}
+
+func TestManager_AcceptJobs(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().AcceptJobs("printer1").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ assert.NoError(t, m.AcceptJobs("printer1"))
+}
+
+func TestManager_RejectJobs(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().RejectJobs("printer1").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ assert.NoError(t, m.RejectJobs("printer1"))
+}
+
+func TestManager_SetPrinterShared(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().SetPrinterIsShared("printer1", true).Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ assert.NoError(t, m.SetPrinterShared("printer1", true))
+}
+
+func TestManager_SetPrinterLocation(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().SetPrinterLocation("printer1", "Office").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ assert.NoError(t, m.SetPrinterLocation("printer1", "Office"))
+}
+
+func TestManager_SetPrinterInfo(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().SetPrinterInformation("printer1", "Main Printer").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ assert.NoError(t, m.SetPrinterInfo("printer1", "Main Printer"))
+}
+
+func TestManager_MoveJob(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().MoveJob(1, "printer2").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ err := m.MoveJob(1, "printer2")
+ assert.NoError(t, err)
+}
+
+func TestManager_PrintTestPage(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().PrintTestPage("printer1", mock.Anything, mock.Anything).Return(42, nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ jobID, err := m.PrintTestPage("printer1")
+ assert.NoError(t, err)
+ assert.Equal(t, 42, jobID)
+}
+
+func TestManager_AddPrinterToClass(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().AddPrinterToClass("office", "printer1").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ assert.NoError(t, m.AddPrinterToClass("office", "printer1"))
+}
+
+func TestManager_RemovePrinterFromClass(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().DeletePrinterFromClass("office", "printer1").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ assert.NoError(t, m.RemovePrinterFromClass("office", "printer1"))
+}
+
+func TestManager_DeleteClass(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().DeleteClass("office").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ assert.NoError(t, m.DeleteClass("office"))
+}
+
+func TestManager_RestartJob(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().RestartJob(1).Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ assert.NoError(t, m.RestartJob(1))
+}
+
+func TestManager_HoldJob(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().HoldJobUntil(1, "indefinite").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ assert.NoError(t, m.HoldJob(1, "indefinite"))
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/handlers.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/handlers.go
new file mode 100644
index 0000000..57b45d5
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/handlers.go
@@ -0,0 +1,487 @@
+package cups
+
+import (
+ "encoding/json"
+ "fmt"
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/params"
+)
+
+type CUPSEvent struct {
+ Type string `json:"type"`
+ Data CUPSState `json:"data"`
+}
+
+type TestPageResult struct {
+ Success bool `json:"success"`
+ JobID int `json:"jobId"`
+ Message string `json:"message"`
+}
+
+func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
+ switch req.Method {
+ case "cups.subscribe":
+ handleSubscribe(conn, req, manager)
+ case "cups.getPrinters":
+ handleGetPrinters(conn, req, manager)
+ case "cups.getJobs":
+ handleGetJobs(conn, req, manager)
+ case "cups.pausePrinter":
+ handlePausePrinter(conn, req, manager)
+ case "cups.resumePrinter":
+ handleResumePrinter(conn, req, manager)
+ case "cups.cancelJob":
+ handleCancelJob(conn, req, manager)
+ case "cups.purgeJobs":
+ handlePurgeJobs(conn, req, manager)
+ case "cups.getDevices":
+ handleGetDevices(conn, req, manager)
+ case "cups.getPPDs":
+ handleGetPPDs(conn, req, manager)
+ case "cups.getClasses":
+ handleGetClasses(conn, req, manager)
+ case "cups.createPrinter":
+ handleCreatePrinter(conn, req, manager)
+ case "cups.deletePrinter":
+ handleDeletePrinter(conn, req, manager)
+ case "cups.acceptJobs":
+ handleAcceptJobs(conn, req, manager)
+ case "cups.rejectJobs":
+ handleRejectJobs(conn, req, manager)
+ case "cups.setPrinterShared":
+ handleSetPrinterShared(conn, req, manager)
+ case "cups.setPrinterLocation":
+ handleSetPrinterLocation(conn, req, manager)
+ case "cups.setPrinterInfo":
+ handleSetPrinterInfo(conn, req, manager)
+ case "cups.moveJob":
+ handleMoveJob(conn, req, manager)
+ case "cups.printTestPage":
+ handlePrintTestPage(conn, req, manager)
+ case "cups.addPrinterToClass":
+ handleAddPrinterToClass(conn, req, manager)
+ case "cups.removePrinterFromClass":
+ handleRemovePrinterFromClass(conn, req, manager)
+ case "cups.deleteClass":
+ handleDeleteClass(conn, req, manager)
+ case "cups.restartJob":
+ handleRestartJob(conn, req, manager)
+ case "cups.holdJob":
+ handleHoldJob(conn, req, manager)
+ case "cups.testConnection":
+ handleTestConnection(conn, req, manager)
+ default:
+ models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
+ }
+}
+
+func handleGetPrinters(conn net.Conn, req models.Request, manager *Manager) {
+ printers, err := manager.GetPrinters()
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, printers)
+}
+
+func handleGetJobs(conn net.Conn, req models.Request, manager *Manager) {
+ printerName, err := params.String(req.Params, "printerName")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ jobs, err := manager.GetJobs(printerName, "not-completed")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, jobs)
+}
+
+func handlePausePrinter(conn net.Conn, req models.Request, manager *Manager) {
+ printerName, err := params.String(req.Params, "printerName")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.PausePrinter(printerName); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "paused"})
+}
+
+func handleResumePrinter(conn net.Conn, req models.Request, manager *Manager) {
+ printerName, err := params.String(req.Params, "printerName")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.ResumePrinter(printerName); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "resumed"})
+}
+
+func handleCancelJob(conn net.Conn, req models.Request, manager *Manager) {
+ jobID, err := params.Int(req.Params, "jobID")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.CancelJob(jobID); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "job canceled"})
+}
+
+func handlePurgeJobs(conn net.Conn, req models.Request, manager *Manager) {
+ printerName, err := params.String(req.Params, "printerName")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.PurgeJobs(printerName); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "jobs canceled"})
+}
+
+func handleSubscribe(conn net.Conn, req models.Request, manager *Manager) {
+ clientID := fmt.Sprintf("client-%p", conn)
+ stateChan := manager.Subscribe(clientID)
+ defer manager.Unsubscribe(clientID)
+
+ initialState := manager.GetState()
+ event := CUPSEvent{
+ Type: "state_changed",
+ Data: initialState,
+ }
+
+ if err := json.NewEncoder(conn).Encode(models.Response[CUPSEvent]{
+ ID: req.ID,
+ Result: &event,
+ }); err != nil {
+ return
+ }
+
+ for state := range stateChan {
+ event := CUPSEvent{
+ Type: "state_changed",
+ Data: state,
+ }
+ if err := json.NewEncoder(conn).Encode(models.Response[CUPSEvent]{
+ Result: &event,
+ }); err != nil {
+ return
+ }
+ }
+}
+
+func handleGetDevices(conn net.Conn, req models.Request, manager *Manager) {
+ devices, err := manager.GetDevices()
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, devices)
+}
+
+func handleGetPPDs(conn net.Conn, req models.Request, manager *Manager) {
+ ppds, err := manager.GetPPDs()
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, ppds)
+}
+
+func handleGetClasses(conn net.Conn, req models.Request, manager *Manager) {
+ classes, err := manager.GetClasses()
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, classes)
+}
+
+func handleCreatePrinter(conn net.Conn, req models.Request, manager *Manager) {
+ name, err := params.StringNonEmpty(req.Params, "name")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ deviceURI, err := params.StringNonEmpty(req.Params, "deviceURI")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ ppd, err := params.StringNonEmpty(req.Params, "ppd")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ shared := params.BoolOpt(req.Params, "shared", false)
+ errorPolicy := params.StringOpt(req.Params, "errorPolicy", "")
+ information := params.StringOpt(req.Params, "information", "")
+ location := params.StringOpt(req.Params, "location", "")
+
+ if err := manager.CreatePrinter(name, deviceURI, ppd, shared, errorPolicy, information, location); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "printer created"})
+}
+
+func handleDeletePrinter(conn net.Conn, req models.Request, manager *Manager) {
+ printerName, err := params.StringNonEmpty(req.Params, "printerName")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.DeletePrinter(printerName); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "printer deleted"})
+}
+
+func handleAcceptJobs(conn net.Conn, req models.Request, manager *Manager) {
+ printerName, err := params.StringNonEmpty(req.Params, "printerName")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.AcceptJobs(printerName); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "accepting jobs"})
+}
+
+func handleRejectJobs(conn net.Conn, req models.Request, manager *Manager) {
+ printerName, err := params.StringNonEmpty(req.Params, "printerName")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.RejectJobs(printerName); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "rejecting jobs"})
+}
+
+func handleSetPrinterShared(conn net.Conn, req models.Request, manager *Manager) {
+ printerName, err := params.StringNonEmpty(req.Params, "printerName")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ shared, err := params.Bool(req.Params, "shared")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.SetPrinterShared(printerName, shared); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "sharing updated"})
+}
+
+func handleSetPrinterLocation(conn net.Conn, req models.Request, manager *Manager) {
+ printerName, err := params.StringNonEmpty(req.Params, "printerName")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ location, err := params.String(req.Params, "location")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.SetPrinterLocation(printerName, location); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "location updated"})
+}
+
+func handleSetPrinterInfo(conn net.Conn, req models.Request, manager *Manager) {
+ printerName, err := params.StringNonEmpty(req.Params, "printerName")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ info, err := params.String(req.Params, "info")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.SetPrinterInfo(printerName, info); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "info updated"})
+}
+
+func handleMoveJob(conn net.Conn, req models.Request, manager *Manager) {
+ jobID, err := params.Int(req.Params, "jobID")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ destPrinter, err := params.StringNonEmpty(req.Params, "destPrinter")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.MoveJob(jobID, destPrinter); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "job moved"})
+}
+
+func handlePrintTestPage(conn net.Conn, req models.Request, manager *Manager) {
+ printerName, err := params.StringNonEmpty(req.Params, "printerName")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ jobID, err := manager.PrintTestPage(printerName)
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, TestPageResult{Success: true, JobID: jobID, Message: "test page queued"})
+}
+
+func handleAddPrinterToClass(conn net.Conn, req models.Request, manager *Manager) {
+ className, err := params.StringNonEmpty(req.Params, "className")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ printerName, err := params.StringNonEmpty(req.Params, "printerName")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.AddPrinterToClass(className, printerName); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "printer added to class"})
+}
+
+func handleRemovePrinterFromClass(conn net.Conn, req models.Request, manager *Manager) {
+ className, err := params.StringNonEmpty(req.Params, "className")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ printerName, err := params.StringNonEmpty(req.Params, "printerName")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.RemovePrinterFromClass(className, printerName); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "printer removed from class"})
+}
+
+func handleDeleteClass(conn net.Conn, req models.Request, manager *Manager) {
+ className, err := params.StringNonEmpty(req.Params, "className")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.DeleteClass(className); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "class deleted"})
+}
+
+func handleRestartJob(conn net.Conn, req models.Request, manager *Manager) {
+ jobID, err := params.Int(req.Params, "jobID")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.RestartJob(jobID); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "job restarted"})
+}
+
+func handleHoldJob(conn net.Conn, req models.Request, manager *Manager) {
+ jobID, err := params.Int(req.Params, "jobID")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ holdUntil := params.StringOpt(req.Params, "holdUntil", "indefinite")
+
+ if err := manager.HoldJob(jobID, holdUntil); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "job held"})
+}
+
+func handleTestConnection(conn net.Conn, req models.Request, manager *Manager) {
+ host, err := params.StringNonEmpty(req.Params, "host")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ port := params.IntOpt(req.Params, "port", 631)
+ protocol := params.StringOpt(req.Params, "protocol", "ipp")
+
+ result, err := manager.TestRemotePrinter(host, port, protocol)
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, result)
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/handlers_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/handlers_test.go
new file mode 100644
index 0000000..b73931e
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/handlers_test.go
@@ -0,0 +1,711 @@
+package cups
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "net"
+ "testing"
+ "time"
+
+ mocks_cups "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/cups"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/ipp"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+)
+
+type mockConn struct {
+ *bytes.Buffer
+}
+
+func (m *mockConn) Close() error { return nil }
+func (m *mockConn) LocalAddr() net.Addr { return nil }
+func (m *mockConn) RemoteAddr() net.Addr { return nil }
+func (m *mockConn) SetDeadline(t time.Time) error { return nil }
+func (m *mockConn) SetReadDeadline(t time.Time) error { return nil }
+func (m *mockConn) SetWriteDeadline(t time.Time) error { return nil }
+
+func TestHandleGetPrinters(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{
+ "printer1": {
+ ipp.AttributePrinterName: []ipp.Attribute{{Value: "printer1"}},
+ ipp.AttributePrinterState: []ipp.Attribute{{Value: 3}},
+ ipp.AttributePrinterUriSupported: []ipp.Attribute{{Value: "ipp://localhost/printers/printer1"}},
+ },
+ }, nil)
+
+ m := &Manager{
+ client: mockClient,
+ }
+
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.getPrinters",
+ }
+
+ handleGetPrinters(conn, req, m)
+
+ var resp models.Response[[]Printer]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.Equal(t, 1, len(*resp.Result))
+}
+
+func TestHandleGetPrinters_Error(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(nil, errors.New("test error"))
+
+ m := &Manager{
+ client: mockClient,
+ }
+
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.getPrinters",
+ }
+
+ handleGetPrinters(conn, req, m)
+
+ var resp models.Response[any]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.Nil(t, resp.Result)
+ assert.NotNil(t, resp.Error)
+}
+
+func TestHandleGetJobs(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().GetJobs("printer1", "", "not-completed", false, 0, 0, mock.Anything).
+ Return(map[int]ipp.Attributes{
+ 1: {
+ ipp.AttributeJobID: []ipp.Attribute{{Value: 1}},
+ ipp.AttributeJobName: []ipp.Attribute{{Value: "job1"}},
+ ipp.AttributeJobState: []ipp.Attribute{{Value: 5}},
+ },
+ }, nil)
+
+ m := &Manager{
+ client: mockClient,
+ }
+
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.getJobs",
+ Params: map[string]any{
+ "printerName": "printer1",
+ },
+ }
+
+ handleGetJobs(conn, req, m)
+
+ var resp models.Response[[]Job]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.Equal(t, 1, len(*resp.Result))
+}
+
+func TestHandleGetJobs_MissingParam(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+
+ m := &Manager{
+ client: mockClient,
+ }
+
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.getJobs",
+ Params: map[string]any{},
+ }
+
+ handleGetJobs(conn, req, m)
+
+ var resp models.Response[any]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.Nil(t, resp.Result)
+ assert.NotNil(t, resp.Error)
+}
+
+func TestHandlePausePrinter(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().PausePrinter("printer1").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.pausePrinter",
+ Params: map[string]any{
+ "printerName": "printer1",
+ },
+ }
+
+ handlePausePrinter(conn, req, m)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+}
+
+func TestHandleResumePrinter(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().ResumePrinter("printer1").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.resumePrinter",
+ Params: map[string]any{
+ "printerName": "printer1",
+ },
+ }
+
+ handleResumePrinter(conn, req, m)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+}
+
+func TestHandleCancelJob(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().CancelJob(1, false).Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.cancelJob",
+ Params: map[string]any{
+ "jobID": float64(1),
+ },
+ }
+
+ handleCancelJob(conn, req, m)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+}
+
+func TestHandlePurgeJobs(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().CancelAllJob("printer1", true).Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.purgeJobs",
+ Params: map[string]any{
+ "printerName": "printer1",
+ },
+ }
+
+ handlePurgeJobs(conn, req, m)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+}
+
+func TestHandleRequest_UnknownMethod(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+
+ m := &Manager{
+ client: mockClient,
+ }
+
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.unknownMethod",
+ }
+
+ HandleRequest(conn, req, m)
+
+ var resp models.Response[any]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.Nil(t, resp.Result)
+ assert.NotNil(t, resp.Error)
+}
+
+func TestHandleGetDevices(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().GetDevices().Return(map[string]ipp.Attributes{
+ "usb://HP/LaserJet": {
+ "device-class": []ipp.Attribute{{Value: "direct"}},
+ "device-info": []ipp.Attribute{{Value: "HP LaserJet"}},
+ },
+ }, nil)
+
+ m := &Manager{client: mockClient}
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{ID: 1, Method: "cups.getDevices"}
+ handleGetDevices(conn, req, m)
+
+ var resp models.Response[[]Device]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.Equal(t, 1, len(*resp.Result))
+}
+
+func TestHandleGetPPDs(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().GetPPDs().Return(map[string]ipp.Attributes{
+ "generic.ppd": {
+ "ppd-make-and-model": []ipp.Attribute{{Value: "Generic"}},
+ },
+ }, nil)
+
+ m := &Manager{client: mockClient}
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{ID: 1, Method: "cups.getPPDs"}
+ handleGetPPDs(conn, req, m)
+
+ var resp models.Response[[]PPD]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.Equal(t, 1, len(*resp.Result))
+}
+
+func TestHandleGetClasses(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().GetClasses(mock.Anything).Return(map[string]ipp.Attributes{
+ "office": {
+ ipp.AttributePrinterName: []ipp.Attribute{{Value: "office"}},
+ ipp.AttributePrinterState: []ipp.Attribute{{Value: 3}},
+ },
+ }, nil)
+
+ m := &Manager{client: mockClient}
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{ID: 1, Method: "cups.getClasses"}
+ handleGetClasses(conn, req, m)
+
+ var resp models.Response[[]PrinterClass]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.Equal(t, 1, len(*resp.Result))
+}
+
+func TestHandleCreatePrinter(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().CreatePrinter("newprinter", "usb://HP", "generic.ppd", false, "", "", "").Return(nil)
+ mockClient.EXPECT().ResumePrinter("newprinter").Return(nil)
+ mockClient.EXPECT().AcceptJobs("newprinter").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.createPrinter",
+ Params: map[string]any{
+ "name": "newprinter",
+ "deviceURI": "usb://HP",
+ "ppd": "generic.ppd",
+ },
+ }
+ handleCreatePrinter(conn, req, m)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+}
+
+func TestHandleCreatePrinter_MissingParams(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ m := &Manager{client: mockClient}
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{ID: 1, Method: "cups.createPrinter", Params: map[string]any{}}
+ handleCreatePrinter(conn, req, m)
+
+ var resp models.Response[any]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.Nil(t, resp.Result)
+ assert.NotNil(t, resp.Error)
+}
+
+func TestHandleDeletePrinter(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().DeletePrinter("printer1").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.deletePrinter",
+ Params: map[string]any{"printerName": "printer1"},
+ }
+ handleDeletePrinter(conn, req, m)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+}
+
+func TestHandleAcceptJobs(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().AcceptJobs("printer1").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.acceptJobs",
+ Params: map[string]any{"printerName": "printer1"},
+ }
+ handleAcceptJobs(conn, req, m)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+}
+
+func TestHandleRejectJobs(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().RejectJobs("printer1").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.rejectJobs",
+ Params: map[string]any{"printerName": "printer1"},
+ }
+ handleRejectJobs(conn, req, m)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+}
+
+func TestHandleSetPrinterShared(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().SetPrinterIsShared("printer1", true).Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.setPrinterShared",
+ Params: map[string]any{"printerName": "printer1", "shared": true},
+ }
+ handleSetPrinterShared(conn, req, m)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+}
+
+func TestHandleSetPrinterLocation(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().SetPrinterLocation("printer1", "Office").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.setPrinterLocation",
+ Params: map[string]any{"printerName": "printer1", "location": "Office"},
+ }
+ handleSetPrinterLocation(conn, req, m)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+}
+
+func TestHandleSetPrinterInfo(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().SetPrinterInformation("printer1", "Main Printer").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.setPrinterInfo",
+ Params: map[string]any{"printerName": "printer1", "info": "Main Printer"},
+ }
+ handleSetPrinterInfo(conn, req, m)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+}
+
+func TestHandleMoveJob(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().MoveJob(1, "printer2").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.moveJob",
+ Params: map[string]any{"jobID": float64(1), "destPrinter": "printer2"},
+ }
+ handleMoveJob(conn, req, m)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+}
+
+func TestHandlePrintTestPage(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().PrintTestPage("printer1", mock.Anything, mock.Anything).Return(42, nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.printTestPage",
+ Params: map[string]any{"printerName": "printer1"},
+ }
+ handlePrintTestPage(conn, req, m)
+
+ var resp models.Response[TestPageResult]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+ assert.Equal(t, 42, resp.Result.JobID)
+}
+
+func TestHandleAddPrinterToClass(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().AddPrinterToClass("office", "printer1").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.addPrinterToClass",
+ Params: map[string]any{"className": "office", "printerName": "printer1"},
+ }
+ handleAddPrinterToClass(conn, req, m)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+}
+
+func TestHandleRemovePrinterFromClass(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().DeletePrinterFromClass("office", "printer1").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.removePrinterFromClass",
+ Params: map[string]any{"className": "office", "printerName": "printer1"},
+ }
+ handleRemovePrinterFromClass(conn, req, m)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+}
+
+func TestHandleDeleteClass(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().DeleteClass("office").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.deleteClass",
+ Params: map[string]any{"className": "office"},
+ }
+ handleDeleteClass(conn, req, m)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+}
+
+func TestHandleRestartJob(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().RestartJob(1).Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.restartJob",
+ Params: map[string]any{"jobID": float64(1)},
+ }
+ handleRestartJob(conn, req, m)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+}
+
+func TestHandleHoldJob(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().HoldJobUntil(1, "indefinite").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.holdJob",
+ Params: map[string]any{"jobID": float64(1)},
+ }
+ handleHoldJob(conn, req, m)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+}
+
+func TestHandleHoldJob_WithHoldUntil(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().HoldJobUntil(1, "no-hold").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.holdJob",
+ Params: map[string]any{"jobID": float64(1), "holdUntil": "no-hold"},
+ }
+ handleHoldJob(conn, req, m)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/manager.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/manager.go
new file mode 100644
index 0000000..e5b7e8c
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/manager.go
@@ -0,0 +1,390 @@
+package cups
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "strconv"
+ "sync"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/ipp"
+)
+
+func NewManager() (*Manager, error) {
+ host := os.Getenv("DMS_IPP_HOST")
+ if host == "" {
+ host = "localhost"
+ }
+
+ portStr := os.Getenv("DMS_IPP_PORT")
+ port := 631
+ if portStr != "" {
+ if p, err := strconv.Atoi(portStr); err == nil {
+ port = p
+ }
+ }
+
+ username := os.Getenv("DMS_IPP_USERNAME")
+ password := os.Getenv("DMS_IPP_PASSWORD")
+
+ client := ipp.NewCUPSClient(host, port, username, password, false)
+ baseURL := fmt.Sprintf("http://%s:%d", host, port)
+
+ var pkHelper PkHelper
+ if isLocalCUPS(host) {
+ var err error
+ pkHelper, err = NewPkHelper()
+ if err != nil {
+ log.Warnf("[CUPS] Failed to initialize pkhelper: %v", err)
+ }
+ }
+
+ m := &Manager{
+ state: &CUPSState{
+ Printers: make(map[string]*Printer),
+ },
+ client: client,
+ pkHelper: pkHelper,
+ baseURL: baseURL,
+ stateMutex: sync.RWMutex{},
+ stopChan: make(chan struct{}),
+ dirty: make(chan struct{}, 1),
+ }
+
+ if err := m.updateState(); err != nil {
+ return nil, err
+ }
+
+ if isLocalCUPS(host) {
+ m.subscription = NewDBusSubscriptionManager(client, baseURL)
+ log.Infof("[CUPS] Using D-Bus notifications for local CUPS")
+ } else {
+ m.subscription = NewSubscriptionManager(client, baseURL)
+ log.Infof("[CUPS] Using IPPGET notifications for remote CUPS")
+ }
+
+ m.notifierWg.Add(1)
+ go m.notifier()
+
+ return m, nil
+}
+
+func isLocalCUPS(host string) bool {
+ switch host {
+ case "localhost", "127.0.0.1", "::1", "":
+ return true
+ }
+ return false
+}
+
+func (m *Manager) eventHandler() {
+ defer m.eventWG.Done()
+
+ if m.subscription == nil {
+ return
+ }
+
+ for {
+ select {
+ case <-m.stopChan:
+ return
+ case event, ok := <-m.subscription.Events():
+ if !ok {
+ return
+ }
+ log.Debugf("[CUPS] Received event: %s (printer: %s, job: %d)",
+ event.EventName, event.PrinterName, event.JobID)
+
+ if err := m.updateState(); err != nil {
+ log.Warnf("[CUPS] Failed to update state after event: %v", err)
+ } else {
+ m.notifySubscribers()
+ }
+ }
+ }
+}
+
+func (m *Manager) updateState() error {
+ printers, err := m.GetPrinters()
+ if err != nil {
+ if isNoPrintersError(err) {
+ m.stateMutex.Lock()
+ m.state.Printers = make(map[string]*Printer)
+ m.stateMutex.Unlock()
+ return nil
+ }
+ return err
+ }
+
+ printerMap := make(map[string]*Printer, len(printers))
+ for _, printer := range printers {
+ jobs, err := m.GetJobs(printer.Name, "not-completed")
+ if err != nil {
+ return err
+ }
+
+ printer.Jobs = jobs
+ printerMap[printer.Name] = &printer
+ }
+
+ m.stateMutex.Lock()
+ m.state.Printers = printerMap
+ m.stateMutex.Unlock()
+
+ return nil
+}
+
+func isNoPrintersError(err error) bool {
+ if err == nil {
+ return false
+ }
+
+ var ippErr ipp.IPPError
+ if errors.As(err, &ippErr) {
+ return ippErr.Status == 1030
+ }
+
+ return false
+}
+
+func (m *Manager) notifier() {
+ defer m.notifierWg.Done()
+ const minGap = 100 * time.Millisecond
+ timer := time.NewTimer(minGap)
+ timer.Stop()
+ var pending bool
+ for {
+ select {
+ case <-m.stopChan:
+ timer.Stop()
+ return
+ case <-m.dirty:
+ if pending {
+ continue
+ }
+ pending = true
+ timer.Reset(minGap)
+ case <-timer.C:
+ if !pending {
+ continue
+ }
+
+ currentState := m.snapshotState()
+
+ if m.lastNotifiedState != nil && !stateChanged(m.lastNotifiedState, &currentState) {
+ pending = false
+ continue
+ }
+
+ m.subscribers.Range(func(key string, ch chan CUPSState) bool {
+ select {
+ case ch <- currentState:
+ default:
+ }
+ return true
+ })
+
+ stateCopy := currentState
+ m.lastNotifiedState = &stateCopy
+ pending = false
+ }
+ }
+}
+
+func (m *Manager) notifySubscribers() {
+ select {
+ case m.dirty <- struct{}{}:
+ default:
+ }
+}
+
+func (m *Manager) RefreshState() {
+ if err := m.updateState(); err != nil {
+ log.Warnf("[CUPS] Failed to refresh state: %v", err)
+ return
+ }
+ m.notifySubscribers()
+}
+
+func (m *Manager) GetState() CUPSState {
+ return m.snapshotState()
+}
+
+func (m *Manager) snapshotState() CUPSState {
+ m.stateMutex.RLock()
+ defer m.stateMutex.RUnlock()
+
+ s := CUPSState{
+ Printers: make(map[string]*Printer, len(m.state.Printers)),
+ }
+ for name, printer := range m.state.Printers {
+ printerCopy := *printer
+ s.Printers[name] = &printerCopy
+ }
+ return s
+}
+
+func (m *Manager) Subscribe(id string) chan CUPSState {
+ ch := make(chan CUPSState, 64)
+
+ wasEmpty := true
+ m.subscribers.Range(func(key string, ch chan CUPSState) bool {
+ wasEmpty = false
+ return false
+ })
+
+ m.subscribers.Store(id, ch)
+
+ if wasEmpty && m.subscription != nil {
+ if err := m.subscription.Start(); err != nil {
+ log.Warnf("[CUPS] Failed to start subscription manager: %v", err)
+ } else {
+ m.eventWG.Add(1)
+ go m.eventHandler()
+ }
+ }
+
+ return ch
+}
+
+func (m *Manager) Unsubscribe(id string) {
+ if val, ok := m.subscribers.LoadAndDelete(id); ok {
+ close(val)
+ }
+
+ isEmpty := true
+ m.subscribers.Range(func(key string, ch chan CUPSState) bool {
+ isEmpty = false
+ return false
+ })
+
+ if isEmpty && m.subscription != nil {
+ m.subscription.Stop()
+ m.eventWG.Wait()
+ }
+}
+
+func (m *Manager) Close() {
+ close(m.stopChan)
+
+ if m.subscription != nil {
+ m.subscription.Stop()
+ }
+
+ m.eventWG.Wait()
+ m.notifierWg.Wait()
+
+ m.subscribers.Range(func(key string, ch chan CUPSState) bool {
+ close(ch)
+ m.subscribers.Delete(key)
+ return true
+ })
+}
+
+func stateChanged(old, new *CUPSState) bool {
+ if len(old.Printers) != len(new.Printers) {
+ return true
+ }
+ for name, oldPrinter := range old.Printers {
+ newPrinter, exists := new.Printers[name]
+ if !exists {
+ return true
+ }
+ if oldPrinter.State != newPrinter.State ||
+ oldPrinter.StateReason != newPrinter.StateReason ||
+ oldPrinter.Accepting != newPrinter.Accepting ||
+ len(oldPrinter.Jobs) != len(newPrinter.Jobs) {
+ return true
+ }
+ }
+ return false
+}
+
+func parsePrinterState(attrs ipp.Attributes) string {
+ if stateAttr, ok := attrs[ipp.AttributePrinterState]; ok && len(stateAttr) > 0 {
+ if state, ok := stateAttr[0].Value.(int); ok {
+ switch state {
+ case 3:
+ return "idle"
+ case 4:
+ return "processing"
+ case 5:
+ return "stopped"
+ default:
+ return fmt.Sprintf("%d", state)
+ }
+ }
+ }
+ return "unknown"
+}
+
+func parseJobState(attrs ipp.Attributes) string {
+ if stateAttr, ok := attrs[ipp.AttributeJobState]; ok && len(stateAttr) > 0 {
+ if state, ok := stateAttr[0].Value.(int); ok {
+ switch state {
+ case 3:
+ return "pending"
+ case 4:
+ return "pending-held"
+ case 5:
+ return "processing"
+ case 6:
+ return "processing-stopped"
+ case 7:
+ return "canceled"
+ case 8:
+ return "aborted"
+ case 9:
+ return "completed"
+ default:
+ return fmt.Sprintf("%d", state)
+ }
+ }
+ }
+ return "unknown"
+}
+
+func getStringAttr(attrs ipp.Attributes, key string) string {
+ if attr, ok := attrs[key]; ok && len(attr) > 0 {
+ if val, ok := attr[0].Value.(string); ok {
+ return val
+ }
+ return fmt.Sprintf("%v", attr[0].Value)
+ }
+ return ""
+}
+
+func getIntAttr(attrs ipp.Attributes, key string) int {
+ if attr, ok := attrs[key]; ok && len(attr) > 0 {
+ if val, ok := attr[0].Value.(int); ok {
+ return val
+ }
+ }
+ return 0
+}
+
+func getBoolAttr(attrs ipp.Attributes, key string) bool {
+ if attr, ok := attrs[key]; ok && len(attr) > 0 {
+ if val, ok := attr[0].Value.(bool); ok {
+ return val
+ }
+ }
+ return false
+}
+
+func getStringSliceAttr(attrs ipp.Attributes, key string) []string {
+ attr, ok := attrs[key]
+ if !ok {
+ return nil
+ }
+
+ result := make([]string, 0, len(attr))
+ for _, a := range attr {
+ if val, ok := a.Value.(string); ok {
+ result = append(result, val)
+ }
+ }
+ return result
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/manager_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/manager_test.go
new file mode 100644
index 0000000..7f09e67
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/manager_test.go
@@ -0,0 +1,363 @@
+package cups
+
+import (
+ "testing"
+
+ mocks_cups "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/cups"
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/ipp"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestNewManager(t *testing.T) {
+ m := &Manager{
+ state: &CUPSState{
+ Printers: make(map[string]*Printer),
+ },
+ client: nil,
+ stopChan: make(chan struct{}),
+ dirty: make(chan struct{}, 1),
+ }
+
+ assert.NotNil(t, m)
+ assert.NotNil(t, m.state)
+}
+
+func TestManager_GetState(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+
+ m := &Manager{
+ state: &CUPSState{
+ Printers: map[string]*Printer{
+ "test-printer": {
+ Name: "test-printer",
+ State: "idle",
+ },
+ },
+ },
+ client: mockClient,
+ stopChan: make(chan struct{}),
+ dirty: make(chan struct{}, 1),
+ }
+
+ state := m.GetState()
+ assert.Equal(t, 1, len(state.Printers))
+ assert.Equal(t, "test-printer", state.Printers["test-printer"].Name)
+}
+
+func TestManager_Subscribe(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+
+ m := &Manager{
+ state: &CUPSState{
+ Printers: make(map[string]*Printer),
+ },
+ client: mockClient,
+ stopChan: make(chan struct{}),
+ dirty: make(chan struct{}, 1),
+ }
+
+ ch := m.Subscribe("test-client")
+ assert.NotNil(t, ch)
+
+ count := 0
+ m.subscribers.Range(func(key string, ch chan CUPSState) bool {
+ count++
+ return true
+ })
+ assert.Equal(t, 1, count)
+
+ m.Unsubscribe("test-client")
+ count = 0
+ m.subscribers.Range(func(key string, ch chan CUPSState) bool {
+ count++
+ return true
+ })
+ assert.Equal(t, 0, count)
+}
+
+func TestManager_Close(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+
+ m := &Manager{
+ state: &CUPSState{
+ Printers: make(map[string]*Printer),
+ },
+ client: mockClient,
+ stopChan: make(chan struct{}),
+ dirty: make(chan struct{}, 1),
+ }
+
+ m.eventWG.Add(1)
+ go func() {
+ defer m.eventWG.Done()
+ <-m.stopChan
+ }()
+
+ m.notifierWg.Add(1)
+ go func() {
+ defer m.notifierWg.Done()
+ <-m.stopChan
+ }()
+
+ m.Close()
+ count := 0
+ m.subscribers.Range(func(key string, ch chan CUPSState) bool {
+ count++
+ return true
+ })
+ assert.Equal(t, 0, count)
+}
+
+func TestStateChanged(t *testing.T) {
+ tests := []struct {
+ name string
+ oldState *CUPSState
+ newState *CUPSState
+ want bool
+ }{
+ {
+ name: "no change",
+ oldState: &CUPSState{
+ Printers: map[string]*Printer{
+ "p1": {Name: "p1", State: "idle"},
+ },
+ },
+ newState: &CUPSState{
+ Printers: map[string]*Printer{
+ "p1": {Name: "p1", State: "idle"},
+ },
+ },
+ want: false,
+ },
+ {
+ name: "state changed",
+ oldState: &CUPSState{
+ Printers: map[string]*Printer{
+ "p1": {Name: "p1", State: "idle"},
+ },
+ },
+ newState: &CUPSState{
+ Printers: map[string]*Printer{
+ "p1": {Name: "p1", State: "processing"},
+ },
+ },
+ want: true,
+ },
+ {
+ name: "printer added",
+ oldState: &CUPSState{
+ Printers: map[string]*Printer{},
+ },
+ newState: &CUPSState{
+ Printers: map[string]*Printer{
+ "p1": {Name: "p1", State: "idle"},
+ },
+ },
+ want: true,
+ },
+ {
+ name: "printer removed",
+ oldState: &CUPSState{
+ Printers: map[string]*Printer{
+ "p1": {Name: "p1", State: "idle"},
+ },
+ },
+ newState: &CUPSState{
+ Printers: map[string]*Printer{},
+ },
+ want: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := stateChanged(tt.oldState, tt.newState)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func TestParsePrinterState(t *testing.T) {
+ tests := []struct {
+ name string
+ attrs ipp.Attributes
+ want string
+ }{
+ {
+ name: "idle",
+ attrs: ipp.Attributes{
+ ipp.AttributePrinterState: []ipp.Attribute{{Value: 3}},
+ },
+ want: "idle",
+ },
+ {
+ name: "processing",
+ attrs: ipp.Attributes{
+ ipp.AttributePrinterState: []ipp.Attribute{{Value: 4}},
+ },
+ want: "processing",
+ },
+ {
+ name: "stopped",
+ attrs: ipp.Attributes{
+ ipp.AttributePrinterState: []ipp.Attribute{{Value: 5}},
+ },
+ want: "stopped",
+ },
+ {
+ name: "unknown",
+ attrs: ipp.Attributes{},
+ want: "unknown",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := parsePrinterState(tt.attrs)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func TestParseJobState(t *testing.T) {
+ tests := []struct {
+ name string
+ attrs ipp.Attributes
+ want string
+ }{
+ {
+ name: "pending",
+ attrs: ipp.Attributes{
+ ipp.AttributeJobState: []ipp.Attribute{{Value: 3}},
+ },
+ want: "pending",
+ },
+ {
+ name: "processing",
+ attrs: ipp.Attributes{
+ ipp.AttributeJobState: []ipp.Attribute{{Value: 5}},
+ },
+ want: "processing",
+ },
+ {
+ name: "completed",
+ attrs: ipp.Attributes{
+ ipp.AttributeJobState: []ipp.Attribute{{Value: 9}},
+ },
+ want: "completed",
+ },
+ {
+ name: "unknown",
+ attrs: ipp.Attributes{},
+ want: "unknown",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := parseJobState(tt.attrs)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func TestGetStringAttr(t *testing.T) {
+ tests := []struct {
+ name string
+ attrs ipp.Attributes
+ key string
+ want string
+ }{
+ {
+ name: "string value",
+ attrs: ipp.Attributes{
+ "test-key": []ipp.Attribute{{Value: "test-value"}},
+ },
+ key: "test-key",
+ want: "test-value",
+ },
+ {
+ name: "missing key",
+ attrs: ipp.Attributes{},
+ key: "missing",
+ want: "",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := getStringAttr(tt.attrs, tt.key)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func TestGetIntAttr(t *testing.T) {
+ tests := []struct {
+ name string
+ attrs ipp.Attributes
+ key string
+ want int
+ }{
+ {
+ name: "int value",
+ attrs: ipp.Attributes{
+ "test-key": []ipp.Attribute{{Value: 42}},
+ },
+ key: "test-key",
+ want: 42,
+ },
+ {
+ name: "missing key",
+ attrs: ipp.Attributes{},
+ key: "missing",
+ want: 0,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := getIntAttr(tt.attrs, tt.key)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func TestGetBoolAttr(t *testing.T) {
+ tests := []struct {
+ name string
+ attrs ipp.Attributes
+ key string
+ want bool
+ }{
+ {
+ name: "true value",
+ attrs: ipp.Attributes{
+ "test-key": []ipp.Attribute{{Value: true}},
+ },
+ key: "test-key",
+ want: true,
+ },
+ {
+ name: "false value",
+ attrs: ipp.Attributes{
+ "test-key": []ipp.Attribute{{Value: false}},
+ },
+ key: "test-key",
+ want: false,
+ },
+ {
+ name: "missing key",
+ attrs: ipp.Attributes{},
+ key: "missing",
+ want: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := getBoolAttr(tt.attrs, tt.key)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/pkhelper.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/pkhelper.go
new file mode 100644
index 0000000..81dd458
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/pkhelper.go
@@ -0,0 +1,184 @@
+package cups
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/godbus/dbus/v5"
+)
+
+const (
+ pkHelperDest = "org.opensuse.CupsPkHelper.Mechanism"
+ pkHelperPath = "/"
+ pkHelperInterface = "org.opensuse.CupsPkHelper.Mechanism"
+)
+
+type PkHelper interface {
+ DevicesGet(timeout, limit int, includeSchemes, excludeSchemes []string) ([]Device, error)
+ PrinterAdd(name, uri, ppd, info, location string) error
+ PrinterDelete(name string) error
+ PrinterSetEnabled(name string, enabled bool) error
+ PrinterSetAcceptJobs(name string, enabled bool, reason string) error
+ PrinterSetInfo(name, info string) error
+ PrinterSetLocation(name, location string) error
+ PrinterSetShared(name string, shared bool) error
+ ClassAddPrinter(className, printerName string) error
+ ClassDeletePrinter(className, printerName string) error
+ ClassDelete(className string) error
+ JobCancelPurge(jobID int, purge bool) error
+ JobRestart(jobID int) error
+ JobSetHoldUntil(jobID int, holdUntil string) error
+}
+
+type DBusPkHelper struct {
+ conn *dbus.Conn
+ obj dbus.BusObject
+}
+
+func NewPkHelper() (*DBusPkHelper, error) {
+ conn, err := dbus.SystemBus()
+ if err != nil {
+ return nil, fmt.Errorf("failed to connect to system bus: %w", err)
+ }
+
+ return &DBusPkHelper{
+ conn: conn,
+ obj: conn.Object(pkHelperDest, pkHelperPath),
+ }, nil
+}
+
+func (p *DBusPkHelper) DevicesGet(timeout, limit int, includeSchemes, excludeSchemes []string) ([]Device, error) {
+ if includeSchemes == nil {
+ includeSchemes = []string{}
+ }
+ if excludeSchemes == nil {
+ excludeSchemes = []string{}
+ }
+
+ var errStr string
+ var devicesMap map[string]string
+
+ call := p.obj.Call(pkHelperInterface+".DevicesGet", 0, int32(timeout), int32(limit), includeSchemes, excludeSchemes)
+ if call.Err != nil {
+ return nil, call.Err
+ }
+ if err := call.Store(&errStr, &devicesMap); err != nil {
+ return nil, err
+ }
+ if errStr != "" {
+ return nil, fmt.Errorf("%s", errStr)
+ }
+
+ return parseDevicesMap(devicesMap), nil
+}
+
+func parseDevicesMap(devicesMap map[string]string) []Device {
+ devicesByIndex := make(map[string]*Device)
+
+ for key, value := range devicesMap {
+ idx := strings.LastIndex(key, ":")
+ if idx == -1 {
+ continue
+ }
+
+ attr := key[:idx]
+ index := key[idx+1:]
+
+ dev, ok := devicesByIndex[index]
+ if !ok {
+ dev = &Device{}
+ devicesByIndex[index] = dev
+ }
+
+ switch attr {
+ case "device-uri":
+ dev.URI = value
+ case "device-class":
+ dev.Class = value
+ case "device-info":
+ dev.Info = value
+ case "device-make-and-model":
+ dev.MakeModel = value
+ case "device-id":
+ dev.ID = value
+ case "device-location":
+ dev.Location = value
+ }
+ }
+
+ devices := make([]Device, 0, len(devicesByIndex))
+ for _, dev := range devicesByIndex {
+ if dev.URI != "" {
+ devices = append(devices, *dev)
+ }
+ }
+ return devices
+}
+
+func (p *DBusPkHelper) PrinterAdd(name, uri, ppd, info, location string) error {
+ return p.callSimple("PrinterAdd", name, uri, ppd, info, location)
+}
+
+func (p *DBusPkHelper) PrinterDelete(name string) error {
+ return p.callSimple("PrinterDelete", name)
+}
+
+func (p *DBusPkHelper) PrinterSetEnabled(name string, enabled bool) error {
+ return p.callSimple("PrinterSetEnabled", name, enabled)
+}
+
+func (p *DBusPkHelper) PrinterSetAcceptJobs(name string, enabled bool, reason string) error {
+ return p.callSimple("PrinterSetAcceptJobs", name, enabled, reason)
+}
+
+func (p *DBusPkHelper) PrinterSetInfo(name, info string) error {
+ return p.callSimple("PrinterSetInfo", name, info)
+}
+
+func (p *DBusPkHelper) PrinterSetLocation(name, location string) error {
+ return p.callSimple("PrinterSetLocation", name, location)
+}
+
+func (p *DBusPkHelper) PrinterSetShared(name string, shared bool) error {
+ return p.callSimple("PrinterSetShared", name, shared)
+}
+
+func (p *DBusPkHelper) ClassAddPrinter(className, printerName string) error {
+ return p.callSimple("ClassAddPrinter", className, printerName)
+}
+
+func (p *DBusPkHelper) ClassDeletePrinter(className, printerName string) error {
+ return p.callSimple("ClassDeletePrinter", className, printerName)
+}
+
+func (p *DBusPkHelper) ClassDelete(className string) error {
+ return p.callSimple("ClassDelete", className)
+}
+
+func (p *DBusPkHelper) JobCancelPurge(jobID int, purge bool) error {
+ return p.callSimple("JobCancelPurge", int32(jobID), purge)
+}
+
+func (p *DBusPkHelper) JobRestart(jobID int) error {
+ return p.callSimple("JobRestart", int32(jobID))
+}
+
+func (p *DBusPkHelper) JobSetHoldUntil(jobID int, holdUntil string) error {
+ return p.callSimple("JobSetHoldUntil", int32(jobID), holdUntil)
+}
+
+func (p *DBusPkHelper) callSimple(method string, args ...any) error {
+ var errStr string
+
+ call := p.obj.Call(pkHelperInterface+"."+method, 0, args...)
+ if call.Err != nil {
+ return call.Err
+ }
+ if err := call.Store(&errStr); err != nil {
+ return err
+ }
+ if errStr != "" {
+ return fmt.Errorf("%s", errStr)
+ }
+ return nil
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/pkhelper_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/pkhelper_test.go
new file mode 100644
index 0000000..596f1d0
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/pkhelper_test.go
@@ -0,0 +1,95 @@
+package cups
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestParseDevicesMap(t *testing.T) {
+ tests := []struct {
+ name string
+ input map[string]string
+ wantLen int
+ wantURIs []string
+ }{
+ {
+ name: "empty",
+ input: map[string]string{},
+ wantLen: 0,
+ wantURIs: nil,
+ },
+ {
+ name: "single_device",
+ input: map[string]string{
+ "device-uri:0": "usb://HP/LaserJet",
+ "device-class:0": "direct",
+ "device-info:0": "HP LaserJet",
+ "device-make-and-model:0": "HP LaserJet 1020",
+ "device-id:0": "MFG:HP;MDL:LaserJet",
+ },
+ wantLen: 1,
+ wantURIs: []string{"usb://HP/LaserJet"},
+ },
+ {
+ name: "multiple_devices",
+ input: map[string]string{
+ "device-uri:0": "usb://HP/LaserJet",
+ "device-class:0": "direct",
+ "device-info:0": "HP LaserJet",
+ "device-uri:1": "socket://192.168.1.100",
+ "device-class:1": "network",
+ "device-info:1": "Network Printer",
+ },
+ wantLen: 2,
+ wantURIs: []string{"usb://HP/LaserJet", "socket://192.168.1.100"},
+ },
+ {
+ name: "malformed_key",
+ input: map[string]string{
+ "no-colon-here": "value",
+ },
+ wantLen: 0,
+ wantURIs: nil,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := parseDevicesMap(tt.input)
+ assert.Len(t, got, tt.wantLen)
+
+ if tt.wantURIs != nil {
+ gotURIs := make(map[string]bool)
+ for _, d := range got {
+ gotURIs[d.URI] = true
+ }
+ for _, uri := range tt.wantURIs {
+ assert.True(t, gotURIs[uri], "expected URI %s not found", uri)
+ }
+ }
+ })
+ }
+}
+
+func TestParseDevicesMap_Attributes(t *testing.T) {
+ input := map[string]string{
+ "device-uri:0": "usb://HP/LaserJet",
+ "device-class:0": "direct",
+ "device-info:0": "HP LaserJet",
+ "device-make-and-model:0": "HP LaserJet 1020",
+ "device-id:0": "MFG:HP;MDL:LaserJet",
+ "device-location:0": "USB Port",
+ }
+
+ got := parseDevicesMap(input)
+ assert.Len(t, got, 1)
+
+ dev := got[0]
+ assert.Equal(t, "usb://HP/LaserJet", dev.URI)
+ assert.Equal(t, "direct", dev.Class)
+ assert.Equal(t, "HP LaserJet", dev.Info)
+ assert.Equal(t, "HP LaserJet 1020", dev.MakeModel)
+ assert.Equal(t, "MFG:HP;MDL:LaserJet", dev.ID)
+ assert.Equal(t, "USB Port", dev.Location)
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/subscription.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/subscription.go
new file mode 100644
index 0000000..6b369b4
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/subscription.go
@@ -0,0 +1,245 @@
+package cups
+
+import (
+ "fmt"
+ "sync"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/ipp"
+)
+
+type SubscriptionManager struct {
+ client CUPSClientInterface
+ subscriptionID int
+ sequenceNumber int
+ eventChan chan SubscriptionEvent
+ stopChan chan struct{}
+ wg sync.WaitGroup
+ baseURL string
+ running bool
+ mu sync.Mutex
+}
+
+func NewSubscriptionManager(client CUPSClientInterface, baseURL string) *SubscriptionManager {
+ return &SubscriptionManager{
+ client: client,
+ eventChan: make(chan SubscriptionEvent, 100),
+ stopChan: make(chan struct{}),
+ baseURL: baseURL,
+ }
+}
+
+func (sm *SubscriptionManager) Start() error {
+ sm.mu.Lock()
+ if sm.running {
+ sm.mu.Unlock()
+ return fmt.Errorf("subscription manager already running")
+ }
+ sm.running = true
+ sm.mu.Unlock()
+
+ subID, err := sm.createSubscription()
+ if err != nil {
+ sm.mu.Lock()
+ sm.running = false
+ sm.mu.Unlock()
+ return fmt.Errorf("failed to create subscription: %w", err)
+ }
+
+ sm.subscriptionID = subID
+ log.Infof("[CUPS] Created IPP subscription with ID %d", subID)
+
+ sm.wg.Add(1)
+ go sm.notificationLoop()
+
+ return nil
+}
+
+func (sm *SubscriptionManager) createSubscription() (int, error) {
+ req := ipp.NewRequest(ipp.OperationCreatePrinterSubscriptions, 1)
+ req.OperationAttributes[ipp.AttributePrinterURI] = fmt.Sprintf("%s/", sm.baseURL)
+ req.OperationAttributes[ipp.AttributeRequestingUserName] = "dms"
+
+ // Subscription attributes go in SubscriptionAttributes (subscription-attributes-tag in IPP)
+ req.SubscriptionAttributes = map[string]any{
+ "notify-events": []string{
+ "printer-state-changed",
+ "printer-added",
+ "printer-deleted",
+ "job-created",
+ "job-completed",
+ "job-state-changed",
+ },
+ "notify-pull-method": "ippget",
+ "notify-lease-duration": 0,
+ }
+
+ // Send to root IPP endpoint
+ resp, err := sm.client.SendRequest(fmt.Sprintf("%s/", sm.baseURL), req, nil)
+ if err != nil {
+ return 0, fmt.Errorf("SendRequest failed: %w", err)
+ }
+
+ // Check for IPP errors
+ if err := resp.CheckForErrors(); err != nil {
+ return 0, fmt.Errorf("IPP error: %w", err)
+ }
+
+ // Subscription ID comes back in SubscriptionAttributes
+ if len(resp.SubscriptionAttributes) > 0 {
+ if idAttr, ok := resp.SubscriptionAttributes[0]["notify-subscription-id"]; ok && len(idAttr) > 0 {
+ if val, ok := idAttr[0].Value.(int); ok {
+ return val, nil
+ }
+ }
+ }
+
+ return 0, fmt.Errorf("no subscription ID returned")
+}
+
+func (sm *SubscriptionManager) notificationLoop() {
+ defer sm.wg.Done()
+
+ backoff := 1 * time.Second
+
+ for {
+ select {
+ case <-sm.stopChan:
+ return
+ default:
+ }
+
+ gotAny, err := sm.fetchNotificationsWithWait()
+ if err != nil {
+ log.Warnf("[CUPS] Error fetching notifications: %v", err)
+ jitter := time.Duration(50+(time.Now().UnixNano()%200)) * time.Millisecond
+ sleepTime := backoff + jitter
+ if sleepTime > 30*time.Second {
+ sleepTime = 30 * time.Second
+ }
+ select {
+ case <-sm.stopChan:
+ return
+ case <-time.After(sleepTime):
+ }
+ if backoff < 30*time.Second {
+ backoff *= 2
+ }
+ continue
+ }
+
+ backoff = 1 * time.Second
+
+ if gotAny {
+ continue
+ }
+
+ select {
+ case <-sm.stopChan:
+ return
+ case <-time.After(2 * time.Second):
+ }
+ }
+}
+
+func (sm *SubscriptionManager) fetchNotificationsWithWait() (bool, error) {
+ req := ipp.NewRequest(ipp.OperationGetNotifications, 1)
+ req.OperationAttributes[ipp.AttributePrinterURI] = fmt.Sprintf("%s/", sm.baseURL)
+ req.OperationAttributes[ipp.AttributeRequestingUserName] = "dms"
+ req.OperationAttributes["notify-subscription-ids"] = sm.subscriptionID
+ if sm.sequenceNumber > 0 {
+ req.OperationAttributes["notify-sequence-numbers"] = sm.sequenceNumber
+ }
+
+ resp, err := sm.client.SendRequest(fmt.Sprintf("%s/", sm.baseURL), req, nil)
+ if err != nil {
+ return false, err
+ }
+
+ gotAny := false
+ for _, eventGroup := range resp.SubscriptionAttributes {
+ if seqAttr, ok := eventGroup["notify-sequence-number"]; ok && len(seqAttr) > 0 {
+ if seqNum, ok := seqAttr[0].Value.(int); ok {
+ sm.sequenceNumber = seqNum + 1
+ }
+ }
+
+ event := sm.parseEvent(eventGroup)
+ gotAny = true
+ select {
+ case sm.eventChan <- event:
+ case <-sm.stopChan:
+ return gotAny, nil
+ default:
+ log.Warn("[CUPS] Event channel full, dropping event")
+ }
+ }
+
+ return gotAny, nil
+}
+
+func (sm *SubscriptionManager) parseEvent(attrs ipp.Attributes) SubscriptionEvent {
+ event := SubscriptionEvent{
+ SubscribedAt: time.Now(),
+ }
+
+ if attr, ok := attrs["notify-subscribed-event"]; ok && len(attr) > 0 {
+ if val, ok := attr[0].Value.(string); ok {
+ event.EventName = val
+ }
+ }
+
+ if attr, ok := attrs["printer-name"]; ok && len(attr) > 0 {
+ if val, ok := attr[0].Value.(string); ok {
+ event.PrinterName = val
+ }
+ }
+
+ if attr, ok := attrs["notify-job-id"]; ok && len(attr) > 0 {
+ if val, ok := attr[0].Value.(int); ok {
+ event.JobID = val
+ }
+ }
+
+ return event
+}
+
+func (sm *SubscriptionManager) Events() <-chan SubscriptionEvent {
+ return sm.eventChan
+}
+
+func (sm *SubscriptionManager) Stop() {
+ sm.mu.Lock()
+ if !sm.running {
+ sm.mu.Unlock()
+ return
+ }
+ sm.running = false
+ sm.mu.Unlock()
+
+ close(sm.stopChan)
+ sm.wg.Wait()
+
+ if sm.subscriptionID != 0 {
+ sm.cancelSubscription()
+ sm.subscriptionID = 0
+ sm.sequenceNumber = 0
+ }
+
+ sm.stopChan = make(chan struct{})
+}
+
+func (sm *SubscriptionManager) cancelSubscription() {
+ req := ipp.NewRequest(ipp.OperationCancelSubscription, 1)
+ req.OperationAttributes[ipp.AttributePrinterURI] = fmt.Sprintf("%s/", sm.baseURL)
+ req.OperationAttributes[ipp.AttributeRequestingUserName] = "dms"
+ req.OperationAttributes["notify-subscription-id"] = sm.subscriptionID
+
+ _, err := sm.client.SendRequest(fmt.Sprintf("%s/", sm.baseURL), req, nil)
+ if err != nil {
+ log.Warnf("[CUPS] Failed to cancel subscription %d: %v", sm.subscriptionID, err)
+ } else {
+ log.Infof("[CUPS] Cancelled subscription %d", sm.subscriptionID)
+ }
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/subscription_dbus.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/subscription_dbus.go
new file mode 100644
index 0000000..b205a6b
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/subscription_dbus.go
@@ -0,0 +1,295 @@
+package cups
+
+import (
+ "fmt"
+ "strings"
+ "sync"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/ipp"
+ "github.com/godbus/dbus/v5"
+)
+
+type DBusSubscriptionManager struct {
+ client CUPSClientInterface
+ subscriptionID int
+ eventChan chan SubscriptionEvent
+ stopChan chan struct{}
+ wg sync.WaitGroup
+ baseURL string
+ running bool
+ mu sync.Mutex
+ conn *dbus.Conn
+}
+
+func NewDBusSubscriptionManager(client CUPSClientInterface, baseURL string) *DBusSubscriptionManager {
+ return &DBusSubscriptionManager{
+ client: client,
+ eventChan: make(chan SubscriptionEvent, 100),
+ stopChan: make(chan struct{}),
+ baseURL: baseURL,
+ }
+}
+
+func (sm *DBusSubscriptionManager) Start() error {
+ sm.mu.Lock()
+ if sm.running {
+ sm.mu.Unlock()
+ return fmt.Errorf("subscription manager already running")
+ }
+ sm.running = true
+ sm.mu.Unlock()
+
+ conn, err := dbus.ConnectSystemBus()
+ if err != nil {
+ sm.mu.Lock()
+ sm.running = false
+ sm.mu.Unlock()
+ return fmt.Errorf("connect to system bus: %w", err)
+ }
+ sm.conn = conn
+
+ subID, err := sm.createDBusSubscription()
+ if err != nil {
+ sm.conn.Close()
+ sm.mu.Lock()
+ sm.running = false
+ sm.mu.Unlock()
+ return fmt.Errorf("failed to create D-Bus subscription: %w", err)
+ }
+
+ sm.subscriptionID = subID
+ log.Infof("[CUPS] Created D-Bus subscription with ID %d", subID)
+
+ if err := sm.conn.AddMatchSignal(
+ dbus.WithMatchInterface("org.cups.cupsd.Notifier"),
+ ); err != nil {
+ sm.cancelSubscription()
+ sm.conn.Close()
+ sm.mu.Lock()
+ sm.running = false
+ sm.mu.Unlock()
+ return fmt.Errorf("failed to add D-Bus match: %w", err)
+ }
+
+ sm.wg.Add(1)
+ go sm.dbusListenerLoop()
+
+ return nil
+}
+
+func (sm *DBusSubscriptionManager) createDBusSubscription() (int, error) {
+ req := ipp.NewRequest(ipp.OperationCreatePrinterSubscriptions, 2)
+ req.OperationAttributes[ipp.AttributePrinterURI] = fmt.Sprintf("%s/", sm.baseURL)
+ req.OperationAttributes[ipp.AttributeRequestingUserName] = "dms"
+
+ req.SubscriptionAttributes = map[string]any{
+ "notify-events": []string{
+ "printer-state-changed",
+ "printer-added",
+ "printer-deleted",
+ "job-created",
+ "job-completed",
+ "job-state-changed",
+ },
+ "notify-recipient-uri": "dbus:/",
+ "notify-lease-duration": 86400,
+ }
+
+ resp, err := sm.client.SendRequest(fmt.Sprintf("%s/", sm.baseURL), req, nil)
+ if err != nil {
+ return 0, fmt.Errorf("SendRequest failed: %w", err)
+ }
+
+ if err := resp.CheckForErrors(); err != nil {
+ return 0, fmt.Errorf("IPP error: %w", err)
+ }
+
+ if len(resp.SubscriptionAttributes) > 0 {
+ if idAttr, ok := resp.SubscriptionAttributes[0]["notify-subscription-id"]; ok && len(idAttr) > 0 {
+ if val, ok := idAttr[0].Value.(int); ok {
+ return val, nil
+ }
+ }
+ }
+
+ return 0, fmt.Errorf("no subscription ID returned")
+}
+
+func (sm *DBusSubscriptionManager) dbusListenerLoop() {
+ defer sm.wg.Done()
+
+ signalChan := make(chan *dbus.Signal, 10)
+ sm.conn.Signal(signalChan)
+ defer sm.conn.RemoveSignal(signalChan)
+
+ for {
+ select {
+ case <-sm.stopChan:
+ return
+ case sig := <-signalChan:
+ if sig == nil {
+ continue
+ }
+
+ event := sm.parseDBusSignal(sig)
+ if event.EventName == "" {
+ continue
+ }
+
+ select {
+ case sm.eventChan <- event:
+ case <-sm.stopChan:
+ return
+ default:
+ log.Warn("[CUPS] Event channel full, dropping event")
+ }
+ }
+ }
+}
+
+func (sm *DBusSubscriptionManager) parseDBusSignal(sig *dbus.Signal) SubscriptionEvent {
+ event := SubscriptionEvent{}
+
+ switch sig.Name {
+ case "org.cups.cupsd.Notifier.JobStateChanged":
+ if len(sig.Body) >= 6 {
+ if text, ok := sig.Body[0].(string); ok {
+ event.EventName = "job-state-changed"
+ parts := strings.Split(text, " ")
+ if len(parts) >= 2 {
+ event.PrinterName = parts[0]
+ }
+ }
+ if printerURI, ok := sig.Body[1].(string); ok && event.PrinterName == "" {
+ if idx := strings.LastIndex(printerURI, "/"); idx != -1 {
+ event.PrinterName = printerURI[idx+1:]
+ }
+ }
+ if jobID, ok := sig.Body[3].(uint32); ok {
+ event.JobID = int(jobID)
+ }
+ }
+
+ case "org.cups.cupsd.Notifier.JobCreated":
+ if len(sig.Body) >= 6 {
+ if text, ok := sig.Body[0].(string); ok {
+ event.EventName = "job-created"
+ parts := strings.Split(text, " ")
+ if len(parts) >= 2 {
+ event.PrinterName = parts[0]
+ }
+ }
+ if printerURI, ok := sig.Body[1].(string); ok && event.PrinterName == "" {
+ if idx := strings.LastIndex(printerURI, "/"); idx != -1 {
+ event.PrinterName = printerURI[idx+1:]
+ }
+ }
+ if jobID, ok := sig.Body[3].(uint32); ok {
+ event.JobID = int(jobID)
+ }
+ }
+
+ case "org.cups.cupsd.Notifier.JobCompleted":
+ if len(sig.Body) >= 6 {
+ if text, ok := sig.Body[0].(string); ok {
+ event.EventName = "job-completed"
+ parts := strings.Split(text, " ")
+ if len(parts) >= 2 {
+ event.PrinterName = parts[0]
+ }
+ }
+ if printerURI, ok := sig.Body[1].(string); ok && event.PrinterName == "" {
+ if idx := strings.LastIndex(printerURI, "/"); idx != -1 {
+ event.PrinterName = printerURI[idx+1:]
+ }
+ }
+ if jobID, ok := sig.Body[3].(uint32); ok {
+ event.JobID = int(jobID)
+ }
+ }
+
+ case "org.cups.cupsd.Notifier.PrinterStateChanged":
+ if len(sig.Body) >= 6 {
+ if text, ok := sig.Body[0].(string); ok {
+ event.EventName = "printer-state-changed"
+ parts := strings.Split(text, " ")
+ if len(parts) >= 2 {
+ event.PrinterName = parts[0]
+ }
+ }
+ if printerURI, ok := sig.Body[1].(string); ok && event.PrinterName == "" {
+ if idx := strings.LastIndex(printerURI, "/"); idx != -1 {
+ event.PrinterName = printerURI[idx+1:]
+ }
+ }
+ }
+
+ case "org.cups.cupsd.Notifier.PrinterAdded":
+ if len(sig.Body) >= 6 {
+ if text, ok := sig.Body[0].(string); ok {
+ event.EventName = "printer-added"
+ parts := strings.Split(text, " ")
+ if len(parts) >= 2 {
+ event.PrinterName = parts[0]
+ }
+ }
+ }
+
+ case "org.cups.cupsd.Notifier.PrinterDeleted":
+ if len(sig.Body) >= 6 {
+ if text, ok := sig.Body[0].(string); ok {
+ event.EventName = "printer-deleted"
+ parts := strings.Split(text, " ")
+ if len(parts) >= 2 {
+ event.PrinterName = parts[0]
+ }
+ }
+ }
+ }
+
+ return event
+}
+
+func (sm *DBusSubscriptionManager) Events() <-chan SubscriptionEvent {
+ return sm.eventChan
+}
+
+func (sm *DBusSubscriptionManager) Stop() {
+ sm.mu.Lock()
+ if !sm.running {
+ sm.mu.Unlock()
+ return
+ }
+ sm.running = false
+ sm.mu.Unlock()
+
+ close(sm.stopChan)
+ sm.wg.Wait()
+
+ if sm.subscriptionID != 0 {
+ sm.cancelSubscription()
+ sm.subscriptionID = 0
+ }
+
+ if sm.conn != nil {
+ sm.conn.Close()
+ sm.conn = nil
+ }
+
+ sm.stopChan = make(chan struct{})
+}
+
+func (sm *DBusSubscriptionManager) cancelSubscription() {
+ req := ipp.NewRequest(ipp.OperationCancelSubscription, 1)
+ req.OperationAttributes[ipp.AttributePrinterURI] = fmt.Sprintf("%s/", sm.baseURL)
+ req.OperationAttributes[ipp.AttributeRequestingUserName] = "dms"
+ req.OperationAttributes["notify-subscription-id"] = sm.subscriptionID
+
+ _, err := sm.client.SendRequest(fmt.Sprintf("%s/", sm.baseURL), req, nil)
+ if err != nil {
+ log.Warnf("[CUPS] Failed to cancel subscription %d: %v", sm.subscriptionID, err)
+ } else {
+ log.Infof("[CUPS] Cancelled subscription %d", sm.subscriptionID)
+ }
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/test_connection.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/test_connection.go
new file mode 100644
index 0000000..d0203e9
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/test_connection.go
@@ -0,0 +1,176 @@
+package cups
+
+import (
+ "errors"
+ "fmt"
+ "net"
+ "strings"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/ipp"
+)
+
+var validProtocols = map[string]bool{
+ "ipp": true,
+ "ipps": true,
+ "lpd": true,
+ "socket": true,
+}
+
+func validateTestConnectionParams(host string, port int, protocol string) error {
+ if host == "" {
+ return errors.New("host is required")
+ }
+ if strings.ContainsAny(host, " \t\n\r/\\") {
+ return errors.New("host contains invalid characters")
+ }
+ if port < 1 || port > 65535 {
+ return errors.New("port must be between 1 and 65535")
+ }
+ if protocol != "" && !validProtocols[protocol] {
+ return errors.New("protocol must be one of: ipp, ipps, lpd, socket")
+ }
+ return nil
+}
+
+const probeTimeout = 10 * time.Second
+
+func probeRemotePrinter(host string, port int, useTLS bool) (*RemotePrinterInfo, error) {
+ addr := net.JoinHostPort(host, fmt.Sprintf("%d", port))
+
+ // Fast fail: TCP reachability check
+ conn, err := net.DialTimeout("tcp", addr, probeTimeout)
+ if err != nil {
+ return &RemotePrinterInfo{
+ Reachable: false,
+ Error: fmt.Sprintf("cannot reach %s: %s", addr, err.Error()),
+ }, nil
+ }
+ conn.Close()
+
+ // Create a temporary IPP client pointing at the remote host.
+ // The TCP dial above provides fast-fail for unreachable hosts.
+ // The IPP adapter's ResponseHeaderTimeout (90s) bounds stalling servers.
+ client := ipp.NewIPPClient(host, port, "", "", useTLS)
+
+ // Try /ipp/print first (modern driverless printers), then / (legacy)
+ info, err := probeIPPEndpoint(client, host, port, useTLS, "/ipp/print")
+ if err != nil {
+ // If we got an auth error, the printer exists but requires credentials.
+ // Report it as reachable with the URI that triggered the auth challenge.
+ if isAuthError(err) {
+ proto := "ipp"
+ if useTLS {
+ proto = "ipps"
+ }
+ return &RemotePrinterInfo{
+ Reachable: true,
+ URI: fmt.Sprintf("%s://%s:%d/ipp/print", proto, host, port),
+ Info: "authentication required",
+ }, nil
+ }
+ info, err = probeIPPEndpoint(client, host, port, useTLS, "/")
+ }
+ if err != nil {
+ if isAuthError(err) {
+ proto := "ipp"
+ if useTLS {
+ proto = "ipps"
+ }
+ return &RemotePrinterInfo{
+ Reachable: true,
+ URI: fmt.Sprintf("%s://%s:%d/", proto, host, port),
+ Info: "authentication required",
+ }, nil
+ }
+ // TCP reachable but not an IPP printer
+ return &RemotePrinterInfo{
+ Reachable: true,
+ Error: fmt.Sprintf("host is reachable but does not appear to be an IPP printer: %s", err.Error()),
+ }, nil
+ }
+
+ return info, nil
+}
+
+func probeIPPEndpoint(client *ipp.IPPClient, host string, port int, useTLS bool, resourcePath string) (*RemotePrinterInfo, error) {
+ proto := "ipp"
+ if useTLS {
+ proto = "ipps"
+ }
+ printerURI := fmt.Sprintf("%s://%s:%d%s", proto, host, port, resourcePath)
+
+ httpProto := "http"
+ if useTLS {
+ httpProto = "https"
+ }
+ httpURL := fmt.Sprintf("%s://%s:%d%s", httpProto, host, port, resourcePath)
+
+ req := ipp.NewRequest(ipp.OperationGetPrinterAttributes, 1)
+ req.OperationAttributes[ipp.AttributePrinterURI] = printerURI
+ req.OperationAttributes[ipp.AttributeRequestedAttributes] = []string{
+ ipp.AttributePrinterName,
+ ipp.AttributePrinterMakeAndModel,
+ ipp.AttributePrinterState,
+ ipp.AttributePrinterInfo,
+ ipp.AttributePrinterUriSupported,
+ }
+
+ resp, err := client.SendRequest(httpURL, req, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(resp.PrinterAttributes) == 0 {
+ return nil, errors.New("no printer attributes returned")
+ }
+
+ attrs := resp.PrinterAttributes[0]
+
+ return &RemotePrinterInfo{
+ Reachable: true,
+ MakeModel: getStringAttr(attrs, ipp.AttributePrinterMakeAndModel),
+ Name: getStringAttr(attrs, ipp.AttributePrinterName),
+ Info: getStringAttr(attrs, ipp.AttributePrinterInfo),
+ State: parsePrinterState(attrs),
+ URI: printerURI,
+ }, nil
+}
+
+// TestRemotePrinter validates inputs and probes a remote printer via IPP.
+// For lpd/socket protocols, only TCP reachability is tested.
+func (m *Manager) TestRemotePrinter(host string, port int, protocol string) (*RemotePrinterInfo, error) {
+ if protocol == "" {
+ protocol = "ipp"
+ }
+
+ if err := validateTestConnectionParams(host, port, protocol); err != nil {
+ return nil, err
+ }
+
+ // For non-IPP protocols, only check TCP reachability
+ if protocol == "lpd" || protocol == "socket" {
+ addr := net.JoinHostPort(host, fmt.Sprintf("%d", port))
+ conn, err := net.DialTimeout("tcp", addr, probeTimeout)
+ if err != nil {
+ return &RemotePrinterInfo{
+ Reachable: false,
+ Error: fmt.Sprintf("cannot reach %s: %s", addr, err.Error()),
+ }, nil
+ }
+ conn.Close()
+ return &RemotePrinterInfo{
+ Reachable: true,
+ URI: fmt.Sprintf("%s://%s:%d", protocol, host, port),
+ }, nil
+ }
+
+ useTLS := protocol == "ipps"
+
+ probeFn := m.probeRemoteFn
+ if probeFn == nil {
+ probeFn = probeRemotePrinter
+ }
+
+ return probeFn(host, port, useTLS)
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/test_connection_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/test_connection_test.go
new file mode 100644
index 0000000..b835028
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/test_connection_test.go
@@ -0,0 +1,397 @@
+package cups
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "testing"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/ipp"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestValidateTestConnectionParams(t *testing.T) {
+ tests := []struct {
+ name string
+ host string
+ port int
+ protocol string
+ wantErr string
+ }{
+ {
+ name: "valid ipp",
+ host: "192.168.0.5",
+ port: 631,
+ protocol: "ipp",
+ wantErr: "",
+ },
+ {
+ name: "valid ipps",
+ host: "printer.local",
+ port: 443,
+ protocol: "ipps",
+ wantErr: "",
+ },
+ {
+ name: "valid lpd",
+ host: "10.0.0.1",
+ port: 515,
+ protocol: "lpd",
+ wantErr: "",
+ },
+ {
+ name: "valid socket",
+ host: "10.0.0.1",
+ port: 9100,
+ protocol: "socket",
+ wantErr: "",
+ },
+ {
+ name: "empty host",
+ host: "",
+ port: 631,
+ protocol: "ipp",
+ wantErr: "host is required",
+ },
+ {
+ name: "port too low",
+ host: "192.168.0.5",
+ port: 0,
+ protocol: "ipp",
+ wantErr: "port must be between 1 and 65535",
+ },
+ {
+ name: "port too high",
+ host: "192.168.0.5",
+ port: 70000,
+ protocol: "ipp",
+ wantErr: "port must be between 1 and 65535",
+ },
+ {
+ name: "invalid protocol",
+ host: "192.168.0.5",
+ port: 631,
+ protocol: "ftp",
+ wantErr: "protocol must be one of: ipp, ipps, lpd, socket",
+ },
+ {
+ name: "empty protocol treated as ipp",
+ host: "192.168.0.5",
+ port: 631,
+ protocol: "",
+ wantErr: "",
+ },
+ {
+ name: "host with slash",
+ host: "192.168.0.5/admin",
+ port: 631,
+ protocol: "ipp",
+ wantErr: "host contains invalid characters",
+ },
+ {
+ name: "host with space",
+ host: "192.168.0.5 ",
+ port: 631,
+ protocol: "ipp",
+ wantErr: "host contains invalid characters",
+ },
+ {
+ name: "host with newline",
+ host: "192.168.0.5\n",
+ port: 631,
+ protocol: "ipp",
+ wantErr: "host contains invalid characters",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := validateTestConnectionParams(tt.host, tt.port, tt.protocol)
+ if tt.wantErr == "" {
+ assert.NoError(t, err)
+ } else {
+ assert.EqualError(t, err, tt.wantErr)
+ }
+ })
+ }
+}
+
+func TestManager_TestRemotePrinter_Validation(t *testing.T) {
+ m := NewTestManager(nil, nil)
+
+ tests := []struct {
+ name string
+ host string
+ port int
+ protocol string
+ wantErr string
+ }{
+ {
+ name: "empty host returns error",
+ host: "",
+ port: 631,
+ protocol: "ipp",
+ wantErr: "host is required",
+ },
+ {
+ name: "invalid port returns error",
+ host: "192.168.0.5",
+ port: 0,
+ protocol: "ipp",
+ wantErr: "port must be between 1 and 65535",
+ },
+ {
+ name: "invalid protocol returns error",
+ host: "192.168.0.5",
+ port: 631,
+ protocol: "ftp",
+ wantErr: "protocol must be one of: ipp, ipps, lpd, socket",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ _, err := m.TestRemotePrinter(tt.host, tt.port, tt.protocol)
+ assert.EqualError(t, err, tt.wantErr)
+ })
+ }
+}
+
+func TestManager_TestRemotePrinter_IPP(t *testing.T) {
+ tests := []struct {
+ name string
+ protocol string
+ probeRet *RemotePrinterInfo
+ probeErr error
+ wantTLS bool
+ wantReach bool
+ wantModel string
+ }{
+ {
+ name: "successful ipp probe",
+ protocol: "ipp",
+ probeRet: &RemotePrinterInfo{
+ Reachable: true,
+ MakeModel: "HP OfficeJet 8010",
+ Name: "OfficeJet",
+ State: "idle",
+ URI: "ipp://192.168.0.5:631/ipp/print",
+ },
+ wantTLS: false,
+ wantReach: true,
+ wantModel: "HP OfficeJet 8010",
+ },
+ {
+ name: "successful ipps probe",
+ protocol: "ipps",
+ probeRet: &RemotePrinterInfo{
+ Reachable: true,
+ MakeModel: "HP OfficeJet 8010",
+ URI: "ipps://192.168.0.5:631/ipp/print",
+ },
+ wantTLS: true,
+ wantReach: true,
+ wantModel: "HP OfficeJet 8010",
+ },
+ {
+ name: "unreachable host",
+ protocol: "ipp",
+ probeRet: &RemotePrinterInfo{
+ Reachable: false,
+ Error: "cannot reach 192.168.0.5:631: connection refused",
+ },
+ wantReach: false,
+ },
+ {
+ name: "empty protocol defaults to ipp",
+ protocol: "",
+ probeRet: &RemotePrinterInfo{
+ Reachable: true,
+ MakeModel: "Test Printer",
+ },
+ wantTLS: false,
+ wantReach: true,
+ wantModel: "Test Printer",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var capturedTLS bool
+ m := NewTestManager(nil, nil)
+ m.probeRemoteFn = func(host string, port int, useTLS bool) (*RemotePrinterInfo, error) {
+ capturedTLS = useTLS
+ return tt.probeRet, tt.probeErr
+ }
+
+ result, err := m.TestRemotePrinter("192.168.0.5", 631, tt.protocol)
+ if tt.probeErr != nil {
+ assert.Error(t, err)
+ return
+ }
+ assert.NoError(t, err)
+ assert.Equal(t, tt.wantReach, result.Reachable)
+ assert.Equal(t, tt.wantModel, result.MakeModel)
+ assert.Equal(t, tt.wantTLS, capturedTLS)
+ })
+ }
+}
+
+func TestManager_TestRemotePrinter_AuthRequired(t *testing.T) {
+ m := NewTestManager(nil, nil)
+ m.probeRemoteFn = func(host string, port int, useTLS bool) (*RemotePrinterInfo, error) {
+ // Simulate what happens when the printer returns HTTP 401
+ return probeRemotePrinterWithAuthError(host, port, useTLS)
+ }
+
+ result, err := m.TestRemotePrinter("192.168.0.107", 631, "ipp")
+ assert.NoError(t, err)
+ assert.True(t, result.Reachable)
+ assert.Equal(t, "authentication required", result.Info)
+ assert.Contains(t, result.URI, "ipp://192.168.0.107:631")
+}
+
+// probeRemotePrinterWithAuthError simulates a probe where the printer
+// returns HTTP 401 on both endpoints.
+func probeRemotePrinterWithAuthError(host string, port int, useTLS bool) (*RemotePrinterInfo, error) {
+ // This simulates what probeRemotePrinter does when both endpoints
+ // return auth errors. We test the auth detection logic directly.
+ err := ipp.HTTPError{Code: 401}
+ if isAuthError(err) {
+ proto := "ipp"
+ if useTLS {
+ proto = "ipps"
+ }
+ return &RemotePrinterInfo{
+ Reachable: true,
+ URI: fmt.Sprintf("%s://%s:%d/ipp/print", proto, host, port),
+ Info: "authentication required",
+ }, nil
+ }
+ return nil, err
+}
+
+func TestManager_TestRemotePrinter_NonIPPProtocol(t *testing.T) {
+ m := NewTestManager(nil, nil)
+ probeCalled := false
+ m.probeRemoteFn = func(host string, port int, useTLS bool) (*RemotePrinterInfo, error) {
+ probeCalled = true
+ return nil, nil
+ }
+
+ // These will fail at TCP dial (no real server), but the important
+ // thing is that probeRemoteFn is NOT called for lpd/socket.
+ m.TestRemotePrinter("192.168.0.5", 9100, "socket")
+ assert.False(t, probeCalled, "probe function should not be called for socket protocol")
+
+ m.TestRemotePrinter("192.168.0.5", 515, "lpd")
+ assert.False(t, probeCalled, "probe function should not be called for lpd protocol")
+}
+
+func TestHandleTestConnection_Success(t *testing.T) {
+ m := NewTestManager(nil, nil)
+ m.probeRemoteFn = func(host string, port int, useTLS bool) (*RemotePrinterInfo, error) {
+ return &RemotePrinterInfo{
+ Reachable: true,
+ MakeModel: "HP OfficeJet 8010",
+ Name: "OfficeJet",
+ State: "idle",
+ URI: "ipp://192.168.0.5:631/ipp/print",
+ }, nil
+ }
+
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.testConnection",
+ Params: map[string]any{
+ "host": "192.168.0.5",
+ },
+ }
+
+ handleTestConnection(conn, req, m)
+
+ var resp models.Response[RemotePrinterInfo]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Reachable)
+ assert.Equal(t, "HP OfficeJet 8010", resp.Result.MakeModel)
+}
+
+func TestHandleTestConnection_MissingHost(t *testing.T) {
+ m := NewTestManager(nil, nil)
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.testConnection",
+ Params: map[string]any{},
+ }
+
+ handleTestConnection(conn, req, m)
+
+ var resp models.Response[any]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.Nil(t, resp.Result)
+ assert.NotNil(t, resp.Error)
+}
+
+func TestHandleTestConnection_CustomPortAndProtocol(t *testing.T) {
+ m := NewTestManager(nil, nil)
+ m.probeRemoteFn = func(host string, port int, useTLS bool) (*RemotePrinterInfo, error) {
+ assert.Equal(t, 9631, port)
+ assert.True(t, useTLS)
+ return &RemotePrinterInfo{Reachable: true, URI: "ipps://192.168.0.5:9631/ipp/print"}, nil
+ }
+
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.testConnection",
+ Params: map[string]any{
+ "host": "192.168.0.5",
+ "port": float64(9631),
+ "protocol": "ipps",
+ },
+ }
+
+ handleTestConnection(conn, req, m)
+
+ var resp models.Response[RemotePrinterInfo]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Reachable)
+}
+
+func TestHandleRequest_TestConnection(t *testing.T) {
+ m := NewTestManager(nil, nil)
+ m.probeRemoteFn = func(host string, port int, useTLS bool) (*RemotePrinterInfo, error) {
+ return &RemotePrinterInfo{Reachable: true}, nil
+ }
+
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.testConnection",
+ Params: map[string]any{"host": "192.168.0.5"},
+ }
+
+ HandleRequest(conn, req, m)
+
+ var resp models.Response[RemotePrinterInfo]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Reachable)
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/testing.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/testing.go
new file mode 100644
index 0000000..4820e34
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/testing.go
@@ -0,0 +1,13 @@
+package cups
+
+func NewTestManager(client CUPSClientInterface, pkHelper PkHelper) *Manager {
+ return &Manager{
+ client: client,
+ pkHelper: pkHelper,
+ state: &CUPSState{
+ Printers: make(map[string]*Printer),
+ },
+ stopChan: make(chan struct{}),
+ dirty: make(chan struct{}, 1),
+ }
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/types.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/types.go
new file mode 100644
index 0000000..52e9d2c
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/types.go
@@ -0,0 +1,132 @@
+package cups
+
+import (
+ "io"
+ "sync"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/ipp"
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
+)
+
+type CUPSState struct {
+ Printers map[string]*Printer `json:"printers"`
+}
+
+type Printer struct {
+ Name string `json:"name"`
+ URI string `json:"uri"`
+ State string `json:"state"`
+ StateReason string `json:"stateReason"`
+ Location string `json:"location"`
+ Info string `json:"info"`
+ MakeModel string `json:"makeModel"`
+ Accepting bool `json:"accepting"`
+ Jobs []Job `json:"jobs"`
+}
+
+type Job struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ State string `json:"state"`
+ Printer string `json:"printer"`
+ User string `json:"user"`
+ Size int `json:"size"`
+ TimeCreated time.Time `json:"timeCreated"`
+}
+
+type Device struct {
+ URI string `json:"uri"`
+ Class string `json:"class"`
+ Info string `json:"info"`
+ MakeModel string `json:"makeModel"`
+ ID string `json:"id"`
+ Location string `json:"location"`
+ IP string `json:"ip,omitempty"`
+}
+
+type PPD struct {
+ Name string `json:"name"`
+ NaturalLanguage string `json:"naturalLanguage"`
+ MakeModel string `json:"makeModel"`
+ DeviceID string `json:"deviceId"`
+ Product string `json:"product"`
+ PSVersion string `json:"psVersion"`
+ Type string `json:"type"`
+}
+
+type RemotePrinterInfo struct {
+ Reachable bool `json:"reachable"`
+ MakeModel string `json:"makeModel"`
+ Name string `json:"name"`
+ Info string `json:"info"`
+ State string `json:"state"`
+ URI string `json:"uri"`
+ Error string `json:"error,omitempty"`
+}
+
+type PrinterClass struct {
+ Name string `json:"name"`
+ URI string `json:"uri"`
+ State string `json:"state"`
+ Members []string `json:"members"`
+ Location string `json:"location"`
+ Info string `json:"info"`
+}
+
+type Manager struct {
+ state *CUPSState
+ client CUPSClientInterface
+ pkHelper PkHelper
+ subscription SubscriptionManagerInterface
+ stateMutex sync.RWMutex
+ subscribers syncmap.Map[string, chan CUPSState]
+ stopChan chan struct{}
+ eventWG sync.WaitGroup
+ dirty chan struct{}
+ notifierWg sync.WaitGroup
+ lastNotifiedState *CUPSState
+ baseURL string
+ probeRemoteFn func(host string, port int, useTLS bool) (*RemotePrinterInfo, error)
+}
+
+type SubscriptionManagerInterface interface {
+ Start() error
+ Stop()
+ Events() <-chan SubscriptionEvent
+}
+
+type CUPSClientInterface interface {
+ GetPrinters(attributes []string) (map[string]ipp.Attributes, error)
+ GetJobs(printer, class string, whichJobs string, myJobs bool, firstJobId, limit int, attributes []string) (map[int]ipp.Attributes, error)
+ CancelJob(jobID int, purge bool) error
+ PausePrinter(printer string) error
+ ResumePrinter(printer string) error
+ CancelAllJob(printer string, purge bool) error
+ SendRequest(url string, req *ipp.Request, additionalResponseData io.Writer) (*ipp.Response, error)
+
+ GetDevices() (map[string]ipp.Attributes, error)
+ GetPPDs() (map[string]ipp.Attributes, error)
+ GetClasses(attributes []string) (map[string]ipp.Attributes, error)
+ CreatePrinter(name, deviceURI, ppd string, shared bool, errorPolicy, information, location string) error
+ DeletePrinter(printer string) error
+ AcceptJobs(printer string) error
+ RejectJobs(printer string) error
+ SetPrinterIsShared(printer string, shared bool) error
+ SetPrinterLocation(printer, location string) error
+ SetPrinterInformation(printer, information string) error
+ MoveJob(jobID int, destPrinter string) error
+ PrintTestPage(printer string, testPageData io.Reader, size int) (int, error)
+ AddPrinterToClass(class, printer string) error
+ DeletePrinterFromClass(class, printer string) error
+ DeleteClass(class string) error
+ RestartJob(jobID int) error
+ HoldJobUntil(jobID int, holdUntil string) error
+}
+
+type SubscriptionEvent struct {
+ EventName string
+ PrinterName string
+ JobID int
+ SubscribedAt time.Time
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/dbus/handlers.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/dbus/handlers.go
new file mode 100644
index 0000000..e625d79
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/dbus/handlers.go
@@ -0,0 +1,237 @@
+package dbus
+
+import (
+ "fmt"
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/params"
+)
+
+type objectParams struct {
+ bus string
+ dest string
+ path string
+ iface string
+}
+
+func extractObjectParams(p map[string]any, requirePath bool) (objectParams, error) {
+ bus, err := params.String(p, "bus")
+ if err != nil {
+ return objectParams{}, err
+ }
+ dest, err := params.String(p, "dest")
+ if err != nil {
+ return objectParams{}, err
+ }
+
+ var path string
+ if requirePath {
+ path, err = params.String(p, "path")
+ if err != nil {
+ return objectParams{}, err
+ }
+ } else {
+ path = params.StringOpt(p, "path", "/")
+ }
+
+ iface, err := params.String(p, "interface")
+ if err != nil {
+ return objectParams{}, err
+ }
+
+ return objectParams{bus: bus, dest: dest, path: path, iface: iface}, nil
+}
+
+func HandleRequest(conn net.Conn, req models.Request, m *Manager, clientID string) {
+ switch req.Method {
+ case "dbus.call":
+ handleCall(conn, req, m)
+ case "dbus.getProperty":
+ handleGetProperty(conn, req, m)
+ case "dbus.setProperty":
+ handleSetProperty(conn, req, m)
+ case "dbus.getAllProperties":
+ handleGetAllProperties(conn, req, m)
+ case "dbus.introspect":
+ handleIntrospect(conn, req, m)
+ case "dbus.listNames":
+ handleListNames(conn, req, m)
+ case "dbus.subscribe":
+ handleSubscribe(conn, req, m, clientID)
+ case "dbus.unsubscribe":
+ handleUnsubscribe(conn, req, m)
+ default:
+ models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
+ }
+}
+
+func handleCall(conn net.Conn, req models.Request, m *Manager) {
+ op, err := extractObjectParams(req.Params, true)
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ method, err := params.String(req.Params, "method")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ var args []any
+ if argsRaw, ok := params.Any(req.Params, "args"); ok {
+ if argsSlice, ok := argsRaw.([]any); ok {
+ args = argsSlice
+ }
+ }
+
+ result, err := m.Call(op.bus, op.dest, op.path, op.iface, method, args)
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, result)
+}
+
+func handleGetProperty(conn net.Conn, req models.Request, m *Manager) {
+ op, err := extractObjectParams(req.Params, true)
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ property, err := params.String(req.Params, "property")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ result, err := m.GetProperty(op.bus, op.dest, op.path, op.iface, property)
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, result)
+}
+
+func handleSetProperty(conn net.Conn, req models.Request, m *Manager) {
+ op, err := extractObjectParams(req.Params, true)
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ property, err := params.String(req.Params, "property")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ value, ok := params.Any(req.Params, "value")
+ if !ok {
+ models.RespondError(conn, req.ID, "missing 'value' parameter")
+ return
+ }
+
+ if err := m.SetProperty(op.bus, op.dest, op.path, op.iface, property, value); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true})
+}
+
+func handleGetAllProperties(conn net.Conn, req models.Request, m *Manager) {
+ op, err := extractObjectParams(req.Params, true)
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ result, err := m.GetAllProperties(op.bus, op.dest, op.path, op.iface)
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, result)
+}
+
+func handleIntrospect(conn net.Conn, req models.Request, m *Manager) {
+ bus, err := params.String(req.Params, "bus")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ dest, err := params.String(req.Params, "dest")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ path := params.StringOpt(req.Params, "path", "/")
+
+ result, err := m.Introspect(bus, dest, path)
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, result)
+}
+
+func handleListNames(conn net.Conn, req models.Request, m *Manager) {
+ bus, err := params.String(req.Params, "bus")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ result, err := m.ListNames(bus)
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, result)
+}
+
+func handleSubscribe(conn net.Conn, req models.Request, m *Manager, clientID string) {
+ bus, err := params.String(req.Params, "bus")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ sender := params.StringOpt(req.Params, "sender", "")
+ path := params.StringOpt(req.Params, "path", "")
+ iface := params.StringOpt(req.Params, "interface", "")
+ member := params.StringOpt(req.Params, "member", "")
+
+ result, err := m.Subscribe(clientID, bus, sender, path, iface, member)
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, result)
+}
+
+func handleUnsubscribe(conn net.Conn, req models.Request, m *Manager) {
+ subID, err := params.String(req.Params, "subscriptionId")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := m.Unsubscribe(subID); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true})
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/dbus/manager.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/dbus/manager.go
new file mode 100644
index 0000000..86da6bd
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/dbus/manager.go
@@ -0,0 +1,392 @@
+package dbus
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "fmt"
+ "strings"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/dbusutil"
+ "github.com/godbus/dbus/v5"
+)
+
+func NewManager() (*Manager, error) {
+ systemConn, err := dbus.ConnectSystemBus()
+ if err != nil {
+ return nil, fmt.Errorf("failed to connect to system bus: %w", err)
+ }
+
+ sessionConn, err := dbus.ConnectSessionBus()
+ if err != nil {
+ systemConn.Close()
+ return nil, fmt.Errorf("failed to connect to session bus: %w", err)
+ }
+
+ m := &Manager{
+ systemConn: systemConn,
+ sessionConn: sessionConn,
+ }
+
+ go m.processSystemSignals()
+ go m.processSessionSignals()
+
+ return m, nil
+}
+
+func (m *Manager) getConn(bus string) (*dbus.Conn, error) {
+ switch bus {
+ case "system":
+ if m.systemConn == nil {
+ return nil, fmt.Errorf("system bus not connected")
+ }
+ return m.systemConn, nil
+ case "session":
+ if m.sessionConn == nil {
+ return nil, fmt.Errorf("session bus not connected")
+ }
+ return m.sessionConn, nil
+ default:
+ return nil, fmt.Errorf("invalid bus: %s (must be 'system' or 'session')", bus)
+ }
+}
+
+func (m *Manager) Call(bus, dest, path, iface, method string, args []any) (*CallResult, error) {
+ conn, err := m.getConn(bus)
+ if err != nil {
+ return nil, err
+ }
+
+ obj := conn.Object(dest, dbus.ObjectPath(path))
+ fullMethod := iface + "." + method
+
+ convertedArgs := convertArgs(args)
+ call := obj.Call(fullMethod, 0, convertedArgs...)
+ if call.Err != nil {
+ return nil, fmt.Errorf("dbus call failed: %w", call.Err)
+ }
+
+ return &CallResult{Values: dbusutil.NormalizeSlice(call.Body)}, nil
+}
+
+func convertArgs(args []any) []any {
+ result := make([]any, len(args))
+ for i, arg := range args {
+ result[i] = convertArg(arg)
+ }
+ return result
+}
+
+func convertArg(arg any) any {
+ switch v := arg.(type) {
+ case float64:
+ if v == float64(uint32(v)) && v >= 0 && v <= float64(^uint32(0)) {
+ return uint32(v)
+ }
+ if v == float64(int32(v)) {
+ return int32(v)
+ }
+ return v
+ case []any:
+ return convertArgs(v)
+ case map[string]any:
+ result := make(map[string]any)
+ for k, val := range v {
+ result[k] = convertArg(val)
+ }
+ return result
+ default:
+ return arg
+ }
+}
+
+func (m *Manager) GetProperty(bus, dest, path, iface, property string) (*PropertyResult, error) {
+ conn, err := m.getConn(bus)
+ if err != nil {
+ return nil, err
+ }
+
+ obj := conn.Object(dest, dbus.ObjectPath(path))
+
+ var variant dbus.Variant
+ err = obj.Call("org.freedesktop.DBus.Properties.Get", 0, iface, property).Store(&variant)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get property: %w", err)
+ }
+
+ return &PropertyResult{Value: dbusutil.Normalize(variant.Value())}, nil
+}
+
+func (m *Manager) SetProperty(bus, dest, path, iface, property string, value any) error {
+ conn, err := m.getConn(bus)
+ if err != nil {
+ return err
+ }
+
+ obj := conn.Object(dest, dbus.ObjectPath(path))
+
+ call := obj.Call("org.freedesktop.DBus.Properties.Set", 0, iface, property, dbus.MakeVariant(value))
+ if call.Err != nil {
+ return fmt.Errorf("failed to set property: %w", call.Err)
+ }
+
+ return nil
+}
+
+func (m *Manager) GetAllProperties(bus, dest, path, iface string) (map[string]any, error) {
+ conn, err := m.getConn(bus)
+ if err != nil {
+ return nil, err
+ }
+
+ obj := conn.Object(dest, dbus.ObjectPath(path))
+
+ var props map[string]dbus.Variant
+ err = obj.Call("org.freedesktop.DBus.Properties.GetAll", 0, iface).Store(&props)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get properties: %w", err)
+ }
+
+ result := make(map[string]any)
+ for k, v := range props {
+ result[k] = dbusutil.Normalize(v.Value())
+ }
+
+ return result, nil
+}
+
+func (m *Manager) Introspect(bus, dest, path string) (*IntrospectResult, error) {
+ conn, err := m.getConn(bus)
+ if err != nil {
+ return nil, err
+ }
+
+ obj := conn.Object(dest, dbus.ObjectPath(path))
+
+ var xml string
+ err = obj.Call("org.freedesktop.DBus.Introspectable.Introspect", 0).Store(&xml)
+ if err != nil {
+ return nil, fmt.Errorf("failed to introspect: %w", err)
+ }
+
+ return &IntrospectResult{XML: xml}, nil
+}
+
+func (m *Manager) ListNames(bus string) (*ListNamesResult, error) {
+ conn, err := m.getConn(bus)
+ if err != nil {
+ return nil, err
+ }
+
+ var names []string
+ err = conn.BusObject().Call("org.freedesktop.DBus.ListNames", 0).Store(&names)
+ if err != nil {
+ return nil, fmt.Errorf("failed to list names: %w", err)
+ }
+
+ return &ListNamesResult{Names: names}, nil
+}
+
+func (m *Manager) Subscribe(clientID, bus, sender, path, iface, member string) (*SubscribeResult, error) {
+ conn, err := m.getConn(bus)
+ if err != nil {
+ return nil, err
+ }
+
+ subID := generateSubscriptionID()
+
+ parts := []string{"type='signal'"}
+ if sender != "" {
+ parts = append(parts, fmt.Sprintf("sender='%s'", sender))
+ }
+ if path != "" {
+ parts = append(parts, fmt.Sprintf("path='%s'", path))
+ }
+ if iface != "" {
+ parts = append(parts, fmt.Sprintf("interface='%s'", iface))
+ }
+ if member != "" {
+ parts = append(parts, fmt.Sprintf("member='%s'", member))
+ }
+ matchRule := strings.Join(parts, ",")
+
+ call := conn.BusObject().Call("org.freedesktop.DBus.AddMatch", 0, matchRule)
+ if call.Err != nil {
+ return nil, fmt.Errorf("failed to add match rule: %w", call.Err)
+ }
+
+ sub := &signalSubscription{
+ Bus: bus,
+ Sender: sender,
+ Path: path,
+ Interface: iface,
+ Member: member,
+ ClientID: clientID,
+ }
+ m.subscriptions.Store(subID, sub)
+
+ log.Debugf("dbus: subscribed %s to %s", subID, matchRule)
+
+ return &SubscribeResult{SubscriptionID: subID}, nil
+}
+
+func (m *Manager) Unsubscribe(subID string) error {
+ sub, ok := m.subscriptions.LoadAndDelete(subID)
+ if !ok {
+ return fmt.Errorf("subscription not found: %s", subID)
+ }
+
+ conn, err := m.getConn(sub.Bus)
+ if err != nil {
+ return err
+ }
+
+ parts := []string{"type='signal'"}
+ if sub.Sender != "" {
+ parts = append(parts, fmt.Sprintf("sender='%s'", sub.Sender))
+ }
+ if sub.Path != "" {
+ parts = append(parts, fmt.Sprintf("path='%s'", sub.Path))
+ }
+ if sub.Interface != "" {
+ parts = append(parts, fmt.Sprintf("interface='%s'", sub.Interface))
+ }
+ if sub.Member != "" {
+ parts = append(parts, fmt.Sprintf("member='%s'", sub.Member))
+ }
+ matchRule := strings.Join(parts, ",")
+
+ call := conn.BusObject().Call("org.freedesktop.DBus.RemoveMatch", 0, matchRule)
+ if call.Err != nil {
+ log.Warnf("dbus: failed to remove match rule: %v", call.Err)
+ }
+
+ log.Debugf("dbus: unsubscribed %s", subID)
+
+ return nil
+}
+
+func (m *Manager) UnsubscribeClient(clientID string) {
+ var toDelete []string
+ m.subscriptions.Range(func(subID string, sub *signalSubscription) bool {
+ if sub.ClientID == clientID {
+ toDelete = append(toDelete, subID)
+ }
+ return true
+ })
+
+ for _, subID := range toDelete {
+ _ = m.Unsubscribe(subID)
+ }
+}
+
+func (m *Manager) SubscribeSignals(clientID string) chan SignalEvent {
+ ch := make(chan SignalEvent, 64)
+ existing, loaded := m.signalSubscribers.LoadOrStore(clientID, ch)
+ if loaded {
+ return existing
+ }
+ return ch
+}
+
+func (m *Manager) UnsubscribeSignals(clientID string) {
+ if ch, ok := m.signalSubscribers.LoadAndDelete(clientID); ok {
+ close(ch)
+ }
+ m.UnsubscribeClient(clientID)
+}
+
+func (m *Manager) processSystemSignals() {
+ if m.systemConn == nil {
+ return
+ }
+ ch := make(chan *dbus.Signal, 256)
+ m.systemConn.Signal(ch)
+
+ for sig := range ch {
+ m.dispatchSignal("system", sig)
+ }
+}
+
+func (m *Manager) processSessionSignals() {
+ if m.sessionConn == nil {
+ return
+ }
+ ch := make(chan *dbus.Signal, 256)
+ m.sessionConn.Signal(ch)
+
+ for sig := range ch {
+ m.dispatchSignal("session", sig)
+ }
+}
+
+func (m *Manager) dispatchSignal(bus string, sig *dbus.Signal) {
+ path := string(sig.Path)
+ iface := ""
+ member := sig.Name
+
+ if idx := strings.LastIndex(sig.Name, "."); idx != -1 {
+ iface = sig.Name[:idx]
+ member = sig.Name[idx+1:]
+ }
+
+ m.subscriptions.Range(func(subID string, sub *signalSubscription) bool {
+ if sub.Bus != bus {
+ return true
+ }
+ if sub.Path != "" && sub.Path != path && !strings.HasPrefix(path, sub.Path) {
+ return true
+ }
+ if sub.Interface != "" && sub.Interface != iface {
+ return true
+ }
+ if sub.Member != "" && sub.Member != member {
+ return true
+ }
+
+ event := SignalEvent{
+ SubscriptionID: subID,
+ Sender: sig.Sender,
+ Path: path,
+ Interface: iface,
+ Member: member,
+ Body: dbusutil.NormalizeSlice(sig.Body),
+ }
+
+ ch, ok := m.signalSubscribers.Load(sub.ClientID)
+ if !ok {
+ return true
+ }
+
+ select {
+ case ch <- event:
+ default:
+ log.Warnf("dbus: channel full for %s, dropping signal", subID)
+ }
+
+ return true
+ })
+}
+
+func (m *Manager) Close() {
+ m.signalSubscribers.Range(func(clientID string, ch chan SignalEvent) bool {
+ close(ch)
+ m.signalSubscribers.Delete(clientID)
+ return true
+ })
+
+ if m.systemConn != nil {
+ m.systemConn.Close()
+ }
+ if m.sessionConn != nil {
+ m.sessionConn.Close()
+ }
+}
+
+func generateSubscriptionID() string {
+ b := make([]byte, 8)
+ if _, err := rand.Read(b); err != nil {
+ log.Warnf("dbus: failed to generate random subscription ID: %v", err)
+ }
+ return hex.EncodeToString(b)
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/dbus/types.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/dbus/types.go
new file mode 100644
index 0000000..55c45bf
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/dbus/types.go
@@ -0,0 +1,52 @@
+package dbus
+
+import (
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
+ "github.com/godbus/dbus/v5"
+)
+
+type Manager struct {
+ systemConn *dbus.Conn
+ sessionConn *dbus.Conn
+
+ subscriptions syncmap.Map[string, *signalSubscription]
+ signalSubscribers syncmap.Map[string, chan SignalEvent]
+}
+
+type signalSubscription struct {
+ Bus string
+ Sender string
+ Path string
+ Interface string
+ Member string
+ ClientID string
+}
+
+type SignalEvent struct {
+ SubscriptionID string `json:"subscriptionId"`
+ Sender string `json:"sender"`
+ Path string `json:"path"`
+ Interface string `json:"interface"`
+ Member string `json:"member"`
+ Body []any `json:"body"`
+}
+
+type CallResult struct {
+ Values []any `json:"values"`
+}
+
+type PropertyResult struct {
+ Value any `json:"value"`
+}
+
+type IntrospectResult struct {
+ XML string `json:"xml"`
+}
+
+type ListNamesResult struct {
+ Names []string `json:"names"`
+}
+
+type SubscribeResult struct {
+ SubscriptionID string `json:"subscriptionId"`
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/dwl/handlers.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/dwl/handlers.go
new file mode 100644
index 0000000..e83cc84
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/dwl/handlers.go
@@ -0,0 +1,138 @@
+package dwl
+
+import (
+ "encoding/json"
+ "fmt"
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+)
+
+type SuccessResult struct {
+ Success bool `json:"success"`
+ Message string `json:"message"`
+}
+
+func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
+ if manager == nil {
+ models.RespondError(conn, req.ID, "dwl manager not initialized")
+ return
+ }
+
+ switch req.Method {
+ case "dwl.getState":
+ handleGetState(conn, req, manager)
+ case "dwl.setTags":
+ handleSetTags(conn, req, manager)
+ case "dwl.setClientTags":
+ handleSetClientTags(conn, req, manager)
+ case "dwl.setLayout":
+ handleSetLayout(conn, req, manager)
+ case "dwl.subscribe":
+ handleSubscribe(conn, req, manager)
+ default:
+ models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
+ }
+}
+
+func handleGetState(conn net.Conn, req models.Request, manager *Manager) {
+ state := manager.GetState()
+ models.Respond(conn, req.ID, state)
+}
+
+func handleSetTags(conn net.Conn, req models.Request, manager *Manager) {
+ output, ok := models.Get[string](req, "output")
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'output' parameter")
+ return
+ }
+
+ tagmask, ok := models.Get[float64](req, "tagmask")
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'tagmask' parameter")
+ return
+ }
+
+ toggleTagset, ok := models.Get[float64](req, "toggleTagset")
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'toggleTagset' parameter")
+ return
+ }
+
+ if err := manager.SetTags(output, uint32(tagmask), uint32(toggleTagset)); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "tags set"})
+}
+
+func handleSetClientTags(conn net.Conn, req models.Request, manager *Manager) {
+ output, ok := models.Get[string](req, "output")
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'output' parameter")
+ return
+ }
+
+ andTags, ok := models.Get[float64](req, "andTags")
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'andTags' parameter")
+ return
+ }
+
+ xorTags, ok := models.Get[float64](req, "xorTags")
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'xorTags' parameter")
+ return
+ }
+
+ if err := manager.SetClientTags(output, uint32(andTags), uint32(xorTags)); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "client tags set"})
+}
+
+func handleSetLayout(conn net.Conn, req models.Request, manager *Manager) {
+ output, ok := models.Get[string](req, "output")
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'output' parameter")
+ return
+ }
+
+ index, ok := models.Get[float64](req, "index")
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'index' parameter")
+ return
+ }
+
+ if err := manager.SetLayout(output, uint32(index)); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "layout set"})
+}
+
+func handleSubscribe(conn net.Conn, req models.Request, manager *Manager) {
+ clientID := fmt.Sprintf("client-%p", conn)
+ stateChan := manager.Subscribe(clientID)
+ defer manager.Unsubscribe(clientID)
+
+ initialState := manager.GetState()
+ if err := json.NewEncoder(conn).Encode(models.Response[State]{
+ ID: req.ID,
+ Result: &initialState,
+ }); err != nil {
+ return
+ }
+
+ for state := range stateChan {
+ if err := json.NewEncoder(conn).Encode(models.Response[State]{
+ Result: &state,
+ }); err != nil {
+ return
+ }
+ }
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/dwl/manager.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/dwl/manager.go
new file mode 100644
index 0000000..0d21afa
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/dwl/manager.go
@@ -0,0 +1,522 @@
+package dwl
+
+import (
+ "fmt"
+ "time"
+
+ wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/dwl_ipc"
+)
+
+func NewManager(display wlclient.WaylandDisplay) (*Manager, error) {
+ m := &Manager{
+ display: display,
+ ctx: display.Context(),
+ cmdq: make(chan cmd, 128),
+ outputSetupReq: make(chan uint32, 16),
+ stopChan: make(chan struct{}),
+
+ dirty: make(chan struct{}, 1),
+ layouts: make([]string, 0),
+ }
+
+ if err := m.setupRegistry(); err != nil {
+ return nil, err
+ }
+
+ m.updateState()
+
+ m.notifierWg.Add(1)
+ go m.notifier()
+
+ m.wg.Add(1)
+ go m.waylandActor()
+
+ return m, nil
+}
+
+func (m *Manager) post(fn func()) {
+ select {
+ case m.cmdq <- cmd{fn: fn}:
+ default:
+ log.Warn("DWL actor command queue full, dropping command")
+ }
+}
+
+func (m *Manager) waylandActor() {
+ defer m.wg.Done()
+
+ for {
+ select {
+ case <-m.stopChan:
+ return
+ case c := <-m.cmdq:
+ c.fn()
+ case outputID := <-m.outputSetupReq:
+ out, exists := m.outputs.Load(outputID)
+ if !exists {
+ log.Warnf("DWL: Output %d no longer exists, skipping setup", outputID)
+ continue
+ }
+
+ if out.ipcOutput != nil {
+ continue
+ }
+
+ mgr, ok := m.manager.(*dwl_ipc.ZdwlIpcManagerV2)
+ if !ok || mgr == nil {
+ log.Errorf("DWL: Manager not available for output %d setup", outputID)
+ continue
+ }
+
+ log.Infof("DWL: Setting up ipcOutput for dynamically added output %d", outputID)
+ if err := m.setupOutput(mgr, out.output); err != nil {
+ log.Errorf("DWL: Failed to setup output %d: %v", outputID, err)
+ } else {
+ m.updateState()
+ }
+ }
+ }
+}
+
+func (m *Manager) setupRegistry() error {
+ log.Info("DWL: starting registry setup")
+
+ registry, err := m.display.GetRegistry()
+ if err != nil {
+ return fmt.Errorf("failed to get registry: %w", err)
+ }
+ m.registry = registry
+
+ outputs := make([]*wlclient.Output, 0)
+ outputRegNames := make(map[uint32]uint32)
+ var dwlMgr *dwl_ipc.ZdwlIpcManagerV2
+
+ registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
+ switch e.Interface {
+ case dwl_ipc.ZdwlIpcManagerV2InterfaceName:
+ log.Infof("DWL: found %s", dwl_ipc.ZdwlIpcManagerV2InterfaceName)
+ manager := dwl_ipc.NewZdwlIpcManagerV2(m.ctx)
+ version := e.Version
+ if version > 2 {
+ version = 2
+ }
+ if err := registry.Bind(e.Name, e.Interface, version, manager); err == nil {
+ dwlMgr = manager
+ log.Info("DWL: manager bound successfully")
+
+ // Set handlers immediately after binding, before roundtrips
+ manager.SetTagsHandler(func(e dwl_ipc.ZdwlIpcManagerV2TagsEvent) {
+ log.Infof("DWL: Tags count: %d", e.Amount)
+ m.tagCount = e.Amount
+ m.updateState()
+ })
+
+ manager.SetLayoutHandler(func(e dwl_ipc.ZdwlIpcManagerV2LayoutEvent) {
+ log.Infof("DWL: Layout: %s", e.Name)
+ m.layouts = append(m.layouts, e.Name)
+ m.updateState()
+ })
+ } else {
+ log.Errorf("DWL: failed to bind manager: %v", err)
+ }
+ case "wl_output":
+ log.Debugf("DWL: found wl_output (name=%d)", e.Name)
+ output := wlclient.NewOutput(m.ctx)
+
+ outState := &outputState{
+ registryName: e.Name,
+ output: output,
+ tags: make([]TagState, 0),
+ }
+
+ output.SetNameHandler(func(ev wlclient.OutputNameEvent) {
+ log.Debugf("DWL: Output name: %s (registry=%d)", ev.Name, e.Name)
+ outState.name = ev.Name
+ })
+
+ output.SetDescriptionHandler(func(ev wlclient.OutputDescriptionEvent) {
+ log.Debugf("DWL: Output description: %s", ev.Description)
+ })
+
+ version := e.Version
+ if version > 4 {
+ version = 4
+ }
+ if err := registry.Bind(e.Name, e.Interface, version, output); err == nil {
+ outputID := output.ID()
+ outState.id = outputID
+ log.Infof("DWL: Bound wl_output id=%d registry_name=%d", outputID, e.Name)
+ outputs = append(outputs, output)
+ outputRegNames[outputID] = e.Name
+
+ m.outputs.Store(outputID, outState)
+
+ if m.manager != nil {
+ select {
+ case m.outputSetupReq <- outputID:
+ log.Debugf("DWL: Queued setup for output %d", outputID)
+ default:
+ log.Warnf("DWL: Setup queue full, output %d will not be initialized", outputID)
+ }
+ }
+ } else {
+ log.Errorf("DWL: Failed to bind wl_output: %v", err)
+ }
+ }
+ })
+
+ registry.SetGlobalRemoveHandler(func(e wlclient.RegistryGlobalRemoveEvent) {
+ m.post(func() {
+ var outToRelease *outputState
+ m.outputs.Range(func(id uint32, out *outputState) bool {
+ if out.registryName == e.Name {
+ log.Infof("DWL: Output %d removed", id)
+ outToRelease = out
+ m.outputs.Delete(id)
+ return false
+ }
+ return true
+ })
+
+ if outToRelease != nil {
+ if ipcOut, ok := outToRelease.ipcOutput.(*dwl_ipc.ZdwlIpcOutputV2); ok && ipcOut != nil {
+ m.wlMutex.Lock()
+ ipcOut.Release()
+ m.wlMutex.Unlock()
+ log.Debugf("DWL: Released ipcOutput for removed output %d", outToRelease.id)
+ }
+ m.updateState()
+ }
+ })
+ })
+
+ if err := m.display.Roundtrip(); err != nil {
+ return fmt.Errorf("first roundtrip failed: %w", err)
+ }
+ if err := m.display.Roundtrip(); err != nil {
+ return fmt.Errorf("second roundtrip failed: %w", err)
+ }
+
+ if dwlMgr == nil {
+ log.Info("DWL: manager not found in registry")
+ return fmt.Errorf("dwl_ipc_manager_v2 not available")
+ }
+
+ m.manager = dwlMgr
+
+ for _, output := range outputs {
+ if err := m.setupOutput(dwlMgr, output); err != nil {
+ log.Warnf("DWL: Failed to setup output %d: %v", output.ID(), err)
+ }
+ }
+
+ if err := m.display.Roundtrip(); err != nil {
+ return fmt.Errorf("final roundtrip failed: %w", err)
+ }
+
+ log.Info("DWL: registry setup complete")
+ return nil
+}
+
+func (m *Manager) setupOutput(manager *dwl_ipc.ZdwlIpcManagerV2, output *wlclient.Output) error {
+ m.wlMutex.Lock()
+ ipcOutput, err := manager.GetOutput(output)
+ m.wlMutex.Unlock()
+ if err != nil {
+ return fmt.Errorf("failed to get dwl output: %w", err)
+ }
+
+ outState, exists := m.outputs.Load(output.ID())
+ if !exists {
+ return fmt.Errorf("output state not found for id %d", output.ID())
+ }
+ outState.ipcOutput = ipcOutput
+
+ ipcOutput.SetActiveHandler(func(e dwl_ipc.ZdwlIpcOutputV2ActiveEvent) {
+ outState.active = e.Active
+ })
+
+ ipcOutput.SetTagHandler(func(e dwl_ipc.ZdwlIpcOutputV2TagEvent) {
+ updated := false
+ for i, tag := range outState.tags {
+ if tag.Tag == e.Tag {
+ outState.tags[i] = TagState{
+ Tag: e.Tag,
+ State: e.State,
+ Clients: e.Clients,
+ Focused: e.Focused,
+ }
+ updated = true
+ break
+ }
+ }
+
+ if !updated {
+ outState.tags = append(outState.tags, TagState{
+ Tag: e.Tag,
+ State: e.State,
+ Clients: e.Clients,
+ Focused: e.Focused,
+ })
+ }
+
+ m.updateState()
+ })
+
+ ipcOutput.SetLayoutHandler(func(e dwl_ipc.ZdwlIpcOutputV2LayoutEvent) {
+ outState.layout = e.Layout
+ })
+
+ ipcOutput.SetTitleHandler(func(e dwl_ipc.ZdwlIpcOutputV2TitleEvent) {
+ outState.title = e.Title
+ })
+
+ ipcOutput.SetAppidHandler(func(e dwl_ipc.ZdwlIpcOutputV2AppidEvent) {
+ outState.appID = e.Appid
+ })
+
+ ipcOutput.SetLayoutSymbolHandler(func(e dwl_ipc.ZdwlIpcOutputV2LayoutSymbolEvent) {
+ outState.layoutSymbol = e.Layout
+ })
+
+ ipcOutput.SetKbLayoutHandler(func(e dwl_ipc.ZdwlIpcOutputV2KbLayoutEvent) {
+ outState.kbLayout = e.KbLayout
+ })
+
+ ipcOutput.SetKeymodeHandler(func(e dwl_ipc.ZdwlIpcOutputV2KeymodeEvent) {
+ outState.keymode = e.Keymode
+ })
+
+ ipcOutput.SetFrameHandler(func(e dwl_ipc.ZdwlIpcOutputV2FrameEvent) {
+ m.updateState()
+ })
+
+ return nil
+}
+
+func (m *Manager) updateState() {
+ outputs := make(map[string]*OutputState)
+ activeOutput := ""
+
+ m.outputs.Range(func(key uint32, out *outputState) bool {
+ name := out.name
+ if name == "" {
+ name = fmt.Sprintf("output-%d", out.id)
+ }
+
+ tagsCopy := make([]TagState, len(out.tags))
+ copy(tagsCopy, out.tags)
+
+ outputs[name] = &OutputState{
+ Name: name,
+ Active: out.active,
+ Tags: tagsCopy,
+ Layout: out.layout,
+ LayoutSymbol: out.layoutSymbol,
+ Title: out.title,
+ AppID: out.appID,
+ KbLayout: out.kbLayout,
+ Keymode: out.keymode,
+ }
+
+ if out.active != 0 {
+ activeOutput = name
+ }
+ return true
+ })
+
+ newState := State{
+ Outputs: outputs,
+ TagCount: m.tagCount,
+ Layouts: m.layouts,
+ ActiveOutput: activeOutput,
+ }
+
+ m.stateMutex.Lock()
+ m.state = &newState
+ m.stateMutex.Unlock()
+
+ m.notifySubscribers()
+}
+
+func (m *Manager) notifier() {
+ defer m.notifierWg.Done()
+ const minGap = 100 * time.Millisecond
+ timer := time.NewTimer(minGap)
+ timer.Stop()
+ var pending bool
+
+ for {
+ select {
+ case <-m.stopChan:
+ timer.Stop()
+ return
+ case <-m.dirty:
+ if pending {
+ continue
+ }
+ pending = true
+ timer.Reset(minGap)
+ case <-timer.C:
+ if !pending {
+ continue
+ }
+
+ currentState := m.GetState()
+
+ if m.lastNotified != nil && !stateChanged(m.lastNotified, &currentState) {
+ pending = false
+ continue
+ }
+
+ m.subscribers.Range(func(key string, ch chan State) bool {
+ select {
+ case ch <- currentState:
+ default:
+ log.Warn("DWL: subscriber channel full, dropping update")
+ }
+ return true
+ })
+
+ stateCopy := currentState
+ m.lastNotified = &stateCopy
+ pending = false
+ }
+ }
+}
+
+func (m *Manager) ensureOutputSetup(out *outputState) error {
+ if out.ipcOutput != nil {
+ return nil
+ }
+
+ return fmt.Errorf("output not yet initialized - setup in progress, retry in a moment")
+}
+
+func (m *Manager) SetTags(outputName string, tagmask uint32, toggleTagset uint32) error {
+ availableOutputs := make([]string, 0)
+ var targetOut *outputState
+ m.outputs.Range(func(key uint32, out *outputState) bool {
+ name := out.name
+ if name == "" {
+ name = fmt.Sprintf("output-%d", out.id)
+ }
+ availableOutputs = append(availableOutputs, name)
+ if name == outputName {
+ targetOut = out
+ return false
+ }
+ return true
+ })
+
+ if targetOut == nil {
+ return fmt.Errorf("output not found: %s (available: %v)", outputName, availableOutputs)
+ }
+
+ if err := m.ensureOutputSetup(targetOut); err != nil {
+ return fmt.Errorf("failed to setup output %s: %w", outputName, err)
+ }
+
+ ipcOut, ok := targetOut.ipcOutput.(*dwl_ipc.ZdwlIpcOutputV2)
+ if !ok {
+ return fmt.Errorf("output %s has invalid ipcOutput type", outputName)
+ }
+
+ m.wlMutex.Lock()
+ err := ipcOut.SetTags(tagmask, toggleTagset)
+ m.wlMutex.Unlock()
+ return err
+}
+
+func (m *Manager) SetClientTags(outputName string, andTags uint32, xorTags uint32) error {
+ var targetOut *outputState
+ m.outputs.Range(func(key uint32, out *outputState) bool {
+ name := out.name
+ if name == "" {
+ name = fmt.Sprintf("output-%d", out.id)
+ }
+ if name == outputName {
+ targetOut = out
+ return false
+ }
+ return true
+ })
+
+ if targetOut == nil {
+ return fmt.Errorf("output not found: %s", outputName)
+ }
+
+ if err := m.ensureOutputSetup(targetOut); err != nil {
+ return fmt.Errorf("failed to setup output %s: %w", outputName, err)
+ }
+
+ ipcOut, ok := targetOut.ipcOutput.(*dwl_ipc.ZdwlIpcOutputV2)
+ if !ok {
+ return fmt.Errorf("output %s has invalid ipcOutput type", outputName)
+ }
+
+ m.wlMutex.Lock()
+ err := ipcOut.SetClientTags(andTags, xorTags)
+ m.wlMutex.Unlock()
+ return err
+}
+
+func (m *Manager) SetLayout(outputName string, index uint32) error {
+ var targetOut *outputState
+ m.outputs.Range(func(key uint32, out *outputState) bool {
+ name := out.name
+ if name == "" {
+ name = fmt.Sprintf("output-%d", out.id)
+ }
+ if name == outputName {
+ targetOut = out
+ return false
+ }
+ return true
+ })
+
+ if targetOut == nil {
+ return fmt.Errorf("output not found: %s", outputName)
+ }
+
+ if err := m.ensureOutputSetup(targetOut); err != nil {
+ return fmt.Errorf("failed to setup output %s: %w", outputName, err)
+ }
+
+ ipcOut, ok := targetOut.ipcOutput.(*dwl_ipc.ZdwlIpcOutputV2)
+ if !ok {
+ return fmt.Errorf("output %s has invalid ipcOutput type", outputName)
+ }
+
+ m.wlMutex.Lock()
+ err := ipcOut.SetLayout(index)
+ m.wlMutex.Unlock()
+ return err
+}
+
+func (m *Manager) Close() {
+ close(m.stopChan)
+ m.wg.Wait()
+ m.notifierWg.Wait()
+
+ m.subscribers.Range(func(key string, ch chan State) bool {
+ close(ch)
+ m.subscribers.Delete(key)
+ return true
+ })
+
+ m.outputs.Range(func(key uint32, out *outputState) bool {
+ if ipcOut, ok := out.ipcOutput.(*dwl_ipc.ZdwlIpcOutputV2); ok {
+ ipcOut.Release()
+ }
+ m.outputs.Delete(key)
+ return true
+ })
+
+ if mgr, ok := m.manager.(*dwl_ipc.ZdwlIpcManagerV2); ok {
+ mgr.Release()
+ }
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/dwl/manager_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/dwl/manager_test.go
new file mode 100644
index 0000000..13af137
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/dwl/manager_test.go
@@ -0,0 +1,366 @@
+package dwl
+
+import (
+ "errors"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+
+ mocks_wlclient "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/wlclient"
+)
+
+func TestStateChanged_BothNil(t *testing.T) {
+ assert.True(t, stateChanged(nil, nil))
+}
+
+func TestStateChanged_OneNil(t *testing.T) {
+ s := &State{TagCount: 9}
+ assert.True(t, stateChanged(s, nil))
+ assert.True(t, stateChanged(nil, s))
+}
+
+func TestStateChanged_TagCountDiffers(t *testing.T) {
+ a := &State{TagCount: 9, Outputs: make(map[string]*OutputState), Layouts: []string{}}
+ b := &State{TagCount: 10, Outputs: make(map[string]*OutputState), Layouts: []string{}}
+ assert.True(t, stateChanged(a, b))
+}
+
+func TestStateChanged_LayoutLengthDiffers(t *testing.T) {
+ a := &State{TagCount: 9, Layouts: []string{"tile"}, Outputs: make(map[string]*OutputState)}
+ b := &State{TagCount: 9, Layouts: []string{"tile", "monocle"}, Outputs: make(map[string]*OutputState)}
+ assert.True(t, stateChanged(a, b))
+}
+
+func TestStateChanged_ActiveOutputDiffers(t *testing.T) {
+ a := &State{TagCount: 9, ActiveOutput: "eDP-1", Outputs: make(map[string]*OutputState), Layouts: []string{}}
+ b := &State{TagCount: 9, ActiveOutput: "HDMI-A-1", Outputs: make(map[string]*OutputState), Layouts: []string{}}
+ assert.True(t, stateChanged(a, b))
+}
+
+func TestStateChanged_OutputCountDiffers(t *testing.T) {
+ a := &State{
+ TagCount: 9,
+ Outputs: map[string]*OutputState{"eDP-1": {}},
+ Layouts: []string{},
+ }
+ b := &State{
+ TagCount: 9,
+ Outputs: map[string]*OutputState{},
+ Layouts: []string{},
+ }
+ assert.True(t, stateChanged(a, b))
+}
+
+func TestStateChanged_OutputFieldsDiffer(t *testing.T) {
+ a := &State{
+ TagCount: 9,
+ Layouts: []string{},
+ Outputs: map[string]*OutputState{
+ "eDP-1": {Active: 1, Layout: 0, Title: "Firefox"},
+ },
+ }
+ b := &State{
+ TagCount: 9,
+ Layouts: []string{},
+ Outputs: map[string]*OutputState{
+ "eDP-1": {Active: 0, Layout: 0, Title: "Firefox"},
+ },
+ }
+ assert.True(t, stateChanged(a, b))
+
+ b.Outputs["eDP-1"].Active = 1
+ b.Outputs["eDP-1"].Layout = 1
+ assert.True(t, stateChanged(a, b))
+
+ b.Outputs["eDP-1"].Layout = 0
+ b.Outputs["eDP-1"].Title = "Code"
+ assert.True(t, stateChanged(a, b))
+}
+
+func TestStateChanged_TagsDiffer(t *testing.T) {
+ a := &State{
+ TagCount: 9,
+ Layouts: []string{},
+ Outputs: map[string]*OutputState{
+ "eDP-1": {Tags: []TagState{{Tag: 1, State: 1, Clients: 2, Focused: 1}}},
+ },
+ }
+ b := &State{
+ TagCount: 9,
+ Layouts: []string{},
+ Outputs: map[string]*OutputState{
+ "eDP-1": {Tags: []TagState{{Tag: 1, State: 2, Clients: 2, Focused: 1}}},
+ },
+ }
+ assert.True(t, stateChanged(a, b))
+
+ b.Outputs["eDP-1"].Tags[0].State = 1
+ b.Outputs["eDP-1"].Tags[0].Clients = 3
+ assert.True(t, stateChanged(a, b))
+}
+
+func TestStateChanged_Equal(t *testing.T) {
+ a := &State{
+ TagCount: 9,
+ ActiveOutput: "eDP-1",
+ Layouts: []string{"tile", "monocle"},
+ Outputs: map[string]*OutputState{
+ "eDP-1": {
+ Name: "eDP-1",
+ Active: 1,
+ Layout: 0,
+ LayoutSymbol: "[]=",
+ Title: "Firefox",
+ AppID: "firefox",
+ KbLayout: "us",
+ Keymode: "",
+ Tags: []TagState{{Tag: 1, State: 1, Clients: 2, Focused: 1}},
+ },
+ },
+ }
+ b := &State{
+ TagCount: 9,
+ ActiveOutput: "eDP-1",
+ Layouts: []string{"tile", "monocle"},
+ Outputs: map[string]*OutputState{
+ "eDP-1": {
+ Name: "eDP-1",
+ Active: 1,
+ Layout: 0,
+ LayoutSymbol: "[]=",
+ Title: "Firefox",
+ AppID: "firefox",
+ KbLayout: "us",
+ Keymode: "",
+ Tags: []TagState{{Tag: 1, State: 1, Clients: 2, Focused: 1}},
+ },
+ },
+ }
+ assert.False(t, stateChanged(a, b))
+}
+
+func TestManager_ConcurrentGetState(t *testing.T) {
+ m := &Manager{
+ state: &State{
+ TagCount: 9,
+ Layouts: []string{"tile"},
+ Outputs: map[string]*OutputState{"eDP-1": {Name: "eDP-1"}},
+ },
+ }
+
+ var wg sync.WaitGroup
+ const goroutines = 50
+ const iterations = 100
+
+ for i := 0; i < goroutines/2; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for j := 0; j < iterations; j++ {
+ s := m.GetState()
+ _ = s.TagCount
+ _ = s.Outputs
+ }
+ }()
+ }
+
+ for i := 0; i < goroutines/2; i++ {
+ wg.Add(1)
+ go func(i int) {
+ defer wg.Done()
+ for j := 0; j < iterations; j++ {
+ m.stateMutex.Lock()
+ m.state = &State{
+ TagCount: uint32(j % 10),
+ Layouts: []string{"tile", "monocle"},
+ Outputs: map[string]*OutputState{"eDP-1": {Active: uint32(j % 2)}},
+ }
+ m.stateMutex.Unlock()
+ }
+ }(i)
+ }
+
+ wg.Wait()
+}
+
+func TestManager_ConcurrentSubscriberAccess(t *testing.T) {
+ m := &Manager{
+ stopChan: make(chan struct{}),
+ dirty: make(chan struct{}, 1),
+ }
+
+ var wg sync.WaitGroup
+ const goroutines = 20
+
+ for i := 0; i < goroutines; i++ {
+ wg.Add(1)
+ go func(id int) {
+ defer wg.Done()
+ subID := string(rune('a' + id))
+ ch := m.Subscribe(subID)
+ assert.NotNil(t, ch)
+ time.Sleep(time.Millisecond)
+ m.Unsubscribe(subID)
+ }(i)
+ }
+
+ wg.Wait()
+}
+
+func TestManager_SyncmapOutputsConcurrentAccess(t *testing.T) {
+ m := &Manager{}
+
+ var wg sync.WaitGroup
+ const goroutines = 30
+ const iterations = 50
+
+ for i := 0; i < goroutines; i++ {
+ wg.Add(1)
+ go func(id int) {
+ defer wg.Done()
+ key := uint32(id)
+
+ for j := 0; j < iterations; j++ {
+ state := &outputState{
+ id: key,
+ name: "test-output",
+ active: uint32(j % 2),
+ tags: []TagState{{Tag: uint32(j), State: 1}},
+ }
+ m.outputs.Store(key, state)
+
+ if loaded, ok := m.outputs.Load(key); ok {
+ assert.Equal(t, key, loaded.id)
+ }
+
+ m.outputs.Range(func(k uint32, v *outputState) bool {
+ _ = v.name
+ _ = v.active
+ return true
+ })
+ }
+
+ m.outputs.Delete(key)
+ }(i)
+ }
+
+ wg.Wait()
+}
+
+func TestManager_NotifySubscribersNonBlocking(t *testing.T) {
+ m := &Manager{
+ dirty: make(chan struct{}, 1),
+ }
+
+ for i := 0; i < 10; i++ {
+ m.notifySubscribers()
+ }
+
+ assert.Len(t, m.dirty, 1)
+}
+
+func TestManager_PostQueueFull(t *testing.T) {
+ m := &Manager{
+ cmdq: make(chan cmd, 2),
+ stopChan: make(chan struct{}),
+ }
+
+ m.post(func() {})
+ m.post(func() {})
+ m.post(func() {})
+ m.post(func() {})
+
+ assert.Len(t, m.cmdq, 2)
+}
+
+func TestManager_GetStateNilState(t *testing.T) {
+ m := &Manager{}
+
+ s := m.GetState()
+ assert.NotNil(t, s.Outputs)
+ assert.NotNil(t, s.Layouts)
+ assert.Equal(t, uint32(0), s.TagCount)
+}
+
+func TestTagState_Fields(t *testing.T) {
+ tag := TagState{
+ Tag: 1,
+ State: 2,
+ Clients: 3,
+ Focused: 1,
+ }
+
+ assert.Equal(t, uint32(1), tag.Tag)
+ assert.Equal(t, uint32(2), tag.State)
+ assert.Equal(t, uint32(3), tag.Clients)
+ assert.Equal(t, uint32(1), tag.Focused)
+}
+
+func TestOutputState_Fields(t *testing.T) {
+ out := OutputState{
+ Name: "eDP-1",
+ Active: 1,
+ Tags: []TagState{{Tag: 1}},
+ Layout: 0,
+ LayoutSymbol: "[]=",
+ Title: "Firefox",
+ AppID: "firefox",
+ KbLayout: "us",
+ Keymode: "",
+ }
+
+ assert.Equal(t, "eDP-1", out.Name)
+ assert.Equal(t, uint32(1), out.Active)
+ assert.Len(t, out.Tags, 1)
+ assert.Equal(t, "[]=", out.LayoutSymbol)
+}
+
+func TestStateChanged_NewOutputAppears(t *testing.T) {
+ a := &State{
+ TagCount: 9,
+ Layouts: []string{},
+ Outputs: map[string]*OutputState{
+ "eDP-1": {Name: "eDP-1"},
+ },
+ }
+ b := &State{
+ TagCount: 9,
+ Layouts: []string{},
+ Outputs: map[string]*OutputState{
+ "eDP-1": {Name: "eDP-1"},
+ "HDMI-A-1": {Name: "HDMI-A-1"},
+ },
+ }
+ assert.True(t, stateChanged(a, b))
+}
+
+func TestStateChanged_TagsLengthDiffers(t *testing.T) {
+ a := &State{
+ TagCount: 9,
+ Layouts: []string{},
+ Outputs: map[string]*OutputState{
+ "eDP-1": {Tags: []TagState{{Tag: 1}}},
+ },
+ }
+ b := &State{
+ TagCount: 9,
+ Layouts: []string{},
+ Outputs: map[string]*OutputState{
+ "eDP-1": {Tags: []TagState{{Tag: 1}, {Tag: 2}}},
+ },
+ }
+ assert.True(t, stateChanged(a, b))
+}
+
+func TestNewManager_GetRegistryError(t *testing.T) {
+ mockDisplay := mocks_wlclient.NewMockWaylandDisplay(t)
+
+ mockDisplay.EXPECT().Context().Return(nil)
+ mockDisplay.EXPECT().GetRegistry().Return(nil, errors.New("failed to get registry"))
+
+ _, err := NewManager(mockDisplay)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "failed to get registry")
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/dwl/types.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/dwl/types.go
new file mode 100644
index 0000000..2b17537
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/dwl/types.go
@@ -0,0 +1,176 @@
+package dwl
+
+import (
+ "sync"
+
+ wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
+)
+
+type TagState struct {
+ Tag uint32 `json:"tag"`
+ State uint32 `json:"state"`
+ Clients uint32 `json:"clients"`
+ Focused uint32 `json:"focused"`
+}
+
+type OutputState struct {
+ Name string `json:"name"`
+ Active uint32 `json:"active"`
+ Tags []TagState `json:"tags"`
+ Layout uint32 `json:"layout"`
+ LayoutSymbol string `json:"layoutSymbol"`
+ Title string `json:"title"`
+ AppID string `json:"appId"`
+ KbLayout string `json:"kbLayout"`
+ Keymode string `json:"keymode"`
+}
+
+type State struct {
+ Outputs map[string]*OutputState `json:"outputs"`
+ TagCount uint32 `json:"tagCount"`
+ Layouts []string `json:"layouts"`
+ ActiveOutput string `json:"activeOutput"`
+}
+
+type cmd struct {
+ fn func()
+}
+
+type Manager struct {
+ display wlclient.WaylandDisplay
+ ctx *wlclient.Context
+ registry *wlclient.Registry
+ manager any
+
+ outputs syncmap.Map[uint32, *outputState]
+
+ tagCount uint32
+ layouts []string
+
+ wlMutex sync.Mutex
+ cmdq chan cmd
+ outputSetupReq chan uint32
+ stopChan chan struct{}
+ wg sync.WaitGroup
+
+ subscribers syncmap.Map[string, chan State]
+ dirty chan struct{}
+ notifierWg sync.WaitGroup
+ lastNotified *State
+
+ stateMutex sync.RWMutex
+ state *State
+}
+
+type outputState struct {
+ id uint32
+ registryName uint32
+ output *wlclient.Output
+ ipcOutput any
+ name string
+ active uint32
+ tags []TagState
+ layout uint32
+ layoutSymbol string
+ title string
+ appID string
+ kbLayout string
+ keymode string
+}
+
+func (m *Manager) GetState() State {
+ m.stateMutex.RLock()
+ defer m.stateMutex.RUnlock()
+ if m.state == nil {
+ return State{
+ Outputs: make(map[string]*OutputState),
+ Layouts: []string{},
+ TagCount: 0,
+ }
+ }
+ stateCopy := *m.state
+ return stateCopy
+}
+
+func (m *Manager) Subscribe(id string) chan State {
+ ch := make(chan State, 64)
+
+ m.subscribers.Store(id, ch)
+
+ return ch
+}
+
+func (m *Manager) Unsubscribe(id string) {
+ if val, ok := m.subscribers.LoadAndDelete(id); ok {
+ close(val)
+ }
+}
+
+func (m *Manager) notifySubscribers() {
+ select {
+ case m.dirty <- struct{}{}:
+ default:
+ }
+}
+
+func stateChanged(old, new *State) bool {
+ if old == nil || new == nil {
+ return true
+ }
+ if old.TagCount != new.TagCount {
+ return true
+ }
+ if len(old.Layouts) != len(new.Layouts) {
+ return true
+ }
+ if old.ActiveOutput != new.ActiveOutput {
+ return true
+ }
+ if len(old.Outputs) != len(new.Outputs) {
+ return true
+ }
+
+ for name, newOut := range new.Outputs {
+ oldOut, exists := old.Outputs[name]
+ if !exists {
+ return true
+ }
+ if oldOut.Active != newOut.Active {
+ return true
+ }
+ if oldOut.Layout != newOut.Layout {
+ return true
+ }
+ if oldOut.LayoutSymbol != newOut.LayoutSymbol {
+ return true
+ }
+ if oldOut.Title != newOut.Title {
+ return true
+ }
+ if oldOut.AppID != newOut.AppID {
+ return true
+ }
+ if oldOut.KbLayout != newOut.KbLayout {
+ return true
+ }
+ if oldOut.Keymode != newOut.Keymode {
+ return true
+ }
+ if len(oldOut.Tags) != len(newOut.Tags) {
+ return true
+ }
+ for i, newTag := range newOut.Tags {
+ if i >= len(oldOut.Tags) {
+ return true
+ }
+ oldTag := oldOut.Tags[i]
+ if oldTag.Tag != newTag.Tag || oldTag.State != newTag.State ||
+ oldTag.Clients != newTag.Clients || oldTag.Focused != newTag.Focused {
+ return true
+ }
+ }
+ }
+
+ return false
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/evdev/handlers.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/evdev/handlers.go
new file mode 100644
index 0000000..54a7bca
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/evdev/handlers.go
@@ -0,0 +1,20 @@
+package evdev
+
+import (
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+)
+
+func HandleRequest(conn net.Conn, req models.Request, m *Manager) {
+ switch req.Method {
+ case "evdev.getState":
+ handleGetState(conn, req, m)
+ default:
+ models.RespondError(conn, req.ID, "unknown method: "+req.Method)
+ }
+}
+
+func handleGetState(conn net.Conn, req models.Request, m *Manager) {
+ models.Respond(conn, req.ID, m.GetState())
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/evdev/handlers_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/evdev/handlers_test.go
new file mode 100644
index 0000000..f9bd21d
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/evdev/handlers_test.go
@@ -0,0 +1,130 @@
+package evdev
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "net"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ mocks "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/evdev"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+)
+
+type mockNetConn struct {
+ net.Conn
+ readBuf *bytes.Buffer
+ writeBuf *bytes.Buffer
+ closed bool
+}
+
+func newMockNetConn() *mockNetConn {
+ return &mockNetConn{
+ readBuf: &bytes.Buffer{},
+ writeBuf: &bytes.Buffer{},
+ }
+}
+
+func (m *mockNetConn) Read(b []byte) (n int, err error) {
+ return m.readBuf.Read(b)
+}
+
+func (m *mockNetConn) Write(b []byte) (n int, err error) {
+ return m.writeBuf.Write(b)
+}
+
+func (m *mockNetConn) Close() error {
+ m.closed = true
+ return nil
+}
+
+func TestHandleRequest(t *testing.T) {
+ t.Run("getState request", func(t *testing.T) {
+ mockDevice := mocks.NewMockEvdevDevice(t)
+ mockDevice.EXPECT().ReadOne().Return(nil, errors.New("test")).Maybe()
+
+ m := &Manager{
+ devices: []EvdevDevice{mockDevice},
+ state: State{Available: true, CapsLock: true},
+ closeChan: make(chan struct{}),
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{
+ ID: 123,
+ Method: "evdev.getState",
+ Params: map[string]any{},
+ }
+
+ HandleRequest(conn, req, m)
+
+ var resp models.Response[State]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Available)
+ assert.True(t, resp.Result.CapsLock)
+ })
+
+ t.Run("unknown method", func(t *testing.T) {
+ mockDevice := mocks.NewMockEvdevDevice(t)
+ mockDevice.EXPECT().ReadOne().Return(nil, errors.New("test")).Maybe()
+
+ m := &Manager{
+ devices: []EvdevDevice{mockDevice},
+ state: State{Available: true, CapsLock: false},
+ closeChan: make(chan struct{}),
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{
+ ID: 456,
+ Method: "evdev.unknownMethod",
+ Params: map[string]any{},
+ }
+
+ HandleRequest(conn, req, m)
+
+ var resp models.Response[any]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 456, resp.ID)
+ assert.NotEmpty(t, resp.Error)
+ assert.Contains(t, resp.Error, "unknown method")
+ })
+}
+
+func TestHandleGetState(t *testing.T) {
+ mockDevice := mocks.NewMockEvdevDevice(t)
+ mockDevice.EXPECT().ReadOne().Return(nil, errors.New("test")).Maybe()
+
+ m := &Manager{
+ devices: []EvdevDevice{mockDevice},
+ state: State{Available: true, CapsLock: false},
+ closeChan: make(chan struct{}),
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{
+ ID: 789,
+ Method: "evdev.getState",
+ Params: map[string]any{},
+ }
+
+ handleGetState(conn, req, m)
+
+ var resp models.Response[State]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 789, resp.ID)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Available)
+ assert.False(t, resp.Result.CapsLock)
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/evdev/manager.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/evdev/manager.go
new file mode 100644
index 0000000..b578481
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/evdev/manager.go
@@ -0,0 +1,413 @@
+package evdev
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
+ "github.com/fsnotify/fsnotify"
+ evdev "github.com/holoplot/go-evdev"
+)
+
+const (
+ evKeyType = 0x01
+ evLedType = 0x11
+ keyCapslockKey = 58
+ ledCapslockKey = 1
+ keyStateOn = 1
+)
+
+type EvdevDevice interface {
+ Name() (string, error)
+ Path() string
+ Close() error
+ ReadOne() (*evdev.InputEvent, error)
+ State(t evdev.EvType) (evdev.StateMap, error)
+}
+
+type Manager struct {
+ devices []EvdevDevice
+ devicesMutex sync.RWMutex
+ monitoredPaths map[string]bool
+ state State
+ stateMutex sync.RWMutex
+ subscribers syncmap.Map[string, chan State]
+ closeChan chan struct{}
+ closeOnce sync.Once
+ watcher *fsnotify.Watcher
+}
+
+func NewManager() (*Manager, error) {
+ devices, err := findKeyboards()
+ if err != nil {
+ return nil, fmt.Errorf("failed to find keyboards: %w", err)
+ }
+
+ initialCapsLock := readInitialCapsLockState(devices[0])
+
+ watcher, err := fsnotify.NewWatcher()
+ if err != nil {
+ log.Warnf("Failed to create fsnotify watcher, hotplug detection disabled: %v", err)
+ watcher = nil
+ } else if err := watcher.Add("/dev/input"); err != nil {
+ log.Warnf("Failed to watch /dev/input, hotplug detection disabled: %v", err)
+ watcher.Close()
+ watcher = nil
+ }
+
+ monitoredPaths := make(map[string]bool)
+ for _, device := range devices {
+ monitoredPaths[device.Path()] = true
+ }
+
+ m := &Manager{
+ devices: devices,
+ monitoredPaths: monitoredPaths,
+ state: State{Available: true, CapsLock: initialCapsLock},
+
+ closeChan: make(chan struct{}),
+ watcher: watcher,
+ }
+
+ for i, device := range devices {
+ go m.monitorDevice(device, i)
+ }
+
+ if watcher != nil {
+ go m.watchForNewKeyboards()
+ }
+
+ return m, nil
+}
+
+func readInitialCapsLockState(device EvdevDevice) bool {
+ ledStates, err := device.State(evLedType)
+ if err != nil {
+ log.Debugf("Could not read LED state: %v", err)
+ return false
+ }
+
+ return ledStates[ledCapslockKey]
+}
+
+func findKeyboards() ([]EvdevDevice, error) {
+ pattern := "/dev/input/event*"
+ matches, err := filepath.Glob(pattern)
+ if err != nil {
+ return nil, fmt.Errorf("failed to glob input devices: %w", err)
+ }
+
+ if len(matches) == 0 {
+ return nil, fmt.Errorf("no input devices found")
+ }
+
+ var keyboards []EvdevDevice
+ for _, path := range matches {
+ device, err := evdev.Open(path)
+ if err != nil {
+ continue
+ }
+
+ if !isKeyboard(device) {
+ device.Close()
+ continue
+ }
+
+ deviceName, _ := device.Name()
+ log.Debugf("Found keyboard: %s at %s", deviceName, path)
+ keyboards = append(keyboards, device)
+ }
+
+ if len(keyboards) == 0 {
+ return nil, fmt.Errorf("no keyboard device found")
+ }
+
+ return keyboards, nil
+}
+
+func isKeyboard(device EvdevDevice) bool {
+ deviceName, err := device.Name()
+ if err != nil {
+ return false
+ }
+
+ name := strings.ToLower(deviceName)
+
+ switch {
+ case strings.Contains(name, "keyboard"):
+ return true
+ case strings.Contains(name, "kbd"):
+ return true
+ case strings.Contains(name, "input") && strings.Contains(name, "key"):
+ return true
+ }
+
+ keyStates, err := device.State(evKeyType)
+ if err != nil {
+ return false
+ }
+
+ hasKeyA := len(keyStates) > 30
+ hasKeyZ := len(keyStates) > 44
+ hasEnter := len(keyStates) > 28
+
+ return hasKeyA && hasKeyZ && hasEnter && len(keyStates) > 100
+}
+
+func (m *Manager) watchForNewKeyboards() {
+ defer func() {
+ if r := recover(); r != nil {
+ log.Errorf("Panic in keyboard hotplug monitor: %v", r)
+ }
+ }()
+
+ for {
+ select {
+ case <-m.closeChan:
+ return
+ case event, ok := <-m.watcher.Events:
+ if !ok {
+ return
+ }
+
+ if !strings.HasPrefix(filepath.Base(event.Name), "event") {
+ continue
+ }
+
+ if event.Op&fsnotify.Create == fsnotify.Create {
+ time.Sleep(100 * time.Millisecond)
+
+ m.devicesMutex.Lock()
+ if m.monitoredPaths[event.Name] {
+ m.devicesMutex.Unlock()
+ continue
+ }
+
+ device, err := evdev.Open(event.Name)
+ if err != nil {
+ m.devicesMutex.Unlock()
+ continue
+ }
+
+ if !isKeyboard(device) {
+ device.Close()
+ m.devicesMutex.Unlock()
+ continue
+ }
+
+ deviceName, _ := device.Name()
+ log.Debugf("Hotplugged keyboard: %s at %s", deviceName, event.Name)
+
+ m.devices = append(m.devices, device)
+ m.monitoredPaths[event.Name] = true
+ deviceIndex := len(m.devices) - 1
+ m.devicesMutex.Unlock()
+
+ go m.monitorDevice(device, deviceIndex)
+ } else if event.Op&fsnotify.Remove == fsnotify.Remove {
+ m.devicesMutex.Lock()
+ if !m.monitoredPaths[event.Name] {
+ m.devicesMutex.Unlock()
+ continue
+ }
+
+ delete(m.monitoredPaths, event.Name)
+ for i, device := range m.devices {
+ if device != nil && device.Path() == event.Name {
+ log.Debugf("Keyboard removed: %s", event.Name)
+ device.Close()
+ m.devices[i] = nil
+ break
+ }
+ }
+ m.devicesMutex.Unlock()
+ }
+
+ case err, ok := <-m.watcher.Errors:
+ if !ok {
+ return
+ }
+ log.Warnf("Keyboard hotplug watcher error: %v", err)
+ }
+ }
+}
+
+func (m *Manager) monitorDevice(device EvdevDevice, deviceIndex int) {
+ defer func() {
+ if r := recover(); r != nil {
+ log.Errorf("Panic in evdev monitor: %v", r)
+ }
+ }()
+
+ for {
+ select {
+ case <-m.closeChan:
+ return
+ default:
+ }
+
+ event, err := device.ReadOne()
+ if err != nil {
+ if isClosedError(err) {
+ return
+ }
+ log.Warnf("Failed to read evdev event: %v", err)
+ time.Sleep(100 * time.Millisecond)
+ continue
+ }
+
+ if event == nil {
+ continue
+ }
+
+ if event.Type == evKeyType && event.Code == keyCapslockKey && event.Value == keyStateOn {
+ time.Sleep(50 * time.Millisecond)
+ m.readAndUpdateCapsLockState(deviceIndex)
+ } else if event.Type == evLedType && event.Code == ledCapslockKey {
+ capsLockState := event.Value == keyStateOn
+ m.updateCapsLockStateDirect(capsLockState)
+ }
+ }
+}
+
+func isClosedError(err error) bool {
+ if err == nil {
+ return false
+ }
+
+ errStr := err.Error()
+ switch {
+ case strings.Contains(errStr, "closed"):
+ return true
+ case strings.Contains(errStr, "bad file descriptor"):
+ return true
+ default:
+ return false
+ }
+}
+
+func (m *Manager) readAndUpdateCapsLockState(deviceIndex int) {
+ m.devicesMutex.RLock()
+ if deviceIndex >= len(m.devices) {
+ m.devicesMutex.RUnlock()
+ return
+ }
+ device := m.devices[deviceIndex]
+ m.devicesMutex.RUnlock()
+
+ ledStates, err := device.State(evLedType)
+ if err != nil {
+ log.Warnf("Failed to read LED state: %v", err)
+ return
+ }
+
+ if len(ledStates) == 0 {
+ log.Debug("No LED state available (empty map)")
+
+ // This means the device either:
+ // - doesn't support LED reporting at all, or
+ // - the kernel returned an empty state
+ return
+ }
+
+ capsLockState := ledStates[ledCapslockKey]
+ m.updateCapsLockStateDirect(capsLockState)
+}
+
+func (m *Manager) updateCapsLockStateDirect(capsLockState bool) {
+ m.stateMutex.Lock()
+ if m.state.CapsLock == capsLockState {
+ m.stateMutex.Unlock()
+ return
+ }
+
+ m.state.CapsLock = capsLockState
+ newState := m.state
+ m.stateMutex.Unlock()
+
+ log.Debugf("Caps lock state: %v", newState.CapsLock)
+ m.notifySubscribers(newState)
+}
+
+func (m *Manager) GetState() State {
+ m.stateMutex.RLock()
+ defer m.stateMutex.RUnlock()
+ return m.state
+}
+
+func (m *Manager) Subscribe(id string) chan State {
+ ch := make(chan State, 16)
+ m.subscribers.Store(id, ch)
+ return ch
+}
+
+func (m *Manager) Unsubscribe(id string) {
+ if val, ok := m.subscribers.LoadAndDelete(id); ok {
+ close(val)
+ }
+}
+
+func (m *Manager) notifySubscribers(state State) {
+ m.subscribers.Range(func(key string, ch chan State) bool {
+ select {
+ case ch <- state:
+ default:
+ }
+ return true
+ })
+}
+
+func (m *Manager) Close() {
+ m.closeOnce.Do(func() {
+ close(m.closeChan)
+
+ if m.watcher != nil {
+ m.watcher.Close()
+ }
+
+ m.devicesMutex.Lock()
+ for _, device := range m.devices {
+ if device == nil {
+ continue
+ }
+ if err := device.Close(); err != nil && !isClosedError(err) {
+ log.Warnf("Error closing evdev device: %v", err)
+ }
+ }
+ m.devicesMutex.Unlock()
+
+ m.subscribers.Range(func(key string, ch chan State) bool {
+ close(ch)
+ m.subscribers.Delete(key)
+ return true
+ })
+ })
+}
+
+func InitializeManager() (*Manager, error) {
+ if os.Getuid() != 0 && !hasInputGroupAccess() {
+ return nil, fmt.Errorf("insufficient permissions to access input devices")
+ }
+
+ return NewManager()
+}
+
+func hasInputGroupAccess() bool {
+ pattern := "/dev/input/event*"
+ matches, err := filepath.Glob(pattern)
+ if err != nil || len(matches) == 0 {
+ return false
+ }
+
+ testFile, err := os.Open(matches[0])
+ if err != nil {
+ return false
+ }
+ testFile.Close()
+ return true
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/evdev/manager_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/evdev/manager_test.go
new file mode 100644
index 0000000..667c078
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/evdev/manager_test.go
@@ -0,0 +1,344 @@
+package evdev
+
+import (
+ "errors"
+ "testing"
+
+ evdev "github.com/holoplot/go-evdev"
+ "github.com/stretchr/testify/assert"
+
+ mocks "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/evdev"
+)
+
+func TestManager_Creation(t *testing.T) {
+ t.Run("manager created successfully with caps lock off", func(t *testing.T) {
+ mockDevice := mocks.NewMockEvdevDevice(t)
+ mockDevice.EXPECT().ReadOne().Return(nil, errors.New("test")).Maybe()
+
+ m := &Manager{
+ devices: []EvdevDevice{mockDevice},
+ state: State{Available: true, CapsLock: false},
+ closeChan: make(chan struct{}),
+ }
+
+ assert.NotNil(t, m)
+ assert.True(t, m.state.Available)
+ assert.False(t, m.state.CapsLock)
+ })
+
+ t.Run("manager created successfully with caps lock on", func(t *testing.T) {
+ mockDevice := mocks.NewMockEvdevDevice(t)
+ mockDevice.EXPECT().ReadOne().Return(nil, errors.New("test")).Maybe()
+
+ m := &Manager{
+ devices: []EvdevDevice{mockDevice},
+ state: State{Available: true, CapsLock: true},
+ closeChan: make(chan struct{}),
+ }
+
+ assert.NotNil(t, m)
+ assert.True(t, m.state.Available)
+ assert.True(t, m.state.CapsLock)
+ })
+}
+
+func TestManager_GetState(t *testing.T) {
+ mockDevice := mocks.NewMockEvdevDevice(t)
+ mockDevice.EXPECT().ReadOne().Return(nil, errors.New("test")).Maybe()
+
+ m := &Manager{
+ devices: []EvdevDevice{mockDevice},
+ monitoredPaths: make(map[string]bool),
+ state: State{Available: true, CapsLock: false},
+ closeChan: make(chan struct{}),
+ }
+
+ state := m.GetState()
+ assert.True(t, state.Available)
+ assert.False(t, state.CapsLock)
+}
+
+func TestManager_Subscribe(t *testing.T) {
+ mockDevice := mocks.NewMockEvdevDevice(t)
+ mockDevice.EXPECT().ReadOne().Return(nil, errors.New("test")).Maybe()
+
+ m := &Manager{
+ devices: []EvdevDevice{mockDevice},
+ monitoredPaths: make(map[string]bool),
+ state: State{Available: true, CapsLock: false},
+ closeChan: make(chan struct{}),
+ }
+
+ ch := m.Subscribe("test-client")
+ assert.NotNil(t, ch)
+ count := 0
+ m.subscribers.Range(func(key string, ch chan State) bool {
+ count++
+ return true
+ })
+ assert.Equal(t, 1, count)
+}
+
+func TestManager_Unsubscribe(t *testing.T) {
+ mockDevice := mocks.NewMockEvdevDevice(t)
+ mockDevice.EXPECT().ReadOne().Return(nil, errors.New("test")).Maybe()
+
+ m := &Manager{
+ devices: []EvdevDevice{mockDevice},
+ monitoredPaths: make(map[string]bool),
+ state: State{Available: true, CapsLock: false},
+ closeChan: make(chan struct{}),
+ }
+
+ ch := m.Subscribe("test-client")
+ count := 0
+ m.subscribers.Range(func(key string, ch chan State) bool {
+ count++
+ return true
+ })
+ assert.Equal(t, 1, count)
+
+ m.Unsubscribe("test-client")
+ count = 0
+ m.subscribers.Range(func(key string, ch chan State) bool {
+ count++
+ return true
+ })
+ assert.Equal(t, 0, count)
+
+ select {
+ case _, ok := <-ch:
+ assert.False(t, ok, "channel should be closed")
+ default:
+ t.Error("channel should be closed")
+ }
+}
+
+func TestManager_UpdateCapsLock(t *testing.T) {
+ mockDevice := mocks.NewMockEvdevDevice(t)
+ mockDevice.EXPECT().ReadOne().Return(nil, errors.New("test")).Maybe()
+
+ m := &Manager{
+ devices: []EvdevDevice{mockDevice},
+ monitoredPaths: make(map[string]bool),
+ state: State{Available: true, CapsLock: false},
+ closeChan: make(chan struct{}),
+ }
+
+ ch := m.Subscribe("test-client")
+
+ ledStateOn := evdev.StateMap{ledCapslockKey: true}
+ mockDevice.EXPECT().State(evdev.EvType(evLedType)).Return(ledStateOn, nil).Once()
+
+ go func() {
+ m.readAndUpdateCapsLockState(0)
+ }()
+
+ newState := <-ch
+ assert.True(t, newState.CapsLock)
+
+ ledStateOff := evdev.StateMap{ledCapslockKey: false}
+ mockDevice.EXPECT().State(evdev.EvType(evLedType)).Return(ledStateOff, nil).Once()
+
+ go func() {
+ m.readAndUpdateCapsLockState(0)
+ }()
+
+ newState = <-ch
+ assert.False(t, newState.CapsLock)
+}
+
+func TestManager_Close(t *testing.T) {
+ mockDevice := mocks.NewMockEvdevDevice(t)
+ mockDevice.EXPECT().Close().Return(nil).Once()
+ mockDevice.EXPECT().ReadOne().Return(nil, errors.New("test")).Maybe()
+
+ m := &Manager{
+ devices: []EvdevDevice{mockDevice},
+ monitoredPaths: make(map[string]bool),
+ state: State{Available: true, CapsLock: false},
+ closeChan: make(chan struct{}),
+ }
+
+ ch1 := m.Subscribe("client1")
+ ch2 := m.Subscribe("client2")
+
+ m.Close()
+
+ select {
+ case _, ok := <-ch1:
+ assert.False(t, ok, "channel 1 should be closed")
+ default:
+ t.Error("channel 1 should be closed")
+ }
+
+ select {
+ case _, ok := <-ch2:
+ assert.False(t, ok, "channel 2 should be closed")
+ default:
+ t.Error("channel 2 should be closed")
+ }
+
+ count := 0
+ m.subscribers.Range(func(key string, ch chan State) bool {
+ count++
+ return true
+ })
+ assert.Equal(t, 0, count)
+
+ m.Close()
+}
+
+func TestIsKeyboard(t *testing.T) {
+ tests := []struct {
+ name string
+ devName string
+ expected bool
+ }{
+ {"keyboard in name", "AT Translated Set 2 keyboard", true},
+ {"kbd in name", "USB kbd", true},
+ {"input and key", "input key device", true},
+ {"random device", "Mouse", false},
+ {"empty name", "", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ mockDevice := mocks.NewMockEvdevDevice(t)
+ mockDevice.EXPECT().Name().Return(tt.devName, nil).Once()
+
+ if !tt.expected {
+ mockDevice.EXPECT().State(evdev.EvType(evKeyType)).Return(evdev.StateMap{}, nil).Maybe()
+ }
+
+ result := isKeyboard(mockDevice)
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
+
+func TestIsKeyboard_ErrorHandling(t *testing.T) {
+ mockDevice := mocks.NewMockEvdevDevice(t)
+ mockDevice.EXPECT().Name().Return("", errors.New("device error")).Once()
+
+ result := isKeyboard(mockDevice)
+ assert.False(t, result)
+}
+
+func TestManager_MonitorDevice(t *testing.T) {
+ t.Run("caps lock key press updates state", func(t *testing.T) {
+ mockDevice := mocks.NewMockEvdevDevice(t)
+
+ capsLockEvent := &evdev.InputEvent{
+ Type: evKeyType,
+ Code: keyCapslockKey,
+ Value: keyStateOn,
+ }
+
+ ledState := evdev.StateMap{ledCapslockKey: true}
+
+ mockDevice.EXPECT().ReadOne().Return(capsLockEvent, nil).Once()
+ mockDevice.EXPECT().State(evdev.EvType(evLedType)).Return(ledState, nil).Once()
+ mockDevice.EXPECT().ReadOne().Return(nil, errors.New("stop")).Maybe()
+ mockDevice.EXPECT().Close().Return(nil).Maybe()
+
+ m := &Manager{
+ devices: []EvdevDevice{mockDevice},
+ state: State{Available: true, CapsLock: false},
+ closeChan: make(chan struct{}),
+ }
+
+ ch := m.Subscribe("test")
+
+ go m.monitorDevice(mockDevice, 0)
+
+ state := <-ch
+ assert.True(t, state.CapsLock)
+
+ m.Close()
+ })
+}
+
+func TestIsClosedError(t *testing.T) {
+ tests := []struct {
+ name string
+ err error
+ expected bool
+ }{
+ {"nil error", nil, false},
+ {"closed error", errors.New("device closed"), true},
+ {"bad file descriptor", errors.New("bad file descriptor"), true},
+ {"other error", errors.New("some other error"), false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := isClosedError(tt.err)
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
+
+func TestNotifySubscribers(t *testing.T) {
+ mockDevice := mocks.NewMockEvdevDevice(t)
+ mockDevice.EXPECT().ReadOne().Return(nil, errors.New("test")).Maybe()
+ mockDevice.EXPECT().Close().Return(nil).Maybe()
+
+ m := &Manager{
+ devices: []EvdevDevice{mockDevice},
+ monitoredPaths: make(map[string]bool),
+ state: State{Available: true, CapsLock: false},
+ closeChan: make(chan struct{}),
+ }
+
+ ch1 := m.Subscribe("client1")
+ ch2 := m.Subscribe("client2")
+
+ newState := State{Available: true, CapsLock: true}
+ go m.notifySubscribers(newState)
+
+ state1 := <-ch1
+ state2 := <-ch2
+
+ assert.Equal(t, newState, state1)
+ assert.Equal(t, newState, state2)
+
+ m.Close()
+}
+
+func TestReadInitialCapsLockState(t *testing.T) {
+ t.Run("caps lock is on", func(t *testing.T) {
+ mockDevice := mocks.NewMockEvdevDevice(t)
+ ledState := evdev.StateMap{
+ ledCapslockKey: true,
+ }
+ mockDevice.EXPECT().State(evdev.EvType(evLedType)).Return(ledState, nil).Once()
+
+ result := readInitialCapsLockState(mockDevice)
+ assert.True(t, result)
+ })
+
+ t.Run("caps lock is off", func(t *testing.T) {
+ mockDevice := mocks.NewMockEvdevDevice(t)
+ ledState := evdev.StateMap{
+ ledCapslockKey: false,
+ }
+ mockDevice.EXPECT().State(evdev.EvType(evLedType)).Return(ledState, nil).Once()
+
+ result := readInitialCapsLockState(mockDevice)
+ assert.False(t, result)
+ })
+
+ t.Run("error reading LED state", func(t *testing.T) {
+ mockDevice := mocks.NewMockEvdevDevice(t)
+ mockDevice.EXPECT().State(evdev.EvType(evLedType)).Return(nil, errors.New("read error")).Once()
+
+ result := readInitialCapsLockState(mockDevice)
+ assert.False(t, result)
+ })
+}
+
+func TestHasInputGroupAccess(t *testing.T) {
+ result := hasInputGroupAccess()
+ t.Logf("hasInputGroupAccess: %v", result)
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/evdev/models.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/evdev/models.go
new file mode 100644
index 0000000..df8ec9e
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/evdev/models.go
@@ -0,0 +1,6 @@
+package evdev
+
+type State struct {
+ Available bool `json:"available"`
+ CapsLock bool `json:"capsLock"`
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/extworkspace/handlers.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/extworkspace/handlers.go
new file mode 100644
index 0000000..3f7e17f
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/extworkspace/handlers.go
@@ -0,0 +1,134 @@
+package extworkspace
+
+import (
+ "encoding/json"
+ "fmt"
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+)
+
+type SuccessResult struct {
+ Success bool `json:"success"`
+ Message string `json:"message"`
+}
+
+func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
+ if manager == nil {
+ models.RespondError(conn, req.ID, "extworkspace manager not initialized")
+ return
+ }
+
+ switch req.Method {
+ case "extworkspace.getState":
+ handleGetState(conn, req, manager)
+ case "extworkspace.activateWorkspace":
+ handleActivateWorkspace(conn, req, manager)
+ case "extworkspace.deactivateWorkspace":
+ handleDeactivateWorkspace(conn, req, manager)
+ case "extworkspace.removeWorkspace":
+ handleRemoveWorkspace(conn, req, manager)
+ case "extworkspace.createWorkspace":
+ handleCreateWorkspace(conn, req, manager)
+ case "extworkspace.subscribe":
+ handleSubscribe(conn, req, manager)
+ default:
+ models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
+ }
+}
+
+func handleGetState(conn net.Conn, req models.Request, manager *Manager) {
+ state := manager.GetState()
+ models.Respond(conn, req.ID, state)
+}
+
+func handleActivateWorkspace(conn net.Conn, req models.Request, manager *Manager) {
+ groupID := models.GetOr(req, "groupID", "")
+ workspaceID, ok := models.Get[string](req, "workspaceID")
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'workspaceID' parameter")
+ return
+ }
+
+ if err := manager.ActivateWorkspace(groupID, workspaceID); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "workspace activated"})
+}
+
+func handleDeactivateWorkspace(conn net.Conn, req models.Request, manager *Manager) {
+ groupID := models.GetOr(req, "groupID", "")
+ workspaceID, ok := models.Get[string](req, "workspaceID")
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'workspaceID' parameter")
+ return
+ }
+
+ if err := manager.DeactivateWorkspace(groupID, workspaceID); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "workspace deactivated"})
+}
+
+func handleRemoveWorkspace(conn net.Conn, req models.Request, manager *Manager) {
+ groupID := models.GetOr(req, "groupID", "")
+ workspaceID, ok := models.Get[string](req, "workspaceID")
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'workspaceID' parameter")
+ return
+ }
+
+ if err := manager.RemoveWorkspace(groupID, workspaceID); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "workspace removed"})
+}
+
+func handleCreateWorkspace(conn net.Conn, req models.Request, manager *Manager) {
+ groupID, ok := models.Get[string](req, "groupID")
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'groupID' parameter")
+ return
+ }
+
+ workspaceName, ok := models.Get[string](req, "name")
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'name' parameter")
+ return
+ }
+
+ if err := manager.CreateWorkspace(groupID, workspaceName); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "workspace create requested"})
+}
+
+func handleSubscribe(conn net.Conn, req models.Request, manager *Manager) {
+ clientID := fmt.Sprintf("client-%p", conn)
+ stateChan := manager.Subscribe(clientID)
+ defer manager.Unsubscribe(clientID)
+
+ initialState := manager.GetState()
+ if err := json.NewEncoder(conn).Encode(models.Response[State]{
+ ID: req.ID,
+ Result: &initialState,
+ }); err != nil {
+ return
+ }
+
+ for state := range stateChan {
+ if err := json.NewEncoder(conn).Encode(models.Response[State]{
+ Result: &state,
+ }); err != nil {
+ return
+ }
+ }
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/extworkspace/manager.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/extworkspace/manager.go
new file mode 100644
index 0000000..b791b39
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/extworkspace/manager.go
@@ -0,0 +1,598 @@
+package extworkspace
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_workspace"
+ wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
+)
+
+func CheckCapability() bool {
+ display, err := wlclient.Connect("")
+ if err != nil {
+ return false
+ }
+ defer display.Destroy()
+
+ registry, err := display.GetRegistry()
+ if err != nil {
+ return false
+ }
+ defer registry.Destroy()
+
+ found := false
+
+ registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
+ if e.Interface == ext_workspace.ExtWorkspaceManagerV1InterfaceName {
+ found = true
+ }
+ })
+
+ // Roundtrip to ensure all registry events are processed
+ if err := display.Roundtrip(); err != nil {
+ return false
+ }
+
+ return found
+}
+
+func NewManager(display wlclient.WaylandDisplay) (*Manager, error) {
+ m := &Manager{
+ display: display,
+ ctx: display.Context(),
+ cmdq: make(chan cmd, 128),
+ stopChan: make(chan struct{}),
+
+ dirty: make(chan struct{}, 1),
+ }
+
+ m.wg.Add(1)
+ go m.waylandActor()
+
+ if err := m.setupRegistry(); err != nil {
+ close(m.stopChan)
+ m.wg.Wait()
+ return nil, err
+ }
+
+ m.updateState()
+
+ m.notifierWg.Add(1)
+ go m.notifier()
+
+ return m, nil
+}
+
+func (m *Manager) post(fn func()) {
+ select {
+ case m.cmdq <- cmd{fn: fn}:
+ default:
+ log.Warn("ExtWorkspace actor command queue full, dropping command")
+ }
+}
+
+func (m *Manager) waylandActor() {
+ defer m.wg.Done()
+
+ for {
+ select {
+ case <-m.stopChan:
+ return
+ case c := <-m.cmdq:
+ c.fn()
+ }
+ }
+}
+
+func (m *Manager) setupRegistry() error {
+ log.Info("ExtWorkspace: starting registry setup")
+
+ registry, err := m.display.GetRegistry()
+ if err != nil {
+ return fmt.Errorf("failed to get registry: %w", err)
+ }
+ m.registry = registry
+
+ registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
+ if e.Interface == "wl_output" {
+ output := wlclient.NewOutput(m.ctx)
+ if err := registry.Bind(e.Name, e.Interface, 4, output); err == nil {
+ outputID := output.ID()
+
+ output.SetNameHandler(func(ev wlclient.OutputNameEvent) {
+ m.outputNames.Store(outputID, ev.Name)
+ log.Debugf("ExtWorkspace: Output %d (%s) name received", outputID, ev.Name)
+ m.post(func() {
+ m.updateState()
+ })
+ })
+ }
+ return
+ }
+
+ if e.Interface == ext_workspace.ExtWorkspaceManagerV1InterfaceName {
+ log.Infof("ExtWorkspace: found %s", ext_workspace.ExtWorkspaceManagerV1InterfaceName)
+ manager := ext_workspace.NewExtWorkspaceManagerV1(m.ctx)
+ version := e.Version
+ if version > 1 {
+ version = 1
+ }
+
+ manager.SetWorkspaceGroupHandler(func(e ext_workspace.ExtWorkspaceManagerV1WorkspaceGroupEvent) {
+ m.handleWorkspaceGroup(e)
+ })
+
+ manager.SetWorkspaceHandler(func(e ext_workspace.ExtWorkspaceManagerV1WorkspaceEvent) {
+ m.handleWorkspace(e)
+ })
+
+ manager.SetDoneHandler(func(e ext_workspace.ExtWorkspaceManagerV1DoneEvent) {
+ log.Debug("ExtWorkspace: done event received")
+ m.post(func() {
+ m.updateState()
+ })
+ })
+
+ manager.SetFinishedHandler(func(e ext_workspace.ExtWorkspaceManagerV1FinishedEvent) {
+ log.Info("ExtWorkspace: finished event received")
+ })
+
+ if err := registry.Bind(e.Name, e.Interface, version, manager); err == nil {
+ m.manager = manager
+ log.Info("ExtWorkspace: manager bound successfully")
+ } else {
+ log.Errorf("ExtWorkspace: failed to bind manager: %v", err)
+ }
+ }
+ })
+
+ log.Info("ExtWorkspace: registry setup complete (events will be processed async)")
+ return nil
+}
+
+func (m *Manager) handleWorkspaceGroup(e ext_workspace.ExtWorkspaceManagerV1WorkspaceGroupEvent) {
+ handle := e.WorkspaceGroup
+ groupID := handle.ID()
+
+ log.Debugf("ExtWorkspace: New workspace group (id=%d)", groupID)
+
+ group := &workspaceGroupState{
+ id: groupID,
+ handle: handle,
+ outputIDs: make(map[uint32]bool),
+ workspaceIDs: make([]uint32, 0),
+ }
+
+ m.groups.Store(groupID, group)
+
+ handle.SetCapabilitiesHandler(func(e ext_workspace.ExtWorkspaceGroupHandleV1CapabilitiesEvent) {
+ log.Debugf("ExtWorkspace: Group %d capabilities: %d", groupID, e.Capabilities)
+ })
+
+ handle.SetOutputEnterHandler(func(e ext_workspace.ExtWorkspaceGroupHandleV1OutputEnterEvent) {
+ outputID := e.Output.ID()
+ log.Debugf("ExtWorkspace: Group %d output enter (output=%d)", groupID, outputID)
+
+ m.post(func() {
+ group.outputIDs[outputID] = true
+ m.updateState()
+ })
+ })
+
+ handle.SetOutputLeaveHandler(func(e ext_workspace.ExtWorkspaceGroupHandleV1OutputLeaveEvent) {
+ outputID := e.Output.ID()
+ log.Debugf("ExtWorkspace: Group %d output leave (output=%d)", groupID, outputID)
+ m.post(func() {
+ delete(group.outputIDs, outputID)
+ m.updateState()
+ })
+ })
+
+ handle.SetWorkspaceEnterHandler(func(e ext_workspace.ExtWorkspaceGroupHandleV1WorkspaceEnterEvent) {
+ workspaceID := e.Workspace.ID()
+ log.Debugf("ExtWorkspace: Group %d workspace enter (workspace=%d)", groupID, workspaceID)
+
+ m.post(func() {
+ if ws, ok := m.workspaces.Load(workspaceID); ok {
+ ws.groupID = groupID
+ }
+
+ group.workspaceIDs = append(group.workspaceIDs, workspaceID)
+ m.updateState()
+ })
+ })
+
+ handle.SetWorkspaceLeaveHandler(func(e ext_workspace.ExtWorkspaceGroupHandleV1WorkspaceLeaveEvent) {
+ workspaceID := e.Workspace.ID()
+ log.Debugf("ExtWorkspace: Group %d workspace leave (workspace=%d)", groupID, workspaceID)
+
+ m.post(func() {
+ if ws, ok := m.workspaces.Load(workspaceID); ok {
+ ws.groupID = 0
+ }
+
+ for i, id := range group.workspaceIDs {
+ if id == workspaceID {
+ group.workspaceIDs = append(group.workspaceIDs[:i], group.workspaceIDs[i+1:]...)
+ break
+ }
+ }
+ m.updateState()
+ })
+ })
+
+ handle.SetRemovedHandler(func(e ext_workspace.ExtWorkspaceGroupHandleV1RemovedEvent) {
+ log.Debugf("ExtWorkspace: Group %d removed", groupID)
+
+ m.post(func() {
+ group.removed = true
+
+ m.groups.Delete(groupID)
+
+ m.wlMutex.Lock()
+ handle.Destroy()
+ m.wlMutex.Unlock()
+
+ m.updateState()
+ })
+ })
+}
+
+func (m *Manager) handleWorkspace(e ext_workspace.ExtWorkspaceManagerV1WorkspaceEvent) {
+ handle := e.Workspace
+ workspaceID := handle.ID()
+
+ log.Debugf("ExtWorkspace: New workspace (proxy_id=%d)", workspaceID)
+
+ ws := &workspaceState{
+ id: workspaceID,
+ handle: handle,
+ coordinates: make([]uint32, 0),
+ }
+
+ m.workspaces.Store(workspaceID, ws)
+
+ handle.SetIdHandler(func(e ext_workspace.ExtWorkspaceHandleV1IdEvent) {
+ log.Debugf("ExtWorkspace: Workspace %d id: %s", workspaceID, e.Id)
+ m.post(func() {
+ ws.workspaceID = e.Id
+ m.updateState()
+ })
+ })
+
+ handle.SetNameHandler(func(e ext_workspace.ExtWorkspaceHandleV1NameEvent) {
+ log.Debugf("ExtWorkspace: Workspace %d name: %s", workspaceID, e.Name)
+ m.post(func() {
+ ws.name = e.Name
+ m.updateState()
+ })
+ })
+
+ handle.SetCoordinatesHandler(func(e ext_workspace.ExtWorkspaceHandleV1CoordinatesEvent) {
+ coords := make([]uint32, 0)
+ for i := 0; i < len(e.Coordinates); i += 4 {
+ if i+4 <= len(e.Coordinates) {
+ val := uint32(e.Coordinates[i]) |
+ uint32(e.Coordinates[i+1])<<8 |
+ uint32(e.Coordinates[i+2])<<16 |
+ uint32(e.Coordinates[i+3])<<24
+ coords = append(coords, val)
+ }
+ }
+ log.Debugf("ExtWorkspace: Workspace %d coordinates: %v", workspaceID, coords)
+ m.post(func() {
+ ws.coordinates = coords
+ m.updateState()
+ })
+ })
+
+ handle.SetStateHandler(func(e ext_workspace.ExtWorkspaceHandleV1StateEvent) {
+ log.Debugf("ExtWorkspace: Workspace %d state: %d", workspaceID, e.State)
+ m.post(func() {
+ ws.state = e.State
+ m.updateState()
+ })
+ })
+
+ handle.SetCapabilitiesHandler(func(e ext_workspace.ExtWorkspaceHandleV1CapabilitiesEvent) {
+ log.Debugf("ExtWorkspace: Workspace %d capabilities: %d", workspaceID, e.Capabilities)
+ })
+
+ handle.SetRemovedHandler(func(e ext_workspace.ExtWorkspaceHandleV1RemovedEvent) {
+ log.Debugf("ExtWorkspace: Workspace %d removed", workspaceID)
+
+ m.post(func() {
+ ws.removed = true
+
+ m.workspaces.Delete(workspaceID)
+
+ m.wlMutex.Lock()
+ handle.Destroy()
+ m.wlMutex.Unlock()
+
+ m.updateState()
+ })
+ })
+}
+
+func (m *Manager) updateState() {
+ groups := make([]*WorkspaceGroup, 0)
+
+ m.groups.Range(func(key uint32, group *workspaceGroupState) bool {
+ if group.removed {
+ return true
+ }
+
+ outputs := make([]string, 0)
+ for outputID := range group.outputIDs {
+ if name, ok := m.outputNames.Load(outputID); ok && name != "" {
+ outputs = append(outputs, name)
+ }
+ }
+
+ workspaces := make([]*Workspace, 0)
+ for _, wsID := range group.workspaceIDs {
+ ws, exists := m.workspaces.Load(wsID)
+ if !exists {
+ continue
+ }
+ if ws.removed {
+ continue
+ }
+
+ workspace := &Workspace{
+ ID: ws.workspaceID,
+ Name: ws.name,
+ Coordinates: ws.coordinates,
+ State: ws.state,
+ Active: ws.state&uint32(ext_workspace.ExtWorkspaceHandleV1StateActive) != 0,
+ Urgent: ws.state&uint32(ext_workspace.ExtWorkspaceHandleV1StateUrgent) != 0,
+ Hidden: ws.state&uint32(ext_workspace.ExtWorkspaceHandleV1StateHidden) != 0,
+ }
+ workspaces = append(workspaces, workspace)
+ }
+
+ groupState := &WorkspaceGroup{
+ ID: fmt.Sprintf("group-%d", group.id),
+ Outputs: outputs,
+ Workspaces: workspaces,
+ }
+ groups = append(groups, groupState)
+ return true
+ })
+
+ newState := State{
+ Groups: groups,
+ }
+
+ m.stateMutex.Lock()
+ m.state = &newState
+ m.stateMutex.Unlock()
+
+ m.notifySubscribers()
+}
+
+func (m *Manager) notifier() {
+ defer m.notifierWg.Done()
+ const minGap = 100 * time.Millisecond
+ timer := time.NewTimer(minGap)
+ timer.Stop()
+ var pending bool
+
+ for {
+ select {
+ case <-m.stopChan:
+ timer.Stop()
+ return
+ case <-m.dirty:
+ if pending {
+ continue
+ }
+ pending = true
+ timer.Reset(minGap)
+ case <-timer.C:
+ if !pending {
+ continue
+ }
+
+ currentState := m.GetState()
+
+ if m.lastNotified != nil && !stateChanged(m.lastNotified, &currentState) {
+ pending = false
+ continue
+ }
+
+ m.subscribers.Range(func(key string, ch chan State) bool {
+ select {
+ case ch <- currentState:
+ default:
+ log.Warn("ExtWorkspace: subscriber channel full, dropping update")
+ }
+ return true
+ })
+
+ stateCopy := currentState
+ m.lastNotified = &stateCopy
+ pending = false
+ }
+ }
+}
+
+func (m *Manager) ActivateWorkspace(groupID, workspaceID string) error {
+ errChan := make(chan error, 1)
+
+ m.post(func() {
+ var targetGroupID uint32
+ if groupID != "" {
+ var parsedID uint32
+ if _, err := fmt.Sscanf(groupID, "group-%d", &parsedID); err == nil {
+ targetGroupID = parsedID
+ }
+ }
+
+ var found bool
+ m.workspaces.Range(func(key uint32, ws *workspaceState) bool {
+ if targetGroupID != 0 && ws.groupID != targetGroupID {
+ return true
+ }
+ if ws.workspaceID == workspaceID || ws.name == workspaceID {
+ m.wlMutex.Lock()
+ err := ws.handle.Activate()
+ if err == nil {
+ err = m.manager.Commit()
+ }
+ m.wlMutex.Unlock()
+ errChan <- err
+ found = true
+ return false
+ }
+ return true
+ })
+
+ if !found {
+ errChan <- fmt.Errorf("workspace not found: %s in group %s", workspaceID, groupID)
+ }
+ })
+
+ return <-errChan
+}
+
+func (m *Manager) DeactivateWorkspace(groupID, workspaceID string) error {
+ errChan := make(chan error, 1)
+
+ m.post(func() {
+ var targetGroupID uint32
+ if groupID != "" {
+ var parsedID uint32
+ if _, err := fmt.Sscanf(groupID, "group-%d", &parsedID); err == nil {
+ targetGroupID = parsedID
+ }
+ }
+
+ var found bool
+ m.workspaces.Range(func(key uint32, ws *workspaceState) bool {
+ if targetGroupID != 0 && ws.groupID != targetGroupID {
+ return true
+ }
+ if ws.workspaceID == workspaceID || ws.name == workspaceID {
+ m.wlMutex.Lock()
+ err := ws.handle.Deactivate()
+ if err == nil {
+ err = m.manager.Commit()
+ }
+ m.wlMutex.Unlock()
+ errChan <- err
+ found = true
+ return false
+ }
+ return true
+ })
+
+ if !found {
+ errChan <- fmt.Errorf("workspace not found: %s in group %s", workspaceID, groupID)
+ }
+ })
+
+ return <-errChan
+}
+
+func (m *Manager) RemoveWorkspace(groupID, workspaceID string) error {
+ errChan := make(chan error, 1)
+
+ m.post(func() {
+ var targetGroupID uint32
+ if groupID != "" {
+ var parsedID uint32
+ if _, err := fmt.Sscanf(groupID, "group-%d", &parsedID); err == nil {
+ targetGroupID = parsedID
+ }
+ }
+
+ var found bool
+ m.workspaces.Range(func(key uint32, ws *workspaceState) bool {
+ if targetGroupID != 0 && ws.groupID != targetGroupID {
+ return true
+ }
+ if ws.workspaceID == workspaceID || ws.name == workspaceID {
+ m.wlMutex.Lock()
+ err := ws.handle.Remove()
+ if err == nil {
+ err = m.manager.Commit()
+ }
+ m.wlMutex.Unlock()
+ errChan <- err
+ found = true
+ return false
+ }
+ return true
+ })
+
+ if !found {
+ errChan <- fmt.Errorf("workspace not found: %s in group %s", workspaceID, groupID)
+ }
+ })
+
+ return <-errChan
+}
+
+func (m *Manager) CreateWorkspace(groupID, workspaceName string) error {
+ errChan := make(chan error, 1)
+
+ m.post(func() {
+ var found bool
+ m.groups.Range(func(key uint32, group *workspaceGroupState) bool {
+ if fmt.Sprintf("group-%d", group.id) == groupID {
+ m.wlMutex.Lock()
+ err := group.handle.CreateWorkspace(workspaceName)
+ if err == nil {
+ err = m.manager.Commit()
+ }
+ m.wlMutex.Unlock()
+ errChan <- err
+ found = true
+ return false
+ }
+ return true
+ })
+
+ if !found {
+ errChan <- fmt.Errorf("workspace group not found: %s", groupID)
+ }
+ })
+
+ return <-errChan
+}
+
+func (m *Manager) Close() {
+ close(m.stopChan)
+ m.wg.Wait()
+ m.notifierWg.Wait()
+
+ m.subscribers.Range(func(key string, ch chan State) bool {
+ close(ch)
+ m.subscribers.Delete(key)
+ return true
+ })
+
+ m.workspaces.Range(func(key uint32, ws *workspaceState) bool {
+ if ws.handle != nil {
+ ws.handle.Destroy()
+ }
+ m.workspaces.Delete(key)
+ return true
+ })
+
+ m.groups.Range(func(key uint32, group *workspaceGroupState) bool {
+ if group.handle != nil {
+ group.handle.Destroy()
+ }
+ m.groups.Delete(key)
+ return true
+ })
+
+ if m.manager != nil {
+ m.manager.Stop()
+ }
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/extworkspace/manager_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/extworkspace/manager_test.go
new file mode 100644
index 0000000..b20d457
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/extworkspace/manager_test.go
@@ -0,0 +1,392 @@
+package extworkspace
+
+import (
+ "errors"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+
+ mocks_wlclient "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/wlclient"
+)
+
+func TestStateChanged_BothNil(t *testing.T) {
+ assert.True(t, stateChanged(nil, nil))
+}
+
+func TestStateChanged_OneNil(t *testing.T) {
+ s := &State{Groups: []*WorkspaceGroup{}}
+ assert.True(t, stateChanged(s, nil))
+ assert.True(t, stateChanged(nil, s))
+}
+
+func TestStateChanged_GroupCountDiffers(t *testing.T) {
+ a := &State{Groups: []*WorkspaceGroup{{ID: "group-1"}}}
+ b := &State{Groups: []*WorkspaceGroup{}}
+ assert.True(t, stateChanged(a, b))
+}
+
+func TestStateChanged_GroupIDDiffers(t *testing.T) {
+ a := &State{Groups: []*WorkspaceGroup{{ID: "group-1", Outputs: []string{}, Workspaces: []*Workspace{}}}}
+ b := &State{Groups: []*WorkspaceGroup{{ID: "group-2", Outputs: []string{}, Workspaces: []*Workspace{}}}}
+ assert.True(t, stateChanged(a, b))
+}
+
+func TestStateChanged_OutputCountDiffers(t *testing.T) {
+ a := &State{Groups: []*WorkspaceGroup{{ID: "group-1", Outputs: []string{"eDP-1"}, Workspaces: []*Workspace{}}}}
+ b := &State{Groups: []*WorkspaceGroup{{ID: "group-1", Outputs: []string{}, Workspaces: []*Workspace{}}}}
+ assert.True(t, stateChanged(a, b))
+}
+
+func TestStateChanged_OutputNameDiffers(t *testing.T) {
+ a := &State{Groups: []*WorkspaceGroup{{ID: "group-1", Outputs: []string{"eDP-1"}, Workspaces: []*Workspace{}}}}
+ b := &State{Groups: []*WorkspaceGroup{{ID: "group-1", Outputs: []string{"HDMI-A-1"}, Workspaces: []*Workspace{}}}}
+ assert.True(t, stateChanged(a, b))
+}
+
+func TestStateChanged_WorkspaceCountDiffers(t *testing.T) {
+ a := &State{Groups: []*WorkspaceGroup{{
+ ID: "group-1",
+ Outputs: []string{},
+ Workspaces: []*Workspace{{ID: "1", Name: "ws1"}},
+ }}}
+ b := &State{Groups: []*WorkspaceGroup{{
+ ID: "group-1",
+ Outputs: []string{},
+ Workspaces: []*Workspace{},
+ }}}
+ assert.True(t, stateChanged(a, b))
+}
+
+func TestStateChanged_WorkspaceFieldsDiffer(t *testing.T) {
+ a := &State{Groups: []*WorkspaceGroup{{
+ ID: "group-1",
+ Outputs: []string{},
+ Workspaces: []*Workspace{{
+ ID: "1", Name: "ws1", State: 0, Active: false, Urgent: false, Hidden: false,
+ }},
+ }}}
+ b := &State{Groups: []*WorkspaceGroup{{
+ ID: "group-1",
+ Outputs: []string{},
+ Workspaces: []*Workspace{{
+ ID: "2", Name: "ws1", State: 0, Active: false, Urgent: false, Hidden: false,
+ }},
+ }}}
+ assert.True(t, stateChanged(a, b))
+
+ b.Groups[0].Workspaces[0].ID = "1"
+ b.Groups[0].Workspaces[0].Name = "ws2"
+ assert.True(t, stateChanged(a, b))
+
+ b.Groups[0].Workspaces[0].Name = "ws1"
+ b.Groups[0].Workspaces[0].State = 1
+ assert.True(t, stateChanged(a, b))
+
+ b.Groups[0].Workspaces[0].State = 0
+ b.Groups[0].Workspaces[0].Active = true
+ assert.True(t, stateChanged(a, b))
+
+ b.Groups[0].Workspaces[0].Active = false
+ b.Groups[0].Workspaces[0].Urgent = true
+ assert.True(t, stateChanged(a, b))
+
+ b.Groups[0].Workspaces[0].Urgent = false
+ b.Groups[0].Workspaces[0].Hidden = true
+ assert.True(t, stateChanged(a, b))
+}
+
+func TestStateChanged_WorkspaceCoordinatesDiffer(t *testing.T) {
+ a := &State{Groups: []*WorkspaceGroup{{
+ ID: "group-1",
+ Outputs: []string{},
+ Workspaces: []*Workspace{{
+ ID: "1", Name: "ws1", Coordinates: []uint32{0, 0},
+ }},
+ }}}
+ b := &State{Groups: []*WorkspaceGroup{{
+ ID: "group-1",
+ Outputs: []string{},
+ Workspaces: []*Workspace{{
+ ID: "1", Name: "ws1", Coordinates: []uint32{1, 0},
+ }},
+ }}}
+ assert.True(t, stateChanged(a, b))
+
+ b.Groups[0].Workspaces[0].Coordinates = []uint32{0}
+ assert.True(t, stateChanged(a, b))
+}
+
+func TestStateChanged_Equal(t *testing.T) {
+ a := &State{Groups: []*WorkspaceGroup{{
+ ID: "group-1",
+ Outputs: []string{"eDP-1", "HDMI-A-1"},
+ Workspaces: []*Workspace{
+ {ID: "1", Name: "ws1", Coordinates: []uint32{0, 0}, State: 1, Active: true},
+ {ID: "2", Name: "ws2", Coordinates: []uint32{1, 0}, State: 0, Active: false},
+ },
+ }}}
+ b := &State{Groups: []*WorkspaceGroup{{
+ ID: "group-1",
+ Outputs: []string{"eDP-1", "HDMI-A-1"},
+ Workspaces: []*Workspace{
+ {ID: "1", Name: "ws1", Coordinates: []uint32{0, 0}, State: 1, Active: true},
+ {ID: "2", Name: "ws2", Coordinates: []uint32{1, 0}, State: 0, Active: false},
+ },
+ }}}
+ assert.False(t, stateChanged(a, b))
+}
+
+func TestManager_ConcurrentGetState(t *testing.T) {
+ m := &Manager{
+ state: &State{
+ Groups: []*WorkspaceGroup{{ID: "group-1", Outputs: []string{"eDP-1"}}},
+ },
+ }
+
+ var wg sync.WaitGroup
+ const goroutines = 50
+ const iterations = 100
+
+ for i := 0; i < goroutines/2; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for j := 0; j < iterations; j++ {
+ s := m.GetState()
+ _ = s.Groups
+ }
+ }()
+ }
+
+ for i := 0; i < goroutines/2; i++ {
+ wg.Add(1)
+ go func(i int) {
+ defer wg.Done()
+ for j := 0; j < iterations; j++ {
+ m.stateMutex.Lock()
+ m.state = &State{
+ Groups: []*WorkspaceGroup{{ID: "group-1", Outputs: []string{"eDP-1"}}},
+ }
+ m.stateMutex.Unlock()
+ }
+ }(i)
+ }
+
+ wg.Wait()
+}
+
+func TestManager_ConcurrentSubscriberAccess(t *testing.T) {
+ m := &Manager{
+ stopChan: make(chan struct{}),
+ dirty: make(chan struct{}, 1),
+ }
+
+ var wg sync.WaitGroup
+ const goroutines = 20
+
+ for i := 0; i < goroutines; i++ {
+ wg.Add(1)
+ go func(id int) {
+ defer wg.Done()
+ subID := string(rune('a' + id))
+ ch := m.Subscribe(subID)
+ assert.NotNil(t, ch)
+ time.Sleep(time.Millisecond)
+ m.Unsubscribe(subID)
+ }(i)
+ }
+
+ wg.Wait()
+}
+
+func TestManager_SyncmapGroupsConcurrentAccess(t *testing.T) {
+ m := &Manager{}
+
+ var wg sync.WaitGroup
+ const goroutines = 30
+ const iterations = 50
+
+ for i := 0; i < goroutines; i++ {
+ wg.Add(1)
+ go func(id int) {
+ defer wg.Done()
+ key := uint32(id)
+
+ for j := 0; j < iterations; j++ {
+ state := &workspaceGroupState{
+ id: key,
+ outputIDs: map[uint32]bool{1: true},
+ workspaceIDs: []uint32{uint32(j)},
+ }
+ m.groups.Store(key, state)
+
+ if loaded, ok := m.groups.Load(key); ok {
+ assert.Equal(t, key, loaded.id)
+ }
+
+ m.groups.Range(func(k uint32, v *workspaceGroupState) bool {
+ _ = v.id
+ _ = v.outputIDs
+ return true
+ })
+ }
+
+ m.groups.Delete(key)
+ }(i)
+ }
+
+ wg.Wait()
+}
+
+func TestManager_SyncmapWorkspacesConcurrentAccess(t *testing.T) {
+ m := &Manager{}
+
+ var wg sync.WaitGroup
+ const goroutines = 30
+ const iterations = 50
+
+ for i := 0; i < goroutines; i++ {
+ wg.Add(1)
+ go func(id int) {
+ defer wg.Done()
+ key := uint32(id)
+
+ for j := 0; j < iterations; j++ {
+ state := &workspaceState{
+ id: key,
+ workspaceID: "ws-1",
+ name: "workspace",
+ state: uint32(j % 4),
+ coordinates: []uint32{uint32(j), 0},
+ }
+ m.workspaces.Store(key, state)
+
+ if loaded, ok := m.workspaces.Load(key); ok {
+ assert.Equal(t, key, loaded.id)
+ }
+
+ m.workspaces.Range(func(k uint32, v *workspaceState) bool {
+ _ = v.name
+ _ = v.state
+ return true
+ })
+ }
+
+ m.workspaces.Delete(key)
+ }(i)
+ }
+
+ wg.Wait()
+}
+
+func TestManager_SyncmapOutputNamesConcurrentAccess(t *testing.T) {
+ m := &Manager{}
+
+ var wg sync.WaitGroup
+ const goroutines = 30
+ const iterations = 50
+
+ for i := 0; i < goroutines; i++ {
+ wg.Add(1)
+ go func(id int) {
+ defer wg.Done()
+ key := uint32(id)
+
+ for j := 0; j < iterations; j++ {
+ m.outputNames.Store(key, "eDP-1")
+
+ if loaded, ok := m.outputNames.Load(key); ok {
+ assert.NotEmpty(t, loaded)
+ }
+
+ m.outputNames.Range(func(k uint32, v string) bool {
+ _ = v
+ return true
+ })
+ }
+
+ m.outputNames.Delete(key)
+ }(i)
+ }
+
+ wg.Wait()
+}
+
+func TestManager_NotifySubscribersNonBlocking(t *testing.T) {
+ m := &Manager{
+ dirty: make(chan struct{}, 1),
+ }
+
+ for i := 0; i < 10; i++ {
+ m.notifySubscribers()
+ }
+
+ assert.Len(t, m.dirty, 1)
+}
+
+func TestManager_PostQueueFull(t *testing.T) {
+ m := &Manager{
+ cmdq: make(chan cmd, 2),
+ stopChan: make(chan struct{}),
+ }
+
+ m.post(func() {})
+ m.post(func() {})
+ m.post(func() {})
+ m.post(func() {})
+
+ assert.Len(t, m.cmdq, 2)
+}
+
+func TestManager_GetStateNilState(t *testing.T) {
+ m := &Manager{}
+
+ s := m.GetState()
+ assert.NotNil(t, s.Groups)
+ assert.Empty(t, s.Groups)
+}
+
+func TestWorkspace_Fields(t *testing.T) {
+ ws := Workspace{
+ ID: "ws-1",
+ Name: "workspace 1",
+ Coordinates: []uint32{0, 0},
+ State: 1,
+ Active: true,
+ Urgent: false,
+ Hidden: false,
+ }
+
+ assert.Equal(t, "ws-1", ws.ID)
+ assert.Equal(t, "workspace 1", ws.Name)
+ assert.True(t, ws.Active)
+ assert.False(t, ws.Urgent)
+ assert.False(t, ws.Hidden)
+}
+
+func TestWorkspaceGroup_Fields(t *testing.T) {
+ group := WorkspaceGroup{
+ ID: "group-1",
+ Outputs: []string{"eDP-1", "HDMI-A-1"},
+ Workspaces: []*Workspace{
+ {ID: "ws-1", Name: "workspace 1"},
+ },
+ }
+
+ assert.Equal(t, "group-1", group.ID)
+ assert.Len(t, group.Outputs, 2)
+ assert.Len(t, group.Workspaces, 1)
+}
+
+func TestNewManager_GetRegistryError(t *testing.T) {
+ mockDisplay := mocks_wlclient.NewMockWaylandDisplay(t)
+
+ mockDisplay.EXPECT().Context().Return(nil)
+ mockDisplay.EXPECT().GetRegistry().Return(nil, errors.New("failed to get registry"))
+
+ _, err := NewManager(mockDisplay)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "failed to get registry")
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/extworkspace/types.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/extworkspace/types.go
new file mode 100644
index 0000000..903a1cb
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/extworkspace/types.go
@@ -0,0 +1,169 @@
+package extworkspace
+
+import (
+ "sync"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_workspace"
+ wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
+)
+
+type Workspace struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Coordinates []uint32 `json:"coordinates"`
+ State uint32 `json:"state"`
+ Active bool `json:"active"`
+ Urgent bool `json:"urgent"`
+ Hidden bool `json:"hidden"`
+}
+
+type WorkspaceGroup struct {
+ ID string `json:"id"`
+ Outputs []string `json:"outputs"`
+ Workspaces []*Workspace `json:"workspaces"`
+}
+
+type State struct {
+ Groups []*WorkspaceGroup `json:"groups"`
+}
+
+type cmd struct {
+ fn func()
+}
+
+type Manager struct {
+ display wlclient.WaylandDisplay
+ ctx *wlclient.Context
+ registry *wlclient.Registry
+ manager *ext_workspace.ExtWorkspaceManagerV1
+
+ outputNames syncmap.Map[uint32, string]
+
+ groups syncmap.Map[uint32, *workspaceGroupState]
+
+ workspaces syncmap.Map[uint32, *workspaceState]
+
+ wlMutex sync.Mutex
+ cmdq chan cmd
+ stopChan chan struct{}
+ wg sync.WaitGroup
+
+ subscribers syncmap.Map[string, chan State]
+ dirty chan struct{}
+ notifierWg sync.WaitGroup
+ lastNotified *State
+
+ stateMutex sync.RWMutex
+ state *State
+}
+
+type workspaceGroupState struct {
+ id uint32
+ handle *ext_workspace.ExtWorkspaceGroupHandleV1
+ outputIDs map[uint32]bool
+ workspaceIDs []uint32
+ removed bool
+}
+
+type workspaceState struct {
+ id uint32
+ handle *ext_workspace.ExtWorkspaceHandleV1
+ workspaceID string
+ name string
+ coordinates []uint32
+ state uint32
+ groupID uint32
+ removed bool
+}
+
+func (m *Manager) GetState() State {
+ m.stateMutex.RLock()
+ defer m.stateMutex.RUnlock()
+ if m.state == nil {
+ return State{
+ Groups: []*WorkspaceGroup{},
+ }
+ }
+ stateCopy := *m.state
+ return stateCopy
+}
+
+func (m *Manager) Subscribe(id string) chan State {
+ ch := make(chan State, 64)
+
+ m.subscribers.Store(id, ch)
+
+ return ch
+}
+
+func (m *Manager) Unsubscribe(id string) {
+ if ch, ok := m.subscribers.LoadAndDelete(id); ok {
+ close(ch)
+ }
+}
+
+func (m *Manager) notifySubscribers() {
+ select {
+ case m.dirty <- struct{}{}:
+ default:
+ }
+}
+
+func stateChanged(old, new *State) bool {
+ if old == nil || new == nil {
+ return true
+ }
+ if len(old.Groups) != len(new.Groups) {
+ return true
+ }
+
+ for i, newGroup := range new.Groups {
+ if i >= len(old.Groups) {
+ return true
+ }
+ oldGroup := old.Groups[i]
+ if oldGroup.ID != newGroup.ID {
+ return true
+ }
+ if len(oldGroup.Outputs) != len(newGroup.Outputs) {
+ return true
+ }
+ for j, newOutput := range newGroup.Outputs {
+ if j >= len(oldGroup.Outputs) {
+ return true
+ }
+ if oldGroup.Outputs[j] != newOutput {
+ return true
+ }
+ }
+ if len(oldGroup.Workspaces) != len(newGroup.Workspaces) {
+ return true
+ }
+ for j, newWs := range newGroup.Workspaces {
+ if j >= len(oldGroup.Workspaces) {
+ return true
+ }
+ oldWs := oldGroup.Workspaces[j]
+ if oldWs.ID != newWs.ID || oldWs.Name != newWs.Name || oldWs.State != newWs.State {
+ return true
+ }
+ if oldWs.Active != newWs.Active || oldWs.Urgent != newWs.Urgent || oldWs.Hidden != newWs.Hidden {
+ return true
+ }
+ if len(oldWs.Coordinates) != len(newWs.Coordinates) {
+ return true
+ }
+ for k, coord := range newWs.Coordinates {
+ if k >= len(oldWs.Coordinates) {
+ return true
+ }
+ if oldWs.Coordinates[k] != coord {
+ return true
+ }
+ }
+ }
+ }
+
+ return false
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/actions.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/actions.go
new file mode 100644
index 0000000..52515d1
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/actions.go
@@ -0,0 +1,112 @@
+package freedesktop
+
+import (
+ "fmt"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
+ "github.com/godbus/dbus/v5"
+)
+
+func (m *Manager) SetIconFile(iconPath string) error {
+ if !m.state.Accounts.Available || m.accountsObj == nil {
+ return fmt.Errorf("accounts service not available")
+ }
+
+ err := m.accountsObj.Call(dbusAccountsUserInterface+".SetIconFile", 0, iconPath).Err
+ if err != nil {
+ return fmt.Errorf("failed to set icon file: %w", err)
+ }
+
+ m.updateAccountsState()
+ return nil
+}
+
+func (m *Manager) SetRealName(name string) error {
+ if !m.state.Accounts.Available || m.accountsObj == nil {
+ return fmt.Errorf("accounts service not available")
+ }
+
+ err := m.accountsObj.Call(dbusAccountsUserInterface+".SetRealName", 0, name).Err
+ if err != nil {
+ return fmt.Errorf("failed to set real name: %w", err)
+ }
+
+ m.updateAccountsState()
+ return nil
+}
+
+func (m *Manager) SetEmail(email string) error {
+ if !m.state.Accounts.Available || m.accountsObj == nil {
+ return fmt.Errorf("accounts service not available")
+ }
+
+ err := m.accountsObj.Call(dbusAccountsUserInterface+".SetEmail", 0, email).Err
+ if err != nil {
+ return fmt.Errorf("failed to set email: %w", err)
+ }
+
+ m.updateAccountsState()
+ return nil
+}
+
+func (m *Manager) SetLanguage(language string) error {
+ if !m.state.Accounts.Available || m.accountsObj == nil {
+ return fmt.Errorf("accounts service not available")
+ }
+
+ err := m.accountsObj.Call(dbusAccountsUserInterface+".SetLanguage", 0, language).Err
+ if err != nil {
+ return fmt.Errorf("failed to set language: %w", err)
+ }
+
+ m.updateAccountsState()
+ return nil
+}
+
+func (m *Manager) SetLocation(location string) error {
+ if !m.state.Accounts.Available || m.accountsObj == nil {
+ return fmt.Errorf("accounts service not available")
+ }
+
+ err := m.accountsObj.Call(dbusAccountsUserInterface+".SetLocation", 0, location).Err
+ if err != nil {
+ return fmt.Errorf("failed to set location: %w", err)
+ }
+
+ m.updateAccountsState()
+ return nil
+}
+
+func (m *Manager) GetUserIconFile(username string) (string, error) {
+ if m.systemConn == nil {
+ return "", fmt.Errorf("accounts service not available")
+ }
+
+ accountsManager := m.systemConn.Object(dbusAccountsDest, dbus.ObjectPath(dbusAccountsPath))
+
+ var userPath dbus.ObjectPath
+ err := accountsManager.Call(dbusAccountsInterface+".FindUserByName", 0, username).Store(&userPath)
+ if err != nil {
+ return "", fmt.Errorf("user not found: %w", err)
+ }
+
+ userObj := m.systemConn.Object(dbusAccountsDest, userPath)
+ variant, err := userObj.GetProperty(dbusAccountsUserInterface + ".IconFile")
+ if err != nil {
+ return "", err
+ }
+
+ var iconFile string
+ if err := variant.Store(&iconFile); err != nil {
+ return "", err
+ }
+
+ return iconFile, nil
+}
+
+func (m *Manager) SetIconTheme(iconTheme string) error {
+ if err := utils.GsettingsSet("org.gnome.desktop.interface", "icon-theme", iconTheme); err != nil {
+ return fmt.Errorf("failed to set icon theme: %w", err)
+ }
+ return nil
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/actions_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/actions_test.go
new file mode 100644
index 0000000..e2c21e8
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/actions_test.go
@@ -0,0 +1,145 @@
+package freedesktop
+
+import (
+ "sync"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestManager_SetIconFile(t *testing.T) {
+ t.Run("accounts not available", func(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{
+ Accounts: AccountsState{
+ Available: false,
+ },
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ err := manager.SetIconFile("/path/to/icon.png")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "accounts service not available")
+ })
+}
+
+func TestManager_SetRealName(t *testing.T) {
+ t.Run("accounts not available", func(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{
+ Accounts: AccountsState{
+ Available: false,
+ },
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ err := manager.SetRealName("New Name")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "accounts service not available")
+ })
+}
+
+func TestManager_SetEmail(t *testing.T) {
+ t.Run("accounts not available", func(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{
+ Accounts: AccountsState{
+ Available: false,
+ },
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ err := manager.SetEmail("test@example.com")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "accounts service not available")
+ })
+}
+
+func TestManager_SetLanguage(t *testing.T) {
+ t.Run("accounts not available", func(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{
+ Accounts: AccountsState{
+ Available: false,
+ },
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ err := manager.SetLanguage("en_US.UTF-8")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "accounts service not available")
+ })
+}
+
+func TestManager_SetLocation(t *testing.T) {
+ t.Run("accounts not available", func(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{
+ Accounts: AccountsState{
+ Available: false,
+ },
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ err := manager.SetLocation("Test Location")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "accounts service not available")
+ })
+}
+
+func TestManager_GetUserIconFile(t *testing.T) {
+ t.Run("accounts not available", func(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{
+ Accounts: AccountsState{
+ Available: false,
+ },
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ iconFile, err := manager.GetUserIconFile("testuser")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "accounts service not available")
+ assert.Empty(t, iconFile)
+ })
+}
+
+func TestManager_UpdateAccountsState(t *testing.T) {
+ t.Run("accounts not available", func(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{
+ Accounts: AccountsState{
+ Available: false,
+ },
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ err := manager.updateAccountsState()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "accounts service not available")
+ })
+}
+
+func TestManager_UpdateSettingsState(t *testing.T) {
+ t.Run("settings not available", func(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{
+ Settings: SettingsState{
+ Available: false,
+ },
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ err := manager.updateSettingsState()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "settings portal not available")
+ })
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/constants.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/constants.go
new file mode 100644
index 0000000..12db87b
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/constants.go
@@ -0,0 +1,23 @@
+package freedesktop
+
+const (
+ dbusAccountsDest = "org.freedesktop.Accounts"
+ dbusAccountsPath = "/org/freedesktop/Accounts"
+ dbusAccountsInterface = "org.freedesktop.Accounts"
+ dbusAccountsUserInterface = "org.freedesktop.Accounts.User"
+
+ dbusPortalDest = "org.freedesktop.portal.Desktop"
+ dbusPortalPath = "/org/freedesktop/portal/desktop"
+ dbusPortalSettingsInterface = "org.freedesktop.portal.Settings"
+
+ dbusPropsInterface = "org.freedesktop.DBus.Properties"
+
+ dbusScreensaverName = "org.freedesktop.ScreenSaver"
+ dbusScreensaverPath = "/ScreenSaver"
+ dbusScreensaverPath2 = "/org/freedesktop/ScreenSaver"
+ dbusScreensaverInterface = "org.freedesktop.ScreenSaver"
+
+ dbusGnomeScreensaverName = "org.gnome.ScreenSaver"
+ dbusGnomeScreensaverPath = "/org/gnome/ScreenSaver"
+ dbusGnomeScreensaverInterface = "org.gnome.ScreenSaver"
+)
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/handlers.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/handlers.go
new file mode 100644
index 0000000..c3090fa
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/handlers.go
@@ -0,0 +1,154 @@
+package freedesktop
+
+import (
+ "fmt"
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/params"
+)
+
+func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
+ switch req.Method {
+ case "freedesktop.getState":
+ handleGetState(conn, req, manager)
+ case "freedesktop.accounts.setIconFile":
+ handleSetIconFile(conn, req, manager)
+ case "freedesktop.accounts.setRealName":
+ handleSetRealName(conn, req, manager)
+ case "freedesktop.accounts.setEmail":
+ handleSetEmail(conn, req, manager)
+ case "freedesktop.accounts.setLanguage":
+ handleSetLanguage(conn, req, manager)
+ case "freedesktop.accounts.setLocation":
+ handleSetLocation(conn, req, manager)
+ case "freedesktop.accounts.getUserIconFile":
+ handleGetUserIconFile(conn, req, manager)
+ case "freedesktop.settings.getColorScheme":
+ handleGetColorScheme(conn, req, manager)
+ case "freedesktop.settings.setIconTheme":
+ handleSetIconTheme(conn, req, manager)
+ default:
+ models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
+ }
+}
+
+func handleGetState(conn net.Conn, req models.Request, manager *Manager) {
+ models.Respond(conn, req.ID, manager.GetState())
+}
+
+func handleSetIconFile(conn net.Conn, req models.Request, manager *Manager) {
+ iconPath, err := params.String(req.Params, "path")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.SetIconFile(iconPath); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "icon file set"})
+}
+
+func handleSetRealName(conn net.Conn, req models.Request, manager *Manager) {
+ name, err := params.String(req.Params, "name")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.SetRealName(name); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "real name set"})
+}
+
+func handleSetEmail(conn net.Conn, req models.Request, manager *Manager) {
+ email, err := params.String(req.Params, "email")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.SetEmail(email); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "email set"})
+}
+
+func handleSetLanguage(conn net.Conn, req models.Request, manager *Manager) {
+ language, err := params.String(req.Params, "language")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.SetLanguage(language); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "language set"})
+}
+
+func handleSetLocation(conn net.Conn, req models.Request, manager *Manager) {
+ location, err := params.String(req.Params, "location")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.SetLocation(location); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "location set"})
+}
+
+func handleGetUserIconFile(conn net.Conn, req models.Request, manager *Manager) {
+ username, err := params.String(req.Params, "username")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ iconFile, err := manager.GetUserIconFile(username)
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Value: iconFile})
+}
+
+func handleGetColorScheme(conn net.Conn, req models.Request, manager *Manager) {
+ if err := manager.updateSettingsState(); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ state := manager.GetState()
+ models.Respond(conn, req.ID, map[string]uint32{"colorScheme": state.Settings.ColorScheme})
+}
+
+func handleSetIconTheme(conn net.Conn, req models.Request, manager *Manager) {
+ iconTheme, err := params.String(req.Params, "iconTheme")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.SetIconTheme(iconTheme); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "icon theme set"})
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/handlers_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/handlers_test.go
new file mode 100644
index 0000000..f2cb5a2
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/handlers_test.go
@@ -0,0 +1,581 @@
+package freedesktop
+
+import (
+ "bytes"
+ "encoding/json"
+ "net"
+ "sync"
+ "testing"
+
+ mockdbus "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/github.com/godbus/dbus/v5"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+ "github.com/godbus/dbus/v5"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+ "github.com/stretchr/testify/require"
+)
+
+type mockNetConn struct {
+ net.Conn
+ readBuf *bytes.Buffer
+ writeBuf *bytes.Buffer
+ closed bool
+}
+
+func newMockNetConn() *mockNetConn {
+ return &mockNetConn{
+ readBuf: &bytes.Buffer{},
+ writeBuf: &bytes.Buffer{},
+ }
+}
+
+func (m *mockNetConn) Read(b []byte) (n int, err error) {
+ return m.readBuf.Read(b)
+}
+
+func (m *mockNetConn) Write(b []byte) (n int, err error) {
+ return m.writeBuf.Write(b)
+}
+
+func (m *mockNetConn) Close() error {
+ m.closed = true
+ return nil
+}
+
+func mockGetAllAccountsProperties() *dbus.Call {
+ props := map[string]dbus.Variant{
+ "IconFile": dbus.MakeVariant("/path/to/icon.png"),
+ "RealName": dbus.MakeVariant("Test"),
+ "UserName": dbus.MakeVariant("test"),
+ "AccountType": dbus.MakeVariant(int32(0)),
+ "HomeDirectory": dbus.MakeVariant("/home/test"),
+ "Shell": dbus.MakeVariant("/bin/bash"),
+ "Email": dbus.MakeVariant(""),
+ "Language": dbus.MakeVariant(""),
+ "Location": dbus.MakeVariant(""),
+ "Locked": dbus.MakeVariant(false),
+ "PasswordMode": dbus.MakeVariant(int32(1)),
+ }
+ return &dbus.Call{Err: nil, Body: []any{props}}
+}
+
+func TestRespondError_Freedesktop(t *testing.T) {
+ conn := newMockNetConn()
+ models.RespondError(conn, 123, "test error")
+
+ var resp models.Response[any]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Equal(t, "test error", resp.Error)
+ assert.Nil(t, resp.Result)
+}
+
+func TestRespond_Freedesktop(t *testing.T) {
+ conn := newMockNetConn()
+ result := models.SuccessResult{Success: true, Message: "test"}
+ models.Respond(conn, 123, result)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Empty(t, resp.Error)
+ require.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+ assert.Equal(t, "test", resp.Result.Message)
+}
+
+func TestHandleGetState(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{
+ Accounts: AccountsState{
+ Available: true,
+ UserName: "testuser",
+ RealName: "Test User",
+ UID: 1000,
+ },
+ Settings: SettingsState{
+ Available: true,
+ ColorScheme: 1,
+ },
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{ID: 123, Method: "freedesktop.getState"}
+
+ handleGetState(conn, req, manager)
+
+ var resp models.Response[FreedeskState]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Empty(t, resp.Error)
+ require.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Accounts.Available)
+ assert.Equal(t, "testuser", resp.Result.Accounts.UserName)
+ assert.True(t, resp.Result.Settings.Available)
+ assert.Equal(t, uint32(1), resp.Result.Settings.ColorScheme)
+}
+
+func TestHandleSetIconFile(t *testing.T) {
+ t.Run("missing path parameter", func(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{},
+ stateMutex: sync.RWMutex{},
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{
+ ID: 123,
+ Method: "freedesktop.accounts.setIconFile",
+ Params: map[string]any{},
+ }
+
+ handleSetIconFile(conn, req, manager)
+
+ var resp models.Response[any]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "missing or invalid 'path' parameter")
+ })
+
+ t.Run("successful set icon file", func(t *testing.T) {
+ mockAccountsObj := mockdbus.NewMockBusObject(t)
+ mockCall := &dbus.Call{Err: nil}
+ mockAccountsObj.EXPECT().Call("org.freedesktop.Accounts.User.SetIconFile", dbus.Flags(0), "/path/to/icon.png").Return(mockCall)
+ mockAccountsObj.EXPECT().CallWithContext(mock.Anything, "org.freedesktop.DBus.Properties.GetAll", dbus.Flags(0), "org.freedesktop.Accounts.User").Return(mockGetAllAccountsProperties())
+
+ manager := &Manager{
+ state: &FreedeskState{
+ Accounts: AccountsState{
+ Available: true,
+ },
+ },
+ stateMutex: sync.RWMutex{},
+ accountsObj: mockAccountsObj,
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{
+ ID: 123,
+ Method: "freedesktop.accounts.setIconFile",
+ Params: map[string]any{
+ "path": "/path/to/icon.png",
+ },
+ }
+
+ handleSetIconFile(conn, req, manager)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Empty(t, resp.Error)
+ require.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+ assert.Equal(t, "icon file set", resp.Result.Message)
+ })
+
+ t.Run("accounts not available", func(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{
+ Accounts: AccountsState{
+ Available: false,
+ },
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{
+ ID: 123,
+ Method: "freedesktop.accounts.setIconFile",
+ Params: map[string]any{
+ "path": "/path/to/icon.png",
+ },
+ }
+
+ handleSetIconFile(conn, req, manager)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "accounts service not available")
+ })
+}
+
+func TestHandleSetRealName(t *testing.T) {
+ t.Run("missing name parameter", func(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{},
+ stateMutex: sync.RWMutex{},
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{
+ ID: 123,
+ Method: "freedesktop.accounts.setRealName",
+ Params: map[string]any{},
+ }
+
+ handleSetRealName(conn, req, manager)
+
+ var resp models.Response[any]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "missing or invalid 'name' parameter")
+ })
+
+ t.Run("successful set real name", func(t *testing.T) {
+ mockAccountsObj := mockdbus.NewMockBusObject(t)
+ mockCall := &dbus.Call{Err: nil}
+ mockAccountsObj.EXPECT().Call("org.freedesktop.Accounts.User.SetRealName", dbus.Flags(0), "New Name").Return(mockCall)
+ mockAccountsObj.EXPECT().CallWithContext(mock.Anything, "org.freedesktop.DBus.Properties.GetAll", dbus.Flags(0), "org.freedesktop.Accounts.User").Return(mockGetAllAccountsProperties())
+
+ manager := &Manager{
+ state: &FreedeskState{
+ Accounts: AccountsState{
+ Available: true,
+ },
+ },
+ stateMutex: sync.RWMutex{},
+ accountsObj: mockAccountsObj,
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{
+ ID: 123,
+ Method: "freedesktop.accounts.setRealName",
+ Params: map[string]any{
+ "name": "New Name",
+ },
+ }
+
+ handleSetRealName(conn, req, manager)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Empty(t, resp.Error)
+ require.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+ assert.Equal(t, "real name set", resp.Result.Message)
+ })
+}
+
+func TestHandleSetEmail(t *testing.T) {
+ t.Run("missing email parameter", func(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{},
+ stateMutex: sync.RWMutex{},
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{
+ ID: 123,
+ Method: "freedesktop.accounts.setEmail",
+ Params: map[string]any{},
+ }
+
+ handleSetEmail(conn, req, manager)
+
+ var resp models.Response[any]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "missing or invalid 'email' parameter")
+ })
+
+ t.Run("successful set email", func(t *testing.T) {
+ mockAccountsObj := mockdbus.NewMockBusObject(t)
+ mockCall := &dbus.Call{Err: nil}
+ mockAccountsObj.EXPECT().Call("org.freedesktop.Accounts.User.SetEmail", dbus.Flags(0), "test@example.com").Return(mockCall)
+ mockAccountsObj.EXPECT().CallWithContext(mock.Anything, "org.freedesktop.DBus.Properties.GetAll", dbus.Flags(0), "org.freedesktop.Accounts.User").Return(mockGetAllAccountsProperties())
+
+ manager := &Manager{
+ state: &FreedeskState{
+ Accounts: AccountsState{
+ Available: true,
+ },
+ },
+ stateMutex: sync.RWMutex{},
+ accountsObj: mockAccountsObj,
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{
+ ID: 123,
+ Method: "freedesktop.accounts.setEmail",
+ Params: map[string]any{
+ "email": "test@example.com",
+ },
+ }
+
+ handleSetEmail(conn, req, manager)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Empty(t, resp.Error)
+ require.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+ assert.Equal(t, "email set", resp.Result.Message)
+ })
+}
+
+func TestHandleSetLanguage(t *testing.T) {
+ t.Run("missing language parameter", func(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{},
+ stateMutex: sync.RWMutex{},
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{
+ ID: 123,
+ Method: "freedesktop.accounts.setLanguage",
+ Params: map[string]any{},
+ }
+
+ handleSetLanguage(conn, req, manager)
+
+ var resp models.Response[any]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "missing or invalid 'language' parameter")
+ })
+}
+
+func TestHandleSetLocation(t *testing.T) {
+ t.Run("missing location parameter", func(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{},
+ stateMutex: sync.RWMutex{},
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{
+ ID: 123,
+ Method: "freedesktop.accounts.setLocation",
+ Params: map[string]any{},
+ }
+
+ handleSetLocation(conn, req, manager)
+
+ var resp models.Response[any]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "missing or invalid 'location' parameter")
+ })
+}
+
+func TestHandleGetUserIconFile(t *testing.T) {
+ t.Run("missing username parameter", func(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{},
+ stateMutex: sync.RWMutex{},
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{
+ ID: 123,
+ Method: "freedesktop.accounts.getUserIconFile",
+ Params: map[string]any{},
+ }
+
+ handleGetUserIconFile(conn, req, manager)
+
+ var resp models.Response[any]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "missing or invalid 'username' parameter")
+ })
+
+ t.Run("accounts not available", func(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{
+ Accounts: AccountsState{
+ Available: false,
+ },
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{
+ ID: 123,
+ Method: "freedesktop.accounts.getUserIconFile",
+ Params: map[string]any{
+ "username": "testuser",
+ },
+ }
+
+ handleGetUserIconFile(conn, req, manager)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "accounts service not available")
+ })
+}
+
+func TestHandleGetColorScheme(t *testing.T) {
+ t.Run("settings not available", func(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{
+ Settings: SettingsState{
+ Available: false,
+ },
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{ID: 123, Method: "freedesktop.settings.getColorScheme"}
+
+ handleGetColorScheme(conn, req, manager)
+
+ var resp models.Response[map[string]uint32]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "settings portal not available")
+ })
+
+ t.Run("successful get color scheme", func(t *testing.T) {
+ mockSettingsObj := mockdbus.NewMockBusObject(t)
+ mockCall := &dbus.Call{
+ Err: nil,
+ Body: []any{dbus.MakeVariant(uint32(1))},
+ }
+ mockSettingsObj.EXPECT().Call("org.freedesktop.portal.Settings.ReadOne", dbus.Flags(0), "org.freedesktop.appearance", "color-scheme").Return(mockCall)
+
+ manager := &Manager{
+ state: &FreedeskState{
+ Settings: SettingsState{
+ Available: true,
+ },
+ },
+ stateMutex: sync.RWMutex{},
+ settingsObj: mockSettingsObj,
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{ID: 123, Method: "freedesktop.settings.getColorScheme"}
+
+ handleGetColorScheme(conn, req, manager)
+
+ var resp models.Response[map[string]uint32]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Empty(t, resp.Error)
+ require.NotNil(t, resp.Result)
+ assert.Equal(t, uint32(1), (*resp.Result)["colorScheme"])
+ })
+}
+
+func TestHandleRequest(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{
+ Accounts: AccountsState{
+ Available: true,
+ UserName: "testuser",
+ },
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ t.Run("unknown method", func(t *testing.T) {
+ conn := newMockNetConn()
+ req := models.Request{
+ ID: 123,
+ Method: "freedesktop.unknown",
+ }
+
+ HandleRequest(conn, req, manager)
+
+ var resp models.Response[any]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "unknown method")
+ })
+
+ t.Run("valid method - getState", func(t *testing.T) {
+ conn := newMockNetConn()
+ req := models.Request{
+ ID: 123,
+ Method: "freedesktop.getState",
+ }
+
+ HandleRequest(conn, req, manager)
+
+ var resp models.Response[FreedeskState]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Empty(t, resp.Error)
+ })
+
+ t.Run("all method routes", func(t *testing.T) {
+ tests := []string{
+ "freedesktop.accounts.setIconFile",
+ "freedesktop.accounts.setRealName",
+ "freedesktop.accounts.setEmail",
+ "freedesktop.accounts.setLanguage",
+ "freedesktop.accounts.setLocation",
+ "freedesktop.accounts.getUserIconFile",
+ "freedesktop.settings.getColorScheme",
+ }
+
+ for _, method := range tests {
+ conn := newMockNetConn()
+ req := models.Request{
+ ID: 123,
+ Method: method,
+ Params: map[string]any{},
+ }
+
+ HandleRequest(conn, req, manager)
+
+ var resp models.Response[any]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ // Will have errors due to missing params or service unavailable
+ // but the method routing should work
+ }
+ })
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/manager.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/manager.go
new file mode 100644
index 0000000..054f859
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/manager.go
@@ -0,0 +1,206 @@
+package freedesktop
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "sync"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/dbusutil"
+ "github.com/godbus/dbus/v5"
+)
+
+func NewManager() (*Manager, error) {
+ systemConn, err := dbus.ConnectSystemBus()
+ if err != nil {
+ return nil, fmt.Errorf("failed to connect to system bus: %w", err)
+ }
+
+ sessionConn, err := dbus.ConnectSessionBus()
+ if err != nil {
+ sessionConn = nil
+ }
+
+ m := &Manager{
+ state: &FreedeskState{
+ Accounts: AccountsState{},
+ Settings: SettingsState{},
+ Screensaver: ScreensaverState{},
+ },
+ stateMutex: sync.RWMutex{},
+ systemConn: systemConn,
+ sessionConn: sessionConn,
+ currentUID: uint64(os.Getuid()),
+ }
+
+ m.initializeAccounts()
+ m.initializeSettings()
+ m.initializeScreensaver()
+
+ return m, nil
+}
+
+func (m *Manager) initializeAccounts() error {
+ accountsManager := m.systemConn.Object(dbusAccountsDest, dbus.ObjectPath(dbusAccountsPath))
+
+ var userPath dbus.ObjectPath
+ err := accountsManager.Call(dbusAccountsInterface+".FindUserById", 0, int64(m.currentUID)).Store(&userPath)
+ if err != nil {
+ m.stateMutex.Lock()
+ m.state.Accounts.Available = false
+ m.stateMutex.Unlock()
+ return err
+ }
+
+ m.accountsObj = m.systemConn.Object(dbusAccountsDest, userPath)
+
+ m.stateMutex.Lock()
+ m.state.Accounts.Available = true
+ m.state.Accounts.UserPath = string(userPath)
+ m.state.Accounts.UID = m.currentUID
+ m.stateMutex.Unlock()
+
+ if err := m.updateAccountsState(); err != nil {
+ return fmt.Errorf("failed to update accounts state: %w", err)
+ }
+
+ return nil
+}
+
+func (m *Manager) initializeSettings() error {
+ if m.sessionConn == nil {
+ m.stateMutex.Lock()
+ m.state.Settings.Available = false
+ m.stateMutex.Unlock()
+ return fmt.Errorf("no session bus connection")
+ }
+
+ m.settingsObj = m.sessionConn.Object(dbusPortalDest, dbus.ObjectPath(dbusPortalPath))
+
+ var variant dbus.Variant
+ err := m.settingsObj.Call(dbusPortalSettingsInterface+".ReadOne", 0, "org.freedesktop.appearance", "color-scheme").Store(&variant)
+ if err != nil {
+ m.stateMutex.Lock()
+ m.state.Settings.Available = false
+ m.stateMutex.Unlock()
+ return err
+ }
+
+ m.stateMutex.Lock()
+ m.state.Settings.Available = true
+ m.stateMutex.Unlock()
+
+ if err := m.updateSettingsState(); err != nil {
+ return fmt.Errorf("failed to update settings state: %w", err)
+ }
+
+ return nil
+}
+
+func (m *Manager) updateAccountsState() error {
+ if !m.state.Accounts.Available || m.accountsObj == nil {
+ return fmt.Errorf("accounts service not available")
+ }
+
+ ctx := context.Background()
+ props, err := m.getAccountProperties(ctx)
+ if err != nil {
+ return err
+ }
+
+ m.stateMutex.Lock()
+ defer m.stateMutex.Unlock()
+
+ m.state.Accounts.IconFile = dbusutil.GetOr(props, "IconFile", "")
+ m.state.Accounts.RealName = dbusutil.GetOr(props, "RealName", "")
+ m.state.Accounts.UserName = dbusutil.GetOr(props, "UserName", "")
+ m.state.Accounts.AccountType = dbusutil.GetOr(props, "AccountType", int32(0))
+ m.state.Accounts.HomeDirectory = dbusutil.GetOr(props, "HomeDirectory", "")
+ m.state.Accounts.Shell = dbusutil.GetOr(props, "Shell", "")
+ m.state.Accounts.Email = dbusutil.GetOr(props, "Email", "")
+ m.state.Accounts.Language = dbusutil.GetOr(props, "Language", "")
+ m.state.Accounts.Location = dbusutil.GetOr(props, "Location", "")
+ m.state.Accounts.Locked = dbusutil.GetOr(props, "Locked", false)
+ m.state.Accounts.PasswordMode = dbusutil.GetOr(props, "PasswordMode", int32(0))
+
+ return nil
+}
+
+func (m *Manager) updateSettingsState() error {
+ if !m.state.Settings.Available || m.settingsObj == nil {
+ return fmt.Errorf("settings portal not available")
+ }
+
+ var variant dbus.Variant
+ err := m.settingsObj.Call(dbusPortalSettingsInterface+".ReadOne", 0, "org.freedesktop.appearance", "color-scheme").Store(&variant)
+ if err != nil {
+ return err
+ }
+
+ if colorScheme, ok := dbusutil.As[uint32](variant); ok {
+ m.stateMutex.Lock()
+ m.state.Settings.ColorScheme = colorScheme
+ m.stateMutex.Unlock()
+ }
+
+ return nil
+}
+
+func (m *Manager) getAccountProperties(ctx context.Context) (map[string]dbus.Variant, error) {
+ var props map[string]dbus.Variant
+ err := m.accountsObj.CallWithContext(ctx, dbusPropsInterface+".GetAll", 0, dbusAccountsUserInterface).Store(&props)
+ if err != nil {
+ return nil, err
+ }
+ return props, nil
+}
+
+func (m *Manager) GetState() FreedeskState {
+ m.stateMutex.RLock()
+ defer m.stateMutex.RUnlock()
+ return *m.state
+}
+
+func (m *Manager) Subscribe(id string) chan FreedeskState {
+ ch := make(chan FreedeskState, 64)
+ m.subscribers.Store(id, ch)
+ return ch
+}
+
+func (m *Manager) Unsubscribe(id string) {
+ if val, ok := m.subscribers.LoadAndDelete(id); ok {
+ close(val)
+ }
+}
+
+func (m *Manager) NotifySubscribers() {
+ state := m.GetState()
+ m.subscribers.Range(func(key string, ch chan FreedeskState) bool {
+ select {
+ case ch <- state:
+ default:
+ }
+ return true
+ })
+}
+
+func (m *Manager) Close() {
+ m.subscribers.Range(func(key string, ch chan FreedeskState) bool {
+ close(ch)
+ m.subscribers.Delete(key)
+ return true
+ })
+
+ m.screensaverSubscribers.Range(func(key string, ch chan ScreensaverState) bool {
+ close(ch)
+ m.screensaverSubscribers.Delete(key)
+ return true
+ })
+
+ if m.systemConn != nil {
+ m.systemConn.Close()
+ }
+ if m.sessionConn != nil {
+ m.sessionConn.Close()
+ }
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/manager_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/manager_test.go
new file mode 100644
index 0000000..b3617f5
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/manager_test.go
@@ -0,0 +1,143 @@
+package freedesktop
+
+import (
+ "sync"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestManager_GetState(t *testing.T) {
+ state := &FreedeskState{
+ Accounts: AccountsState{
+ Available: true,
+ UserName: "testuser",
+ RealName: "Test User",
+ UID: 1000,
+ },
+ Settings: SettingsState{
+ Available: true,
+ ColorScheme: 1,
+ },
+ }
+
+ manager := &Manager{
+ state: state,
+ stateMutex: sync.RWMutex{},
+ }
+
+ result := manager.GetState()
+ assert.True(t, result.Accounts.Available)
+ assert.Equal(t, "testuser", result.Accounts.UserName)
+ assert.Equal(t, "Test User", result.Accounts.RealName)
+ assert.Equal(t, uint64(1000), result.Accounts.UID)
+ assert.True(t, result.Settings.Available)
+ assert.Equal(t, uint32(1), result.Settings.ColorScheme)
+}
+
+func TestManager_GetState_ThreadSafe(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{
+ Accounts: AccountsState{
+ Available: true,
+ UserName: "testuser",
+ },
+ Settings: SettingsState{
+ Available: true,
+ ColorScheme: 1,
+ },
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ done := make(chan bool)
+ for i := 0; i < 10; i++ {
+ go func() {
+ state := manager.GetState()
+ assert.True(t, state.Accounts.Available)
+ assert.Equal(t, "testuser", state.Accounts.UserName)
+ done <- true
+ }()
+ }
+
+ for i := 0; i < 10; i++ {
+ <-done
+ }
+}
+
+func TestManager_Close(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{},
+ stateMutex: sync.RWMutex{},
+ systemConn: nil,
+ sessionConn: nil,
+ }
+
+ assert.NotPanics(t, func() {
+ manager.Close()
+ })
+}
+
+func TestNewManager(t *testing.T) {
+ t.Run("attempts to create manager", func(t *testing.T) {
+ manager, err := NewManager()
+ if err != nil {
+ assert.Nil(t, manager)
+ } else {
+ assert.NotNil(t, manager)
+ assert.NotNil(t, manager.state)
+ assert.NotNil(t, manager.systemConn)
+
+ manager.Close()
+ }
+ })
+}
+
+func TestManager_GetState_EmptyState(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{},
+ stateMutex: sync.RWMutex{},
+ }
+
+ result := manager.GetState()
+ assert.False(t, result.Accounts.Available)
+ assert.Empty(t, result.Accounts.UserName)
+ assert.False(t, result.Settings.Available)
+ assert.Equal(t, uint32(0), result.Settings.ColorScheme)
+}
+
+func TestManager_AccountsState_Modification(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{
+ Accounts: AccountsState{
+ Available: true,
+ UserName: "testuser",
+ },
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ state := manager.GetState()
+ state.Accounts.UserName = "modifieduser"
+
+ original := manager.GetState()
+ assert.Equal(t, "testuser", original.Accounts.UserName)
+}
+
+func TestManager_SettingsState_Modification(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{
+ Settings: SettingsState{
+ Available: true,
+ ColorScheme: 0,
+ },
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ state := manager.GetState()
+ state.Settings.ColorScheme = 1
+
+ original := manager.GetState()
+ assert.Equal(t, uint32(0), original.Settings.ColorScheme)
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/screensaver.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/screensaver.go
new file mode 100644
index 0000000..095a4c0
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/screensaver.go
@@ -0,0 +1,349 @@
+package freedesktop
+
+import (
+ "fmt"
+ "path/filepath"
+ "strings"
+ "sync/atomic"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "github.com/godbus/dbus/v5"
+ "github.com/godbus/dbus/v5/introspect"
+)
+
+type screensaverHandler struct {
+ manager *Manager
+}
+
+func screensaverIntrospectIface(ifaceName string) introspect.Interface {
+ return introspect.Interface{
+ Name: ifaceName,
+ Methods: []introspect.Method{
+ {
+ Name: "Inhibit",
+ Args: []introspect.Arg{
+ {Name: "application_name", Type: "s", Direction: "in"},
+ {Name: "reason_for_inhibit", Type: "s", Direction: "in"},
+ {Name: "cookie", Type: "u", Direction: "out"},
+ },
+ },
+ {
+ Name: "UnInhibit",
+ Args: []introspect.Arg{
+ {Name: "cookie", Type: "u", Direction: "in"},
+ },
+ },
+ {
+ Name: "GetActive",
+ Args: []introspect.Arg{
+ {Name: "active", Type: "b", Direction: "out"},
+ },
+ },
+ {
+ Name: "SetActive",
+ Args: []introspect.Arg{
+ {Name: "active", Type: "b", Direction: "in"},
+ },
+ },
+ {
+ Name: "Lock",
+ },
+ },
+ Signals: []introspect.Signal{
+ {
+ Name: "ActiveChanged",
+ Args: []introspect.Arg{
+ {Name: "new_value", Type: "b"},
+ },
+ },
+ },
+ }
+}
+
+func (m *Manager) initializeScreensaver() error {
+ if m.sessionConn == nil {
+ m.stateMutex.Lock()
+ m.state.Screensaver.Available = false
+ m.stateMutex.Unlock()
+ return nil
+ }
+
+ handler := &screensaverHandler{manager: m}
+
+ m.screensaverFreedesktopClaimed = m.claimScreensaverName(handler,
+ dbusScreensaverName, dbusScreensaverInterface, dbusScreensaverPath, dbusScreensaverPath2)
+ m.screensaverGnomeClaimed = m.claimScreensaverName(handler,
+ dbusGnomeScreensaverName, dbusGnomeScreensaverInterface, dbusGnomeScreensaverPath)
+
+ if !m.screensaverFreedesktopClaimed && !m.screensaverGnomeClaimed {
+ log.Warn("No screensaver interface could be claimed")
+ m.stateMutex.Lock()
+ m.state.Screensaver.Available = false
+ m.stateMutex.Unlock()
+ return nil
+ }
+
+ go m.watchPeerDisconnects()
+
+ m.stateMutex.Lock()
+ m.state.Screensaver.Available = true
+ m.state.Screensaver.Active = false
+ m.state.Screensaver.Inhibited = false
+ m.state.Screensaver.Inhibitors = []ScreensaverInhibitor{}
+ m.stateMutex.Unlock()
+
+ log.Info("Screensaver listener initialized")
+ return nil
+}
+
+func (m *Manager) claimScreensaverName(handler *screensaverHandler, name, iface string, paths ...dbus.ObjectPath) bool {
+ reply, err := m.sessionConn.RequestName(name, dbus.NameFlagDoNotQueue)
+ if err != nil {
+ log.Warnf("Failed to request screensaver name %s: %v", name, err)
+ return false
+ }
+ if reply != dbus.RequestNameReplyPrimaryOwner {
+ log.Warnf("Screensaver name %s already owned by another process", name)
+ return false
+ }
+ if err := m.exportScreensaverOnPaths(handler, iface, paths...); err != nil {
+ log.Warnf("Failed to export screensaver on %s: %v", name, err)
+ return false
+ }
+ log.Infof("Claimed %s on session bus", name)
+ return true
+}
+
+// exportScreensaverOnPaths exports the handler and introspection on the given
+// paths under the specified interface name.
+func (m *Manager) exportScreensaverOnPaths(handler *screensaverHandler, ifaceName string, paths ...dbus.ObjectPath) error {
+ iface := screensaverIntrospectIface(ifaceName)
+ for _, path := range paths {
+ if err := m.sessionConn.Export(handler, path, ifaceName); err != nil {
+ return fmt.Errorf("export handler on %s: %w", path, err)
+ }
+ node := &introspect.Node{
+ Name: string(path),
+ Interfaces: []introspect.Interface{
+ introspect.IntrospectData,
+ iface,
+ },
+ }
+ if err := m.sessionConn.Export(introspect.NewIntrospectable(node), path, "org.freedesktop.DBus.Introspectable"); err != nil {
+ log.Warnf("Failed to export introspectable on %s: %v", path, err)
+ }
+ }
+ return nil
+}
+
+func (h *screensaverHandler) Inhibit(sender dbus.Sender, appName, reason string) (uint32, *dbus.Error) {
+ if appName == "" {
+ return 0, dbus.NewError("org.freedesktop.DBus.Error.InvalidArgs", []any{"application name required"})
+ }
+
+ if reason == "" {
+ return 0, dbus.NewError("org.freedesktop.DBus.Error.InvalidArgs", []any{"reason required"})
+ }
+
+ if strings.Contains(strings.ToLower(reason), "audio") && !strings.Contains(strings.ToLower(reason), "video") {
+ log.Debugf("Ignoring audio-only inhibit from %s: %s", appName, reason)
+ return 0, nil
+ }
+
+ if idx := strings.LastIndex(appName, "/"); idx != -1 && idx < len(appName)-1 {
+ appName = appName[idx+1:]
+ }
+ appName = filepath.Base(appName)
+
+ cookie := atomic.AddUint32(&h.manager.screensaverCookieCounter, 1)
+
+ inhibitor := ScreensaverInhibitor{
+ Cookie: cookie,
+ AppName: appName,
+ Reason: reason,
+ Peer: string(sender),
+ StartTime: time.Now().Unix(),
+ }
+
+ h.manager.stateMutex.Lock()
+ h.manager.state.Screensaver.Inhibitors = append(h.manager.state.Screensaver.Inhibitors, inhibitor)
+ h.manager.state.Screensaver.Inhibited = len(h.manager.state.Screensaver.Inhibitors) > 0
+ h.manager.stateMutex.Unlock()
+
+ log.Infof("Screensaver inhibited by %s (%s): %s -> cookie %08X", appName, sender, reason, cookie)
+
+ h.manager.NotifyScreensaverSubscribers()
+
+ return cookie, nil
+}
+
+func (h *screensaverHandler) UnInhibit(sender dbus.Sender, cookie uint32) *dbus.Error {
+ h.manager.stateMutex.Lock()
+ defer h.manager.stateMutex.Unlock()
+
+ found := false
+ inhibitors := h.manager.state.Screensaver.Inhibitors
+ for i, inh := range inhibitors {
+ if inh.Cookie != cookie {
+ continue
+ }
+ log.Infof("Screensaver uninhibited by %s (%s) cookie %08X", inh.AppName, sender, cookie)
+ h.manager.state.Screensaver.Inhibitors = append(inhibitors[:i], inhibitors[i+1:]...)
+ found = true
+ break
+ }
+
+ if !found {
+ log.Debugf("UnInhibit: no match for cookie %08X", cookie)
+ return nil
+ }
+
+ h.manager.state.Screensaver.Inhibited = len(h.manager.state.Screensaver.Inhibitors) > 0
+
+ go h.manager.NotifyScreensaverSubscribers()
+
+ return nil
+}
+
+func (m *Manager) watchPeerDisconnects() {
+ if m.sessionConn == nil {
+ return
+ }
+
+ if err := m.sessionConn.AddMatchSignal(
+ dbus.WithMatchInterface("org.freedesktop.DBus"),
+ dbus.WithMatchMember("NameOwnerChanged"),
+ ); err != nil {
+ log.Warnf("Failed to watch peer disconnects: %v", err)
+ return
+ }
+
+ signals := make(chan *dbus.Signal, 64)
+ m.sessionConn.Signal(signals)
+
+ for sig := range signals {
+ if sig.Name != "org.freedesktop.DBus.NameOwnerChanged" {
+ continue
+ }
+ if len(sig.Body) < 3 {
+ continue
+ }
+
+ name, ok1 := sig.Body[0].(string)
+ newOwner, ok2 := sig.Body[2].(string)
+ if !ok1 || !ok2 {
+ continue
+ }
+ if newOwner != "" {
+ continue
+ }
+
+ m.removeInhibitorsByPeer(name)
+ }
+}
+
+func (m *Manager) removeInhibitorsByPeer(peer string) {
+ m.stateMutex.Lock()
+ defer m.stateMutex.Unlock()
+
+ var remaining []ScreensaverInhibitor
+ var removed []ScreensaverInhibitor
+ for _, inh := range m.state.Screensaver.Inhibitors {
+ if inh.Peer == peer {
+ removed = append(removed, inh)
+ continue
+ }
+ remaining = append(remaining, inh)
+ }
+
+ if len(removed) == 0 {
+ return
+ }
+
+ for _, inh := range removed {
+ log.Infof("Screensaver: peer %s died, removing inhibitor from %s (cookie %08X)", peer, inh.AppName, inh.Cookie)
+ }
+
+ m.state.Screensaver.Inhibitors = remaining
+ m.state.Screensaver.Inhibited = len(remaining) > 0
+
+ go m.NotifyScreensaverSubscribers()
+}
+
+func (m *Manager) GetScreensaverState() ScreensaverState {
+ m.stateMutex.RLock()
+ defer m.stateMutex.RUnlock()
+ return m.state.Screensaver
+}
+
+func (m *Manager) SubscribeScreensaver(id string) chan ScreensaverState {
+ ch := make(chan ScreensaverState, 64)
+ m.screensaverSubscribers.Store(id, ch)
+ return ch
+}
+
+func (m *Manager) UnsubscribeScreensaver(id string) {
+ if val, ok := m.screensaverSubscribers.LoadAndDelete(id); ok {
+ close(val)
+ }
+}
+
+func (m *Manager) NotifyScreensaverSubscribers() {
+ state := m.GetScreensaverState()
+ m.screensaverSubscribers.Range(func(key string, ch chan ScreensaverState) bool {
+ select {
+ case ch <- state:
+ default:
+ }
+ return true
+ })
+}
+
+func (h *screensaverHandler) GetActive() (bool, *dbus.Error) {
+ h.manager.stateMutex.RLock()
+ active := h.manager.state.Screensaver.Active
+ h.manager.stateMutex.RUnlock()
+ return active, nil
+}
+
+func (h *screensaverHandler) SetActive(active bool) *dbus.Error {
+ h.manager.SetScreenLockActive(active)
+ return nil
+}
+
+func (h *screensaverHandler) Lock() *dbus.Error {
+ h.manager.SetScreenLockActive(true)
+ return nil
+}
+
+func (m *Manager) SetScreenLockActive(active bool) {
+ m.stateMutex.Lock()
+ changed := m.state.Screensaver.Active != active
+ m.state.Screensaver.Active = active
+ m.stateMutex.Unlock()
+
+ if !changed {
+ return
+ }
+
+ log.Infof("Screen lock active changed: %v", active)
+ defer m.NotifyScreensaverSubscribers()
+
+ if m.sessionConn == nil {
+ return
+ }
+ if m.screensaverFreedesktopClaimed {
+ if err := m.sessionConn.Emit(dbusScreensaverPath, dbusScreensaverInterface+".ActiveChanged", active); err != nil {
+ log.Warnf("Failed to emit ActiveChanged on %s: %v", dbusScreensaverPath, err)
+ }
+ if err := m.sessionConn.Emit(dbusScreensaverPath2, dbusScreensaverInterface+".ActiveChanged", active); err != nil {
+ log.Warnf("Failed to emit ActiveChanged on %s: %v", dbusScreensaverPath2, err)
+ }
+ }
+ if m.screensaverGnomeClaimed {
+ if err := m.sessionConn.Emit(dbusGnomeScreensaverPath, dbusGnomeScreensaverInterface+".ActiveChanged", active); err != nil {
+ log.Warnf("Failed to emit ActiveChanged on %s: %v", dbusGnomeScreensaverPath, err)
+ }
+ }
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/screensaver_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/screensaver_test.go
new file mode 100644
index 0000000..324c17e
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/screensaver_test.go
@@ -0,0 +1,102 @@
+package freedesktop
+
+import (
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestSetScreenLockActive_ChangesState(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{
+ Screensaver: ScreensaverState{Available: true},
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ assert.False(t, manager.GetScreensaverState().Active)
+
+ manager.SetScreenLockActive(true)
+ assert.True(t, manager.GetScreensaverState().Active)
+
+ manager.SetScreenLockActive(false)
+ assert.False(t, manager.GetScreensaverState().Active)
+}
+
+func TestSetScreenLockActive_NoChangeNoDuplicate(t *testing.T) {
+ ch := make(chan ScreensaverState, 64)
+ manager := &Manager{
+ state: &FreedeskState{
+ Screensaver: ScreensaverState{Available: true, Active: false},
+ },
+ stateMutex: sync.RWMutex{},
+ }
+ manager.screensaverSubscribers.Store("test", ch)
+ defer manager.screensaverSubscribers.Delete("test")
+
+ // Setting to same value should not notify
+ manager.SetScreenLockActive(false)
+
+ select {
+ case <-ch:
+ t.Fatal("should not have received notification for no-change")
+ case <-time.After(50 * time.Millisecond):
+ // Expected: no notification
+ }
+}
+
+func TestSetScreenLockActive_NotifiesSubscribers(t *testing.T) {
+ ch := make(chan ScreensaverState, 64)
+ manager := &Manager{
+ state: &FreedeskState{
+ Screensaver: ScreensaverState{Available: true, Active: false},
+ },
+ stateMutex: sync.RWMutex{},
+ }
+ manager.screensaverSubscribers.Store("test", ch)
+ defer manager.screensaverSubscribers.Delete("test")
+
+ manager.SetScreenLockActive(true)
+
+ select {
+ case state := <-ch:
+ assert.True(t, state.Active)
+ case <-time.After(time.Second):
+ t.Fatal("timeout waiting for subscriber notification")
+ }
+}
+
+func TestSetScreenLockActive_NilSessionConn(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{
+ Screensaver: ScreensaverState{Available: true},
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ assert.NotPanics(t, func() {
+ manager.SetScreenLockActive(true)
+ })
+ assert.True(t, manager.GetScreensaverState().Active)
+}
+
+func TestGetActive_ReturnsCurrentState(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{
+ Screensaver: ScreensaverState{Available: true, Active: true},
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ handler := &screensaverHandler{manager: manager}
+ active, dbusErr := handler.GetActive()
+ assert.Nil(t, dbusErr)
+ assert.True(t, active)
+}
+
+func TestScreensaverState_ActiveDefaultsFalse(t *testing.T) {
+ state := ScreensaverState{}
+ assert.False(t, state.Active)
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/types.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/types.go
new file mode 100644
index 0000000..0a9517a
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/types.go
@@ -0,0 +1,66 @@
+package freedesktop
+
+import (
+ "sync"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
+ "github.com/godbus/dbus/v5"
+)
+
+type AccountsState struct {
+ Available bool `json:"available"`
+ UserPath string `json:"userPath"`
+ IconFile string `json:"iconFile"`
+ RealName string `json:"realName"`
+ UserName string `json:"userName"`
+ AccountType int32 `json:"accountType"`
+ HomeDirectory string `json:"homeDirectory"`
+ Shell string `json:"shell"`
+ Email string `json:"email"`
+ Language string `json:"language"`
+ Location string `json:"location"`
+ Locked bool `json:"locked"`
+ PasswordMode int32 `json:"passwordMode"`
+ UID uint64 `json:"uid"`
+}
+
+type SettingsState struct {
+ Available bool `json:"available"`
+ ColorScheme uint32 `json:"colorScheme"`
+}
+
+type ScreensaverInhibitor struct {
+ Cookie uint32 `json:"cookie"`
+ AppName string `json:"appName"`
+ Reason string `json:"reason"`
+ Peer string `json:"peer"`
+ StartTime int64 `json:"startTime"`
+}
+
+type ScreensaverState struct {
+ Available bool `json:"available"`
+ Active bool `json:"active"`
+ Inhibited bool `json:"inhibited"`
+ Inhibitors []ScreensaverInhibitor `json:"inhibitors"`
+}
+
+type FreedeskState struct {
+ Accounts AccountsState `json:"accounts"`
+ Settings SettingsState `json:"settings"`
+ Screensaver ScreensaverState `json:"screensaver"`
+}
+
+type Manager struct {
+ state *FreedeskState
+ stateMutex sync.RWMutex
+ systemConn *dbus.Conn
+ sessionConn *dbus.Conn
+ accountsObj dbus.BusObject
+ settingsObj dbus.BusObject
+ currentUID uint64
+ subscribers syncmap.Map[string, chan FreedeskState]
+ screensaverSubscribers syncmap.Map[string, chan ScreensaverState]
+ screensaverCookieCounter uint32
+ screensaverFreedesktopClaimed bool
+ screensaverGnomeClaimed bool
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/types_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/types_test.go
new file mode 100644
index 0000000..deb0327
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/types_test.go
@@ -0,0 +1,70 @@
+package freedesktop
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestAccountsState_Struct(t *testing.T) {
+ state := AccountsState{
+ Available: true,
+ UserPath: "/org/freedesktop/Accounts/User1000",
+ RealName: "Test User",
+ UserName: "testuser",
+ Locked: false,
+ UID: 1000,
+ }
+
+ assert.True(t, state.Available)
+ assert.Equal(t, "/org/freedesktop/Accounts/User1000", state.UserPath)
+ assert.Equal(t, "Test User", state.RealName)
+ assert.Equal(t, "testuser", state.UserName)
+ assert.Equal(t, uint64(1000), state.UID)
+ assert.False(t, state.Locked)
+}
+
+func TestSettingsState_Struct(t *testing.T) {
+ state := SettingsState{
+ Available: true,
+ ColorScheme: 1, // Dark mode
+ }
+
+ assert.True(t, state.Available)
+ assert.Equal(t, uint32(1), state.ColorScheme)
+}
+
+func TestFreedeskState_Struct(t *testing.T) {
+ state := FreedeskState{
+ Accounts: AccountsState{
+ Available: true,
+ UserName: "testuser",
+ UID: 1000,
+ },
+ Settings: SettingsState{
+ Available: true,
+ ColorScheme: 0, // Light mode
+ },
+ }
+
+ assert.True(t, state.Accounts.Available)
+ assert.Equal(t, "testuser", state.Accounts.UserName)
+ assert.True(t, state.Settings.Available)
+ assert.Equal(t, uint32(0), state.Settings.ColorScheme)
+}
+
+func TestAccountsState_DefaultValues(t *testing.T) {
+ state := AccountsState{}
+
+ assert.False(t, state.Available)
+ assert.Empty(t, state.UserPath)
+ assert.Empty(t, state.UserName)
+ assert.Equal(t, uint64(0), state.UID)
+}
+
+func TestSettingsState_DefaultValues(t *testing.T) {
+ state := SettingsState{}
+
+ assert.False(t, state.Available)
+ assert.Equal(t, uint32(0), state.ColorScheme)
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/location/handlers.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/location/handlers.go
new file mode 100644
index 0000000..c9d556c
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/location/handlers.go
@@ -0,0 +1,61 @@
+package location
+
+import (
+ "encoding/json"
+ "fmt"
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+)
+
+type LocationEvent struct {
+ Type string `json:"type"`
+ Data State `json:"data"`
+}
+
+func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
+ switch req.Method {
+ case "location.getState":
+ handleGetState(conn, req, manager)
+ case "location.subscribe":
+ handleSubscribe(conn, req, manager)
+
+ default:
+ models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
+ }
+}
+
+func handleGetState(conn net.Conn, req models.Request, manager *Manager) {
+ models.Respond(conn, req.ID, manager.GetState())
+}
+
+func handleSubscribe(conn net.Conn, req models.Request, manager *Manager) {
+ clientID := fmt.Sprintf("client-%p", conn)
+ stateChan := manager.Subscribe(clientID)
+ defer manager.Unsubscribe(clientID)
+
+ initialState := manager.GetState()
+ event := LocationEvent{
+ Type: "state_changed",
+ Data: initialState,
+ }
+
+ if err := json.NewEncoder(conn).Encode(models.Response[LocationEvent]{
+ ID: req.ID,
+ Result: &event,
+ }); err != nil {
+ return
+ }
+
+ for state := range stateChan {
+ event := LocationEvent{
+ Type: "state_changed",
+ Data: state,
+ }
+ if err := json.NewEncoder(conn).Encode(models.Response[LocationEvent]{
+ Result: &event,
+ }); err != nil {
+ return
+ }
+ }
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/location/manager.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/location/manager.go
new file mode 100644
index 0000000..24bcd95
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/location/manager.go
@@ -0,0 +1,175 @@
+package location
+
+import (
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+)
+
+func NewManager(client geolocation.Client) (*Manager, error) {
+ currLocation, err := client.GetLocation()
+ if err != nil {
+ log.Warnf("Failed to get initial location: %v", err)
+ }
+
+ m := &Manager{
+ client: client,
+ dirty: make(chan struct{}),
+ stopChan: make(chan struct{}),
+
+ state: &State{
+ Latitude: currLocation.Latitude,
+ Longitude: currLocation.Longitude,
+ },
+ }
+
+ if err := m.startSignalPump(); err != nil {
+ return nil, err
+ }
+
+ m.notifierWg.Add(1)
+ go m.notifier()
+
+ return m, nil
+}
+
+func (m *Manager) Close() {
+ close(m.stopChan)
+ m.notifierWg.Wait()
+
+ m.sigWG.Wait()
+
+ m.subscribers.Range(func(key string, ch chan State) bool {
+ close(ch)
+ m.subscribers.Delete(key)
+ return true
+ })
+}
+
+func (m *Manager) Subscribe(id string) chan State {
+ ch := make(chan State, 64)
+ m.subscribers.Store(id, ch)
+ return ch
+}
+
+func (m *Manager) Unsubscribe(id string) {
+ if ch, ok := m.subscribers.LoadAndDelete(id); ok {
+ close(ch)
+ }
+}
+
+func (m *Manager) startSignalPump() error {
+ m.sigWG.Add(1)
+ go func() {
+ defer m.sigWG.Done()
+
+ subscription := m.client.Subscribe("locationManager")
+ defer m.client.Unsubscribe("locationManager")
+
+ for {
+ select {
+ case <-m.stopChan:
+ return
+ case location, ok := <-subscription:
+ if !ok {
+ return
+ }
+
+ m.handleLocationChange(location)
+ }
+ }
+ }()
+
+ return nil
+}
+
+func (m *Manager) handleLocationChange(location geolocation.Location) {
+ m.stateMutex.Lock()
+ defer m.stateMutex.Unlock()
+
+ m.state.Latitude = location.Latitude
+ m.state.Longitude = location.Longitude
+
+ m.notifySubscribers()
+}
+
+func (m *Manager) notifySubscribers() {
+ select {
+ case m.dirty <- struct{}{}:
+ default:
+ }
+}
+
+func (m *Manager) GetState() State {
+ m.stateMutex.RLock()
+ defer m.stateMutex.RUnlock()
+ if m.state == nil {
+ return State{
+ Latitude: 0.0,
+ Longitude: 0.0,
+ }
+ }
+ stateCopy := *m.state
+ return stateCopy
+}
+
+func (m *Manager) notifier() {
+ defer m.notifierWg.Done()
+ const minGap = 200 * time.Millisecond
+ timer := time.NewTimer(minGap)
+ timer.Stop()
+ var pending bool
+
+ for {
+ select {
+ case <-m.stopChan:
+ timer.Stop()
+ return
+ case <-m.dirty:
+ if pending {
+ continue
+ }
+ pending = true
+ timer.Reset(minGap)
+ case <-timer.C:
+ if !pending {
+ continue
+ }
+
+ currentState := m.GetState()
+
+ if m.lastNotified != nil && !stateChanged(m.lastNotified, &currentState) {
+ pending = false
+ continue
+ }
+
+ m.subscribers.Range(func(key string, ch chan State) bool {
+ select {
+ case ch <- currentState:
+ default:
+ log.Warn("Location: subscriber channel full, dropping update")
+ }
+ return true
+ })
+
+ stateCopy := currentState
+ m.lastNotified = &stateCopy
+ pending = false
+ }
+ }
+}
+
+func stateChanged(old, new *State) bool {
+ if old == nil || new == nil {
+ return true
+ }
+ if old.Latitude != new.Latitude {
+ return true
+ }
+ if old.Longitude != new.Longitude {
+ return true
+ }
+
+ return false
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/location/types.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/location/types.go
new file mode 100644
index 0000000..1f3d518
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/location/types.go
@@ -0,0 +1,28 @@
+package location
+
+import (
+ "sync"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation"
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
+)
+
+type State struct {
+ Latitude float64 `json:"latitude"`
+ Longitude float64 `json:"longitude"`
+}
+
+type Manager struct {
+ state *State
+ stateMutex sync.RWMutex
+
+ client geolocation.Client
+
+ stopChan chan struct{}
+ sigWG sync.WaitGroup
+
+ subscribers syncmap.Map[string, chan State]
+ dirty chan struct{}
+ notifierWg sync.WaitGroup
+ lastNotified *State
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/actions.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/actions.go
new file mode 100644
index 0000000..858248e
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/actions.go
@@ -0,0 +1,88 @@
+package loginctl
+
+import (
+ "fmt"
+)
+
+func (m *Manager) Lock() error {
+ if m.sessionObj == nil {
+ return fmt.Errorf("session object not available")
+ }
+ err := m.sessionObj.Call(dbusSessionInterface+".Lock", 0).Err
+ if err != nil {
+ if refreshErr := m.refreshSessionBinding(); refreshErr == nil {
+ err = m.sessionObj.Call(dbusSessionInterface+".Lock", 0).Err
+ }
+ if err != nil {
+ return fmt.Errorf("failed to lock session: %w", err)
+ }
+ }
+ return nil
+}
+
+func (m *Manager) Unlock() error {
+ err := m.sessionObj.Call(dbusSessionInterface+".Unlock", 0).Err
+ if err != nil {
+ if refreshErr := m.refreshSessionBinding(); refreshErr == nil {
+ err = m.sessionObj.Call(dbusSessionInterface+".Unlock", 0).Err
+ }
+ if err != nil {
+ return fmt.Errorf("failed to unlock session: %w", err)
+ }
+ }
+ return nil
+}
+
+func (m *Manager) Activate() error {
+ err := m.sessionObj.Call(dbusSessionInterface+".Activate", 0).Err
+ if err != nil {
+ if refreshErr := m.refreshSessionBinding(); refreshErr == nil {
+ err = m.sessionObj.Call(dbusSessionInterface+".Activate", 0).Err
+ }
+ if err != nil {
+ return fmt.Errorf("failed to activate session: %w", err)
+ }
+ }
+ return nil
+}
+
+func (m *Manager) SetIdleHint(idle bool) error {
+ err := m.sessionObj.Call(dbusSessionInterface+".SetIdleHint", 0, idle).Err
+ if err != nil {
+ if refreshErr := m.refreshSessionBinding(); refreshErr == nil {
+ err = m.sessionObj.Call(dbusSessionInterface+".SetIdleHint", 0, idle).Err
+ }
+ if err != nil {
+ return fmt.Errorf("failed to set idle hint: %w", err)
+ }
+ }
+ return nil
+}
+
+func (m *Manager) Terminate() error {
+ err := m.sessionObj.Call(dbusSessionInterface+".Terminate", 0).Err
+ if err != nil {
+ if refreshErr := m.refreshSessionBinding(); refreshErr == nil {
+ err = m.sessionObj.Call(dbusSessionInterface+".Terminate", 0).Err
+ }
+ if err != nil {
+ return fmt.Errorf("failed to terminate session: %w", err)
+ }
+ }
+ return nil
+}
+
+func (m *Manager) SetLockBeforeSuspend(enabled bool) {
+ m.lockBeforeSuspend.Store(enabled)
+}
+
+func (m *Manager) SetSleepInhibitorEnabled(enabled bool) {
+ m.sleepInhibitorEnabled.Store(enabled)
+ if enabled {
+ // Re-acquire inhibitor if enabled
+ m.acquireSleepInhibitor()
+ } else {
+ // Release inhibitor if disabled
+ m.releaseSleepInhibitor()
+ }
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/constants.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/constants.go
new file mode 100644
index 0000000..1a29588
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/constants.go
@@ -0,0 +1,10 @@
+package loginctl
+
+const (
+ dbusDest = "org.freedesktop.login1"
+ dbusPath = "/org/freedesktop/login1"
+ dbusManagerInterface = "org.freedesktop.login1.Manager"
+ dbusSessionInterface = "org.freedesktop.login1.Session"
+ dbusUserInterface = "org.freedesktop.login1.User"
+ dbusPropsInterface = "org.freedesktop.DBus.Properties"
+)
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/handlers.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/handlers.go
new file mode 100644
index 0000000..564e996
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/handlers.go
@@ -0,0 +1,156 @@
+package loginctl
+
+import (
+ "encoding/json"
+ "fmt"
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/params"
+)
+
+func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
+ switch req.Method {
+ case "loginctl.getState":
+ handleGetState(conn, req, manager)
+ case "loginctl.lock":
+ handleLock(conn, req, manager)
+ case "loginctl.unlock":
+ handleUnlock(conn, req, manager)
+ case "loginctl.activate":
+ handleActivate(conn, req, manager)
+ case "loginctl.setIdleHint":
+ handleSetIdleHint(conn, req, manager)
+ case "loginctl.setLockBeforeSuspend":
+ handleSetLockBeforeSuspend(conn, req, manager)
+ case "loginctl.setSleepInhibitorEnabled":
+ handleSetSleepInhibitorEnabled(conn, req, manager)
+ case "loginctl.lockerReady":
+ handleLockerReady(conn, req, manager)
+ case "loginctl.terminate":
+ handleTerminate(conn, req, manager)
+ case "loginctl.subscribe":
+ handleSubscribe(conn, req, manager)
+ default:
+ models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
+ }
+}
+
+func handleGetState(conn net.Conn, req models.Request, manager *Manager) {
+ models.Respond(conn, req.ID, manager.GetState())
+}
+
+func handleLock(conn net.Conn, req models.Request, manager *Manager) {
+ if err := manager.Lock(); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "locked"})
+}
+
+func handleUnlock(conn net.Conn, req models.Request, manager *Manager) {
+ if err := manager.Unlock(); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "unlocked"})
+}
+
+func handleActivate(conn net.Conn, req models.Request, manager *Manager) {
+ if err := manager.Activate(); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "activated"})
+}
+
+func handleSetIdleHint(conn net.Conn, req models.Request, manager *Manager) {
+ idle, err := params.Bool(req.Params, "idle")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.SetIdleHint(idle); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "idle hint set"})
+}
+
+func handleSetLockBeforeSuspend(conn net.Conn, req models.Request, manager *Manager) {
+ enabled, err := params.Bool(req.Params, "enabled")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ manager.SetLockBeforeSuspend(enabled)
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "lock before suspend set"})
+}
+
+func handleSetSleepInhibitorEnabled(conn net.Conn, req models.Request, manager *Manager) {
+ enabled, err := params.Bool(req.Params, "enabled")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ manager.SetSleepInhibitorEnabled(enabled)
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "sleep inhibitor setting updated"})
+}
+
+func handleLockerReady(conn net.Conn, req models.Request, manager *Manager) {
+ manager.lockTimerMu.Lock()
+ if manager.lockTimer != nil {
+ manager.lockTimer.Stop()
+ manager.lockTimer = nil
+ }
+ manager.lockTimerMu.Unlock()
+
+ id := manager.sleepCycleID.Load()
+ manager.releaseForCycle(id)
+
+ if manager.inSleepCycle.Load() {
+ manager.signalLockerReady()
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "ok"})
+}
+
+func handleTerminate(conn net.Conn, req models.Request, manager *Manager) {
+ if err := manager.Terminate(); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "terminated"})
+}
+
+func handleSubscribe(conn net.Conn, req models.Request, manager *Manager) {
+ clientID := fmt.Sprintf("client-%p", conn)
+ stateChan := manager.Subscribe(clientID)
+ defer manager.Unsubscribe(clientID)
+
+ initialState := manager.GetState()
+ event := SessionEvent{
+ Type: EventStateChanged,
+ Data: initialState,
+ }
+ if err := json.NewEncoder(conn).Encode(models.Response[SessionEvent]{
+ ID: req.ID,
+ Result: &event,
+ }); err != nil {
+ return
+ }
+
+ for state := range stateChan {
+ event := SessionEvent{
+ Type: EventStateChanged,
+ Data: state,
+ }
+ if err := json.NewEncoder(conn).Encode(models.Response[SessionEvent]{
+ Result: &event,
+ }); err != nil {
+ return
+ }
+ }
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/handlers_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/handlers_test.go
new file mode 100644
index 0000000..d3169e4
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/handlers_test.go
@@ -0,0 +1,500 @@
+package loginctl
+
+import (
+ "bytes"
+ "encoding/json"
+ "net"
+ "sync"
+ "testing"
+ "time"
+
+ mockdbus "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/github.com/godbus/dbus/v5"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+ "github.com/godbus/dbus/v5"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+ "github.com/stretchr/testify/require"
+)
+
+type mockNetConn struct {
+ net.Conn
+ readBuf *bytes.Buffer
+ writeBuf *bytes.Buffer
+ closed bool
+}
+
+func newMockNetConn() *mockNetConn {
+ return &mockNetConn{
+ readBuf: &bytes.Buffer{},
+ writeBuf: &bytes.Buffer{},
+ }
+}
+
+func (m *mockNetConn) Read(b []byte) (n int, err error) {
+ return m.readBuf.Read(b)
+}
+
+func (m *mockNetConn) Write(b []byte) (n int, err error) {
+ return m.writeBuf.Write(b)
+}
+
+func (m *mockNetConn) Close() error {
+ m.closed = true
+ return nil
+}
+
+func TestRespondError_Loginctl(t *testing.T) {
+ conn := newMockNetConn()
+ models.RespondError(conn, 123, "test error")
+
+ var resp models.Response[any]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Equal(t, "test error", resp.Error)
+ assert.Nil(t, resp.Result)
+}
+
+func TestRespond_Loginctl(t *testing.T) {
+ conn := newMockNetConn()
+ result := models.SuccessResult{Success: true, Message: "test"}
+ models.Respond(conn, 123, result)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Empty(t, resp.Error)
+ require.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+ assert.Equal(t, "test", resp.Result.Message)
+}
+
+func TestHandleGetState(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ SessionID: "1",
+ Locked: false,
+ Active: true,
+ SessionType: "wayland",
+ SessionClass: "user",
+ UserName: "testuser",
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{ID: 123, Method: "loginctl.getState"}
+
+ handleGetState(conn, req, manager)
+
+ var resp models.Response[SessionState]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Empty(t, resp.Error)
+ require.NotNil(t, resp.Result)
+ assert.Equal(t, "1", resp.Result.SessionID)
+ assert.False(t, resp.Result.Locked)
+ assert.True(t, resp.Result.Active)
+}
+
+func TestHandleLock(t *testing.T) {
+ t.Run("successful lock", func(t *testing.T) {
+ mockSessionObj := mockdbus.NewMockBusObject(t)
+ mockCall := &dbus.Call{Err: nil}
+ mockSessionObj.EXPECT().Call("org.freedesktop.login1.Session.Lock", dbus.Flags(0)).Return(mockCall)
+
+ manager := &Manager{
+ state: &SessionState{},
+ stateMutex: sync.RWMutex{},
+ sessionObj: mockSessionObj,
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{ID: 123, Method: "loginctl.lock"}
+ handleLock(conn, req, manager)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Empty(t, resp.Error)
+ require.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+ assert.Equal(t, "locked", resp.Result.Message)
+ })
+
+ t.Run("lock fails", func(t *testing.T) {
+ mockSessionObj := mockdbus.NewMockBusObject(t)
+ mockCall := &dbus.Call{Err: assert.AnError}
+ mockSessionObj.EXPECT().Call("org.freedesktop.login1.Session.Lock", dbus.Flags(0)).Return(mockCall)
+
+ manager := &Manager{
+ state: &SessionState{},
+ stateMutex: sync.RWMutex{},
+ sessionObj: mockSessionObj,
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{ID: 123, Method: "loginctl.lock"}
+ handleLock(conn, req, manager)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "failed to lock session")
+ })
+}
+
+func TestHandleUnlock(t *testing.T) {
+ t.Run("successful unlock", func(t *testing.T) {
+ mockSessionObj := mockdbus.NewMockBusObject(t)
+ mockCall := &dbus.Call{Err: nil}
+ mockSessionObj.EXPECT().Call("org.freedesktop.login1.Session.Unlock", dbus.Flags(0)).Return(mockCall)
+
+ manager := &Manager{
+ state: &SessionState{},
+ stateMutex: sync.RWMutex{},
+ sessionObj: mockSessionObj,
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{ID: 123, Method: "loginctl.unlock"}
+ handleUnlock(conn, req, manager)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Empty(t, resp.Error)
+ require.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+ assert.Equal(t, "unlocked", resp.Result.Message)
+ })
+
+ t.Run("unlock fails", func(t *testing.T) {
+ mockSessionObj := mockdbus.NewMockBusObject(t)
+ mockCall := &dbus.Call{Err: assert.AnError}
+ mockSessionObj.EXPECT().Call("org.freedesktop.login1.Session.Unlock", dbus.Flags(0)).Return(mockCall)
+
+ manager := &Manager{
+ state: &SessionState{},
+ stateMutex: sync.RWMutex{},
+ sessionObj: mockSessionObj,
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{ID: 123, Method: "loginctl.unlock"}
+ handleUnlock(conn, req, manager)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "failed to unlock session")
+ })
+}
+
+func TestHandleActivate(t *testing.T) {
+ t.Run("successful activate", func(t *testing.T) {
+ mockSessionObj := mockdbus.NewMockBusObject(t)
+ mockCall := &dbus.Call{Err: nil}
+ mockSessionObj.EXPECT().Call("org.freedesktop.login1.Session.Activate", dbus.Flags(0)).Return(mockCall)
+
+ manager := &Manager{
+ state: &SessionState{},
+ stateMutex: sync.RWMutex{},
+ sessionObj: mockSessionObj,
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{ID: 123, Method: "loginctl.activate"}
+ handleActivate(conn, req, manager)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Empty(t, resp.Error)
+ require.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+ assert.Equal(t, "activated", resp.Result.Message)
+ })
+
+ t.Run("activate fails", func(t *testing.T) {
+ mockSessionObj := mockdbus.NewMockBusObject(t)
+ mockCall := &dbus.Call{Err: assert.AnError}
+ mockSessionObj.EXPECT().Call("org.freedesktop.login1.Session.Activate", dbus.Flags(0)).Return(mockCall)
+
+ manager := &Manager{
+ state: &SessionState{},
+ stateMutex: sync.RWMutex{},
+ sessionObj: mockSessionObj,
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{ID: 123, Method: "loginctl.activate"}
+ handleActivate(conn, req, manager)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "failed to activate session")
+ })
+}
+
+func TestHandleSetIdleHint(t *testing.T) {
+ t.Run("missing idle parameter", func(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{},
+ stateMutex: sync.RWMutex{},
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{
+ ID: 123,
+ Method: "loginctl.setIdleHint",
+ Params: map[string]any{},
+ }
+
+ handleSetIdleHint(conn, req, manager)
+
+ var resp models.Response[any]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "missing or invalid 'idle' parameter")
+ })
+
+ t.Run("successful set idle hint true", func(t *testing.T) {
+ mockSessionObj := mockdbus.NewMockBusObject(t)
+ mockCall := &dbus.Call{Err: nil}
+ mockSessionObj.EXPECT().Call("org.freedesktop.login1.Session.SetIdleHint", dbus.Flags(0), true).Return(mockCall)
+
+ manager := &Manager{
+ state: &SessionState{},
+ stateMutex: sync.RWMutex{},
+ sessionObj: mockSessionObj,
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{
+ ID: 123,
+ Method: "loginctl.setIdleHint",
+ Params: map[string]any{
+ "idle": true,
+ },
+ }
+
+ handleSetIdleHint(conn, req, manager)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Empty(t, resp.Error)
+ require.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+ assert.Equal(t, "idle hint set", resp.Result.Message)
+ })
+
+ t.Run("set idle hint fails", func(t *testing.T) {
+ mockSessionObj := mockdbus.NewMockBusObject(t)
+ mockCall := &dbus.Call{Err: assert.AnError}
+ mockSessionObj.EXPECT().Call("org.freedesktop.login1.Session.SetIdleHint", dbus.Flags(0), false).Return(mockCall)
+
+ manager := &Manager{
+ state: &SessionState{},
+ stateMutex: sync.RWMutex{},
+ sessionObj: mockSessionObj,
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{
+ ID: 123,
+ Method: "loginctl.setIdleHint",
+ Params: map[string]any{
+ "idle": false,
+ },
+ }
+
+ handleSetIdleHint(conn, req, manager)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "failed to set idle hint")
+ })
+}
+
+func TestHandleTerminate(t *testing.T) {
+ t.Run("successful terminate", func(t *testing.T) {
+ mockSessionObj := mockdbus.NewMockBusObject(t)
+ mockCall := &dbus.Call{Err: nil}
+ mockSessionObj.EXPECT().Call("org.freedesktop.login1.Session.Terminate", dbus.Flags(0)).Return(mockCall)
+
+ manager := &Manager{
+ state: &SessionState{},
+ stateMutex: sync.RWMutex{},
+ sessionObj: mockSessionObj,
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{ID: 123, Method: "loginctl.terminate"}
+ handleTerminate(conn, req, manager)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Empty(t, resp.Error)
+ require.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+ assert.Equal(t, "terminated", resp.Result.Message)
+ })
+
+ t.Run("terminate fails", func(t *testing.T) {
+ mockSessionObj := mockdbus.NewMockBusObject(t)
+ mockCall := &dbus.Call{Err: assert.AnError}
+ mockSessionObj.EXPECT().Call("org.freedesktop.login1.Session.Terminate", dbus.Flags(0)).Return(mockCall)
+
+ manager := &Manager{
+ state: &SessionState{},
+ stateMutex: sync.RWMutex{},
+ sessionObj: mockSessionObj,
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{ID: 123, Method: "loginctl.terminate"}
+ handleTerminate(conn, req, manager)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "failed to terminate session")
+ })
+}
+
+func TestHandleRequest(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ SessionID: "1",
+ Locked: false,
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ t.Run("unknown method", func(t *testing.T) {
+ conn := newMockNetConn()
+ req := models.Request{
+ ID: 123,
+ Method: "loginctl.unknown",
+ }
+
+ HandleRequest(conn, req, manager)
+
+ var resp models.Response[any]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "unknown method")
+ })
+
+ t.Run("valid method - getState", func(t *testing.T) {
+ conn := newMockNetConn()
+ req := models.Request{
+ ID: 123,
+ Method: "loginctl.getState",
+ }
+
+ HandleRequest(conn, req, manager)
+
+ var resp models.Response[SessionState]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Empty(t, resp.Error)
+ })
+
+ t.Run("lock method", func(t *testing.T) {
+ mockSessionObj := mockdbus.NewMockBusObject(t)
+ mockCall := &dbus.Call{Err: nil}
+ mockSessionObj.EXPECT().Call("org.freedesktop.login1.Session.Lock", mock.Anything).Return(mockCall)
+
+ manager.sessionObj = mockSessionObj
+
+ conn := newMockNetConn()
+ req := models.Request{
+ ID: 123,
+ Method: "loginctl.lock",
+ }
+
+ HandleRequest(conn, req, manager)
+
+ var resp models.Response[any]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ })
+}
+
+func TestHandleSubscribe(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ SessionID: "1",
+ Locked: false,
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{ID: 123, Method: "loginctl.subscribe"}
+
+ done := make(chan bool)
+ go func() {
+ handleSubscribe(conn, req, manager)
+ done <- true
+ }()
+
+ time.Sleep(50 * time.Millisecond)
+
+ conn.Close()
+
+ if conn.writeBuf.Len() > 0 {
+ var resp models.Response[SessionEvent]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ if err == nil {
+ assert.Equal(t, 123, resp.ID)
+ require.NotNil(t, resp.Result)
+ assert.Equal(t, EventStateChanged, resp.Result.Type)
+ assert.Equal(t, "1", resp.Result.Data.SessionID)
+ }
+ }
+
+ select {
+ case <-done:
+ case <-time.After(100 * time.Millisecond):
+ }
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/manager.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/manager.go
new file mode 100644
index 0000000..0c51e64
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/manager.go
@@ -0,0 +1,723 @@
+package loginctl
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "sync"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/dbusutil"
+ "github.com/godbus/dbus/v5"
+)
+
+func NewManager() (*Manager, error) {
+ conn, err := dbus.ConnectSystemBus()
+ if err != nil {
+ return nil, fmt.Errorf("failed to connect to system bus: %w", err)
+ }
+
+ m := &Manager{
+ state: &SessionState{},
+ stateMutex: sync.RWMutex{},
+
+ stopChan: make(chan struct{}),
+ conn: conn,
+ dirty: make(chan struct{}, 1),
+ signals: make(chan *dbus.Signal, 256),
+ }
+ m.sleepInhibitorEnabled.Store(true)
+
+ if err := m.initialize(); err != nil {
+ conn.Close()
+ return nil, err
+ }
+
+ if err := m.acquireSleepInhibitor(); err != nil {
+ fmt.Fprintf(os.Stderr, "sleep inhibitor unavailable: %v\n", err)
+ }
+
+ m.notifierWg.Add(1)
+ go m.notifier()
+
+ if err := m.startSignalPump(); err != nil {
+ m.Close()
+ return nil, err
+ }
+
+ return m, nil
+}
+
+func (m *Manager) initialize() error {
+ m.managerObj = m.conn.Object(dbusDest, dbus.ObjectPath(dbusPath))
+
+ m.initializeFallbackDelay()
+
+ sessionID, sessionPath, err := m.discoverSession()
+ if err != nil {
+ return fmt.Errorf("failed to get session path: %w", err)
+ }
+
+ m.stateMutex.Lock()
+ m.state.SessionID = sessionID
+ m.state.SessionPath = string(sessionPath)
+ m.sessionPath = sessionPath
+ m.stateMutex.Unlock()
+
+ m.sessionObj = m.conn.Object(dbusDest, sessionPath)
+
+ if err := m.updateSessionState(); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (m *Manager) discoverSession() (string, dbus.ObjectPath, error) {
+ // 1. Explicit XDG_SESSION_ID
+ if id := os.Getenv("XDG_SESSION_ID"); id != "" {
+ if path, err := m.getSession(id); err == nil {
+ fmt.Fprintf(os.Stderr, "loginctl: using XDG_SESSION_ID=%s\n", id)
+ return id, path, nil
+ }
+ }
+
+ // 2. PID-based lookup (works when caller is inside a session cgroup)
+ if id, path, err := m.getSessionByPID(uint32(os.Getpid())); err == nil {
+ fmt.Fprintf(os.Stderr, "loginctl: found session %s via PID\n", id)
+ return id, path, nil
+ }
+
+ // 3. User's primary display session (handles UWSM and similar)
+ if id, path, err := m.getUserDisplaySession(); err == nil {
+ fmt.Fprintf(os.Stderr, "loginctl: found session %s via User.Display\n", id)
+ return id, path, nil
+ }
+
+ // 4. Score all sessions for current UID
+ if id, path, err := m.findBestSession(); err == nil {
+ fmt.Fprintf(os.Stderr, "loginctl: found session %s via ListSessions scoring\n", id)
+ return id, path, nil
+ }
+
+ // 5. Last resort: "self"
+ path, err := m.getSession("self")
+ if err != nil {
+ return "", "", fmt.Errorf("%w", err)
+ }
+ return "self", path, nil
+}
+
+func (m *Manager) getSession(id string) (dbus.ObjectPath, error) {
+ var out dbus.ObjectPath
+ err := m.managerObj.Call(dbusManagerInterface+".GetSession", 0, id).Store(&out)
+ if err != nil {
+ return "", err
+ }
+ return out, nil
+}
+
+func (m *Manager) getSessionByPID(pid uint32) (string, dbus.ObjectPath, error) {
+ var path dbus.ObjectPath
+ if err := m.managerObj.Call(dbusManagerInterface+".GetSessionByPID", 0, pid).Store(&path); err != nil {
+ return "", "", err
+ }
+
+ sessionObj := m.conn.Object(dbusDest, path)
+ var id dbus.Variant
+ if err := sessionObj.Call(dbusPropsInterface+".Get", 0, dbusSessionInterface, "Id").Store(&id); err != nil {
+ return "", "", err
+ }
+ return id.Value().(string), path, nil
+}
+
+func (m *Manager) getUserDisplaySession() (string, dbus.ObjectPath, error) {
+ uid := uint32(os.Getuid())
+
+ var userPath dbus.ObjectPath
+ if err := m.managerObj.Call(dbusManagerInterface+".GetUser", 0, uid).Store(&userPath); err != nil {
+ return "", "", err
+ }
+
+ userObj := m.conn.Object(dbusDest, userPath)
+ var display dbus.Variant
+ if err := userObj.Call(dbusPropsInterface+".Get", 0, dbusUserInterface, "Display").Store(&display); err != nil {
+ return "", "", err
+ }
+
+ pair, ok := display.Value().([]any)
+ if !ok || len(pair) < 2 {
+ return "", "", fmt.Errorf("unexpected Display format")
+ }
+
+ sessionID, _ := pair[0].(string)
+ sessionPath, _ := pair[1].(dbus.ObjectPath)
+ if sessionID == "" || sessionPath == "" {
+ return "", "", fmt.Errorf("empty Display session")
+ }
+
+ return sessionID, sessionPath, nil
+}
+
+type sessionCandidate struct {
+ id string
+ path dbus.ObjectPath
+}
+
+func (m *Manager) findBestSession() (string, dbus.ObjectPath, error) {
+ // ListSessions returns a(susso): [][]any where each entry is [id, uid, name, seat, path]
+ var raw [][]any
+ if err := m.managerObj.Call(dbusManagerInterface+".ListSessions", 0).Store(&raw); err != nil {
+ return "", "", err
+ }
+
+ uid := uint32(os.Getuid())
+ var candidates []sessionCandidate
+ for _, entry := range raw {
+ if len(entry) < 5 {
+ continue
+ }
+ entryUID, _ := entry[1].(uint32)
+ if entryUID != uid {
+ continue
+ }
+ id, _ := entry[0].(string)
+ path, _ := entry[4].(dbus.ObjectPath)
+ if id != "" && path != "" {
+ candidates = append(candidates, sessionCandidate{id: id, path: path})
+ }
+ }
+ if len(candidates) == 0 {
+ return "", "", fmt.Errorf("no sessions for uid %d", uid)
+ }
+
+ bestScore := -1
+ var best sessionCandidate
+ for _, c := range candidates {
+ score := m.scoreSession(c.path)
+ if score > bestScore {
+ bestScore = score
+ best = c
+ }
+ }
+ if bestScore < 0 {
+ return "", "", fmt.Errorf("no viable session found")
+ }
+ return best.id, best.path, nil
+}
+
+func (m *Manager) scoreSession(path dbus.ObjectPath) int {
+ obj := m.conn.Object(dbusDest, path)
+ var props map[string]dbus.Variant
+ if err := obj.Call(dbusPropsInterface+".GetAll", 0, dbusSessionInterface).Store(&props); err != nil {
+ return -1
+ }
+
+ getStr := func(key string) string {
+ if v, ok := props[key]; ok {
+ if s, ok := v.Value().(string); ok {
+ return s
+ }
+ }
+ return ""
+ }
+ getBool := func(key string) bool {
+ if v, ok := props[key]; ok {
+ if b, ok := v.Value().(bool); ok {
+ return b
+ }
+ }
+ return false
+ }
+ getUint32 := func(key string) uint32 {
+ if v, ok := props[key]; ok {
+ if u, ok := v.Value().(uint32); ok {
+ return u
+ }
+ }
+ return 0
+ }
+
+ class := getStr("Class")
+ if class != "user" {
+ return -1
+ }
+ if getBool("Remote") {
+ return -1
+ }
+
+ score := 0
+
+ if getBool("Active") {
+ score += 100
+ }
+
+ switch getStr("Type") {
+ case "wayland", "x11":
+ score += 80
+ case "tty":
+ score += 10
+ }
+
+ if v, ok := props["Seat"]; ok {
+ if seatArr, ok := v.Value().([]any); ok && len(seatArr) >= 1 {
+ if seat, ok := seatArr[0].(string); ok && seat != "" {
+ score += 40
+ if seat == "seat0" {
+ score += 10
+ }
+ }
+ }
+ }
+
+ if getUint32("VTNr") > 0 {
+ score += 20
+ }
+
+ return score
+}
+
+func (m *Manager) refreshSessionBinding() error {
+ if m.managerObj == nil || m.conn == nil {
+ return fmt.Errorf("manager not fully initialized")
+ }
+
+ sessionPath, err := m.getSession(m.state.SessionID)
+ if err != nil {
+ return fmt.Errorf("failed to get session path: %w", err)
+ }
+
+ m.stateMutex.RLock()
+ currentPath := m.sessionPath
+ m.stateMutex.RUnlock()
+
+ if sessionPath == currentPath {
+ return nil
+ }
+
+ m.stopSignalPump()
+
+ m.stateMutex.Lock()
+ m.state.SessionPath = string(sessionPath)
+ m.sessionPath = sessionPath
+ m.stateMutex.Unlock()
+
+ m.sessionObj = m.conn.Object(dbusDest, sessionPath)
+
+ if err := m.updateSessionState(); err != nil {
+ return err
+ }
+
+ m.signals = make(chan *dbus.Signal, 256)
+ return m.startSignalPump()
+}
+
+func (m *Manager) updateSessionState() error {
+ ctx := context.Background()
+ props, err := m.getSessionProperties(ctx)
+ if err != nil {
+ return err
+ }
+
+ m.stateMutex.Lock()
+ defer m.stateMutex.Unlock()
+
+ m.state.Active = dbusutil.GetOr(props, "Active", m.state.Active)
+ m.state.IdleHint = dbusutil.GetOr(props, "IdleHint", m.state.IdleHint)
+ m.state.IdleSinceHint = dbusutil.GetOr(props, "IdleSinceHint", m.state.IdleSinceHint)
+ if lockedHint, ok := dbusutil.Get[bool](props, "LockedHint"); ok {
+ m.state.LockedHint = lockedHint
+ m.state.Locked = lockedHint
+ }
+ m.state.SessionType = dbusutil.GetOr(props, "Type", m.state.SessionType)
+ m.state.SessionClass = dbusutil.GetOr(props, "Class", m.state.SessionClass)
+ if v, ok := props["User"]; ok {
+ if userArr, ok := v.Value().([]any); ok && len(userArr) >= 1 {
+ if uid, ok := userArr[0].(uint32); ok {
+ m.state.User = uid
+ }
+ }
+ }
+ m.state.UserName = dbusutil.GetOr(props, "Name", m.state.UserName)
+ m.state.RemoteHost = dbusutil.GetOr(props, "RemoteHost", m.state.RemoteHost)
+ m.state.Service = dbusutil.GetOr(props, "Service", m.state.Service)
+ m.state.TTY = dbusutil.GetOr(props, "TTY", m.state.TTY)
+ m.state.Display = dbusutil.GetOr(props, "Display", m.state.Display)
+ m.state.Remote = dbusutil.GetOr(props, "Remote", m.state.Remote)
+ if v, ok := props["Seat"]; ok {
+ if seatArr, ok := v.Value().([]any); ok && len(seatArr) >= 1 {
+ if seatID, ok := seatArr[0].(string); ok {
+ m.state.Seat = seatID
+ }
+ }
+ }
+ m.state.VTNr = dbusutil.GetOr(props, "VTNr", m.state.VTNr)
+
+ return nil
+}
+
+func (m *Manager) getSessionProperties(ctx context.Context) (map[string]dbus.Variant, error) {
+ var props map[string]dbus.Variant
+ err := m.sessionObj.CallWithContext(ctx, dbusPropsInterface+".GetAll", 0, dbusSessionInterface).Store(&props)
+ if err != nil {
+ return nil, err
+ }
+ return props, nil
+}
+
+func (m *Manager) acquireSleepInhibitor() error {
+ if !m.sleepInhibitorEnabled.Load() {
+ return nil
+ }
+
+ m.inhibitMu.Lock()
+ defer m.inhibitMu.Unlock()
+
+ if m.inhibitFile != nil {
+ return nil
+ }
+
+ if m.managerObj == nil {
+ return fmt.Errorf("manager object not available")
+ }
+
+ file, err := m.inhibit("sleep", "DankMaterialShell", "Lock before suspend", "delay")
+ if err != nil {
+ return err
+ }
+
+ m.inhibitFile = file
+ return nil
+}
+
+func (m *Manager) inhibit(what, who, why, mode string) (*os.File, error) {
+ var fd dbus.UnixFD
+ err := m.managerObj.Call(dbusManagerInterface+".Inhibit", 0, what, who, why, mode).Store(&fd)
+ if err != nil {
+ return nil, err
+ }
+ return os.NewFile(uintptr(fd), "inhibit"), nil
+}
+
+func (m *Manager) releaseSleepInhibitor() {
+ m.inhibitMu.Lock()
+ f := m.inhibitFile
+ m.inhibitFile = nil
+ m.inhibitMu.Unlock()
+ if f != nil {
+ f.Close()
+ }
+}
+
+func (m *Manager) releaseForCycle(id uint64) {
+ if !m.inSleepCycle.Load() || m.sleepCycleID.Load() != id {
+ return
+ }
+ m.releaseSleepInhibitor()
+}
+
+func (m *Manager) initializeFallbackDelay() {
+ var maxDelayUSec uint64
+ err := m.managerObj.Call(
+ dbusPropsInterface+".Get",
+ 0,
+ dbusManagerInterface,
+ "InhibitDelayMaxUSec",
+ ).Store(&maxDelayUSec)
+
+ if err != nil {
+ m.fallbackDelay = 2 * time.Second
+ return
+ }
+
+ maxDelay := time.Duration(maxDelayUSec) * time.Microsecond
+ computed := (maxDelay * 8) / 10
+
+ if computed < 2*time.Second {
+ m.fallbackDelay = 2 * time.Second
+ } else if computed > 4*time.Second {
+ m.fallbackDelay = 4 * time.Second
+ } else {
+ m.fallbackDelay = computed
+ }
+}
+
+func (m *Manager) newLockerReadyCh() chan struct{} {
+ m.lockerReadyChMu.Lock()
+ defer m.lockerReadyChMu.Unlock()
+ m.lockerReadyCh = make(chan struct{})
+ return m.lockerReadyCh
+}
+
+func (m *Manager) signalLockerReady() {
+ m.lockerReadyChMu.Lock()
+ ch := m.lockerReadyCh
+ if ch != nil {
+ close(ch)
+ m.lockerReadyCh = nil
+ }
+ m.lockerReadyChMu.Unlock()
+}
+
+func (m *Manager) snapshotState() SessionState {
+ m.stateMutex.RLock()
+ defer m.stateMutex.RUnlock()
+ return *m.state
+}
+
+func stateChangedMeaningfully(old, new *SessionState) bool {
+ if old.Locked != new.Locked {
+ return true
+ }
+ if old.LockedHint != new.LockedHint {
+ return true
+ }
+ if old.Active != new.Active {
+ return true
+ }
+ if old.IdleHint != new.IdleHint {
+ return true
+ }
+ if old.PreparingForSleep != new.PreparingForSleep {
+ return true
+ }
+ return false
+}
+
+func (m *Manager) GetState() SessionState {
+ return m.snapshotState()
+}
+
+func (m *Manager) Subscribe(id string) chan SessionState {
+ ch := make(chan SessionState, 64)
+ m.subscribers.Store(id, ch)
+ return ch
+}
+
+func (m *Manager) Unsubscribe(id string) {
+ if val, ok := m.subscribers.LoadAndDelete(id); ok {
+ close(val)
+ }
+}
+
+func (m *Manager) notifier() {
+ defer m.notifierWg.Done()
+ const minGap = 100 * time.Millisecond
+ timer := time.NewTimer(minGap)
+ timer.Stop()
+ var pending bool
+ for {
+ select {
+ case <-m.stopChan:
+ timer.Stop()
+ return
+ case <-m.dirty:
+ if pending {
+ continue
+ }
+ pending = true
+ timer.Reset(minGap)
+ case <-timer.C:
+ if !pending {
+ continue
+ }
+
+ currentState := m.snapshotState()
+
+ if m.lastNotifiedState != nil && !stateChangedMeaningfully(m.lastNotifiedState, &currentState) {
+ pending = false
+ continue
+ }
+
+ m.subscribers.Range(func(key string, ch chan SessionState) bool {
+ select {
+ case ch <- currentState:
+ default:
+ }
+ return true
+ })
+
+ stateCopy := currentState
+ m.lastNotifiedState = &stateCopy
+ pending = false
+ }
+ }
+}
+
+func (m *Manager) notifySubscribers() {
+ select {
+ case m.dirty <- struct{}{}:
+ default:
+ }
+}
+
+func (m *Manager) startSignalPump() error {
+ m.conn.Signal(m.signals)
+
+ if err := m.conn.AddMatchSignal(
+ dbus.WithMatchObjectPath(m.sessionPath),
+ dbus.WithMatchInterface(dbusPropsInterface),
+ dbus.WithMatchMember("PropertiesChanged"),
+ ); err != nil {
+ m.conn.RemoveSignal(m.signals)
+ return err
+ }
+ if err := m.conn.AddMatchSignal(
+ dbus.WithMatchObjectPath(m.sessionPath),
+ dbus.WithMatchInterface(dbusSessionInterface),
+ dbus.WithMatchMember("Lock"),
+ ); err != nil {
+ m.conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(m.sessionPath),
+ dbus.WithMatchInterface(dbusPropsInterface),
+ dbus.WithMatchMember("PropertiesChanged"),
+ )
+ m.conn.RemoveSignal(m.signals)
+ return err
+ }
+ if err := m.conn.AddMatchSignal(
+ dbus.WithMatchObjectPath(m.sessionPath),
+ dbus.WithMatchInterface(dbusSessionInterface),
+ dbus.WithMatchMember("Unlock"),
+ ); err != nil {
+ m.conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(m.sessionPath),
+ dbus.WithMatchInterface(dbusPropsInterface),
+ dbus.WithMatchMember("PropertiesChanged"),
+ )
+ m.conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(m.sessionPath),
+ dbus.WithMatchInterface(dbusSessionInterface),
+ dbus.WithMatchMember("Lock"),
+ )
+ m.conn.RemoveSignal(m.signals)
+ return err
+ }
+ if err := m.conn.AddMatchSignal(
+ dbus.WithMatchObjectPath(dbus.ObjectPath(dbusPath)),
+ dbus.WithMatchInterface(dbusManagerInterface),
+ dbus.WithMatchMember("PrepareForSleep"),
+ ); err != nil {
+ m.conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(m.sessionPath),
+ dbus.WithMatchInterface(dbusPropsInterface),
+ dbus.WithMatchMember("PropertiesChanged"),
+ )
+ m.conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(m.sessionPath),
+ dbus.WithMatchInterface(dbusSessionInterface),
+ dbus.WithMatchMember("Lock"),
+ )
+ m.conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(m.sessionPath),
+ dbus.WithMatchInterface(dbusSessionInterface),
+ dbus.WithMatchMember("Unlock"),
+ )
+ m.conn.RemoveSignal(m.signals)
+ return err
+ }
+
+ if err := m.conn.AddMatchSignal(
+ dbus.WithMatchObjectPath("/org/freedesktop/DBus"),
+ dbus.WithMatchInterface("org.freedesktop.DBus"),
+ dbus.WithMatchMember("NameOwnerChanged"),
+ ); err != nil {
+ m.conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(m.sessionPath),
+ dbus.WithMatchInterface(dbusPropsInterface),
+ dbus.WithMatchMember("PropertiesChanged"),
+ )
+ m.conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(m.sessionPath),
+ dbus.WithMatchInterface(dbusSessionInterface),
+ dbus.WithMatchMember("Lock"),
+ )
+ m.conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(m.sessionPath),
+ dbus.WithMatchInterface(dbusSessionInterface),
+ dbus.WithMatchMember("Unlock"),
+ )
+ m.conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(dbus.ObjectPath(dbusPath)),
+ dbus.WithMatchInterface(dbusManagerInterface),
+ dbus.WithMatchMember("PrepareForSleep"),
+ )
+ m.conn.RemoveSignal(m.signals)
+ return err
+ }
+
+ m.sigWG.Add(1)
+ go func() {
+ defer m.sigWG.Done()
+ for {
+ select {
+ case <-m.stopChan:
+ return
+ case sig, ok := <-m.signals:
+ if !ok {
+ return
+ }
+ if sig == nil {
+ continue
+ }
+ m.handleDBusSignal(sig)
+ }
+ }
+ }()
+ return nil
+}
+
+func (m *Manager) stopSignalPump() {
+ if m.conn == nil {
+ return
+ }
+ m.conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(m.sessionPath),
+ dbus.WithMatchInterface(dbusPropsInterface),
+ dbus.WithMatchMember("PropertiesChanged"),
+ )
+ m.conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(m.sessionPath),
+ dbus.WithMatchInterface(dbusSessionInterface),
+ dbus.WithMatchMember("Lock"),
+ )
+ m.conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(m.sessionPath),
+ dbus.WithMatchInterface(dbusSessionInterface),
+ dbus.WithMatchMember("Unlock"),
+ )
+ m.conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(dbus.ObjectPath(dbusPath)),
+ dbus.WithMatchInterface(dbusManagerInterface),
+ dbus.WithMatchMember("PrepareForSleep"),
+ )
+ m.conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath("/org/freedesktop/DBus"),
+ dbus.WithMatchInterface("org.freedesktop.DBus"),
+ dbus.WithMatchMember("NameOwnerChanged"),
+ )
+
+ m.conn.RemoveSignal(m.signals)
+ close(m.signals)
+
+ m.sigWG.Wait()
+}
+
+func (m *Manager) Close() {
+ close(m.stopChan)
+ m.notifierWg.Wait()
+
+ m.stopSignalPump()
+
+ m.releaseSleepInhibitor()
+
+ m.subscribers.Range(func(key string, ch chan SessionState) bool {
+ close(ch)
+ m.subscribers.Delete(key)
+ return true
+ })
+
+ if m.conn != nil {
+ m.conn.Close()
+ }
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/manager_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/manager_test.go
new file mode 100644
index 0000000..148ee60
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/manager_test.go
@@ -0,0 +1,296 @@
+package loginctl
+
+import (
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestManager_GetState(t *testing.T) {
+ state := &SessionState{
+ SessionID: "1",
+ Locked: false,
+ Active: true,
+ IdleHint: false,
+ SessionType: "wayland",
+ SessionClass: "user",
+ UserName: "testuser",
+ }
+
+ manager := &Manager{
+ state: state,
+ stateMutex: sync.RWMutex{},
+ }
+
+ result := manager.GetState()
+ assert.Equal(t, "1", result.SessionID)
+ assert.False(t, result.Locked)
+ assert.True(t, result.Active)
+ assert.Equal(t, "wayland", result.SessionType)
+ assert.Equal(t, "testuser", result.UserName)
+}
+
+func TestManager_Subscribe(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{},
+ }
+
+ ch := manager.Subscribe("test-client")
+ assert.NotNil(t, ch)
+ assert.Equal(t, 64, cap(ch))
+
+ _, exists := manager.subscribers.Load("test-client")
+ assert.True(t, exists)
+}
+
+func TestManager_Unsubscribe(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{},
+ }
+
+ ch := manager.Subscribe("test-client")
+
+ manager.Unsubscribe("test-client")
+
+ _, ok := <-ch
+ assert.False(t, ok)
+
+ _, exists := manager.subscribers.Load("test-client")
+ assert.False(t, exists)
+}
+
+func TestManager_Unsubscribe_NonExistent(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{},
+ }
+
+ // Unsubscribe a non-existent client should not panic
+ assert.NotPanics(t, func() {
+ manager.Unsubscribe("non-existent")
+ })
+}
+
+func TestManager_NotifySubscribers(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ SessionID: "1",
+ Locked: false,
+ },
+ stateMutex: sync.RWMutex{},
+ stopChan: make(chan struct{}),
+ dirty: make(chan struct{}, 1),
+ }
+ manager.notifierWg.Add(1)
+ go manager.notifier()
+
+ ch := make(chan SessionState, 10)
+ manager.subscribers.Store("test-client", ch)
+
+ manager.notifySubscribers()
+
+ select {
+ case state := <-ch:
+ assert.Equal(t, "1", state.SessionID)
+ assert.False(t, state.Locked)
+ case <-time.After(200 * time.Millisecond):
+ t.Fatal("did not receive state update")
+ }
+
+ close(manager.stopChan)
+ manager.notifierWg.Wait()
+}
+
+func TestManager_NotifySubscribers_Debounce(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ SessionID: "1",
+ Locked: false,
+ },
+ stateMutex: sync.RWMutex{},
+ stopChan: make(chan struct{}),
+ dirty: make(chan struct{}, 1),
+ }
+ manager.notifierWg.Add(1)
+ go manager.notifier()
+
+ ch := make(chan SessionState, 10)
+ manager.subscribers.Store("test-client", ch)
+
+ manager.notifySubscribers()
+ manager.notifySubscribers()
+ manager.notifySubscribers()
+
+ receivedCount := 0
+ timeout := time.After(200 * time.Millisecond)
+ for {
+ select {
+ case <-ch:
+ receivedCount++
+ case <-timeout:
+ assert.Equal(t, 1, receivedCount, "should receive exactly one debounced update")
+ close(manager.stopChan)
+ manager.notifierWg.Wait()
+ return
+ }
+ }
+}
+
+func TestManager_Close(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{},
+ stateMutex: sync.RWMutex{},
+ stopChan: make(chan struct{}),
+ }
+
+ ch1 := make(chan SessionState, 1)
+ ch2 := make(chan SessionState, 1)
+ manager.subscribers.Store("client1", ch1)
+ manager.subscribers.Store("client2", ch2)
+
+ manager.Close()
+
+ select {
+ case <-manager.stopChan:
+ case <-time.After(100 * time.Millisecond):
+ t.Fatal("stopChan not closed")
+ }
+
+ _, ok1 := <-ch1
+ _, ok2 := <-ch2
+ assert.False(t, ok1, "ch1 should be closed")
+ assert.False(t, ok2, "ch2 should be closed")
+
+ count := 0
+ manager.subscribers.Range(func(key string, ch chan SessionState) bool {
+ count++
+ return true
+ })
+ assert.Equal(t, 0, count)
+}
+
+func TestManager_GetState_ThreadSafe(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ SessionID: "1",
+ Locked: false,
+ Active: true,
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ done := make(chan bool)
+ for i := 0; i < 10; i++ {
+ go func() {
+ state := manager.GetState()
+ assert.Equal(t, "1", state.SessionID)
+ assert.True(t, state.Active)
+ done <- true
+ }()
+ }
+
+ for i := 0; i < 10; i++ {
+ select {
+ case <-done:
+ case <-time.After(1 * time.Second):
+ t.Fatal("timeout waiting for goroutines")
+ }
+ }
+}
+
+func TestStateChangedMeaningfully(t *testing.T) {
+ tests := []struct {
+ name string
+ old *SessionState
+ new *SessionState
+ expected bool
+ }{
+ {
+ name: "no change",
+ old: &SessionState{Locked: false, Active: true, IdleHint: false},
+ new: &SessionState{Locked: false, Active: true, IdleHint: false},
+ expected: false,
+ },
+ {
+ name: "locked changed",
+ old: &SessionState{Locked: false, Active: true, IdleHint: false},
+ new: &SessionState{Locked: true, Active: true, IdleHint: false},
+ expected: true,
+ },
+ {
+ name: "active changed",
+ old: &SessionState{Locked: false, Active: true, IdleHint: false},
+ new: &SessionState{Locked: false, Active: false, IdleHint: false},
+ expected: true,
+ },
+ {
+ name: "idle hint changed",
+ old: &SessionState{Locked: false, Active: true, IdleHint: false},
+ new: &SessionState{Locked: false, Active: true, IdleHint: true},
+ expected: true,
+ },
+ {
+ name: "locked hint changed",
+ old: &SessionState{Locked: false, Active: true, LockedHint: false},
+ new: &SessionState{Locked: false, Active: true, LockedHint: true},
+ expected: true,
+ },
+ {
+ name: "preparing for sleep changed",
+ old: &SessionState{Locked: false, Active: true, PreparingForSleep: false},
+ new: &SessionState{Locked: false, Active: true, PreparingForSleep: true},
+ expected: true,
+ },
+ {
+ name: "non-meaningful change (username)",
+ old: &SessionState{Locked: false, Active: true, UserName: "user1"},
+ new: &SessionState{Locked: false, Active: true, UserName: "user2"},
+ expected: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := stateChangedMeaningfully(tt.old, tt.new)
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
+
+func TestManager_SnapshotState(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ SessionID: "1",
+ Locked: false,
+ Active: true,
+ UserName: "testuser",
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ snapshot := manager.snapshotState()
+ assert.Equal(t, "1", snapshot.SessionID)
+ assert.False(t, snapshot.Locked)
+ assert.True(t, snapshot.Active)
+ assert.Equal(t, "testuser", snapshot.UserName)
+
+ snapshot.Locked = true
+ assert.False(t, manager.state.Locked)
+}
+
+func TestNewManager(t *testing.T) {
+ t.Run("attempts to create manager", func(t *testing.T) {
+ manager, err := NewManager()
+ if err != nil {
+ assert.Nil(t, manager)
+ } else {
+ assert.NotNil(t, manager)
+ assert.NotNil(t, manager.state)
+ assert.NotNil(t, &manager.subscribers)
+ assert.NotNil(t, manager.stopChan)
+
+ manager.Close()
+ }
+ })
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/monitor.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/monitor.go
new file mode 100644
index 0000000..4c0605a
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/monitor.go
@@ -0,0 +1,155 @@
+package loginctl
+
+import (
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/dbusutil"
+ "github.com/godbus/dbus/v5"
+)
+
+func (m *Manager) handleDBusSignal(sig *dbus.Signal) {
+ switch sig.Name {
+ case dbusSessionInterface + ".Lock":
+ m.stateMutex.Lock()
+ m.state.Locked = true
+ m.state.LockedHint = true
+ m.stateMutex.Unlock()
+ m.notifySubscribers()
+
+ if m.sleepInhibitorEnabled.Load() && m.inSleepCycle.Load() {
+ id := m.sleepCycleID.Load()
+ m.lockTimerMu.Lock()
+ if m.lockTimer != nil {
+ m.lockTimer.Stop()
+ }
+ m.lockTimer = time.AfterFunc(m.fallbackDelay, func() {
+ m.releaseForCycle(id)
+ })
+ m.lockTimerMu.Unlock()
+ }
+
+ case dbusSessionInterface + ".Unlock":
+ m.stateMutex.Lock()
+ m.state.Locked = false
+ m.state.LockedHint = false
+ m.stateMutex.Unlock()
+ m.notifySubscribers()
+
+ // Cancel the lock timer if it's still running
+ m.lockTimerMu.Lock()
+ if m.lockTimer != nil {
+ m.lockTimer.Stop()
+ m.lockTimer = nil
+ }
+ m.lockTimerMu.Unlock()
+
+ // Re-acquire the sleep inhibitor (acquireSleepInhibitor checks the enabled flag)
+ m.acquireSleepInhibitor()
+
+ case dbusManagerInterface + ".PrepareForSleep":
+ if len(sig.Body) == 0 {
+ return
+ }
+ preparing, _ := sig.Body[0].(bool)
+
+ if preparing {
+ cycleID := m.sleepCycleID.Add(1)
+ m.inSleepCycle.Store(true)
+
+ if m.lockBeforeSuspend.Load() {
+ m.Lock()
+ }
+
+ readyCh := m.newLockerReadyCh()
+ go func(id uint64, ch <-chan struct{}) {
+ <-ch
+ if m.inSleepCycle.Load() && m.sleepCycleID.Load() == id {
+ m.releaseSleepInhibitor()
+ }
+ }(cycleID, readyCh)
+ } else {
+ m.inSleepCycle.Store(false)
+ m.signalLockerReady()
+ m.refreshSessionBinding()
+ m.acquireSleepInhibitor()
+ }
+
+ m.stateMutex.Lock()
+ m.state.PreparingForSleep = preparing
+ m.stateMutex.Unlock()
+ m.notifySubscribers()
+
+ case dbusPropsInterface + ".PropertiesChanged":
+ m.handlePropertiesChanged(sig)
+
+ case "org.freedesktop.DBus.NameOwnerChanged":
+ if len(sig.Body) == 3 {
+ name, _ := sig.Body[0].(string)
+ oldOwner, _ := sig.Body[1].(string)
+ newOwner, _ := sig.Body[2].(string)
+ if name == dbusDest && oldOwner != "" && newOwner != "" {
+ m.updateSessionState()
+ if !m.inSleepCycle.Load() {
+ m.acquireSleepInhibitor()
+ }
+ m.notifySubscribers()
+ }
+ }
+ }
+}
+
+func (m *Manager) handlePropertiesChanged(sig *dbus.Signal) {
+ if len(sig.Body) < 2 {
+ return
+ }
+
+ iface, ok := sig.Body[0].(string)
+ if !ok || iface != dbusSessionInterface {
+ return
+ }
+
+ changes, ok := sig.Body[1].(map[string]dbus.Variant)
+ if !ok {
+ return
+ }
+
+ var needsUpdate bool
+
+ for key, variant := range changes {
+ switch key {
+ case "Active":
+ if val, ok := dbusutil.As[bool](variant); ok {
+ m.stateMutex.Lock()
+ m.state.Active = val
+ m.stateMutex.Unlock()
+ needsUpdate = true
+ }
+ case "IdleHint":
+ if val, ok := dbusutil.As[bool](variant); ok {
+ m.stateMutex.Lock()
+ m.state.IdleHint = val
+ m.stateMutex.Unlock()
+ needsUpdate = true
+ }
+ case "IdleSinceHint":
+ if val, ok := dbusutil.As[uint64](variant); ok {
+ m.stateMutex.Lock()
+ m.state.IdleSinceHint = val
+ m.stateMutex.Unlock()
+ needsUpdate = true
+ }
+ case "LockedHint":
+ if val, ok := dbusutil.As[bool](variant); ok {
+ m.stateMutex.Lock()
+ m.state.LockedHint = val
+ m.state.Locked = val
+ m.stateMutex.Unlock()
+ needsUpdate = true
+ }
+ }
+ }
+
+ if needsUpdate {
+ m.notifySubscribers()
+ }
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/monitor_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/monitor_test.go
new file mode 100644
index 0000000..ecbb57f
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/monitor_test.go
@@ -0,0 +1,298 @@
+package loginctl
+
+import (
+ "sync"
+ "testing"
+
+ "github.com/godbus/dbus/v5"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestManager_HandleDBusSignal_Lock(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ Locked: false,
+ LockedHint: false,
+ },
+ stateMutex: sync.RWMutex{},
+ dirty: make(chan struct{}, 1),
+ }
+
+ sig := &dbus.Signal{
+ Name: "org.freedesktop.login1.Session.Lock",
+ }
+
+ manager.handleDBusSignal(sig)
+
+ manager.stateMutex.RLock()
+ defer manager.stateMutex.RUnlock()
+ assert.True(t, manager.state.Locked)
+ assert.True(t, manager.state.LockedHint)
+}
+
+func TestManager_HandleDBusSignal_Unlock(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ Locked: true,
+ LockedHint: true,
+ },
+ stateMutex: sync.RWMutex{},
+ dirty: make(chan struct{}, 1),
+ }
+
+ sig := &dbus.Signal{
+ Name: "org.freedesktop.login1.Session.Unlock",
+ }
+
+ manager.handleDBusSignal(sig)
+
+ manager.stateMutex.RLock()
+ defer manager.stateMutex.RUnlock()
+ assert.False(t, manager.state.Locked)
+ assert.False(t, manager.state.LockedHint)
+}
+
+func TestManager_HandleDBusSignal_PrepareForSleep(t *testing.T) {
+ t.Run("preparing for sleep - true", func(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ PreparingForSleep: false,
+ },
+ stateMutex: sync.RWMutex{},
+ dirty: make(chan struct{}, 1),
+ }
+
+ sig := &dbus.Signal{
+ Name: "org.freedesktop.login1.Manager.PrepareForSleep",
+ Body: []any{true},
+ }
+
+ manager.handleDBusSignal(sig)
+
+ manager.stateMutex.RLock()
+ defer manager.stateMutex.RUnlock()
+ assert.True(t, manager.state.PreparingForSleep)
+ })
+
+ t.Run("preparing for sleep - false", func(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ PreparingForSleep: true,
+ },
+ stateMutex: sync.RWMutex{},
+ dirty: make(chan struct{}, 1),
+ }
+
+ sig := &dbus.Signal{
+ Name: "org.freedesktop.login1.Manager.PrepareForSleep",
+ Body: []any{false},
+ }
+
+ manager.handleDBusSignal(sig)
+
+ manager.stateMutex.RLock()
+ defer manager.stateMutex.RUnlock()
+ assert.False(t, manager.state.PreparingForSleep)
+ })
+
+ t.Run("empty body", func(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ PreparingForSleep: false,
+ },
+ stateMutex: sync.RWMutex{},
+ dirty: make(chan struct{}, 1),
+ }
+
+ sig := &dbus.Signal{
+ Name: "org.freedesktop.login1.Manager.PrepareForSleep",
+ Body: []any{},
+ }
+
+ manager.handleDBusSignal(sig)
+
+ manager.stateMutex.RLock()
+ defer manager.stateMutex.RUnlock()
+ assert.False(t, manager.state.PreparingForSleep)
+ })
+}
+
+func TestManager_HandlePropertiesChanged(t *testing.T) {
+ t.Run("active property changed", func(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ Active: false,
+ },
+ stateMutex: sync.RWMutex{},
+ dirty: make(chan struct{}, 1),
+ }
+
+ sig := &dbus.Signal{
+ Name: "org.freedesktop.DBus.Properties.PropertiesChanged",
+ Body: []any{
+ "org.freedesktop.login1.Session",
+ map[string]dbus.Variant{
+ "Active": dbus.MakeVariant(true),
+ },
+ },
+ }
+
+ manager.handlePropertiesChanged(sig)
+
+ manager.stateMutex.RLock()
+ defer manager.stateMutex.RUnlock()
+ assert.True(t, manager.state.Active)
+ })
+
+ t.Run("idle hint property changed", func(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ IdleHint: false,
+ },
+ stateMutex: sync.RWMutex{},
+ dirty: make(chan struct{}, 1),
+ }
+
+ sig := &dbus.Signal{
+ Name: "org.freedesktop.DBus.Properties.PropertiesChanged",
+ Body: []any{
+ "org.freedesktop.login1.Session",
+ map[string]dbus.Variant{
+ "IdleHint": dbus.MakeVariant(true),
+ },
+ },
+ }
+
+ manager.handlePropertiesChanged(sig)
+
+ manager.stateMutex.RLock()
+ defer manager.stateMutex.RUnlock()
+ assert.True(t, manager.state.IdleHint)
+ })
+
+ t.Run("idle since hint property changed", func(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ IdleSinceHint: 0,
+ },
+ stateMutex: sync.RWMutex{},
+ dirty: make(chan struct{}, 1),
+ }
+
+ sig := &dbus.Signal{
+ Name: "org.freedesktop.DBus.Properties.PropertiesChanged",
+ Body: []any{
+ "org.freedesktop.login1.Session",
+ map[string]dbus.Variant{
+ "IdleSinceHint": dbus.MakeVariant(uint64(123456789)),
+ },
+ },
+ }
+
+ manager.handlePropertiesChanged(sig)
+
+ manager.stateMutex.RLock()
+ defer manager.stateMutex.RUnlock()
+ assert.Equal(t, uint64(123456789), manager.state.IdleSinceHint)
+ })
+
+ t.Run("locked hint property changed", func(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ LockedHint: false,
+ Locked: false,
+ },
+ stateMutex: sync.RWMutex{},
+ dirty: make(chan struct{}, 1),
+ }
+
+ sig := &dbus.Signal{
+ Name: "org.freedesktop.DBus.Properties.PropertiesChanged",
+ Body: []any{
+ "org.freedesktop.login1.Session",
+ map[string]dbus.Variant{
+ "LockedHint": dbus.MakeVariant(true),
+ },
+ },
+ }
+
+ manager.handlePropertiesChanged(sig)
+
+ manager.stateMutex.RLock()
+ defer manager.stateMutex.RUnlock()
+ assert.True(t, manager.state.LockedHint)
+ assert.True(t, manager.state.Locked)
+ })
+
+ t.Run("wrong interface", func(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ Active: false,
+ },
+ stateMutex: sync.RWMutex{},
+ dirty: make(chan struct{}, 1),
+ }
+
+ sig := &dbus.Signal{
+ Name: "org.freedesktop.DBus.Properties.PropertiesChanged",
+ Body: []any{
+ "org.freedesktop.SomeOtherInterface",
+ map[string]dbus.Variant{
+ "Active": dbus.MakeVariant(true),
+ },
+ },
+ }
+
+ manager.handlePropertiesChanged(sig)
+
+ manager.stateMutex.RLock()
+ defer manager.stateMutex.RUnlock()
+ assert.False(t, manager.state.Active)
+ })
+
+ t.Run("empty body", func(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{},
+ stateMutex: sync.RWMutex{},
+ dirty: make(chan struct{}, 1),
+ }
+
+ sig := &dbus.Signal{
+ Name: "org.freedesktop.DBus.Properties.PropertiesChanged",
+ Body: []any{},
+ }
+
+ assert.NotPanics(t, func() {
+ manager.handlePropertiesChanged(sig)
+ })
+ })
+
+ t.Run("multiple properties changed", func(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ Active: false,
+ IdleHint: false,
+ },
+ stateMutex: sync.RWMutex{},
+ dirty: make(chan struct{}, 1),
+ }
+
+ sig := &dbus.Signal{
+ Name: "org.freedesktop.DBus.Properties.PropertiesChanged",
+ Body: []any{
+ "org.freedesktop.login1.Session",
+ map[string]dbus.Variant{
+ "Active": dbus.MakeVariant(true),
+ "IdleHint": dbus.MakeVariant(true),
+ },
+ },
+ }
+
+ manager.handlePropertiesChanged(sig)
+
+ manager.stateMutex.RLock()
+ defer manager.stateMutex.RUnlock()
+ assert.True(t, manager.state.Active)
+ assert.True(t, manager.state.IdleHint)
+ })
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/types.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/types.go
new file mode 100644
index 0000000..76c7414
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/types.go
@@ -0,0 +1,76 @@
+package loginctl
+
+import (
+ "os"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
+ "github.com/godbus/dbus/v5"
+)
+
+type SessionState struct {
+ SessionID string `json:"sessionId"`
+ SessionPath string `json:"sessionPath"`
+ Locked bool `json:"locked"`
+ Active bool `json:"active"`
+ IdleHint bool `json:"idleHint"`
+ IdleSinceHint uint64 `json:"idleSinceHint"`
+ LockedHint bool `json:"lockedHint"`
+ SessionType string `json:"sessionType"`
+ SessionClass string `json:"sessionClass"`
+ User uint32 `json:"user"`
+ UserName string `json:"userName"`
+ RemoteHost string `json:"remoteHost"`
+ Service string `json:"service"`
+ TTY string `json:"tty"`
+ Display string `json:"display"`
+ Remote bool `json:"remote"`
+ Seat string `json:"seat"`
+ VTNr uint32 `json:"vtnr"`
+ PreparingForSleep bool `json:"preparingForSleep"`
+}
+
+type EventType string
+
+const (
+ EventStateChanged EventType = "state_changed"
+ EventLock EventType = "lock"
+ EventUnlock EventType = "unlock"
+ EventPrepareForSleep EventType = "prepare_for_sleep"
+ EventIdleHintChanged EventType = "idle_hint_changed"
+ EventLockedHintChanged EventType = "locked_hint_changed"
+)
+
+type SessionEvent struct {
+ Type EventType `json:"type"`
+ Data SessionState `json:"data"`
+}
+
+type Manager struct {
+ state *SessionState
+ stateMutex sync.RWMutex
+ subscribers syncmap.Map[string, chan SessionState]
+ stopChan chan struct{}
+ conn *dbus.Conn
+ sessionPath dbus.ObjectPath
+ managerObj dbus.BusObject
+ sessionObj dbus.BusObject
+ dirty chan struct{}
+ notifierWg sync.WaitGroup
+ lastNotifiedState *SessionState
+ signals chan *dbus.Signal
+ sigWG sync.WaitGroup
+ inhibitMu sync.Mutex
+ inhibitFile *os.File
+ lockBeforeSuspend atomic.Bool
+ inSleepCycle atomic.Bool
+ sleepCycleID atomic.Uint64
+ lockerReadyChMu sync.Mutex
+ lockerReadyCh chan struct{}
+ lockTimerMu sync.Mutex
+ lockTimer *time.Timer
+ sleepInhibitorEnabled atomic.Bool
+ fallbackDelay time.Duration
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/types_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/types_test.go
new file mode 100644
index 0000000..383300f
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/types_test.go
@@ -0,0 +1,50 @@
+package loginctl
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestEventType_Constants(t *testing.T) {
+ assert.Equal(t, EventType("state_changed"), EventStateChanged)
+ assert.Equal(t, EventType("lock"), EventLock)
+ assert.Equal(t, EventType("unlock"), EventUnlock)
+ assert.Equal(t, EventType("prepare_for_sleep"), EventPrepareForSleep)
+ assert.Equal(t, EventType("idle_hint_changed"), EventIdleHintChanged)
+ assert.Equal(t, EventType("locked_hint_changed"), EventLockedHintChanged)
+}
+
+func TestSessionState_Struct(t *testing.T) {
+ state := SessionState{
+ SessionID: "1",
+ Locked: false,
+ Active: true,
+ SessionType: "wayland",
+ User: 1000,
+ UserName: "testuser",
+ }
+
+ assert.Equal(t, "1", state.SessionID)
+ assert.True(t, state.Active)
+ assert.False(t, state.Locked)
+ assert.Equal(t, "wayland", state.SessionType)
+ assert.Equal(t, uint32(1000), state.User)
+ assert.Equal(t, "testuser", state.UserName)
+}
+
+func TestSessionEvent_Struct(t *testing.T) {
+ state := SessionState{
+ SessionID: "1",
+ Locked: true,
+ }
+
+ event := SessionEvent{
+ Type: EventLock,
+ Data: state,
+ }
+
+ assert.Equal(t, EventLock, event.Type)
+ assert.Equal(t, "1", event.Data.SessionID)
+ assert.True(t, event.Data.Locked)
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/matugen_handler.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/matugen_handler.go
new file mode 100644
index 0000000..73c6ad6
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/matugen_handler.go
@@ -0,0 +1,79 @@
+package server
+
+import (
+ "context"
+ "net"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/matugen"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+)
+
+type MatugenQueueResult struct {
+ Success bool `json:"success"`
+ Message string `json:"message,omitempty"`
+}
+
+func handleMatugenQueue(conn net.Conn, req models.Request) {
+ opts := matugen.Options{
+ StateDir: models.GetOr(req, "stateDir", ""),
+ ShellDir: models.GetOr(req, "shellDir", ""),
+ ConfigDir: models.GetOr(req, "configDir", ""),
+ Kind: models.GetOr(req, "kind", ""),
+ Value: models.GetOr(req, "value", ""),
+ Mode: matugen.ColorMode(models.GetOr(req, "mode", "")),
+ IconTheme: models.GetOr(req, "iconTheme", ""),
+ MatugenType: models.GetOr(req, "matugenType", ""),
+ RunUserTemplates: models.GetOr(req, "runUserTemplates", true),
+ StockColors: models.GetOr(req, "stockColors", ""),
+ SyncModeWithPortal: models.GetOr(req, "syncModeWithPortal", false),
+ TerminalsAlwaysDark: models.GetOr(req, "terminalsAlwaysDark", false),
+ SkipTemplates: models.GetOr(req, "skipTemplates", ""),
+ Contrast: models.GetOr(req, "contrast", 0.0),
+ }
+
+ wait := models.GetOr(req, "wait", true)
+
+ queue := matugen.GetQueue()
+ resultCh := queue.Submit(opts)
+
+ if !wait {
+ models.Respond(conn, req.ID, MatugenQueueResult{
+ Success: true,
+ Message: "queued",
+ })
+ return
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+ defer cancel()
+
+ select {
+ case result := <-resultCh:
+ if result.Error != nil {
+ if result.Error == context.Canceled {
+ models.Respond(conn, req.ID, MatugenQueueResult{
+ Success: false,
+ Message: "cancelled",
+ })
+ return
+ }
+ models.RespondError(conn, req.ID, result.Error.Error())
+ return
+ }
+ models.Respond(conn, req.ID, MatugenQueueResult{
+ Success: true,
+ Message: "completed",
+ })
+ case <-ctx.Done():
+ models.RespondError(conn, req.ID, "timeout waiting for theme generation")
+ }
+}
+
+func handleMatugenStatus(conn net.Conn, req models.Request) {
+ queue := matugen.GetQueue()
+ models.Respond(conn, req.ID, map[string]bool{
+ "running": queue.IsRunning(),
+ "pending": queue.HasPending(),
+ })
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/models/types.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/models/types.go
new file mode 100644
index 0000000..2933fb5
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/models/types.go
@@ -0,0 +1,47 @@
+package models
+
+import (
+ "encoding/json"
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/params"
+)
+
+type Request struct {
+ ID int `json:"id,omitempty"`
+ Method string `json:"method"`
+ Params map[string]any `json:"params,omitempty"`
+}
+
+func Get[T any](r Request, key string) (T, bool) {
+ v, err := params.Get[T](r.Params, key)
+ return v, err == nil
+}
+
+func GetOr[T any](r Request, key string, def T) T {
+ return params.GetOpt(r.Params, key, def)
+}
+
+type Response[T any] struct {
+ ID int `json:"id,omitempty"`
+ Result *T `json:"result,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+func RespondError(conn net.Conn, id int, errMsg string) {
+ log.Errorf("DMS API Error: id=%d error=%s", id, errMsg)
+ resp := Response[any]{ID: id, Error: errMsg}
+ json.NewEncoder(conn).Encode(resp)
+}
+
+func Respond[T any](conn net.Conn, id int, result T) {
+ resp := Response[T]{ID: id, Result: &result}
+ json.NewEncoder(conn).Encode(resp)
+}
+
+type SuccessResult struct {
+ Success bool `json:"success"`
+ Message string `json:"message,omitempty"`
+ Value string `json:"value,omitempty"`
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/models/types_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/models/types_test.go
new file mode 100644
index 0000000..98f9528
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/models/types_test.go
@@ -0,0 +1,52 @@
+package models
+
+import "testing"
+
+func TestGet(t *testing.T) {
+ req := Request{Params: map[string]any{"name": "test", "count": 42, "enabled": true}}
+
+ name, ok := Get[string](req, "name")
+ if !ok || name != "test" {
+ t.Errorf("Get[string] = %q, %v; want 'test', true", name, ok)
+ }
+
+ count, ok := Get[int](req, "count")
+ if !ok || count != 42 {
+ t.Errorf("Get[int] = %d, %v; want 42, true", count, ok)
+ }
+
+ enabled, ok := Get[bool](req, "enabled")
+ if !ok || !enabled {
+ t.Errorf("Get[bool] = %v, %v; want true, true", enabled, ok)
+ }
+
+ _, ok = Get[string](req, "missing")
+ if ok {
+ t.Error("Get missing key should return false")
+ }
+
+ _, ok = Get[int](req, "name")
+ if ok {
+ t.Error("Get wrong type should return false")
+ }
+}
+
+func TestGetOr(t *testing.T) {
+ req := Request{Params: map[string]any{"name": "test", "enabled": true}}
+
+ if v := GetOr(req, "name", "default"); v != "test" {
+ t.Errorf("GetOr existing = %q; want 'test'", v)
+ }
+
+ if v := GetOr(req, "missing", "default"); v != "default" {
+ t.Errorf("GetOr missing = %q; want 'default'", v)
+ }
+
+ if v := GetOr(req, "enabled", false); !v {
+ t.Errorf("GetOr bool = %v; want true", v)
+ }
+
+ if v := GetOr(req, "name", 0); v != 0 {
+ t.Errorf("GetOr wrong type = %d; want 0 (default)", v)
+ }
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/API.md b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/API.md
new file mode 100644
index 0000000..361ebac
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/API.md
@@ -0,0 +1,552 @@
+# NetworkManager API Documentation
+
+## Overview
+
+The network manager API provides methods for managing WiFi connections, monitoring network state, and handling credential prompts through NetworkManager or iwd (and systemd-networkd for ethernet only). Communication occurs over a message-based protocol (websocket, IPC, etc.) with event subscriptions for state updates.
+
+## API Methods
+
+### network.wifi.connect
+
+Initiate a WiFi connection.
+
+**Request:**
+```json
+{
+ "method": "network.wifi.connect",
+ "params": {
+ "ssid": "NetworkName",
+ "password": "optional-password",
+ "interactive": true
+ }
+}
+```
+
+**Parameters:**
+- `ssid` (string, required): Network SSID
+- `password` (string, optional): Pre-shared key for WPA/WPA2/WPA3 networks
+- `interactive` (boolean, optional): Enable credential prompting if authentication fails or password is missing. Automatically set to `true` when connecting to secured networks without providing a password.
+
+**Response:**
+```json
+{
+ "success": true,
+ "message": "connecting"
+}
+```
+
+**Behavior:**
+- Returns immediately; connection happens asynchronously
+- State updates delivered via `network` service subscription
+- Credential prompts delivered via `network.credentials` service subscription
+
+### network.credentials.submit
+
+Submit credentials in response to a prompt.
+
+**Request:**
+```json
+{
+ "method": "network.credentials.submit",
+ "params": {
+ "token": "correlation-token",
+ "secrets": {
+ "psk": "password"
+ },
+ "save": true
+ }
+}
+```
+
+**Parameters:**
+- `token` (string, required): Token from credential prompt
+- `secrets` (object, required): Key-value map of credential fields
+- `save` (boolean, optional): Whether to persist credentials (default: false)
+
+**Common secret fields:**
+- `psk`: Pre-shared key for WPA2/WPA3 personal networks
+- `identity`: Username for 802.1X enterprise networks
+- `password`: Password for 802.1X enterprise networks
+
+### network.credentials.cancel
+
+Cancel a credential prompt.
+
+**Request:**
+```json
+{
+ "method": "network.credentials.cancel",
+ "params": {
+ "token": "correlation-token"
+ }
+}
+```
+
+## Event Subscriptions
+
+### Subscribing to Events
+
+Subscribe to receive network state updates and credential prompts:
+
+```json
+{
+ "method": "subscribe",
+ "params": {
+ "services": ["network", "network.credentials"]
+ }
+}
+```
+
+Both services are required for full connection handling. Missing `network.credentials` means credential prompts won't be received.
+
+### network Service Events
+
+State updates are sent whenever network configuration changes:
+
+```json
+{
+ "service": "network",
+ "data": {
+ "networkStatus": "wifi",
+ "isConnecting": false,
+ "connectingSSID": "",
+ "wifiConnected": true,
+ "wifiSSID": "MyNetwork",
+ "wifiIP": "192.168.1.100",
+ "lastError": ""
+ }
+}
+```
+
+**State fields:**
+- `networkStatus`: Current connection type (`wifi`, `ethernet`, `disconnected`)
+- `isConnecting`: Whether a connection attempt is in progress
+- `connectingSSID`: SSID being connected to (empty when idle)
+- `wifiConnected`: Whether associated with an access point
+- `wifiSSID`: Currently connected network name
+- `wifiIP`: Assigned IP address (empty until DHCP completes)
+- `lastError`: Error message from last failed connection attempt
+
+### network.credentials Service Events
+
+Credential prompts are sent when authentication is required:
+
+```json
+{
+ "service": "network.credentials",
+ "data": {
+ "token": "unique-prompt-id",
+ "ssid": "NetworkName",
+ "setting": "802-11-wireless-security",
+ "fields": ["psk"],
+ "hints": ["wpa3", "sae"],
+ "reason": "Credentials required"
+ }
+}
+```
+
+**Prompt fields:**
+- `token`: Unique identifier for this prompt (use in submit/cancel)
+- `ssid`: Network requesting credentials
+- `setting`: Authentication type (`802-11-wireless-security` for personal WiFi, `802-1x` for enterprise)
+- `fields`: Array of required credential field names
+- `hints`: Additional context about the network type
+- `reason`: Human-readable explanation (e.g., "Previous password was incorrect")
+
+## Connection Flow
+
+### Typical Timeline
+
+```
+T+0ms Call network.wifi.connect
+T+10ms Receive {"success": true, "message": "connecting"}
+T+100ms State update: isConnecting=true, connectingSSID="Network"
+T+500ms Credential prompt (if needed)
+T+1000ms Submit credentials
+T+3000ms State update: wifiConnected=true, wifiIP="192.168.x.x"
+```
+
+### State Machine
+
+```
+IDLE
+ |
+ | network.wifi.connect
+ v
+CONNECTING (isConnecting=true, connectingSSID set)
+ |
+ +-- Needs credentials
+ | |
+ | v
+ | PROMPTING (credential prompt event)
+ | |
+ | | network.credentials.submit
+ | v
+ | back to CONNECTING
+ |
+ +-- Success
+ | |
+ | v
+ | CONNECTED (wifiConnected=true, wifiIP set, isConnecting=false)
+ |
+ +-- Failure
+ |
+ v
+ ERROR (isConnecting=false, !wifiConnected, lastError set)
+```
+
+## Connection Success Detection
+
+A connection is successful when all of the following are true:
+
+1. `wifiConnected` is `true`
+2. `wifiIP` is set and non-empty
+3. `wifiSSID` matches the target network
+4. `isConnecting` is `false`
+
+Do not rely on `wifiConnected` alone - the device may be associated with an access point but not have an IP address yet.
+
+**Example:**
+```javascript
+function isConnectionComplete(state, targetSSID) {
+ return state.wifiConnected &&
+ state.wifiIP &&
+ state.wifiIP !== "" &&
+ state.wifiSSID === targetSSID &&
+ !state.isConnecting;
+}
+```
+
+## Error Handling
+
+### Error Detection
+
+Errors occur when a connection attempt stops without success:
+
+```javascript
+function checkForFailure(state, wasConnecting, targetSSID) {
+ // Was connecting, now idle, but not connected
+ if (wasConnecting &&
+ !state.isConnecting &&
+ state.connectingSSID === "" &&
+ !state.wifiConnected) {
+ return state.lastError || "Connection failed";
+ }
+ return null;
+}
+```
+
+### Common Error Scenarios
+
+#### Wrong Password
+
+**Detection methods:**
+
+1. Quick failure (< 3 seconds from start)
+2. `lastError` contains "password", "auth", or "secrets"
+3. Second credential prompt with `reason: "Previous password was incorrect"`
+
+**Handling:**
+```javascript
+if (prompt.reason === "Previous password was incorrect") {
+ // Show error, clear password field, re-focus input
+}
+```
+
+#### Network Out of Range
+
+**Detection:**
+- `lastError` contains "not-found" or "connection-attempt-failed"
+
+#### Connection Timeout
+
+**Detection:**
+- `isConnecting` remains true for > 30 seconds
+
+**Implementation:**
+```javascript
+let timeout = setTimeout(() => {
+ if (currentState.isConnecting) {
+ handleTimeout();
+ }
+}, 30000);
+```
+
+#### DHCP Failure
+
+**Detection:**
+- `wifiConnected` is true
+- `wifiIP` is empty after 15+ seconds
+
+### Error Message Translation
+
+Map technical errors to user-friendly messages:
+
+| lastError value | Meaning | User message |
+|----------------|---------|--------------|
+| `secrets-required` | Password needed | "Please enter password" |
+| `authentication-failed` | Wrong password | "Incorrect password" |
+| `connection-removed` | Profile deleted | "Network configuration removed" |
+| `connection-attempt-failed` | Generic failure | "Failed to connect" |
+| `network-not-found` | Out of range | "Network not found" |
+| `(timeout)` | Timeout | "Connection timed out" |
+
+## Credential Handling
+
+### Secret Agent Architecture
+
+The credential system uses a broker pattern:
+
+```
+NetworkManager -> SecretAgent -> PromptBroker -> UI -> User
+ ^
+ |
+ User Response
+ |
+NetworkManager <- SecretAgent <- PromptBroker <- UI
+```
+
+### Implementing a Broker
+
+```go
+type CustomBroker struct {
+ ui UIInterface
+ pending map[string]chan network.PromptReply
+}
+
+func (b *CustomBroker) Ask(ctx context.Context, req network.PromptRequest) (string, error) {
+ token := generateToken()
+ b.pending[token] = make(chan network.PromptReply, 1)
+
+ // Send to UI
+ b.ui.ShowCredentialPrompt(token, req)
+
+ return token, nil
+}
+
+func (b *CustomBroker) Wait(ctx context.Context, token string) (network.PromptReply, error) {
+ select {
+ case <-ctx.Done():
+ return network.PromptReply{}, errors.New("timeout")
+ case reply := <-b.pending[token]:
+ return reply, nil
+ }
+}
+
+func (b *CustomBroker) Resolve(token string, reply network.PromptReply) error {
+ if ch, ok := b.pending[token]; ok {
+ ch <- reply
+ close(ch)
+ delete(b.pending, token)
+ }
+ return nil
+}
+```
+
+### Credential Field Types
+
+**Personal WiFi (802-11-wireless-security):**
+- Fields: `["psk"]`
+- UI: Single password input
+
+**Enterprise WiFi (802-1x):**
+- Fields: `["identity", "password"]`
+- UI: Username and password inputs
+
+### Building Secrets Object
+
+```javascript
+function buildSecrets(setting, fields, formData) {
+ let secrets = {};
+
+ if (setting === "802-11-wireless-security") {
+ secrets.psk = formData.password;
+ } else if (setting === "802-1x") {
+ secrets.identity = formData.username;
+ secrets.password = formData.password;
+ }
+
+ return secrets;
+}
+```
+
+## Best Practices
+
+### Track Target Network
+
+Always store which network you're connecting to:
+
+```javascript
+let targetSSID = null;
+
+function connect(ssid) {
+ targetSSID = ssid;
+ // send request
+}
+
+function onStateUpdate(state) {
+ if (!targetSSID) return;
+
+ if (state.wifiSSID === targetSSID && state.wifiConnected && state.wifiIP) {
+ // Success for the network we care about
+ targetSSID = null;
+ }
+}
+```
+
+### Implement Timeouts
+
+Never wait indefinitely for a connection:
+
+```javascript
+const CONNECTION_TIMEOUT = 30000; // 30 seconds
+const DHCP_TIMEOUT = 15000; // 15 seconds
+
+let timer = setTimeout(() => {
+ if (stillConnecting) {
+ handleTimeout();
+ }
+}, CONNECTION_TIMEOUT);
+```
+
+### Handle Credential Re-prompts
+
+Wrong passwords trigger a second prompt:
+
+```javascript
+function onCredentialPrompt(prompt) {
+ if (prompt.reason.includes("incorrect")) {
+ // Show error, but keep dialog open
+ showError("Wrong password");
+ clearPasswordField();
+ } else {
+ // First time prompt
+ showDialog(prompt);
+ }
+}
+```
+
+### Clean Up State
+
+Reset tracking variables on success, failure, or cancellation:
+
+```javascript
+function cleanup() {
+ clearTimeout(timer);
+ targetSSID = null;
+ closeDialogs();
+}
+```
+
+### Subscribe to Both Services
+
+Missing `network.credentials` means prompts won't arrive:
+
+```javascript
+// Correct
+services: ["network", "network.credentials"]
+
+// Wrong - will miss credential prompts
+services: ["network"]
+```
+
+## Testing
+
+### Connection Test Checklist
+
+- [ ] Connect to open network
+- [ ] Connect to WPA2 network with password provided
+- [ ] Connect to WPA2 network without password (triggers prompt)
+- [ ] Enter wrong password (verify error and re-prompt)
+- [ ] Cancel credential prompt
+- [ ] Connection timeout after 30 seconds
+- [ ] DHCP timeout detection
+- [ ] Network out of range
+- [ ] Reconnect to already-configured network
+
+### Verifying Secret Agent Setup
+
+Check connection profile flags:
+```bash
+nmcli connection show "NetworkName" | grep flags
+# Should show: 802-11-wireless-security.psk-flags: 1 (agent-owned)
+```
+
+Check agent registration in logs:
+```
+INFO: Registered with NetworkManager as secret agent
+```
+
+## Security
+
+- Never log credential values (passwords, PSKs)
+- Clear password fields when dialogs close
+- Implement prompt timeouts (default: 2 minutes)
+- Validate user input before submission
+- Use secure channels for credential transmission
+
+## Troubleshooting
+
+### Credential prompt doesn't appear
+
+**Check:**
+- Subscribed to both `network` and `network.credentials`
+- Connection has `interactive: true`
+- Secret flags set to AGENT_OWNED (value: 1)
+- Broker registered successfully
+
+### Connection succeeds without prompting
+
+**Cause:** NetworkManager found saved credentials
+
+**Solution:** Delete existing connection first, or use different credentials
+
+### State updates seem delayed
+
+**Expected behavior:** State changes occur in rapid succession during connection
+
+**Solution:** Debounce UI updates; only act on final state
+
+### Multiple rapid credential prompts
+
+**Cause:** Connection profile has incorrect flags or conflicting agents
+
+**Solution:**
+- Check only one agent is running
+- Verify psk-flags value
+- Check NetworkManager logs for agent conflicts
+
+## Data Structures Reference
+
+### PromptRequest
+```go
+type PromptRequest struct {
+ SSID string `json:"ssid"`
+ SettingName string `json:"setting"`
+ Fields []string `json:"fields"`
+ Hints []string `json:"hints"`
+ Reason string `json:"reason"`
+}
+```
+
+### PromptReply
+```go
+type PromptReply struct {
+ Secrets map[string]string `json:"secrets"`
+ Save bool `json:"save"`
+ Cancel bool `json:"cancel"`
+}
+```
+
+### NetworkState
+```go
+type NetworkState struct {
+ NetworkStatus string `json:"networkStatus"`
+ IsConnecting bool `json:"isConnecting"`
+ ConnectingSSID string `json:"connectingSSID"`
+ WifiConnected bool `json:"wifiConnected"`
+ WifiSSID string `json:"wifiSSID"`
+ WifiIP string `json:"wifiIP"`
+ LastError string `json:"lastError"`
+}
+```
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/agent_iwd.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/agent_iwd.go
new file mode 100644
index 0000000..5d49f04
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/agent_iwd.go
@@ -0,0 +1,306 @@
+package network
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
+ "github.com/godbus/dbus/v5"
+)
+
+const (
+ iwdAgentManagerPath = "/net/connman/iwd"
+ iwdAgentManagerIface = "net.connman.iwd.AgentManager"
+ iwdAgentInterface = "net.connman.iwd.Agent"
+ iwdAgentObjectPath = "/com/danklinux/iwdagent"
+)
+
+type ConnectionStateChecker interface {
+ IsConnectingTo(ssid string) bool
+}
+
+type IWDAgent struct {
+ conn *dbus.Conn
+ objPath dbus.ObjectPath
+ prompts PromptBroker
+ onUserCanceled func()
+ onPromptRetry func(ssid string)
+ lastRequestSSID string
+ stateChecker ConnectionStateChecker
+}
+
+const iwdAgentIntrospectXML = `
+<node>
+ <interface name="net.connman.iwd.Agent">
+ <method name="Release">
+ <annotation name="org.freedesktop.DBus.Method.NoReply" value="true"/>
+ </method>
+ <method name="RequestPassphrase">
+ <arg type="o" name="network" direction="in"/>
+ <arg type="s" name="passphrase" direction="out"/>
+ </method>
+ <method name="RequestPrivateKeyPassphrase">
+ <arg type="o" name="network" direction="in"/>
+ <arg type="s" name="passphrase" direction="out"/>
+ </method>
+ <method name="RequestUserNameAndPassword">
+ <arg type="o" name="network" direction="in"/>
+ <arg type="s" name="username" direction="out"/>
+ <arg type="s" name="password" direction="out"/>
+ </method>
+ <method name="RequestUserPassword">
+ <arg type="o" name="network" direction="in"/>
+ <arg type="s" name="user" direction="in"/>
+ <arg type="s" name="password" direction="out"/>
+ </method>
+ <method name="Cancel">
+ <arg type="s" name="reason" direction="in"/>
+ <annotation name="org.freedesktop.DBus.Method.NoReply" value="true"/>
+ </method>
+ </interface>
+</node>`
+
+func NewIWDAgent(conn *dbus.Conn, prompts PromptBroker) (*IWDAgent, error) {
+ if conn == nil {
+ return nil, fmt.Errorf("dbus connection is nil")
+ }
+
+ agent := &IWDAgent{
+ conn: conn,
+ objPath: dbus.ObjectPath(iwdAgentObjectPath),
+ prompts: prompts,
+ }
+
+ if err := conn.Export(agent, agent.objPath, iwdAgentInterface); err != nil {
+ return nil, fmt.Errorf("failed to export IWD agent: %w", err)
+ }
+
+ if err := conn.Export(agent, agent.objPath, "org.freedesktop.DBus.Introspectable"); err != nil {
+ return nil, fmt.Errorf("failed to export introspection: %w", err)
+ }
+
+ mgr := conn.Object("net.connman.iwd", dbus.ObjectPath(iwdAgentManagerPath))
+ call := mgr.Call(iwdAgentManagerIface+".RegisterAgent", 0, agent.objPath)
+ if call.Err != nil {
+ return nil, fmt.Errorf("failed to register agent with iwd: %w", call.Err)
+ }
+
+ return agent, nil
+}
+
+func (a *IWDAgent) Close() {
+ if a.conn != nil {
+ mgr := a.conn.Object("net.connman.iwd", dbus.ObjectPath(iwdAgentManagerPath))
+ mgr.Call(iwdAgentManagerIface+".UnregisterAgent", 0, a.objPath)
+ }
+}
+
+func (a *IWDAgent) SetStateChecker(checker ConnectionStateChecker) {
+ a.stateChecker = checker
+}
+
+func (a *IWDAgent) getNetworkName(networkPath dbus.ObjectPath) string {
+ netObj := a.conn.Object("net.connman.iwd", networkPath)
+ nameVar, err := netObj.GetProperty("net.connman.iwd.Network.Name")
+ if err == nil {
+ if name, ok := nameVar.Value().(string); ok {
+ return name
+ }
+ }
+ return string(networkPath)
+}
+
+func (a *IWDAgent) RequestPassphrase(network dbus.ObjectPath) (string, *dbus.Error) {
+ ssid := a.getNetworkName(network)
+
+ if a.stateChecker != nil && !a.stateChecker.IsConnectingTo(ssid) {
+ return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
+ }
+
+ if a.prompts == nil {
+ if a.onUserCanceled != nil {
+ a.onUserCanceled()
+ }
+ return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
+ }
+
+ if a.lastRequestSSID == ssid {
+ if a.onPromptRetry != nil {
+ a.onPromptRetry(ssid)
+ }
+ }
+ a.lastRequestSSID = ssid
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
+ defer cancel()
+
+ token, err := a.prompts.Ask(ctx, PromptRequest{
+ SSID: ssid,
+ Fields: []string{"psk"},
+ })
+ if err != nil {
+ if a.onUserCanceled != nil {
+ a.onUserCanceled()
+ }
+ return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
+ }
+
+ reply, err := a.prompts.Wait(ctx, token)
+ if err != nil {
+ if reply.Cancel || errors.Is(err, errdefs.ErrSecretPromptCancelled) {
+ if a.onUserCanceled != nil {
+ a.onUserCanceled()
+ }
+ }
+ return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
+ }
+
+ if passphrase, ok := reply.Secrets["psk"]; ok {
+ return passphrase, nil
+ }
+
+ if a.onUserCanceled != nil {
+ a.onUserCanceled()
+ }
+ return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
+}
+
+func (a *IWDAgent) RequestPrivateKeyPassphrase(network dbus.ObjectPath) (string, *dbus.Error) {
+ ssid := a.getNetworkName(network)
+
+ if a.stateChecker != nil && !a.stateChecker.IsConnectingTo(ssid) {
+ return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
+ }
+
+ if a.prompts == nil {
+ return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
+ }
+
+ if a.lastRequestSSID == ssid {
+ if a.onPromptRetry != nil {
+ a.onPromptRetry(ssid)
+ }
+ }
+ a.lastRequestSSID = ssid
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
+ defer cancel()
+
+ token, err := a.prompts.Ask(ctx, PromptRequest{
+ SSID: ssid,
+ Fields: []string{"private-key-password"},
+ })
+ if err != nil {
+ return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
+ }
+
+ reply, err := a.prompts.Wait(ctx, token)
+ if err != nil || reply.Cancel {
+ return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
+ }
+
+ if passphrase, ok := reply.Secrets["private-key-password"]; ok {
+ return passphrase, nil
+ }
+
+ return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
+}
+
+func (a *IWDAgent) RequestUserNameAndPassword(network dbus.ObjectPath) (string, string, *dbus.Error) {
+ ssid := a.getNetworkName(network)
+
+ if a.stateChecker != nil && !a.stateChecker.IsConnectingTo(ssid) {
+ return "", "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
+ }
+
+ if a.prompts == nil {
+ return "", "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
+ }
+
+ if a.lastRequestSSID == ssid {
+ if a.onPromptRetry != nil {
+ a.onPromptRetry(ssid)
+ }
+ }
+ a.lastRequestSSID = ssid
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
+ defer cancel()
+
+ token, err := a.prompts.Ask(ctx, PromptRequest{
+ SSID: ssid,
+ Fields: []string{"identity", "password"},
+ })
+ if err != nil {
+ return "", "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
+ }
+
+ reply, err := a.prompts.Wait(ctx, token)
+ if err != nil || reply.Cancel {
+ return "", "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
+ }
+
+ username, hasUser := reply.Secrets["identity"]
+ password, hasPass := reply.Secrets["password"]
+
+ if hasUser && hasPass {
+ return username, password, nil
+ }
+
+ return "", "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
+}
+
+func (a *IWDAgent) RequestUserPassword(network dbus.ObjectPath, user string) (string, *dbus.Error) {
+ ssid := a.getNetworkName(network)
+
+ if a.stateChecker != nil && !a.stateChecker.IsConnectingTo(ssid) {
+ return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
+ }
+
+ if a.prompts == nil {
+ return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
+ }
+
+ if a.lastRequestSSID == ssid {
+ if a.onPromptRetry != nil {
+ a.onPromptRetry(ssid)
+ }
+ }
+ a.lastRequestSSID = ssid
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
+ defer cancel()
+
+ token, err := a.prompts.Ask(ctx, PromptRequest{
+ SSID: ssid,
+ Fields: []string{"password"},
+ })
+ if err != nil {
+ return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
+ }
+
+ reply, err := a.prompts.Wait(ctx, token)
+ if err != nil || reply.Cancel {
+ return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
+ }
+
+ if password, ok := reply.Secrets["password"]; ok {
+ return password, nil
+ }
+
+ return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
+}
+
+func (a *IWDAgent) Cancel(reason string) *dbus.Error {
+ return nil
+}
+
+func (a *IWDAgent) Release() *dbus.Error {
+ return nil
+}
+
+func (a *IWDAgent) Introspect() (string, *dbus.Error) {
+ return iwdAgentIntrospectXML, nil
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/agent_networkmanager.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/agent_networkmanager.go
new file mode 100644
index 0000000..088b7fa
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/agent_networkmanager.go
@@ -0,0 +1,897 @@
+package network
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "github.com/godbus/dbus/v5"
+ "golang.org/x/text/cases"
+ "golang.org/x/text/language"
+)
+
+const (
+ nmAgentManagerPath = "/org/freedesktop/NetworkManager/AgentManager"
+ nmAgentManagerIface = "org.freedesktop.NetworkManager.AgentManager"
+ nmSecretAgentIface = "org.freedesktop.NetworkManager.SecretAgent"
+ agentObjectPath = "/org/freedesktop/NetworkManager/SecretAgent"
+ agentIdentifier = "com.danklinux.NMAgent"
+)
+
+type SecretAgent struct {
+ conn *dbus.Conn
+ objPath dbus.ObjectPath
+ id string
+ prompts PromptBroker
+ manager *Manager
+ backend *NetworkManagerBackend
+}
+
+type (
+ nmVariantMap map[string]dbus.Variant
+ nmSettingMap map[string]nmVariantMap
+)
+
+const introspectXML = `
+<node>
+ <interface name="org.freedesktop.NetworkManager.SecretAgent">
+ <method name="GetSecrets">
+ <arg type="a{sa{sv}}" name="connection" direction="in"/>
+ <arg type="o" name="connection_path" direction="in"/>
+ <arg type="s" name="setting_name" direction="in"/>
+ <arg type="as" name="hints" direction="in"/>
+ <arg type="u" name="flags" direction="in"/>
+ <arg type="a{sa{sv}}" name="secrets" direction="out"/>
+ </method>
+ <method name="DeleteSecrets">
+ <arg type="a{sa{sv}}" name="connection" direction="in"/>
+ <arg type="o" name="connection_path" direction="in"/>
+ </method>
+ <method name="DeleteSecrets2">
+ <arg type="o" name="connection_path" direction="in"/>
+ <arg type="s" name="setting" direction="in"/>
+ </method>
+ <method name="CancelGetSecrets">
+ <arg type="o" name="connection_path" direction="in"/>
+ <arg type="s" name="setting_name" direction="in"/>
+ </method>
+ </interface>
+ <interface name="org.freedesktop.DBus.Introspectable">
+ <method name="Introspect">
+ <arg name="data" type="s" direction="out"/>
+ </method>
+ </interface>
+</node>`
+
+func NewSecretAgent(prompts PromptBroker, manager *Manager, backend *NetworkManagerBackend) (*SecretAgent, error) {
+ c, err := dbus.ConnectSystemBus()
+ if err != nil {
+ return nil, fmt.Errorf("failed to connect to system bus: %w", err)
+ }
+
+ sa := &SecretAgent{
+ conn: c,
+ objPath: dbus.ObjectPath(agentObjectPath),
+ id: agentIdentifier,
+ prompts: prompts,
+ manager: manager,
+ backend: backend,
+ }
+
+ if err := c.Export(sa, sa.objPath, nmSecretAgentIface); err != nil {
+ c.Close()
+ return nil, fmt.Errorf("failed to export secret agent: %w", err)
+ }
+
+ if err := c.Export(sa, sa.objPath, "org.freedesktop.DBus.Introspectable"); err != nil {
+ c.Close()
+ return nil, fmt.Errorf("failed to export introspection: %w", err)
+ }
+
+ mgr := c.Object("org.freedesktop.NetworkManager", dbus.ObjectPath(nmAgentManagerPath))
+ call := mgr.Call(nmAgentManagerIface+".Register", 0, sa.id)
+ if call.Err != nil {
+ c.Close()
+ return nil, fmt.Errorf("failed to register agent with NetworkManager: %w", call.Err)
+ }
+
+ log.Infof("[SecretAgent] Registered with NetworkManager (id=%s, unique name=%s, fixed path=%s)", sa.id, c.Names()[0], sa.objPath)
+ return sa, nil
+}
+
+func (a *SecretAgent) Close() {
+ if a.conn != nil {
+ mgr := a.conn.Object("org.freedesktop.NetworkManager", dbus.ObjectPath(nmAgentManagerPath))
+ mgr.Call(nmAgentManagerIface+".Unregister", 0, a.id)
+ a.conn.Close()
+ }
+}
+
+func (a *SecretAgent) GetSecrets(
+ conn map[string]nmVariantMap,
+ path dbus.ObjectPath,
+ settingName string,
+ hints []string,
+ flags uint32,
+) (nmSettingMap, *dbus.Error) {
+ log.Infof("[SecretAgent] GetSecrets called: path=%s, setting=%s, hints=%v, flags=%d",
+ path, settingName, hints, flags)
+
+ connType, displayName, vpnSvc := readConnTypeAndName(conn)
+ ssid := readSSID(conn)
+ fields := fieldsNeeded(settingName, hints, conn)
+ vpnPasswordFlags := readVPNPasswordFlags(conn, settingName)
+
+ log.Infof("[SecretAgent] connType=%s, name=%s, vpnSvc=%s, fields=%v, flags=%d, vpnPasswordFlags=%d", connType, displayName, vpnSvc, fields, flags, vpnPasswordFlags)
+
+ if a.backend != nil {
+ a.backend.stateMutex.RLock()
+ isConnecting := a.backend.state.IsConnecting
+ connectingSSID := a.backend.state.ConnectingSSID
+ isConnectingVPN := a.backend.state.IsConnectingVPN
+ connectingVPNUUID := a.backend.state.ConnectingVPNUUID
+ a.backend.stateMutex.RUnlock()
+
+ switch connType {
+ case "802-11-wireless":
+ // If we're connecting to a WiFi network, only respond if it's the one we're connecting to
+ if isConnecting && connectingSSID != ssid {
+ log.Infof("[SecretAgent] Ignoring WiFi request for SSID '%s' - we're connecting to '%s'", ssid, connectingSSID)
+ return nil, dbus.NewError("org.freedesktop.NetworkManager.SecretAgent.Error.NoSecrets", nil)
+ }
+ case "vpn", "wireguard":
+ var connUuid string
+ if c, ok := conn["connection"]; ok {
+ if v, ok := c["uuid"]; ok {
+ if s, ok2 := v.Value().(string); ok2 {
+ connUuid = s
+ }
+ }
+ }
+
+ // If we're connecting to a VPN, only respond if it's the one we're connecting to
+ // This prevents interfering with nmcli/other tools when our app isn't connecting
+ if isConnectingVPN && connUuid != connectingVPNUUID {
+ log.Infof("[SecretAgent] Ignoring VPN request for UUID '%s' - we're connecting to '%s'", connUuid, connectingVPNUUID)
+ return nil, dbus.NewError("org.freedesktop.NetworkManager.SecretAgent.Error.NoSecrets", nil)
+ }
+ }
+ }
+
+ if len(fields) == 0 {
+ if settingName == "vpn" {
+ if a.backend != nil {
+ a.backend.stateMutex.RLock()
+ isConnectingVPN := a.backend.state.IsConnectingVPN
+ a.backend.stateMutex.RUnlock()
+
+ if !isConnectingVPN {
+ log.Infof("[SecretAgent] VPN with empty hints - deferring to other agents for %s", vpnSvc)
+ return nil, dbus.NewError("org.freedesktop.NetworkManager.SecretAgent.Error.NoSecrets", nil)
+ }
+
+ fields = inferVPNFields(conn, vpnSvc)
+ log.Infof("[SecretAgent] VPN with empty hints but we're connecting - inferred fields: %v", fields)
+ } else {
+ log.Infof("[SecretAgent] VPN with empty hints - deferring to other agents for %s", vpnSvc)
+ return nil, dbus.NewError("org.freedesktop.NetworkManager.SecretAgent.Error.NoSecrets", nil)
+ }
+ }
+
+ if len(fields) == 0 {
+ const (
+ NM_SETTING_SECRET_FLAG_NONE = 0
+ NM_SETTING_SECRET_FLAG_AGENT_OWNED = 1
+ NM_SETTING_SECRET_FLAG_NOT_SAVED = 2
+ NM_SETTING_SECRET_FLAG_NOT_REQUIRED = 4
+ )
+
+ var passwordFlags uint32 = 0xFFFF
+ switch settingName {
+ case "802-11-wireless-security":
+ if wifiSecSettings, ok := conn["802-11-wireless-security"]; ok {
+ if flagsVariant, ok := wifiSecSettings["psk-flags"]; ok {
+ if pwdFlags, ok := flagsVariant.Value().(uint32); ok {
+ passwordFlags = pwdFlags
+ }
+ }
+ }
+ case "802-1x":
+ if dot1xSettings, ok := conn["802-1x"]; ok {
+ if flagsVariant, ok := dot1xSettings["password-flags"]; ok {
+ if pwdFlags, ok := flagsVariant.Value().(uint32); ok {
+ passwordFlags = pwdFlags
+ }
+ }
+ }
+ }
+
+ if passwordFlags == 0xFFFF {
+ log.Warnf("[SecretAgent] Could not determine password-flags for empty hints - returning NoSecrets error")
+ return nil, dbus.NewError("org.freedesktop.NetworkManager.SecretAgent.Error.NoSecrets", nil)
+ } else if passwordFlags&NM_SETTING_SECRET_FLAG_NOT_REQUIRED != 0 {
+ log.Infof("[SecretAgent] Secrets not required (flags=%d)", passwordFlags)
+ out := nmSettingMap{}
+ out[settingName] = nmVariantMap{}
+ return out, nil
+ } else if passwordFlags&NM_SETTING_SECRET_FLAG_AGENT_OWNED != 0 {
+ switch settingName {
+ case "802-11-wireless-security":
+ fields = []string{"psk"}
+ case "802-1x":
+ fields = infer8021xFields(conn)
+ default:
+ log.Warnf("[SecretAgent] Agent-owned secrets for unhandled setting %s (flags=%d)", settingName, passwordFlags)
+ return nil, dbus.NewError("org.freedesktop.NetworkManager.SecretAgent.Error.NoSecrets", nil)
+ }
+ log.Infof("[SecretAgent] Agent-owned secrets, inferred fields: %v", fields)
+ } else {
+ log.Infof("[SecretAgent] No secrets needed, using system stored secrets (flags=%d)", passwordFlags)
+ out := nmSettingMap{}
+ out[settingName] = nmVariantMap{}
+ return out, nil
+ }
+ }
+ }
+
+ reason := reasonFromFlags(flags)
+ if a.manager != nil && connType == "802-11-wireless" && a.manager.WasRecentlyFailed(ssid) {
+ reason = "wrong-password"
+ }
+ if settingName == "vpn" && isPKCS11Auth(conn, vpnSvc) {
+ reason = "pkcs11"
+ }
+
+ var connId, connUuid string
+ if c, ok := conn["connection"]; ok {
+ if v, ok := c["id"]; ok {
+ if s, ok2 := v.Value().(string); ok2 {
+ connId = s
+ }
+ }
+ if v, ok := c["uuid"]; ok {
+ if s, ok2 := v.Value().(string); ok2 {
+ connUuid = s
+ }
+ }
+ }
+
+ if settingName == "vpn" && a.backend != nil {
+ // Check for cached PKCS11 PIN first
+ isPKCS11Request := len(fields) == 1 && fields[0] == "key_pass"
+ if isPKCS11Request {
+ a.backend.cachedPKCS11Mu.Lock()
+ cached := a.backend.cachedPKCS11PIN
+ if cached != nil && cached.ConnectionUUID == connUuid {
+ a.backend.cachedPKCS11PIN = nil
+ a.backend.cachedPKCS11Mu.Unlock()
+
+ log.Infof("[SecretAgent] Using cached PKCS11 PIN")
+
+ out := nmSettingMap{}
+ vpnSec := nmVariantMap{}
+ vpnSec["secrets"] = dbus.MakeVariant(map[string]string{"key_pass": cached.PIN})
+ out[settingName] = vpnSec
+
+ return out, nil
+ }
+ a.backend.cachedPKCS11Mu.Unlock()
+ }
+
+ // Check for cached VPN password
+ a.backend.cachedVPNCredsMu.Lock()
+ cached := a.backend.cachedVPNCreds
+ if cached != nil && cached.ConnectionUUID == connUuid {
+ a.backend.cachedVPNCreds = nil
+ a.backend.cachedVPNCredsMu.Unlock()
+
+ log.Infof("[SecretAgent] Using cached password from pre-activation prompt")
+
+ out := nmSettingMap{}
+ vpnSec := nmVariantMap{}
+ vpnSec["secrets"] = dbus.MakeVariant(map[string]string{"password": cached.Password})
+ out[settingName] = vpnSec
+
+ if cached.SavePassword {
+ a.backend.pendingVPNSaveMu.Lock()
+ a.backend.pendingVPNSave = &pendingVPNCredentials{
+ ConnectionPath: string(path),
+ Password: cached.Password,
+ SavePassword: true,
+ }
+ a.backend.pendingVPNSaveMu.Unlock()
+ }
+
+ return out, nil
+ }
+ a.backend.cachedVPNCredsMu.Unlock()
+
+ a.backend.cachedGPSamlMu.Lock()
+ cachedGPSaml := a.backend.cachedGPSamlCookie
+ if cachedGPSaml != nil && cachedGPSaml.ConnectionUUID == connUuid {
+ a.backend.cachedGPSamlMu.Unlock()
+
+ log.Infof("[SecretAgent] Using cached GlobalProtect SAML cookie for %s", connUuid)
+
+ return buildGPSamlSecretsResponse(settingName, cachedGPSaml.Cookie, cachedGPSaml.Host, cachedGPSaml.Fingerprint), nil
+ }
+ a.backend.cachedGPSamlMu.Unlock()
+
+ if len(fields) == 1 && fields[0] == "gp-saml" {
+ gateway := ""
+ protocol := ""
+ if vpnSettings, ok := conn["vpn"]; ok {
+ if dataVariant, ok := vpnSettings["data"]; ok {
+ if dataMap, ok := dataVariant.Value().(map[string]string); ok {
+ if gw, ok := dataMap["gateway"]; ok {
+ gateway = gw
+ }
+ if proto, ok := dataMap["protocol"]; ok && proto != "" {
+ protocol = proto
+ }
+ }
+ }
+ }
+
+ if protocol != "gp" {
+ return nil, dbus.MakeFailedError(fmt.Errorf("gp-saml auth only supported for GlobalProtect (protocol=gp), got: %s", protocol))
+ }
+
+ log.Infof("[SecretAgent] Starting GlobalProtect SAML authentication for gateway=%s", gateway)
+
+ samlCtx, samlCancel := context.WithTimeout(context.Background(), 5*time.Minute)
+ defer samlCancel()
+
+ authResult, err := a.backend.runGlobalProtectSAMLAuth(samlCtx, gateway, protocol)
+ if err != nil {
+ log.Warnf("[SecretAgent] GlobalProtect SAML authentication failed: %v", err)
+ return nil, dbus.MakeFailedError(fmt.Errorf("GlobalProtect SAML authentication failed: %w", err))
+ }
+
+ log.Infof("[SecretAgent] GlobalProtect SAML authentication successful, returning cookie to NetworkManager")
+
+ a.backend.cachedGPSamlMu.Lock()
+ a.backend.cachedGPSamlCookie = &cachedGPSamlCookie{
+ ConnectionUUID: connUuid,
+ Cookie: authResult.Cookie,
+ Host: authResult.Host,
+ User: authResult.User,
+ Fingerprint: authResult.Fingerprint,
+ }
+ a.backend.cachedGPSamlMu.Unlock()
+
+ return buildGPSamlSecretsResponse(settingName, authResult.Cookie, authResult.Host, authResult.Fingerprint), nil
+ }
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+ defer cancel()
+
+ token, err := a.prompts.Ask(ctx, PromptRequest{
+ Name: displayName,
+ SSID: ssid,
+ ConnType: connType,
+ VpnService: vpnSvc,
+ SettingName: settingName,
+ Fields: fields,
+ FieldsInfo: buildFieldsInfo(settingName, fields, vpnSvc),
+ Hints: hints,
+ Reason: reason,
+ ConnectionId: connId,
+ ConnectionUuid: connUuid,
+ ConnectionPath: string(path),
+ })
+ if err != nil {
+ log.Warnf("[SecretAgent] Failed to create prompt: %v", err)
+ return nil, dbus.MakeFailedError(err)
+ }
+
+ log.Infof("[SecretAgent] Waiting for user input (token=%s)", token)
+ reply, err := a.prompts.Wait(ctx, token)
+ if err != nil {
+ log.Warnf("[SecretAgent] Prompt failed or cancelled: %v", err)
+
+ // Clear connecting state immediately on cancellation
+ if a.backend != nil {
+ a.backend.stateMutex.Lock()
+ wasConnecting := a.backend.state.IsConnecting
+ wasConnectingVPN := a.backend.state.IsConnectingVPN
+ cancelledSSID := a.backend.state.ConnectingSSID
+ cancelledVPNUUID := a.backend.state.ConnectingVPNUUID
+ if wasConnecting || wasConnectingVPN {
+ log.Infof("[SecretAgent] Clearing connecting state due to cancelled prompt")
+ a.backend.state.IsConnecting = false
+ a.backend.state.ConnectingSSID = ""
+ a.backend.state.IsConnectingVPN = false
+ a.backend.state.ConnectingVPNUUID = ""
+ }
+ a.backend.stateMutex.Unlock()
+
+ // If this was a WiFi connection that was just cancelled, remove the connection profile
+ // (it was created with AddConnection but activation was cancelled)
+ if wasConnecting && cancelledSSID != "" && connType == "802-11-wireless" {
+ log.Infof("[SecretAgent] Removing connection profile for cancelled WiFi connection: %s", cancelledSSID)
+ if err := a.backend.ForgetWiFiNetwork(cancelledSSID); err != nil {
+ log.Warnf("[SecretAgent] Failed to remove cancelled connection profile: %v", err)
+ }
+ }
+
+ // If this was a VPN connection that was cancelled, deactivate it
+ if wasConnectingVPN && cancelledVPNUUID != "" {
+ log.Infof("[SecretAgent] Deactivating cancelled VPN connection: %s", cancelledVPNUUID)
+ if err := a.backend.DisconnectVPN(cancelledVPNUUID); err != nil {
+ log.Warnf("[SecretAgent] Failed to deactivate cancelled VPN: %v", err)
+ }
+ }
+
+ if (wasConnecting || wasConnectingVPN) && a.backend.onStateChange != nil {
+ a.backend.onStateChange()
+ }
+ }
+
+ if reply.Cancel || errors.Is(err, errdefs.ErrSecretPromptCancelled) {
+ return nil, dbus.NewError("org.freedesktop.NetworkManager.SecretAgent.Error.UserCanceled", nil)
+ }
+
+ if errors.Is(err, errdefs.ErrSecretPromptTimeout) {
+ return nil, dbus.NewError("org.freedesktop.NetworkManager.SecretAgent.Error.Failed", nil)
+ }
+ return nil, dbus.NewError("org.freedesktop.NetworkManager.SecretAgent.Error.Failed", nil)
+ }
+
+ log.Infof("[SecretAgent] User provided secrets, save=%v", reply.Save)
+
+ out := nmSettingMap{}
+ sec := nmVariantMap{}
+
+ var vpnUsername string
+ for k, v := range reply.Secrets {
+ if settingName == "vpn" && k == "username" {
+ vpnUsername = v
+ }
+ sec[k] = dbus.MakeVariant(v)
+ }
+
+ // Check if this is PKCS11 auth (key_pass)
+ pin, isPKCS11 := reply.Secrets["key_pass"]
+
+ switch settingName {
+ case "vpn":
+ // VPN secrets must be wrapped in a "secrets" key per NM spec
+ secretsDict := make(map[string]string)
+ for k, v := range reply.Secrets {
+ if k != "username" {
+ secretsDict[k] = v
+ }
+ }
+ vpnSec := nmVariantMap{}
+ vpnSec["secrets"] = dbus.MakeVariant(secretsDict)
+ out[settingName] = vpnSec
+ log.Infof("[SecretAgent] Returning VPN secrets with %d fields for %s", len(secretsDict), vpnSvc)
+
+ // Cache PKCS11 PIN in case GetSecrets is called again during activation
+ if isPKCS11 && a.backend != nil {
+ a.backend.cachedPKCS11Mu.Lock()
+ a.backend.cachedPKCS11PIN = &cachedPKCS11PIN{
+ ConnectionUUID: connUuid,
+ PIN: pin,
+ }
+ a.backend.cachedPKCS11Mu.Unlock()
+ log.Infof("[SecretAgent] Cached PKCS11 PIN for potential re-request")
+ }
+ case "802-1x":
+ secretsOnly := nmVariantMap{}
+ for k, v := range reply.Secrets {
+ switch k {
+ case "password", "private-key-password", "phase2-private-key-password", "pin":
+ secretsOnly[k] = dbus.MakeVariant(v)
+ }
+ }
+ out[settingName] = secretsOnly
+
+ if identity, ok := reply.Secrets["identity"]; ok && identity != "" {
+ a.save8021xIdentity(path, identity)
+ }
+ log.Infof("[SecretAgent] Returning 802-1x enterprise secrets with %d fields", len(secretsOnly))
+ default:
+ out[settingName] = sec
+ }
+ if settingName == "vpn" && a.backend != nil && !isPKCS11 && (vpnUsername != "" || reply.Save) {
+ pw := reply.Secrets["password"]
+ a.backend.pendingVPNSaveMu.Lock()
+ a.backend.pendingVPNSave = &pendingVPNCredentials{
+ ConnectionPath: string(path),
+ Username: vpnUsername,
+ Password: pw,
+ SavePassword: reply.Save,
+ }
+ a.backend.pendingVPNSaveMu.Unlock()
+ log.Infof("[SecretAgent] Queued credentials persist for after connection succeeds")
+ }
+
+ return out, nil
+}
+
+func (a *SecretAgent) DeleteSecrets(conn map[string]nmVariantMap, path dbus.ObjectPath) *dbus.Error {
+ ssid := readSSID(conn)
+ log.Infof("[SecretAgent] DeleteSecrets called: path=%s, SSID=%s", path, ssid)
+ return nil
+}
+
+func (a *SecretAgent) DeleteSecrets2(path dbus.ObjectPath, setting string) *dbus.Error {
+ log.Infof("[SecretAgent] DeleteSecrets2 (alternate) called: path=%s, setting=%s", path, setting)
+ return nil
+}
+
+func (a *SecretAgent) CancelGetSecrets(path dbus.ObjectPath, settingName string) *dbus.Error {
+ log.Infof("[SecretAgent] CancelGetSecrets called: path=%s, setting=%s", path, settingName)
+
+ if a.prompts != nil {
+ if err := a.prompts.Cancel(string(path), settingName); err != nil {
+ log.Warnf("[SecretAgent] Failed to cancel prompt: %v", err)
+ }
+ }
+
+ return nil
+}
+
+func (a *SecretAgent) Introspect() (string, *dbus.Error) {
+ return introspectXML, nil
+}
+
+func (a *SecretAgent) save8021xIdentity(path dbus.ObjectPath, identity string) {
+ connObj := a.conn.Object("org.freedesktop.NetworkManager", path)
+ var existing map[string]map[string]dbus.Variant
+ if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.GetSettings", 0).Store(&existing); err != nil {
+ log.Warnf("[SecretAgent] Failed to get settings for identity save: %v", err)
+ return
+ }
+
+ settings := make(map[string]map[string]dbus.Variant)
+ if connSection, ok := existing["connection"]; ok {
+ settings["connection"] = connSection
+ }
+
+ dot1x, ok := existing["802-1x"]
+ if !ok {
+ dot1x = make(map[string]dbus.Variant)
+ }
+ dot1x["identity"] = dbus.MakeVariant(identity)
+ settings["802-1x"] = dot1x
+
+ var result map[string]dbus.Variant
+ if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.Update2", 0,
+ settings, uint32(0x1), map[string]dbus.Variant{}).Store(&result); err != nil {
+ log.Warnf("[SecretAgent] Failed to save 802.1x identity: %v", err)
+ return
+ }
+ log.Infof("[SecretAgent] Saved 802.1x identity to connection profile")
+}
+
+func readSSID(conn map[string]nmVariantMap) string {
+ if w, ok := conn["802-11-wireless"]; ok {
+ if v, ok := w["ssid"]; ok {
+ if b, ok := v.Value().([]byte); ok {
+ return string(b)
+ }
+ if s, ok := v.Value().(string); ok {
+ return s
+ }
+ }
+ }
+ return ""
+}
+
+func readConnTypeAndName(conn map[string]nmVariantMap) (string, string, string) {
+ var connType, name, svc string
+ if c, ok := conn["connection"]; ok {
+ if v, ok := c["type"]; ok {
+ if s, ok2 := v.Value().(string); ok2 {
+ connType = s
+ }
+ }
+ if v, ok := c["id"]; ok {
+ if s, ok2 := v.Value().(string); ok2 {
+ name = s
+ }
+ }
+ }
+ if vpn, ok := conn["vpn"]; ok {
+ if v, ok := vpn["service-type"]; ok {
+ if s, ok2 := v.Value().(string); ok2 {
+ svc = s
+ }
+ }
+ }
+ if name == "" && connType == "802-11-wireless" {
+ name = readSSID(conn)
+ }
+ return connType, name, svc
+}
+
+func fieldsNeeded(setting string, hints []string, conn map[string]nmVariantMap) []string {
+ switch setting {
+ case "802-11-wireless-security":
+ return []string{"psk"}
+ case "802-1x":
+ if len(hints) > 0 {
+ return hints
+ }
+ return infer8021xFields(conn)
+ case "vpn":
+ return hints
+ default:
+ return []string{}
+ }
+}
+
+func infer8021xFields(conn map[string]nmVariantMap) []string {
+ dot1x, ok := conn["802-1x"]
+ if !ok {
+ return []string{"identity", "password"}
+ }
+
+ var fields []string
+
+ if v, ok := dot1x["identity"]; ok {
+ if id, ok := v.Value().(string); ok && id != "" {
+ // identity already stored, don't ask again
+ } else {
+ fields = append(fields, "identity")
+ }
+ } else {
+ fields = append(fields, "identity")
+ }
+
+ var eapMethods []string
+ if v, ok := dot1x["eap"]; ok {
+ if methods, ok := v.Value().([]string); ok {
+ eapMethods = methods
+ }
+ }
+
+ switch {
+ case len(eapMethods) > 0 && eapMethods[0] == "tls":
+ fields = append(fields, "private-key-password")
+ default:
+ fields = append(fields, "password")
+ }
+
+ return fields
+}
+
+func buildFieldsInfo(setting string, fields []string, vpnService string) []FieldInfo {
+ result := make([]FieldInfo, 0, len(fields))
+ for _, f := range fields {
+ info := FieldInfo{Name: f}
+ switch setting {
+ case "802-11-wireless-security":
+ info.Label = "Password"
+ info.IsSecret = true
+ case "802-1x":
+ switch f {
+ case "identity":
+ info.Label = "Username"
+ info.IsSecret = false
+ case "password":
+ info.Label = "Password"
+ info.IsSecret = true
+ default:
+ info.Label = f
+ info.IsSecret = true
+ }
+ case "vpn":
+ info.Label, info.IsSecret = vpnFieldMeta(f, vpnService)
+ default:
+ info.Label = f
+ info.IsSecret = true
+ }
+ result = append(result, info)
+ }
+ return result
+}
+
+func inferVPNFields(conn map[string]nmVariantMap, vpnService string) []string {
+ fields := []string{"password"}
+
+ vpnSettings, ok := conn["vpn"]
+ if !ok {
+ return fields
+ }
+
+ dataVariant, ok := vpnSettings["data"]
+ if !ok {
+ return fields
+ }
+
+ dataMap, ok := dataVariant.Value().(map[string]string)
+ if !ok {
+ return fields
+ }
+
+ connType := dataMap["connection-type"]
+
+ switch {
+ case strings.Contains(vpnService, "openconnect"):
+ protocol := dataMap["protocol"]
+ authType := dataMap["authtype"]
+ username := dataMap["username"]
+
+ if authType == "cert" && strings.HasPrefix(dataMap["usercert"], "pkcs11:") {
+ return []string{"key_pass"}
+ }
+
+ if needsExternalBrowserAuth(protocol, authType, username, dataMap) {
+ switch protocol {
+ case "gp":
+ log.Infof("[SecretAgent] GlobalProtect SAML auth detected")
+ return []string{"gp-saml"}
+ default:
+ log.Infof("[SecretAgent] External browser auth detected for protocol '%s' but only GlobalProtect (gp) SAML is currently supported, falling back to credentials", protocol)
+ }
+ }
+
+ if username == "" {
+ fields = []string{"username", "password"}
+ }
+ case strings.Contains(vpnService, "openvpn"):
+ if connType == "password" || connType == "password-tls" {
+ if dataMap["username"] == "" {
+ fields = []string{"username", "password"}
+ }
+ }
+ case strings.Contains(vpnService, "vpnc"), strings.Contains(vpnService, "l2tp"),
+ strings.Contains(vpnService, "pptp"):
+ if dataMap["username"] == "" {
+ fields = []string{"username", "password"}
+ }
+ }
+
+ return fields
+}
+
+func needsExternalBrowserAuth(protocol, authType, username string, data map[string]string) bool {
+ if method, ok := data["saml-auth-method"]; ok {
+ if method == "REDIRECT" || method == "POST" {
+ return true
+ }
+ }
+
+ if authType != "" && authType != "password" && authType != "cert" {
+ return true
+ }
+
+ switch protocol {
+ case "gp":
+ if authType == "" && username == "" {
+ return true
+ }
+ }
+
+ return false
+}
+
+func vpnFieldMeta(field, vpnService string) (label string, isSecret bool) {
+ switch field {
+ case "gp-saml":
+ return "GlobalProtect SAML/SSO", false
+ case "key_pass":
+ return "PIN", true
+ case "password":
+ return "Password", true
+ case "Xauth password":
+ return "IPSec Password", true
+ case "IPSec secret":
+ return "IPSec Pre-Shared Key", true
+ case "cert-pass":
+ return "Certificate Password", true
+ case "http-proxy-password":
+ return "HTTP Proxy Password", true
+ case "username":
+ return "Username", false
+ case "Xauth username":
+ return "IPSec Username", false
+ case "proxy-password":
+ return "Proxy Password", true
+ case "private-key-password":
+ return "Private Key Password", true
+ }
+ titleCaser := cases.Title(language.English)
+ if strings.HasSuffix(field, "password") || strings.HasSuffix(field, "secret") ||
+ strings.HasSuffix(field, "pass") || strings.HasSuffix(field, "psk") {
+ return titleCaser.String(strings.ReplaceAll(field, "-", " ")), true
+ }
+ return titleCaser.String(strings.ReplaceAll(field, "-", " ")), false
+}
+
+func isPKCS11Auth(conn map[string]nmVariantMap, vpnService string) bool {
+ if !strings.Contains(vpnService, "openconnect") {
+ return false
+ }
+ vpnSettings, ok := conn["vpn"]
+ if !ok {
+ return false
+ }
+ dataVariant, ok := vpnSettings["data"]
+ if !ok {
+ return false
+ }
+ dataMap, ok := dataVariant.Value().(map[string]string)
+ if !ok {
+ return false
+ }
+ return dataMap["authtype"] == "cert" && strings.HasPrefix(dataMap["usercert"], "pkcs11:")
+}
+
+func readVPNPasswordFlags(conn map[string]nmVariantMap, settingName string) uint32 {
+ if settingName != "vpn" {
+ return 0xFFFF
+ }
+
+ vpnSettings, ok := conn["vpn"]
+ if !ok {
+ return 0xFFFF
+ }
+
+ dataVariant, ok := vpnSettings["data"]
+ if !ok {
+ return 0xFFFF
+ }
+
+ dataMap, ok := dataVariant.Value().(map[string]string)
+ if !ok {
+ return 0xFFFF
+ }
+
+ flagsStr, ok := dataMap["password-flags"]
+ if !ok {
+ return 0xFFFF
+ }
+
+ flags64, err := strconv.ParseUint(flagsStr, 10, 32)
+ if err != nil {
+ return 0xFFFF
+ }
+
+ return uint32(flags64)
+}
+
+func reasonFromFlags(flags uint32) string {
+ const (
+ NM_SECRET_AGENT_GET_SECRETS_FLAG_NONE = 0x0
+ NM_SECRET_AGENT_GET_SECRETS_FLAG_ALLOW_INTERACTION = 0x1
+ NM_SECRET_AGENT_GET_SECRETS_FLAG_REQUEST_NEW = 0x2
+ NM_SECRET_AGENT_GET_SECRETS_FLAG_USER_REQUESTED = 0x4
+ NM_SECRET_AGENT_GET_SECRETS_FLAG_WPS_PBC_ACTIVE = 0x8
+ NM_SECRET_AGENT_GET_SECRETS_FLAG_ONLY_SYSTEM = 0x80000000
+ NM_SECRET_AGENT_GET_SECRETS_FLAG_NO_ERRORS = 0x40000000
+ )
+
+ if flags&NM_SECRET_AGENT_GET_SECRETS_FLAG_REQUEST_NEW != 0 {
+ return "wrong-password"
+ }
+ if flags&NM_SECRET_AGENT_GET_SECRETS_FLAG_USER_REQUESTED != 0 {
+ return "user-requested"
+ }
+ return "required"
+}
+
+func buildGPSamlSecretsResponse(settingName, cookie, host, fingerprint string) nmSettingMap {
+ out := nmSettingMap{}
+ vpnSec := nmVariantMap{}
+
+ secrets := map[string]string{
+ "cookie": cookie,
+ "gateway": host,
+ "gwcert": fingerprint,
+ }
+ vpnSec["secrets"] = dbus.MakeVariant(secrets)
+
+ out[settingName] = vpnSec
+ return out
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/agent_networkmanager_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/agent_networkmanager_test.go
new file mode 100644
index 0000000..2040986
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/agent_networkmanager_test.go
@@ -0,0 +1,355 @@
+package network
+
+import (
+ "testing"
+
+ "github.com/godbus/dbus/v5"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestNeedsExternalBrowserAuth(t *testing.T) {
+ tests := []struct {
+ name string
+ protocol string
+ authType string
+ username string
+ data map[string]string
+ expected bool
+ }{
+ {
+ name: "GP with saml-auth-method REDIRECT",
+ protocol: "gp",
+ authType: "password",
+ username: "user",
+ data: map[string]string{"saml-auth-method": "REDIRECT"},
+ expected: true,
+ },
+ {
+ name: "GP with saml-auth-method POST",
+ protocol: "gp",
+ authType: "password",
+ username: "user",
+ data: map[string]string{"saml-auth-method": "POST"},
+ expected: true,
+ },
+ {
+ name: "GP with no authtype and no username",
+ protocol: "gp",
+ authType: "",
+ username: "",
+ data: map[string]string{},
+ expected: true,
+ },
+ {
+ name: "GP with username and password authtype",
+ protocol: "gp",
+ authType: "password",
+ username: "john",
+ data: map[string]string{},
+ expected: false,
+ },
+ {
+ name: "GP with username but no authtype",
+ protocol: "gp",
+ authType: "",
+ username: "john",
+ data: map[string]string{},
+ expected: false,
+ },
+ {
+ name: "GP with authtype but no username - should detect SAML",
+ protocol: "gp",
+ authType: "",
+ username: "",
+ data: map[string]string{},
+ expected: true,
+ },
+ {
+ name: "pulse with SAML",
+ protocol: "pulse",
+ authType: "",
+ username: "",
+ data: map[string]string{"saml-auth-method": "REDIRECT"},
+ expected: true,
+ },
+ {
+ name: "fortinet with non-password authtype",
+ protocol: "fortinet",
+ authType: "saml",
+ username: "",
+ data: map[string]string{},
+ expected: true,
+ },
+ {
+ name: "anyconnect with cert",
+ protocol: "anyconnect",
+ authType: "cert",
+ username: "",
+ data: map[string]string{},
+ expected: false,
+ },
+ {
+ name: "anyconnect with password",
+ protocol: "anyconnect",
+ authType: "password",
+ username: "user",
+ data: map[string]string{},
+ expected: false,
+ },
+ {
+ name: "empty protocol",
+ protocol: "",
+ authType: "",
+ username: "",
+ data: map[string]string{},
+ expected: false,
+ },
+ {
+ name: "GP with cert authtype",
+ protocol: "gp",
+ authType: "cert",
+ username: "",
+ data: map[string]string{},
+ expected: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := needsExternalBrowserAuth(tt.protocol, tt.authType, tt.username, tt.data)
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
+
+func TestBuildGPSamlSecretsResponse(t *testing.T) {
+ tests := []struct {
+ name string
+ settingName string
+ cookie string
+ host string
+ fingerprint string
+ }{
+ {
+ name: "all fields populated",
+ settingName: "vpn",
+ cookie: "authcookie=abc123&portal=GATE",
+ host: "vpn.example.com",
+ fingerprint: "pin-sha256:ABCD1234",
+ },
+ {
+ name: "empty fingerprint",
+ settingName: "vpn",
+ cookie: "authcookie=xyz",
+ host: "10.0.0.1",
+ fingerprint: "",
+ },
+ {
+ name: "complex cookie with special chars",
+ settingName: "vpn",
+ cookie: "authcookie=077058d3bc81&portal=PANGP_GW_01-N&user=john.doe@example.com&domain=Default&preferred-ip=192.168.1.100",
+ host: "connect.seclore.com",
+ fingerprint: "pin-sha256:xp3scfzy3rOgQEXnfPiYKrUk7D66a8b8O+gEXaMPleE=",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := buildGPSamlSecretsResponse(tt.settingName, tt.cookie, tt.host, tt.fingerprint)
+
+ assert.NotNil(t, result)
+ assert.Contains(t, result, tt.settingName)
+
+ vpnSec := result[tt.settingName]
+ assert.NotNil(t, vpnSec)
+
+ secretsVariant, ok := vpnSec["secrets"]
+ assert.True(t, ok, "secrets key should exist")
+
+ secrets, ok := secretsVariant.Value().(map[string]string)
+ assert.True(t, ok, "secrets should be map[string]string")
+
+ assert.Equal(t, tt.cookie, secrets["cookie"])
+ assert.Equal(t, tt.host, secrets["gateway"])
+ assert.Equal(t, tt.fingerprint, secrets["gwcert"])
+ })
+ }
+}
+
+func TestVpnFieldMeta_GPSaml(t *testing.T) {
+ label, isSecret := vpnFieldMeta("gp-saml", "org.freedesktop.NetworkManager.openconnect")
+
+ assert.Equal(t, "GlobalProtect SAML/SSO", label)
+ assert.False(t, isSecret, "gp-saml should not be marked as secret")
+}
+
+func TestVpnFieldMeta_StandardFields(t *testing.T) {
+ tests := []struct {
+ field string
+ vpnService string
+ expectedLabel string
+ expectedSecret bool
+ }{
+ {
+ field: "username",
+ vpnService: "org.freedesktop.NetworkManager.openconnect",
+ expectedLabel: "Username",
+ expectedSecret: false,
+ },
+ {
+ field: "password",
+ vpnService: "org.freedesktop.NetworkManager.openconnect",
+ expectedLabel: "Password",
+ expectedSecret: true,
+ },
+ {
+ field: "key_pass",
+ vpnService: "org.freedesktop.NetworkManager.openconnect",
+ expectedLabel: "PIN",
+ expectedSecret: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.field, func(t *testing.T) {
+ label, isSecret := vpnFieldMeta(tt.field, tt.vpnService)
+ assert.Equal(t, tt.expectedLabel, label)
+ assert.Equal(t, tt.expectedSecret, isSecret)
+ })
+ }
+}
+
+func TestInferVPNFields_GPSaml(t *testing.T) {
+ tests := []struct {
+ name string
+ vpnService string
+ dataMap map[string]string
+ expectedLen int
+ shouldHave []string
+ }{
+ {
+ name: "GP with no authtype and no username - should require SAML",
+ vpnService: "org.freedesktop.NetworkManager.openconnect",
+ dataMap: map[string]string{
+ "protocol": "gp",
+ "gateway": "vpn.example.com",
+ },
+ expectedLen: 1,
+ shouldHave: []string{"gp-saml"},
+ },
+ {
+ name: "GP with saml-auth-method REDIRECT",
+ vpnService: "org.freedesktop.NetworkManager.openconnect",
+ dataMap: map[string]string{
+ "protocol": "gp",
+ "gateway": "vpn.example.com",
+ "saml-auth-method": "REDIRECT",
+ "username": "john",
+ },
+ expectedLen: 1,
+ shouldHave: []string{"gp-saml"},
+ },
+ {
+ name: "GP with saml-auth-method POST",
+ vpnService: "org.freedesktop.NetworkManager.openconnect",
+ dataMap: map[string]string{
+ "protocol": "gp",
+ "gateway": "vpn.example.com",
+ "saml-auth-method": "POST",
+ },
+ expectedLen: 1,
+ shouldHave: []string{"gp-saml"},
+ },
+ {
+ name: "GP with username and password authtype - should use credentials",
+ vpnService: "org.freedesktop.NetworkManager.openconnect",
+ dataMap: map[string]string{
+ "protocol": "gp",
+ "gateway": "vpn.example.com",
+ "authtype": "password",
+ "username": "john",
+ },
+ expectedLen: 1,
+ shouldHave: []string{"password"},
+ },
+ {
+ name: "GP with username but no authtype - password only",
+ vpnService: "org.freedesktop.NetworkManager.openconnect",
+ dataMap: map[string]string{
+ "protocol": "gp",
+ "gateway": "vpn.example.com",
+ "username": "john",
+ },
+ expectedLen: 1,
+ shouldHave: []string{"password"},
+ },
+ {
+ name: "GP with PKCS11 cert",
+ vpnService: "org.freedesktop.NetworkManager.openconnect",
+ dataMap: map[string]string{
+ "protocol": "gp",
+ "gateway": "vpn.example.com",
+ "authtype": "cert",
+ "usercert": "pkcs11:model=PKCS%2315%20emulated;manufacturer=piv_II",
+ },
+ expectedLen: 1,
+ shouldHave: []string{"key_pass"},
+ },
+ {
+ name: "non-GP protocol (anyconnect)",
+ vpnService: "org.freedesktop.NetworkManager.openconnect",
+ dataMap: map[string]string{
+ "protocol": "anyconnect",
+ "gateway": "vpn.example.com",
+ },
+ expectedLen: 2,
+ shouldHave: []string{"username", "password"},
+ },
+ {
+ name: "OpenVPN with username",
+ vpnService: "org.freedesktop.NetworkManager.openvpn",
+ dataMap: map[string]string{
+ "connection-type": "password",
+ "username": "john",
+ },
+ expectedLen: 1,
+ shouldHave: []string{"password"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Convert dataMap to nmVariantMap
+ vpnSettings := make(nmVariantMap)
+ vpnSettings["data"] = dbus.MakeVariant(tt.dataMap)
+ vpnSettings["service-type"] = dbus.MakeVariant(tt.vpnService)
+
+ conn := make(map[string]nmVariantMap)
+ conn["vpn"] = vpnSettings
+
+ fields := inferVPNFields(conn, tt.vpnService)
+
+ assert.Len(t, fields, tt.expectedLen, "unexpected number of fields")
+ if len(tt.shouldHave) > 0 {
+ for _, expected := range tt.shouldHave {
+ assert.Contains(t, fields, expected, "should contain field: %s", expected)
+ }
+ }
+ })
+ }
+}
+
+func TestNmVariantMap(t *testing.T) {
+ // Test that nmVariantMap and nmSettingMap work correctly
+ settingMap := make(nmSettingMap)
+ variantMap := make(nmVariantMap)
+
+ variantMap["test-key"] = dbus.MakeVariant("test-value")
+ settingMap["test-setting"] = variantMap
+
+ assert.Contains(t, settingMap, "test-setting")
+ assert.Contains(t, settingMap["test-setting"], "test-key")
+
+ value := settingMap["test-setting"]["test-key"].Value()
+ assert.Equal(t, "test-value", value)
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend.go
new file mode 100644
index 0000000..f1bb04d
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend.go
@@ -0,0 +1,80 @@
+package network
+
+type Backend interface {
+ Initialize() error
+ Close()
+
+ GetWiFiEnabled() (bool, error)
+ SetWiFiEnabled(enabled bool) error
+
+ ScanWiFi() error
+ ScanWiFiDevice(device string) error
+ GetWiFiNetworkDetails(ssid string) (*NetworkInfoResponse, error)
+ GetWiFiQRCodeContent(ssid string) (string, error)
+ GetWiFiDevices() []WiFiDevice
+
+ ConnectWiFi(req ConnectionRequest) error
+ DisconnectWiFi() error
+ DisconnectWiFiDevice(device string) error
+ ForgetWiFiNetwork(ssid string) error
+ SetWiFiAutoconnect(ssid string, autoconnect bool) error
+
+ GetEthernetDevices() []EthernetDevice
+ GetWiredConnections() ([]WiredConnection, error)
+ GetWiredNetworkDetails(uuid string) (*WiredNetworkInfoResponse, error)
+ ConnectEthernet() error
+ DisconnectEthernet() error
+ DisconnectEthernetDevice(device string) error
+ ActivateWiredConnection(uuid string) error
+
+ ListVPNProfiles() ([]VPNProfile, error)
+ ListActiveVPN() ([]VPNActive, error)
+ ConnectVPN(uuidOrName string, singleActive bool) error
+ DisconnectVPN(uuidOrName string) error
+ DisconnectAllVPN() error
+ ClearVPNCredentials(uuidOrName string) error
+ ListVPNPlugins() ([]VPNPlugin, error)
+ ImportVPN(filePath string, name string) (*VPNImportResult, error)
+ GetVPNConfig(uuidOrName string) (*VPNConfig, error)
+ UpdateVPNConfig(uuid string, updates map[string]any) error
+ SetVPNCredentials(uuid string, username string, password string, save bool) error
+ DeleteVPN(uuidOrName string) error
+
+ GetCurrentState() (*BackendState, error)
+
+ StartMonitoring(onStateChange func()) error
+ StopMonitoring()
+
+ GetPromptBroker() PromptBroker
+ SetPromptBroker(broker PromptBroker) error
+ SubmitCredentials(token string, secrets map[string]string, save bool) error
+ CancelCredentials(token string) error
+}
+
+type BackendState struct {
+ Backend string
+ NetworkStatus NetworkStatus
+ EthernetIP string
+ EthernetDevice string
+ EthernetConnected bool
+ EthernetConnectionUuid string
+ EthernetDevices []EthernetDevice
+ WiFiIP string
+ WiFiDevice string
+ WiFiConnected bool
+ WiFiEnabled bool
+ WiFiSSID string
+ WiFiBSSID string
+ WiFiSignal uint8
+ WiFiNetworks []WiFiNetwork
+ WiFiDevices []WiFiDevice
+ WiredConnections []WiredConnection
+ VPNProfiles []VPNProfile
+ VPNActive []VPNActive
+ IsConnecting bool
+ ConnectingSSID string
+ ConnectingDevice string
+ IsConnectingVPN bool
+ ConnectingVPNUUID string
+ LastError string
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_hybrid_iwd_networkd.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_hybrid_iwd_networkd.go
new file mode 100644
index 0000000..ce883ba
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_hybrid_iwd_networkd.go
@@ -0,0 +1,245 @@
+package network
+
+import (
+ "fmt"
+)
+
+type HybridIwdNetworkdBackend struct {
+ wifi *IWDBackend
+ l3 *SystemdNetworkdBackend
+ onStateChange func()
+}
+
+func NewHybridIwdNetworkdBackend(w *IWDBackend, n *SystemdNetworkdBackend) (*HybridIwdNetworkdBackend, error) {
+ return &HybridIwdNetworkdBackend{
+ wifi: w,
+ l3: n,
+ }, nil
+}
+
+func (b *HybridIwdNetworkdBackend) Initialize() error {
+ if err := b.wifi.Initialize(); err != nil {
+ return fmt.Errorf("iwd init: %w", err)
+ }
+ if err := b.l3.Initialize(); err != nil {
+ return fmt.Errorf("networkd init: %w", err)
+ }
+ return nil
+}
+
+func (b *HybridIwdNetworkdBackend) Close() {
+ b.wifi.Close()
+ b.l3.Close()
+}
+
+func (b *HybridIwdNetworkdBackend) StartMonitoring(onStateChange func()) error {
+ b.onStateChange = onStateChange
+
+ mergedCallback := func() {
+ ws, _ := b.wifi.GetCurrentState()
+ ls, _ := b.l3.GetCurrentState()
+
+ if ws != nil && ls != nil && ws.WiFiDevice != "" && ls.WiFiIP != "" {
+ b.wifi.MarkIPConfigSeen()
+ }
+
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+ }
+
+ if err := b.wifi.StartMonitoring(mergedCallback); err != nil {
+ return fmt.Errorf("wifi monitoring: %w", err)
+ }
+ if err := b.l3.StartMonitoring(mergedCallback); err != nil {
+ return fmt.Errorf("l3 monitoring: %w", err)
+ }
+
+ return nil
+}
+
+func (b *HybridIwdNetworkdBackend) StopMonitoring() {
+ b.wifi.StopMonitoring()
+ b.l3.StopMonitoring()
+}
+
+func (b *HybridIwdNetworkdBackend) GetCurrentState() (*BackendState, error) {
+ ws, err := b.wifi.GetCurrentState()
+ if err != nil {
+ return nil, err
+ }
+ ls, err := b.l3.GetCurrentState()
+ if err != nil {
+ return nil, err
+ }
+
+ merged := *ws
+ merged.Backend = "iwd+networkd"
+
+ merged.WiFiIP = ls.WiFiIP
+ merged.EthernetConnected = ls.EthernetConnected
+ merged.EthernetIP = ls.EthernetIP
+ merged.EthernetDevice = ls.EthernetDevice
+ merged.EthernetConnectionUuid = ls.EthernetConnectionUuid
+ merged.WiredConnections = ls.WiredConnections
+ merged.EthernetDevices = ls.EthernetDevices
+
+ if ls.EthernetConnected && ls.EthernetIP != "" {
+ merged.NetworkStatus = StatusEthernet
+ } else if ws.WiFiConnected && ls.WiFiIP != "" {
+ merged.NetworkStatus = StatusWiFi
+ } else {
+ merged.NetworkStatus = StatusDisconnected
+ }
+
+ return &merged, nil
+}
+
+func (b *HybridIwdNetworkdBackend) GetWiFiEnabled() (bool, error) {
+ return b.wifi.GetWiFiEnabled()
+}
+
+func (b *HybridIwdNetworkdBackend) SetWiFiEnabled(enabled bool) error {
+ return b.wifi.SetWiFiEnabled(enabled)
+}
+
+func (b *HybridIwdNetworkdBackend) ScanWiFi() error {
+ return b.wifi.ScanWiFi()
+}
+
+func (b *HybridIwdNetworkdBackend) GetWiFiNetworkDetails(ssid string) (*NetworkInfoResponse, error) {
+ return b.wifi.GetWiFiNetworkDetails(ssid)
+}
+
+func (b *HybridIwdNetworkdBackend) GetWiFiQRCodeContent(ssid string) (string, error) {
+ return b.wifi.GetWiFiQRCodeContent(ssid)
+}
+
+func (b *HybridIwdNetworkdBackend) ConnectWiFi(req ConnectionRequest) error {
+ if err := b.wifi.ConnectWiFi(req); err != nil {
+ return err
+ }
+
+ ws, err := b.wifi.GetCurrentState()
+ if err == nil && ws.WiFiDevice != "" {
+ b.l3.EnsureDhcpUp(ws.WiFiDevice) //nolint:errcheck
+ }
+
+ return nil
+}
+
+func (b *HybridIwdNetworkdBackend) DisconnectWiFi() error {
+ return b.wifi.DisconnectWiFi()
+}
+
+func (b *HybridIwdNetworkdBackend) ForgetWiFiNetwork(ssid string) error {
+ return b.wifi.ForgetWiFiNetwork(ssid)
+}
+
+func (b *HybridIwdNetworkdBackend) GetWiredConnections() ([]WiredConnection, error) {
+ return b.l3.GetWiredConnections()
+}
+
+func (b *HybridIwdNetworkdBackend) GetWiredNetworkDetails(uuid string) (*WiredNetworkInfoResponse, error) {
+ return b.l3.GetWiredNetworkDetails(uuid)
+}
+
+func (b *HybridIwdNetworkdBackend) ConnectEthernet() error {
+ return b.l3.ConnectEthernet()
+}
+
+func (b *HybridIwdNetworkdBackend) DisconnectEthernet() error {
+ return b.l3.DisconnectEthernet()
+}
+
+func (b *HybridIwdNetworkdBackend) DisconnectEthernetDevice(device string) error {
+ return b.l3.DisconnectEthernetDevice(device)
+}
+
+func (b *HybridIwdNetworkdBackend) GetEthernetDevices() []EthernetDevice {
+ return b.l3.GetEthernetDevices()
+}
+
+func (b *HybridIwdNetworkdBackend) ActivateWiredConnection(uuid string) error {
+ return b.l3.ActivateWiredConnection(uuid)
+}
+
+func (b *HybridIwdNetworkdBackend) ListVPNProfiles() ([]VPNProfile, error) {
+ return []VPNProfile{}, nil
+}
+
+func (b *HybridIwdNetworkdBackend) ListActiveVPN() ([]VPNActive, error) {
+ return []VPNActive{}, nil
+}
+
+func (b *HybridIwdNetworkdBackend) ConnectVPN(uuidOrName string, singleActive bool) error {
+ return fmt.Errorf("VPN not supported in hybrid mode")
+}
+
+func (b *HybridIwdNetworkdBackend) DisconnectVPN(uuidOrName string) error {
+ return fmt.Errorf("VPN not supported in hybrid mode")
+}
+
+func (b *HybridIwdNetworkdBackend) DisconnectAllVPN() error {
+ return fmt.Errorf("VPN not supported in hybrid mode")
+}
+
+func (b *HybridIwdNetworkdBackend) ClearVPNCredentials(uuidOrName string) error {
+ return fmt.Errorf("VPN not supported in hybrid mode")
+}
+
+func (b *HybridIwdNetworkdBackend) ListVPNPlugins() ([]VPNPlugin, error) {
+ return []VPNPlugin{}, nil
+}
+
+func (b *HybridIwdNetworkdBackend) ImportVPN(filePath string, name string) (*VPNImportResult, error) {
+ return nil, fmt.Errorf("VPN not supported in hybrid mode")
+}
+
+func (b *HybridIwdNetworkdBackend) GetVPNConfig(uuidOrName string) (*VPNConfig, error) {
+ return nil, fmt.Errorf("VPN not supported in hybrid mode")
+}
+
+func (b *HybridIwdNetworkdBackend) UpdateVPNConfig(uuid string, updates map[string]any) error {
+ return fmt.Errorf("VPN not supported in hybrid mode")
+}
+
+func (b *HybridIwdNetworkdBackend) DeleteVPN(uuidOrName string) error {
+ return fmt.Errorf("VPN not supported in hybrid mode")
+}
+
+func (b *HybridIwdNetworkdBackend) GetPromptBroker() PromptBroker {
+ return b.wifi.GetPromptBroker()
+}
+
+func (b *HybridIwdNetworkdBackend) SetPromptBroker(broker PromptBroker) error {
+ return b.wifi.SetPromptBroker(broker)
+}
+
+func (b *HybridIwdNetworkdBackend) SubmitCredentials(token string, secrets map[string]string, save bool) error {
+ return b.wifi.SubmitCredentials(token, secrets, save)
+}
+
+func (b *HybridIwdNetworkdBackend) CancelCredentials(token string) error {
+ return b.wifi.CancelCredentials(token)
+}
+
+func (b *HybridIwdNetworkdBackend) SetWiFiAutoconnect(ssid string, autoconnect bool) error {
+ return b.wifi.SetWiFiAutoconnect(ssid, autoconnect)
+}
+
+func (b *HybridIwdNetworkdBackend) ScanWiFiDevice(device string) error {
+ return b.wifi.ScanWiFiDevice(device)
+}
+
+func (b *HybridIwdNetworkdBackend) DisconnectWiFiDevice(device string) error {
+ return b.wifi.DisconnectWiFiDevice(device)
+}
+
+func (b *HybridIwdNetworkdBackend) GetWiFiDevices() []WiFiDevice {
+ return b.wifi.GetWiFiDevices()
+}
+
+func (b *HybridIwdNetworkdBackend) SetVPNCredentials(uuid, username, password string, save bool) error {
+ return fmt.Errorf("VPN not supported in hybrid mode")
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_hybrid_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_hybrid_test.go
new file mode 100644
index 0000000..cd061ed
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_hybrid_test.go
@@ -0,0 +1,135 @@
+package network
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestHybridIwdNetworkdBackend_New(t *testing.T) {
+ wifi, _ := NewIWDBackend()
+ l3, _ := NewSystemdNetworkdBackend()
+
+ hybrid, err := NewHybridIwdNetworkdBackend(wifi, l3)
+ assert.NoError(t, err)
+ assert.NotNil(t, hybrid)
+ assert.NotNil(t, hybrid.wifi)
+ assert.NotNil(t, hybrid.l3)
+}
+
+func TestHybridIwdNetworkdBackend_GetCurrentState_MergesState(t *testing.T) {
+ wifi, _ := NewIWDBackend()
+ l3, _ := NewSystemdNetworkdBackend()
+ hybrid, _ := NewHybridIwdNetworkdBackend(wifi, l3)
+
+ wifi.state.WiFiConnected = true
+ wifi.state.WiFiSSID = "TestNetwork"
+ wifi.state.WiFiBSSID = "00:11:22:33:44:55"
+ wifi.state.WiFiSignal = 75
+ wifi.state.WiFiDevice = "wlan0"
+
+ l3.state.WiFiIP = "192.168.1.100"
+ l3.state.EthernetConnected = false
+
+ state, err := hybrid.GetCurrentState()
+ assert.NoError(t, err)
+ assert.NotNil(t, state)
+ assert.Equal(t, "iwd+networkd", state.Backend)
+ assert.Equal(t, "TestNetwork", state.WiFiSSID)
+ assert.Equal(t, "00:11:22:33:44:55", state.WiFiBSSID)
+ assert.Equal(t, uint8(75), state.WiFiSignal)
+ assert.Equal(t, "192.168.1.100", state.WiFiIP)
+ assert.True(t, state.WiFiConnected)
+ assert.False(t, state.EthernetConnected)
+ assert.Equal(t, StatusWiFi, state.NetworkStatus)
+}
+
+func TestHybridIwdNetworkdBackend_GetCurrentState_EthernetPriority(t *testing.T) {
+ wifi, _ := NewIWDBackend()
+ l3, _ := NewSystemdNetworkdBackend()
+ hybrid, _ := NewHybridIwdNetworkdBackend(wifi, l3)
+
+ wifi.state.WiFiConnected = true
+ wifi.state.WiFiSSID = "TestNetwork"
+
+ l3.state.WiFiIP = "192.168.1.100"
+ l3.state.EthernetConnected = true
+ l3.state.EthernetIP = "192.168.1.50"
+ l3.state.EthernetDevice = "eth0"
+
+ state, err := hybrid.GetCurrentState()
+ assert.NoError(t, err)
+ assert.Equal(t, StatusEthernet, state.NetworkStatus)
+ assert.Equal(t, "192.168.1.50", state.EthernetIP)
+ assert.Equal(t, "eth0", state.EthernetDevice)
+}
+
+func TestHybridIwdNetworkdBackend_GetCurrentState_WiFiNoIP(t *testing.T) {
+ wifi, _ := NewIWDBackend()
+ l3, _ := NewSystemdNetworkdBackend()
+ hybrid, _ := NewHybridIwdNetworkdBackend(wifi, l3)
+
+ wifi.state.WiFiConnected = true
+ wifi.state.WiFiSSID = "TestNetwork"
+
+ l3.state.WiFiIP = ""
+ l3.state.EthernetConnected = false
+
+ state, err := hybrid.GetCurrentState()
+ assert.NoError(t, err)
+ assert.Equal(t, StatusDisconnected, state.NetworkStatus)
+ assert.True(t, state.WiFiConnected)
+ assert.Empty(t, state.WiFiIP)
+}
+
+func TestHybridIwdNetworkdBackend_WiFiDelegation(t *testing.T) {
+ wifi, _ := NewIWDBackend()
+ l3, _ := NewSystemdNetworkdBackend()
+ hybrid, _ := NewHybridIwdNetworkdBackend(wifi, l3)
+
+ enabled, err := hybrid.GetWiFiEnabled()
+ assert.NoError(t, err)
+ assert.True(t, enabled)
+
+ state, err := hybrid.GetCurrentState()
+ assert.NoError(t, err)
+ assert.NotNil(t, state)
+ assert.Equal(t, "iwd+networkd", state.Backend)
+}
+
+func TestHybridIwdNetworkdBackend_WiredDelegation(t *testing.T) {
+ wifi, _ := NewIWDBackend()
+ l3, _ := NewSystemdNetworkdBackend()
+ hybrid, _ := NewHybridIwdNetworkdBackend(wifi, l3)
+
+ conns, err := hybrid.GetWiredConnections()
+ assert.NoError(t, err)
+ assert.Empty(t, conns)
+}
+
+func TestHybridIwdNetworkdBackend_VPNNotSupported(t *testing.T) {
+ wifi, _ := NewIWDBackend()
+ l3, _ := NewSystemdNetworkdBackend()
+ hybrid, _ := NewHybridIwdNetworkdBackend(wifi, l3)
+
+ profiles, err := hybrid.ListVPNProfiles()
+ assert.NoError(t, err)
+ assert.Empty(t, profiles)
+
+ active, err := hybrid.ListActiveVPN()
+ assert.NoError(t, err)
+ assert.Empty(t, active)
+
+ err = hybrid.ConnectVPN("test", false)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not supported")
+}
+
+func TestHybridIwdNetworkdBackend_PromptBrokerDelegation(t *testing.T) {
+ wifi, _ := NewIWDBackend()
+ l3, _ := NewSystemdNetworkdBackend()
+ hybrid, _ := NewHybridIwdNetworkdBackend(wifi, l3)
+
+ broker := hybrid.GetPromptBroker()
+ assert.Nil(t, broker)
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_iwd.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_iwd.go
new file mode 100644
index 0000000..f73aaf4
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_iwd.go
@@ -0,0 +1,238 @@
+package network
+
+import (
+ "fmt"
+ "sync"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "github.com/godbus/dbus/v5"
+)
+
+const (
+ iwdBusName = "net.connman.iwd"
+ iwdObjectPath = "/"
+ iwdAdapterInterface = "net.connman.iwd.Adapter"
+ iwdDeviceInterface = "net.connman.iwd.Device"
+ iwdStationInterface = "net.connman.iwd.Station"
+ iwdNetworkInterface = "net.connman.iwd.Network"
+ iwdKnownNetworkInterface = "net.connman.iwd.KnownNetwork"
+ dbusObjectManager = "org.freedesktop.DBus.ObjectManager"
+ dbusPropertiesInterface = "org.freedesktop.DBus.Properties"
+)
+
+type connectAttempt struct {
+ ssid string
+ netPath dbus.ObjectPath
+ start time.Time
+ deadline time.Time
+ sawAuthish bool
+ connectedAt time.Time
+ sawIPConfig bool
+ sawPromptRetry bool
+ finalized bool
+ mu sync.Mutex
+}
+
+type IWDBackend struct {
+ conn *dbus.Conn
+ state *BackendState
+ stateMutex sync.RWMutex
+ promptBroker PromptBroker
+ onStateChange func()
+
+ devicePath dbus.ObjectPath
+ stationPath dbus.ObjectPath
+ adapterPath dbus.ObjectPath
+
+ iwdAgent *IWDAgent
+
+ stopChan chan struct{}
+ sigWG sync.WaitGroup
+ curAttempt *connectAttempt
+ attemptMutex sync.RWMutex
+ recentScans map[string]time.Time
+ recentScansMu sync.Mutex
+}
+
+func NewIWDBackend() (*IWDBackend, error) {
+ backend := &IWDBackend{
+ state: &BackendState{
+ Backend: "iwd",
+ WiFiEnabled: true,
+ },
+ stopChan: make(chan struct{}),
+ recentScans: make(map[string]time.Time),
+ }
+
+ return backend, nil
+}
+
+func (b *IWDBackend) Initialize() error {
+ conn, err := dbus.ConnectSystemBus()
+ if err != nil {
+ return fmt.Errorf("failed to connect to system bus: %w", err)
+ }
+ b.conn = conn
+
+ if err := b.discoverDevices(); err != nil {
+ conn.Close()
+ return fmt.Errorf("failed to discover iwd devices: %w", err)
+ }
+
+ if err := b.updateState(); err != nil {
+ conn.Close()
+ return fmt.Errorf("failed to get initial state: %w", err)
+ }
+
+ return nil
+}
+
+func (b *IWDBackend) Close() {
+ close(b.stopChan)
+ b.sigWG.Wait()
+
+ if b.iwdAgent != nil {
+ b.iwdAgent.Close()
+ }
+
+ if b.conn != nil {
+ b.conn.Close()
+ }
+}
+
+func (b *IWDBackend) discoverDevices() error {
+ obj := b.conn.Object(iwdBusName, iwdObjectPath)
+
+ var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
+ err := obj.Call(dbusObjectManager+".GetManagedObjects", 0).Store(&objects)
+ if err != nil {
+ return fmt.Errorf("failed to get managed objects: %w", err)
+ }
+
+ for path, interfaces := range objects {
+ if _, hasStation := interfaces[iwdStationInterface]; hasStation {
+ b.stationPath = path
+ }
+ if _, hasDevice := interfaces[iwdDeviceInterface]; hasDevice {
+ b.devicePath = path
+
+ if devProps, ok := interfaces[iwdDeviceInterface]; ok {
+ if nameVar, ok := devProps["Name"]; ok {
+ if name, ok := nameVar.Value().(string); ok {
+ b.stateMutex.Lock()
+ b.state.WiFiDevice = name
+ b.stateMutex.Unlock()
+ }
+ }
+ }
+ }
+ if _, hasAdapter := interfaces[iwdAdapterInterface]; hasAdapter {
+ b.adapterPath = path
+ }
+ }
+
+ if b.stationPath == "" || b.devicePath == "" {
+ return fmt.Errorf("no WiFi device found")
+ }
+
+ return nil
+}
+
+func (b *IWDBackend) GetCurrentState() (*BackendState, error) {
+ b.stateMutex.RLock()
+ defer b.stateMutex.RUnlock()
+
+ state := *b.state
+ state.WiFiNetworks = append([]WiFiNetwork(nil), b.state.WiFiNetworks...)
+ state.WiredConnections = append([]WiredConnection(nil), b.state.WiredConnections...)
+ state.WiFiDevices = b.getWiFiDevicesLocked()
+
+ return &state, nil
+}
+
+func (b *IWDBackend) OnUserCanceledPrompt() {
+ b.stateMutex.RLock()
+ cancelledSSID := b.state.ConnectingSSID
+ b.stateMutex.RUnlock()
+
+ b.setConnectError("user-canceled")
+
+ if cancelledSSID != "" {
+ if err := b.ForgetWiFiNetwork(cancelledSSID); err != nil {
+ log.Warnf("failed to forget cancelled WiFi network %s: %v", cancelledSSID, err)
+ }
+ }
+
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+}
+
+func (b *IWDBackend) OnPromptRetry(ssid string) {
+ b.attemptMutex.RLock()
+ att := b.curAttempt
+ b.attemptMutex.RUnlock()
+
+ if att != nil && att.ssid == ssid {
+ att.mu.Lock()
+ att.sawPromptRetry = true
+ att.mu.Unlock()
+ }
+}
+
+func (b *IWDBackend) MarkIPConfigSeen() {
+ b.attemptMutex.RLock()
+ att := b.curAttempt
+ b.attemptMutex.RUnlock()
+ if att != nil {
+ att.mu.Lock()
+ att.sawIPConfig = true
+ att.mu.Unlock()
+ }
+}
+
+func (b *IWDBackend) GetPromptBroker() PromptBroker {
+ return b.promptBroker
+}
+
+func (b *IWDBackend) SetPromptBroker(broker PromptBroker) error {
+ if broker == nil {
+ return fmt.Errorf("broker cannot be nil")
+ }
+
+ b.promptBroker = broker
+ return nil
+}
+
+func (b *IWDBackend) SubmitCredentials(token string, secrets map[string]string, save bool) error {
+ if b.promptBroker == nil {
+ return fmt.Errorf("prompt broker not initialized")
+ }
+
+ return b.promptBroker.Resolve(token, PromptReply{
+ Secrets: secrets,
+ Save: save,
+ Cancel: false,
+ })
+}
+
+func (b *IWDBackend) CancelCredentials(token string) error {
+ if b.promptBroker == nil {
+ return fmt.Errorf("prompt broker not initialized")
+ }
+
+ return b.promptBroker.Resolve(token, PromptReply{
+ Cancel: true,
+ })
+}
+
+func (b *IWDBackend) StopMonitoring() {
+ select {
+ case <-b.stopChan:
+ return
+ default:
+ close(b.stopChan)
+ }
+ b.sigWG.Wait()
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_iwd_signals.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_iwd_signals.go
new file mode 100644
index 0000000..bde4753
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_iwd_signals.go
@@ -0,0 +1,355 @@
+package network
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/godbus/dbus/v5"
+)
+
+func (b *IWDBackend) StartMonitoring(onStateChange func()) error {
+ b.onStateChange = onStateChange
+
+ if b.promptBroker != nil {
+ agent, err := NewIWDAgent(b.conn, b.promptBroker)
+ if err != nil {
+ return fmt.Errorf("failed to start IWD agent: %w", err)
+ }
+ agent.onUserCanceled = b.OnUserCanceledPrompt
+ agent.onPromptRetry = b.OnPromptRetry
+ b.iwdAgent = agent
+ }
+
+ sigChan := make(chan *dbus.Signal, 100)
+ b.conn.Signal(sigChan)
+
+ if b.devicePath != "" {
+ err := b.conn.AddMatchSignal(
+ dbus.WithMatchObjectPath(b.devicePath),
+ dbus.WithMatchInterface(dbusPropertiesInterface),
+ dbus.WithMatchMember("PropertiesChanged"),
+ )
+ if err != nil {
+ return fmt.Errorf("failed to add device signal match: %w", err)
+ }
+ }
+
+ if b.stationPath != "" {
+ err := b.conn.AddMatchSignal(
+ dbus.WithMatchObjectPath(b.stationPath),
+ dbus.WithMatchInterface(dbusPropertiesInterface),
+ dbus.WithMatchMember("PropertiesChanged"),
+ )
+ if err != nil {
+ return fmt.Errorf("failed to add station signal match: %w", err)
+ }
+ }
+
+ b.sigWG.Add(1)
+ go b.signalHandler(sigChan)
+
+ return nil
+}
+
+func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) {
+ defer b.sigWG.Done()
+
+ for {
+ select {
+ case <-b.stopChan:
+ b.conn.RemoveSignal(sigChan)
+ close(sigChan)
+ return
+
+ case sig := <-sigChan:
+ if sig == nil {
+ return
+ }
+
+ if sig.Name != dbusPropertiesInterface+".PropertiesChanged" {
+ continue
+ }
+
+ if len(sig.Body) < 2 {
+ continue
+ }
+
+ iface, ok := sig.Body[0].(string)
+ if !ok {
+ continue
+ }
+
+ changed, ok := sig.Body[1].(map[string]dbus.Variant)
+ if !ok {
+ continue
+ }
+
+ stateChanged := false
+
+ switch iface {
+ case iwdDeviceInterface:
+ if sig.Path == b.devicePath {
+ if poweredVar, ok := changed["Powered"]; ok {
+ if powered, ok := poweredVar.Value().(bool); ok {
+ b.stateMutex.Lock()
+ if b.state.WiFiEnabled != powered {
+ b.state.WiFiEnabled = powered
+ stateChanged = true
+ }
+ b.stateMutex.Unlock()
+ }
+ }
+ }
+
+ case iwdStationInterface:
+ if sig.Path == b.stationPath {
+ if scanningVar, ok := changed["Scanning"]; ok {
+ if scanning, ok := scanningVar.Value().(bool); ok && !scanning {
+ networks, err := b.updateWiFiNetworks()
+ if err == nil {
+ b.stateMutex.Lock()
+ b.state.WiFiNetworks = networks
+ b.stateMutex.Unlock()
+ stateChanged = true
+ }
+
+ b.stateMutex.RLock()
+ wifiConnected := b.state.WiFiConnected
+ b.stateMutex.RUnlock()
+
+ if wifiConnected {
+ stationObj := b.conn.Object(iwdBusName, b.stationPath)
+ connNetVar, err := stationObj.GetProperty(iwdStationInterface + ".ConnectedNetwork")
+ if err == nil && connNetVar.Value() != nil {
+ if netPath, ok := connNetVar.Value().(dbus.ObjectPath); ok && netPath != "/" {
+ var orderedNetworks [][]dbus.Variant
+ err = stationObj.Call(iwdStationInterface+".GetOrderedNetworks", 0).Store(&orderedNetworks)
+ if err == nil {
+ for _, netData := range orderedNetworks {
+ if len(netData) < 2 {
+ continue
+ }
+ currentNetPath, ok := netData[0].Value().(dbus.ObjectPath)
+ if !ok || currentNetPath != netPath {
+ continue
+ }
+ signalStrength, ok := netData[1].Value().(int16)
+ if !ok {
+ continue
+ }
+ signalDbm := signalStrength / 100
+ signal := uint8(signalDbm + 100)
+ if signalDbm > 0 {
+ signal = 100
+ } else if signalDbm < -100 {
+ signal = 0
+ }
+ b.stateMutex.Lock()
+ if b.state.WiFiSignal != signal {
+ b.state.WiFiSignal = signal
+ stateChanged = true
+ }
+ b.stateMutex.Unlock()
+ break
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ if stateVar, ok := changed["State"]; ok {
+ if state, ok := stateVar.Value().(string); ok {
+ b.attemptMutex.RLock()
+ att := b.curAttempt
+ b.attemptMutex.RUnlock()
+
+ var connPath dbus.ObjectPath
+ if v, ok := changed["ConnectedNetwork"]; ok {
+ if v.Value() != nil {
+ if p, ok := v.Value().(dbus.ObjectPath); ok {
+ connPath = p
+ }
+ }
+ }
+ if connPath == "" {
+ station := b.conn.Object(iwdBusName, b.stationPath)
+ if cnVar, err := station.GetProperty(iwdStationInterface + ".ConnectedNetwork"); err == nil && cnVar.Value() != nil {
+ cnVar.Store(&connPath)
+ }
+ }
+
+ b.stateMutex.RLock()
+ prevConnected := b.state.WiFiConnected
+ prevSSID := b.state.WiFiSSID
+ b.stateMutex.RUnlock()
+
+ targetPath := dbus.ObjectPath("")
+ if att != nil {
+ targetPath = att.netPath
+ }
+
+ isTarget := att != nil && targetPath != "" && connPath == targetPath
+
+ if att != nil {
+ switch state {
+ case "authenticating", "associating", "associated", "roaming":
+ att.mu.Lock()
+ att.sawAuthish = true
+ att.mu.Unlock()
+ }
+ }
+
+ if att != nil && state == "connected" && isTarget {
+ att.mu.Lock()
+ if att.connectedAt.IsZero() {
+ att.connectedAt = time.Now()
+ }
+ att.mu.Unlock()
+ }
+
+ if att != nil && state == "configuring" {
+ att.mu.Lock()
+ att.sawIPConfig = true
+ att.mu.Unlock()
+ }
+
+ switch state {
+ case "connected":
+ b.stateMutex.Lock()
+ b.state.WiFiConnected = true
+ b.state.NetworkStatus = StatusWiFi
+ b.state.IsConnecting = false
+ b.state.ConnectingSSID = ""
+ b.state.LastError = ""
+ b.stateMutex.Unlock()
+
+ if connPath != "" && connPath != "/" {
+ netObj := b.conn.Object(iwdBusName, connPath)
+ if nameVar, err := netObj.GetProperty(iwdNetworkInterface + ".Name"); err == nil {
+ if name, ok := nameVar.Value().(string); ok {
+ b.stateMutex.Lock()
+ b.state.WiFiSSID = name
+ b.stateMutex.Unlock()
+ }
+ }
+ }
+
+ stateChanged = true
+
+ if att != nil && isTarget {
+ go func(attLocal *connectAttempt, tgt dbus.ObjectPath) {
+ time.Sleep(3 * time.Second)
+ station := b.conn.Object(iwdBusName, b.stationPath)
+ var nowState string
+ if stVar, err := station.GetProperty(iwdStationInterface + ".State"); err == nil {
+ stVar.Store(&nowState)
+ }
+ var nowConn dbus.ObjectPath
+ if cnVar, err := station.GetProperty(iwdStationInterface + ".ConnectedNetwork"); err == nil && cnVar.Value() != nil {
+ cnVar.Store(&nowConn)
+ }
+
+ if nowState == "connected" && nowConn == tgt {
+ b.finalizeAttempt(attLocal, "")
+ b.attemptMutex.Lock()
+ if b.curAttempt == attLocal {
+ b.curAttempt = nil
+ }
+ b.attemptMutex.Unlock()
+ }
+ }(att, targetPath)
+ }
+
+ case "disconnecting", "disconnected":
+ if att != nil {
+ wasConnectedToTarget := prevConnected && prevSSID == att.ssid
+ if wasConnectedToTarget || isTarget {
+ code := b.classifyAttempt(att)
+ b.finalizeAttempt(att, code)
+ b.attemptMutex.Lock()
+ if b.curAttempt == att {
+ b.curAttempt = nil
+ }
+ b.attemptMutex.Unlock()
+ }
+ }
+
+ b.stateMutex.Lock()
+ b.state.WiFiConnected = false
+ if state == "disconnected" {
+ b.state.NetworkStatus = StatusDisconnected
+ }
+ b.stateMutex.Unlock()
+ stateChanged = true
+ }
+ }
+ }
+
+ if connNetVar, ok := changed["ConnectedNetwork"]; ok {
+ if netPath, ok := connNetVar.Value().(dbus.ObjectPath); ok && netPath != "/" {
+ netObj := b.conn.Object(iwdBusName, netPath)
+ nameVar, err := netObj.GetProperty(iwdNetworkInterface + ".Name")
+ if err == nil {
+ if name, ok := nameVar.Value().(string); ok {
+ b.stateMutex.Lock()
+ if b.state.WiFiSSID != name {
+ b.state.WiFiSSID = name
+ stateChanged = true
+ }
+ b.stateMutex.Unlock()
+ }
+ }
+
+ stationObj := b.conn.Object(iwdBusName, b.stationPath)
+ var orderedNetworks [][]dbus.Variant
+ err = stationObj.Call(iwdStationInterface+".GetOrderedNetworks", 0).Store(&orderedNetworks)
+ if err == nil {
+ for _, netData := range orderedNetworks {
+ if len(netData) < 2 {
+ continue
+ }
+ currentNetPath, ok := netData[0].Value().(dbus.ObjectPath)
+ if !ok || currentNetPath != netPath {
+ continue
+ }
+ signalStrength, ok := netData[1].Value().(int16)
+ if !ok {
+ continue
+ }
+ signalDbm := signalStrength / 100
+ signal := uint8(signalDbm + 100)
+ if signalDbm > 0 {
+ signal = 100
+ } else if signalDbm < -100 {
+ signal = 0
+ }
+ b.stateMutex.Lock()
+ if b.state.WiFiSignal != signal {
+ b.state.WiFiSignal = signal
+ stateChanged = true
+ }
+ b.stateMutex.Unlock()
+ break
+ }
+ }
+ } else {
+ b.stateMutex.Lock()
+ if b.state.WiFiSSID != "" {
+ b.state.WiFiSSID = ""
+ b.state.WiFiSignal = 0
+ stateChanged = true
+ }
+ b.stateMutex.Unlock()
+ }
+ }
+ }
+ }
+
+ if stateChanged && b.onStateChange != nil {
+ b.onStateChange()
+ }
+ }
+ }
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_iwd_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_iwd_test.go
new file mode 100644
index 0000000..829e413
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_iwd_test.go
@@ -0,0 +1,212 @@
+package network
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestIWDBackend_MarkIPConfigSeen(t *testing.T) {
+ backend, _ := NewIWDBackend()
+
+ att := &connectAttempt{
+ ssid: "TestNetwork",
+ netPath: "/net/connman/iwd/0/1/test",
+ start: time.Now(),
+ deadline: time.Now().Add(15 * time.Second),
+ }
+
+ backend.attemptMutex.Lock()
+ backend.curAttempt = att
+ backend.attemptMutex.Unlock()
+
+ backend.MarkIPConfigSeen()
+
+ att.mu.Lock()
+ assert.True(t, att.sawIPConfig, "sawIPConfig should be true after MarkIPConfigSeen")
+ att.mu.Unlock()
+}
+
+func TestIWDBackend_MarkIPConfigSeen_NoAttempt(t *testing.T) {
+ backend, _ := NewIWDBackend()
+
+ backend.attemptMutex.Lock()
+ backend.curAttempt = nil
+ backend.attemptMutex.Unlock()
+
+ backend.MarkIPConfigSeen()
+}
+
+func TestIWDBackend_OnPromptRetry(t *testing.T) {
+ backend, _ := NewIWDBackend()
+
+ att := &connectAttempt{
+ ssid: "TestNetwork",
+ netPath: "/net/connman/iwd/0/1/test",
+ start: time.Now(),
+ deadline: time.Now().Add(15 * time.Second),
+ }
+
+ backend.attemptMutex.Lock()
+ backend.curAttempt = att
+ backend.attemptMutex.Unlock()
+
+ backend.OnPromptRetry("TestNetwork")
+
+ att.mu.Lock()
+ assert.True(t, att.sawPromptRetry, "sawPromptRetry should be true after OnPromptRetry")
+ att.mu.Unlock()
+}
+
+func TestIWDBackend_OnPromptRetry_WrongSSID(t *testing.T) {
+ backend, _ := NewIWDBackend()
+
+ att := &connectAttempt{
+ ssid: "TestNetwork",
+ netPath: "/net/connman/iwd/0/1/test",
+ start: time.Now(),
+ deadline: time.Now().Add(15 * time.Second),
+ }
+
+ backend.attemptMutex.Lock()
+ backend.curAttempt = att
+ backend.attemptMutex.Unlock()
+
+ backend.OnPromptRetry("DifferentNetwork")
+
+ att.mu.Lock()
+ assert.False(t, att.sawPromptRetry, "sawPromptRetry should remain false for different SSID")
+ att.mu.Unlock()
+}
+
+func TestIWDBackend_ClassifyAttempt_BadCredentials_PromptRetry(t *testing.T) {
+ backend, _ := NewIWDBackend()
+
+ att := &connectAttempt{
+ ssid: "TestNetwork",
+ netPath: "/test",
+ start: time.Now().Add(-5 * time.Second),
+ deadline: time.Now().Add(10 * time.Second),
+ sawPromptRetry: true,
+ }
+
+ code := backend.classifyAttempt(att)
+ assert.Equal(t, "bad-credentials", code)
+}
+
+func TestIWDBackend_ClassifyAttempt_DhcpTimeout(t *testing.T) {
+ backend, _ := NewIWDBackend()
+
+ att := &connectAttempt{
+ ssid: "TestNetwork",
+ netPath: "/test",
+ start: time.Now().Add(-13 * time.Second),
+ deadline: time.Now().Add(2 * time.Second),
+ sawAuthish: true,
+ sawIPConfig: false,
+ }
+
+ code := backend.classifyAttempt(att)
+ assert.Equal(t, "dhcp-timeout", code)
+}
+
+func TestIWDBackend_ClassifyAttempt_AssocTimeout(t *testing.T) {
+ backend, _ := NewIWDBackend()
+
+ att := &connectAttempt{
+ ssid: "TestNetwork",
+ netPath: "/test",
+ start: time.Now().Add(-5 * time.Second),
+ deadline: time.Now().Add(10 * time.Second),
+ }
+
+ backend.recentScansMu.Lock()
+ backend.recentScans["TestNetwork"] = time.Now()
+ backend.recentScansMu.Unlock()
+
+ code := backend.classifyAttempt(att)
+ assert.Equal(t, "assoc-timeout", code)
+}
+
+func TestIWDBackend_ClassifyAttempt_NoSuchSSID(t *testing.T) {
+ backend, _ := NewIWDBackend()
+
+ att := &connectAttempt{
+ ssid: "TestNetwork",
+ netPath: "/test",
+ start: time.Now().Add(-5 * time.Second),
+ deadline: time.Now().Add(10 * time.Second),
+ }
+
+ code := backend.classifyAttempt(att)
+ assert.Equal(t, "no-such-ssid", code)
+}
+
+func TestIWDBackend_MapIwdDBusError(t *testing.T) {
+ backend, _ := NewIWDBackend()
+
+ testCases := []struct {
+ name string
+ expected string
+ }{
+ {"net.connman.iwd.Error.AlreadyConnected", "already-connected"},
+ {"net.connman.iwd.Error.AuthenticationFailed", "bad-credentials"},
+ {"net.connman.iwd.Error.InvalidKey", "bad-credentials"},
+ {"net.connman.iwd.Error.IncorrectPassphrase", "bad-credentials"},
+ {"net.connman.iwd.Error.NotFound", "no-such-ssid"},
+ {"net.connman.iwd.Error.NotSupported", "connection-failed"},
+ {"net.connman.iwd.Agent.Error.Canceled", "user-canceled"},
+ {"net.connman.iwd.Error.Unknown", "connection-failed"},
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ code := backend.mapIwdDBusError(tc.name)
+ assert.Equal(t, tc.expected, code)
+ })
+ }
+}
+
+func TestConnectAttempt_Finalization(t *testing.T) {
+ backend, _ := NewIWDBackend()
+ backend.state = &BackendState{}
+
+ att := &connectAttempt{
+ ssid: "TestNetwork",
+ netPath: "/test",
+ start: time.Now(),
+ deadline: time.Now().Add(15 * time.Second),
+ }
+
+ backend.finalizeAttempt(att, "bad-credentials")
+
+ att.mu.Lock()
+ assert.True(t, att.finalized)
+ att.mu.Unlock()
+
+ backend.stateMutex.RLock()
+ assert.False(t, backend.state.IsConnecting)
+ assert.Empty(t, backend.state.ConnectingSSID)
+ assert.Equal(t, "bad-credentials", backend.state.LastError)
+ backend.stateMutex.RUnlock()
+}
+
+func TestConnectAttempt_DoubleFinalization(t *testing.T) {
+ backend, _ := NewIWDBackend()
+ backend.state = &BackendState{}
+
+ att := &connectAttempt{
+ ssid: "TestNetwork",
+ netPath: "/test",
+ start: time.Now(),
+ deadline: time.Now().Add(15 * time.Second),
+ }
+
+ backend.finalizeAttempt(att, "bad-credentials")
+ backend.finalizeAttempt(att, "dhcp-timeout")
+
+ backend.stateMutex.RLock()
+ assert.Equal(t, "bad-credentials", backend.state.LastError)
+ backend.stateMutex.RUnlock()
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_iwd_unimplemented.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_iwd_unimplemented.go
new file mode 100644
index 0000000..f5a8b03
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_iwd_unimplemented.go
@@ -0,0 +1,133 @@
+package network
+
+import (
+ "fmt"
+ "os"
+)
+
+func (b *IWDBackend) GetWiredConnections() ([]WiredConnection, error) {
+ return nil, fmt.Errorf("wired connections not supported by iwd")
+}
+
+func (b *IWDBackend) GetWiredNetworkDetails(uuid string) (*WiredNetworkInfoResponse, error) {
+ return nil, fmt.Errorf("wired connections not supported by iwd")
+}
+
+func (b *IWDBackend) ConnectEthernet() error {
+ return fmt.Errorf("wired connections not supported by iwd")
+}
+
+func (b *IWDBackend) DisconnectEthernet() error {
+ return fmt.Errorf("wired connections not supported by iwd")
+}
+
+func (b *IWDBackend) DisconnectEthernetDevice(device string) error {
+ return fmt.Errorf("wired connections not supported by iwd")
+}
+
+func (b *IWDBackend) GetEthernetDevices() []EthernetDevice {
+ return []EthernetDevice{}
+}
+
+func (b *IWDBackend) ActivateWiredConnection(uuid string) error {
+ return fmt.Errorf("wired connections not supported by iwd")
+}
+
+func (b *IWDBackend) ListVPNProfiles() ([]VPNProfile, error) {
+ return nil, fmt.Errorf("VPN not supported by iwd backend")
+}
+
+func (b *IWDBackend) ListActiveVPN() ([]VPNActive, error) {
+ return nil, fmt.Errorf("VPN not supported by iwd backend")
+}
+
+func (b *IWDBackend) ConnectVPN(uuidOrName string, singleActive bool) error {
+ return fmt.Errorf("VPN not supported by iwd backend")
+}
+
+func (b *IWDBackend) DisconnectVPN(uuidOrName string) error {
+ return fmt.Errorf("VPN not supported by iwd backend")
+}
+
+func (b *IWDBackend) DisconnectAllVPN() error {
+ return fmt.Errorf("VPN not supported by iwd backend")
+}
+
+func (b *IWDBackend) ClearVPNCredentials(uuidOrName string) error {
+ return fmt.Errorf("VPN not supported by iwd backend")
+}
+
+func (b *IWDBackend) ListVPNPlugins() ([]VPNPlugin, error) {
+ return nil, fmt.Errorf("VPN not supported by iwd backend")
+}
+
+func (b *IWDBackend) ImportVPN(filePath string, name string) (*VPNImportResult, error) {
+ return nil, fmt.Errorf("VPN not supported by iwd backend")
+}
+
+func (b *IWDBackend) GetVPNConfig(uuidOrName string) (*VPNConfig, error) {
+ return nil, fmt.Errorf("VPN not supported by iwd backend")
+}
+
+func (b *IWDBackend) UpdateVPNConfig(uuid string, updates map[string]any) error {
+ return fmt.Errorf("VPN not supported by iwd backend")
+}
+
+func (b *IWDBackend) DeleteVPN(uuidOrName string) error {
+ return fmt.Errorf("VPN not supported by iwd backend")
+}
+
+func (b *IWDBackend) SetVPNCredentials(uuid, username, password string, save bool) error {
+ return fmt.Errorf("VPN not supported by iwd backend")
+}
+
+func (b *IWDBackend) ScanWiFiDevice(device string) error {
+ return b.ScanWiFi()
+}
+
+func (b *IWDBackend) DisconnectWiFiDevice(device string) error {
+ return b.DisconnectWiFi()
+}
+
+func (b *IWDBackend) GetWiFiDevices() []WiFiDevice {
+ b.stateMutex.RLock()
+ defer b.stateMutex.RUnlock()
+ return b.getWiFiDevicesLocked()
+}
+
+func (b *IWDBackend) getWiFiDevicesLocked() []WiFiDevice {
+ if b.state.WiFiDevice == "" {
+ return nil
+ }
+
+ stateStr := "disconnected"
+ if b.state.WiFiConnected {
+ stateStr = "connected"
+ }
+
+ return []WiFiDevice{{
+ Name: b.state.WiFiDevice,
+ State: stateStr,
+ Connected: b.state.WiFiConnected,
+ SSID: b.state.WiFiSSID,
+ Signal: b.state.WiFiSignal,
+ IP: b.state.WiFiIP,
+ Networks: b.state.WiFiNetworks,
+ }}
+}
+
+func (b *IWDBackend) GetWiFiQRCodeContent(ssid string) (string, error) {
+ path := iwdConfigPath(ssid)
+
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return "", fmt.Errorf("no saved iwd config for `%s`: %w", ssid, err)
+ }
+
+ passphrase, err := parseIWDPassphrase(string(data))
+ if err != nil {
+ return "", fmt.Errorf("failed to read passphrase for `%s`: %w", ssid, err)
+ }
+
+ return FormatWiFiQRString("WPA", ssid, passphrase), nil
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_iwd_wifi.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_iwd_wifi.go
new file mode 100644
index 0000000..b3bea13
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_iwd_wifi.go
@@ -0,0 +1,662 @@
+package network
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
+ "github.com/godbus/dbus/v5"
+)
+
+func (b *IWDBackend) updateState() error {
+ if b.devicePath == "" {
+ return nil
+ }
+
+ obj := b.conn.Object(iwdBusName, b.devicePath)
+
+ poweredVar, err := obj.GetProperty(iwdDeviceInterface + ".Powered")
+ if err == nil {
+ if powered, ok := poweredVar.Value().(bool); ok {
+ b.stateMutex.Lock()
+ b.state.WiFiEnabled = powered
+ b.stateMutex.Unlock()
+ }
+ }
+
+ if b.stationPath == "" {
+ return nil
+ }
+
+ stationObj := b.conn.Object(iwdBusName, b.stationPath)
+
+ stateVar, err := stationObj.GetProperty(iwdStationInterface + ".State")
+ if err == nil {
+ if state, ok := stateVar.Value().(string); ok {
+ b.stateMutex.Lock()
+ b.state.WiFiConnected = (state == "connected")
+ if state == "connected" {
+ b.state.NetworkStatus = StatusWiFi
+ } else {
+ b.state.NetworkStatus = StatusDisconnected
+ }
+ b.stateMutex.Unlock()
+ }
+ }
+
+ connNetVar, err := stationObj.GetProperty(iwdStationInterface + ".ConnectedNetwork")
+ if err == nil && connNetVar.Value() != nil {
+ if netPath, ok := connNetVar.Value().(dbus.ObjectPath); ok && netPath != "/" {
+ netObj := b.conn.Object(iwdBusName, netPath)
+
+ nameVar, err := netObj.GetProperty(iwdNetworkInterface + ".Name")
+ if err == nil {
+ if name, ok := nameVar.Value().(string); ok {
+ b.stateMutex.Lock()
+ b.state.WiFiSSID = name
+ b.stateMutex.Unlock()
+ }
+ }
+
+ var orderedNetworks [][]dbus.Variant
+ err = stationObj.Call(iwdStationInterface+".GetOrderedNetworks", 0).Store(&orderedNetworks)
+ if err == nil {
+ for _, netData := range orderedNetworks {
+ if len(netData) < 2 {
+ continue
+ }
+ currentNetPath, ok := netData[0].Value().(dbus.ObjectPath)
+ if !ok || currentNetPath != netPath {
+ continue
+ }
+ signalStrength, ok := netData[1].Value().(int16)
+ if !ok {
+ continue
+ }
+ signalDbm := signalStrength / 100
+ signal := uint8(signalDbm + 100)
+ if signalDbm > 0 {
+ signal = 100
+ } else if signalDbm < -100 {
+ signal = 0
+ }
+ b.stateMutex.Lock()
+ b.state.WiFiSignal = signal
+ b.stateMutex.Unlock()
+ break
+ }
+ }
+ }
+ }
+
+ networks, err := b.updateWiFiNetworks()
+ if err == nil {
+ b.stateMutex.Lock()
+ b.state.WiFiNetworks = networks
+ b.stateMutex.Unlock()
+ }
+
+ return nil
+}
+
+func (b *IWDBackend) GetWiFiEnabled() (bool, error) {
+ b.stateMutex.RLock()
+ defer b.stateMutex.RUnlock()
+ return b.state.WiFiEnabled, nil
+}
+
+func (b *IWDBackend) SetWiFiEnabled(enabled bool) error {
+ if b.devicePath == "" {
+ return fmt.Errorf("no WiFi device available")
+ }
+
+ obj := b.conn.Object(iwdBusName, b.devicePath)
+ call := obj.Call(dbusPropertiesInterface+".Set", 0, iwdDeviceInterface, "Powered", dbus.MakeVariant(enabled))
+ if call.Err != nil {
+ return fmt.Errorf("failed to set WiFi enabled: %w", call.Err)
+ }
+
+ b.stateMutex.Lock()
+ b.state.WiFiEnabled = enabled
+ b.stateMutex.Unlock()
+
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+
+ return nil
+}
+
+func (b *IWDBackend) ScanWiFi() error {
+ if b.stationPath == "" {
+ return fmt.Errorf("no WiFi device available")
+ }
+
+ obj := b.conn.Object(iwdBusName, b.stationPath)
+
+ scanningVar, err := obj.GetProperty(iwdStationInterface + ".Scanning")
+ if err != nil {
+ return fmt.Errorf("failed to check scanning state: %w", err)
+ }
+
+ if scanning, ok := scanningVar.Value().(bool); ok && scanning {
+ return fmt.Errorf("scan already in progress")
+ }
+
+ call := obj.Call(iwdStationInterface+".Scan", 0)
+ if call.Err != nil {
+ return fmt.Errorf("scan request failed: %w", call.Err)
+ }
+
+ return nil
+}
+
+func (b *IWDBackend) updateWiFiNetworks() ([]WiFiNetwork, error) {
+ if b.stationPath == "" {
+ return nil, fmt.Errorf("no WiFi device available")
+ }
+
+ obj := b.conn.Object(iwdBusName, b.stationPath)
+
+ var orderedNetworks [][]dbus.Variant
+ err := obj.Call(iwdStationInterface+".GetOrderedNetworks", 0).Store(&orderedNetworks)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get networks: %w", err)
+ }
+
+ knownNetworks, err := b.getKnownNetworks()
+ if err != nil {
+ knownNetworks = make(map[string]bool)
+ }
+
+ autoconnectMap, err := b.getAutoconnectSettings()
+ if err != nil {
+ autoconnectMap = make(map[string]bool)
+ }
+
+ b.stateMutex.RLock()
+ currentSSID := b.state.WiFiSSID
+ wifiConnected := b.state.WiFiConnected
+ b.stateMutex.RUnlock()
+
+ networks := make([]WiFiNetwork, 0, len(orderedNetworks))
+ for _, netData := range orderedNetworks {
+ if len(netData) < 2 {
+ continue
+ }
+
+ networkPath, ok := netData[0].Value().(dbus.ObjectPath)
+ if !ok {
+ continue
+ }
+
+ signalStrength, ok := netData[1].Value().(int16)
+ if !ok {
+ continue
+ }
+
+ netObj := b.conn.Object(iwdBusName, networkPath)
+
+ nameVar, err := netObj.GetProperty(iwdNetworkInterface + ".Name")
+ if err != nil {
+ continue
+ }
+ name, ok := nameVar.Value().(string)
+ if !ok {
+ continue
+ }
+
+ typeVar, err := netObj.GetProperty(iwdNetworkInterface + ".Type")
+ if err != nil {
+ continue
+ }
+ netType, ok := typeVar.Value().(string)
+ if !ok {
+ continue
+ }
+
+ signalDbm := signalStrength / 100
+ signal := uint8(signalDbm + 100)
+ if signalDbm > 0 {
+ signal = 100
+ } else if signalDbm < -100 {
+ signal = 0
+ }
+
+ secured := netType != "open"
+
+ network := WiFiNetwork{
+ SSID: name,
+ Signal: signal,
+ Secured: secured,
+ Connected: wifiConnected && name == currentSSID,
+ Saved: knownNetworks[name],
+ Autoconnect: autoconnectMap[name],
+ Enterprise: netType == "8021x",
+ }
+
+ networks = append(networks, network)
+ }
+
+ sortWiFiNetworks(networks)
+
+ b.stateMutex.Lock()
+ b.state.WiFiNetworks = networks
+ b.stateMutex.Unlock()
+
+ now := time.Now()
+ b.recentScansMu.Lock()
+ for _, net := range networks {
+ b.recentScans[net.SSID] = now
+ }
+ b.recentScansMu.Unlock()
+
+ return networks, nil
+}
+
+func (b *IWDBackend) getKnownNetworks() (map[string]bool, error) {
+ obj := b.conn.Object(iwdBusName, iwdObjectPath)
+
+ var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
+ err := obj.Call(dbusObjectManager+".GetManagedObjects", 0).Store(&objects)
+ if err != nil {
+ return nil, err
+ }
+
+ known := make(map[string]bool)
+ for _, interfaces := range objects {
+ if knownProps, ok := interfaces[iwdKnownNetworkInterface]; ok {
+ if nameVar, ok := knownProps["Name"]; ok {
+ if name, ok := nameVar.Value().(string); ok {
+ known[name] = true
+ }
+ }
+ }
+ }
+
+ return known, nil
+}
+
+func (b *IWDBackend) getAutoconnectSettings() (map[string]bool, error) {
+ obj := b.conn.Object(iwdBusName, iwdObjectPath)
+
+ var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
+ err := obj.Call(dbusObjectManager+".GetManagedObjects", 0).Store(&objects)
+ if err != nil {
+ return nil, err
+ }
+
+ autoconnectMap := make(map[string]bool)
+ for _, interfaces := range objects {
+ if knownProps, ok := interfaces[iwdKnownNetworkInterface]; ok {
+ if nameVar, ok := knownProps["Name"]; ok {
+ if name, ok := nameVar.Value().(string); ok {
+ autoconnect := true
+ if acVar, ok := knownProps["AutoConnect"]; ok {
+ if ac, ok := acVar.Value().(bool); ok {
+ autoconnect = ac
+ }
+ }
+ autoconnectMap[name] = autoconnect
+ }
+ }
+ }
+ }
+
+ return autoconnectMap, nil
+}
+
+func (b *IWDBackend) GetWiFiNetworkDetails(ssid string) (*NetworkInfoResponse, error) {
+ b.stateMutex.RLock()
+ networks := b.state.WiFiNetworks
+ b.stateMutex.RUnlock()
+
+ var found *WiFiNetwork
+ for i := range networks {
+ if networks[i].SSID == ssid {
+ found = &networks[i]
+ break
+ }
+ }
+
+ if found == nil {
+ return nil, fmt.Errorf("network not found: %s", ssid)
+ }
+
+ return &NetworkInfoResponse{
+ SSID: ssid,
+ Bands: []WiFiNetwork{*found},
+ }, nil
+}
+
+func (b *IWDBackend) setConnectError(code string) {
+ b.stateMutex.Lock()
+ b.state.IsConnecting = false
+ b.state.ConnectingSSID = ""
+ b.state.LastError = code
+ b.stateMutex.Unlock()
+}
+
+func (b *IWDBackend) seenInRecentScan(ssid string) bool {
+ b.recentScansMu.Lock()
+ defer b.recentScansMu.Unlock()
+ lastSeen, ok := b.recentScans[ssid]
+ return ok && time.Since(lastSeen) < 30*time.Second
+}
+
+func (b *IWDBackend) classifyAttempt(att *connectAttempt) string {
+ att.mu.Lock()
+ defer att.mu.Unlock()
+
+ if att.sawPromptRetry {
+ return errdefs.ErrBadCredentials
+ }
+
+ if !att.connectedAt.IsZero() && !att.sawIPConfig {
+ connDuration := time.Since(att.connectedAt)
+ if connDuration > 500*time.Millisecond && connDuration < 3*time.Second {
+ return errdefs.ErrBadCredentials
+ }
+ }
+
+ if (att.sawAuthish || !att.connectedAt.IsZero()) && !att.sawIPConfig {
+ if time.Since(att.start) > 12*time.Second {
+ return errdefs.ErrDhcpTimeout
+ }
+ }
+
+ if !att.sawAuthish && att.connectedAt.IsZero() {
+ if !b.seenInRecentScan(att.ssid) {
+ return errdefs.ErrNoSuchSSID
+ }
+ return errdefs.ErrAssocTimeout
+ }
+
+ return errdefs.ErrAssocTimeout
+}
+
+func (b *IWDBackend) finalizeAttempt(att *connectAttempt, code string) {
+ att.mu.Lock()
+ if att.finalized {
+ att.mu.Unlock()
+ return
+ }
+ att.finalized = true
+ att.mu.Unlock()
+
+ b.stateMutex.Lock()
+ b.state.IsConnecting = false
+ b.state.ConnectingSSID = ""
+ b.state.LastError = code
+ b.stateMutex.Unlock()
+
+ b.updateState()
+
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+}
+
+func (b *IWDBackend) startAttemptWatchdog(att *connectAttempt) {
+ b.sigWG.Add(1)
+ go func() {
+ defer b.sigWG.Done()
+
+ ticker := time.NewTicker(250 * time.Millisecond)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ticker.C:
+ att.mu.Lock()
+ finalized := att.finalized
+ att.mu.Unlock()
+
+ if finalized || time.Now().After(att.deadline) {
+ if !finalized {
+ b.finalizeAttempt(att, b.classifyAttempt(att))
+ }
+ return
+ }
+
+ station := b.conn.Object(iwdBusName, b.stationPath)
+ stVar, err := station.GetProperty(iwdStationInterface + ".State")
+ if err != nil {
+ continue
+ }
+ state, _ := stVar.Value().(string)
+
+ cnVar, err := station.GetProperty(iwdStationInterface + ".ConnectedNetwork")
+ if err != nil {
+ continue
+ }
+ var connPath dbus.ObjectPath
+ if cnVar.Value() != nil {
+ connPath, _ = cnVar.Value().(dbus.ObjectPath)
+ }
+
+ att.mu.Lock()
+ if connPath == att.netPath && state == "connected" && att.connectedAt.IsZero() {
+ att.connectedAt = time.Now()
+ }
+ if state == "configuring" {
+ att.sawIPConfig = true
+ }
+ att.mu.Unlock()
+
+ case <-b.stopChan:
+ return
+ }
+ }
+ }()
+}
+
+func (b *IWDBackend) mapIwdDBusError(name string) string {
+ switch name {
+ case "net.connman.iwd.Error.AlreadyConnected":
+ return errdefs.ErrAlreadyConnected
+ case "net.connman.iwd.Error.AuthenticationFailed",
+ "net.connman.iwd.Error.InvalidKey",
+ "net.connman.iwd.Error.IncorrectPassphrase":
+ return errdefs.ErrBadCredentials
+ case "net.connman.iwd.Error.NotFound":
+ return errdefs.ErrNoSuchSSID
+ case "net.connman.iwd.Error.NotSupported":
+ return errdefs.ErrConnectionFailed
+ case "net.connman.iwd.Agent.Error.Canceled":
+ return errdefs.ErrUserCanceled
+ default:
+ return errdefs.ErrConnectionFailed
+ }
+}
+
+func (b *IWDBackend) ConnectWiFi(req ConnectionRequest) error {
+ if b.stationPath == "" {
+ b.setConnectError(errdefs.ErrWifiDisabled)
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+ return fmt.Errorf("no WiFi device available")
+ }
+
+ networkPath, err := b.findNetworkPath(req.SSID)
+ if err != nil {
+ b.setConnectError(errdefs.ErrNoSuchSSID)
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+ return fmt.Errorf("network not found: %w", err)
+ }
+
+ att := &connectAttempt{
+ ssid: req.SSID,
+ netPath: networkPath,
+ start: time.Now(),
+ deadline: time.Now().Add(15 * time.Second),
+ }
+
+ b.attemptMutex.Lock()
+ b.curAttempt = att
+ b.attemptMutex.Unlock()
+
+ b.stateMutex.Lock()
+ b.state.IsConnecting = true
+ b.state.ConnectingSSID = req.SSID
+ b.state.LastError = ""
+ b.stateMutex.Unlock()
+
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+
+ netObj := b.conn.Object(iwdBusName, networkPath)
+ go func() {
+ call := netObj.Call(iwdNetworkInterface+".Connect", 0)
+ if call.Err != nil {
+ var code string
+ if dbusErr, ok := call.Err.(dbus.Error); ok {
+ code = b.mapIwdDBusError(dbusErr.Name)
+ } else if dbusErrPtr, ok := call.Err.(*dbus.Error); ok {
+ code = b.mapIwdDBusError(dbusErrPtr.Name)
+ } else {
+ code = errdefs.ErrConnectionFailed
+ }
+
+ att.mu.Lock()
+ if att.sawPromptRetry {
+ code = errdefs.ErrBadCredentials
+ }
+ att.mu.Unlock()
+
+ b.finalizeAttempt(att, code)
+ return
+ }
+
+ b.startAttemptWatchdog(att)
+ }()
+
+ return nil
+}
+
+func (b *IWDBackend) findNetworkPath(ssid string) (dbus.ObjectPath, error) {
+ obj := b.conn.Object(iwdBusName, iwdObjectPath)
+
+ var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
+ err := obj.Call(dbusObjectManager+".GetManagedObjects", 0).Store(&objects)
+ if err != nil {
+ return "", err
+ }
+
+ for path, interfaces := range objects {
+ if netProps, ok := interfaces[iwdNetworkInterface]; ok {
+ if nameVar, ok := netProps["Name"]; ok {
+ if name, ok := nameVar.Value().(string); ok && name == ssid {
+ return path, nil
+ }
+ }
+ }
+ }
+
+ return "", fmt.Errorf("network not found")
+}
+
+func (b *IWDBackend) DisconnectWiFi() error {
+ if b.stationPath == "" {
+ return fmt.Errorf("no WiFi device available")
+ }
+
+ obj := b.conn.Object(iwdBusName, b.stationPath)
+ call := obj.Call(iwdStationInterface+".Disconnect", 0)
+ if call.Err != nil {
+ return fmt.Errorf("failed to disconnect: %w", call.Err)
+ }
+
+ b.updateState()
+
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+
+ return nil
+}
+
+func (b *IWDBackend) ForgetWiFiNetwork(ssid string) error {
+ b.stateMutex.RLock()
+ currentSSID := b.state.WiFiSSID
+ isConnected := b.state.WiFiConnected
+ b.stateMutex.RUnlock()
+
+ obj := b.conn.Object(iwdBusName, iwdObjectPath)
+
+ var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
+ err := obj.Call(dbusObjectManager+".GetManagedObjects", 0).Store(&objects)
+ if err != nil {
+ return err
+ }
+
+ for path, interfaces := range objects {
+ if knownProps, ok := interfaces[iwdKnownNetworkInterface]; ok {
+ if nameVar, ok := knownProps["Name"]; ok {
+ if name, ok := nameVar.Value().(string); ok && name == ssid {
+ knownObj := b.conn.Object(iwdBusName, path)
+ call := knownObj.Call(iwdKnownNetworkInterface+".Forget", 0)
+ if call.Err != nil {
+ return fmt.Errorf("failed to forget network: %w", call.Err)
+ }
+
+ if isConnected && currentSSID == ssid {
+ b.stateMutex.Lock()
+ b.state.WiFiConnected = false
+ b.state.WiFiSSID = ""
+ b.state.WiFiSignal = 0
+ b.state.WiFiIP = ""
+ b.state.NetworkStatus = StatusDisconnected
+ b.stateMutex.Unlock()
+ }
+
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+
+ return nil
+ }
+ }
+ }
+ }
+
+ return fmt.Errorf("network not found")
+}
+
+func (b *IWDBackend) SetWiFiAutoconnect(ssid string, autoconnect bool) error {
+ obj := b.conn.Object(iwdBusName, iwdObjectPath)
+
+ var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
+ err := obj.Call(dbusObjectManager+".GetManagedObjects", 0).Store(&objects)
+ if err != nil {
+ return err
+ }
+
+ for path, interfaces := range objects {
+ if knownProps, ok := interfaces[iwdKnownNetworkInterface]; ok {
+ if nameVar, ok := knownProps["Name"]; ok {
+ if name, ok := nameVar.Value().(string); ok && name == ssid {
+ knownObj := b.conn.Object(iwdBusName, path)
+ call := knownObj.Call(dbusPropertiesInterface+".Set", 0, iwdKnownNetworkInterface, "AutoConnect", dbus.MakeVariant(autoconnect))
+ if call.Err != nil {
+ return fmt.Errorf("failed to set autoconnect: %w", call.Err)
+ }
+
+ b.updateState()
+
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+
+ return nil
+ }
+ }
+ }
+ }
+
+ return fmt.Errorf("network not found")
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkd.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkd.go
new file mode 100644
index 0000000..802cbf0
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkd.go
@@ -0,0 +1,301 @@
+package network
+
+import (
+ "fmt"
+ "net"
+ "strings"
+ "sync"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "github.com/godbus/dbus/v5"
+)
+
+const (
+ networkdBusName = "org.freedesktop.network1"
+ networkdManagerPath = "/org/freedesktop/network1"
+ networkdManagerIface = "org.freedesktop.network1.Manager"
+ networkdLinkIface = "org.freedesktop.network1.Link"
+)
+
+type linkInfo struct {
+ ifindex int32
+ name string
+ path dbus.ObjectPath
+ opState string
+}
+
+type SystemdNetworkdBackend struct {
+ conn *dbus.Conn
+ managerPath dbus.ObjectPath
+ links map[string]*linkInfo
+ linksMutex sync.RWMutex
+ state *BackendState
+ stateMutex sync.RWMutex
+ onStateChange func()
+ stopChan chan struct{}
+ signals chan *dbus.Signal
+ sigWG sync.WaitGroup
+}
+
+func NewSystemdNetworkdBackend() (*SystemdNetworkdBackend, error) {
+ return &SystemdNetworkdBackend{
+ managerPath: networkdManagerPath,
+ links: make(map[string]*linkInfo),
+ state: &BackendState{
+ Backend: "networkd",
+ WiFiNetworks: []WiFiNetwork{},
+ },
+ stopChan: make(chan struct{}),
+ }, nil
+}
+
+func (b *SystemdNetworkdBackend) Initialize() error {
+ c, err := dbus.ConnectSystemBus()
+ if err != nil {
+ return fmt.Errorf("connect bus: %w", err)
+ }
+ b.conn = c
+
+ if err := b.enumerateLinks(); err != nil {
+ c.Close()
+ return fmt.Errorf("enumerate links: %w", err)
+ }
+
+ if err := b.updateState(); err != nil {
+ c.Close()
+ return fmt.Errorf("update initial state: %w", err)
+ }
+
+ return nil
+}
+
+func (b *SystemdNetworkdBackend) Close() {
+ close(b.stopChan)
+ b.StopMonitoring()
+
+ if b.conn != nil {
+ b.conn.Close()
+ }
+}
+
+func (b *SystemdNetworkdBackend) enumerateLinks() error {
+ obj := b.conn.Object(networkdBusName, b.managerPath)
+
+ var links []struct {
+ Ifindex int32
+ Name string
+ Path dbus.ObjectPath
+ }
+ err := obj.Call(networkdManagerIface+".ListLinks", 0).Store(&links)
+ if err != nil {
+ return fmt.Errorf("ListLinks: %w", err)
+ }
+
+ b.linksMutex.Lock()
+ defer b.linksMutex.Unlock()
+
+ for _, l := range links {
+ b.links[l.Name] = &linkInfo{
+ ifindex: l.Ifindex,
+ name: l.Name,
+ path: l.Path,
+ }
+ log.Debugf("networkd: enumerated link %s (ifindex=%d, path=%s)", l.Name, l.Ifindex, l.Path)
+ }
+
+ return nil
+}
+
+func (b *SystemdNetworkdBackend) updateState() error {
+ b.linksMutex.RLock()
+ defer b.linksMutex.RUnlock()
+
+ var wiredIface *linkInfo
+ var wifiIface *linkInfo
+
+ for name, link := range b.links {
+ if b.isVirtualInterface(name) {
+ continue
+ }
+
+ linkObj := b.conn.Object(networkdBusName, link.path)
+ opStateVar, err := linkObj.GetProperty(networkdLinkIface + ".OperationalState")
+ if err == nil {
+ if opState, ok := opStateVar.Value().(string); ok {
+ link.opState = opState
+ }
+ }
+
+ if strings.HasPrefix(name, "wlan") || strings.HasPrefix(name, "wlp") {
+ if wifiIface == nil || link.opState == "routable" || link.opState == "carrier" {
+ wifiIface = link
+ }
+ } else if !b.isVirtualInterface(name) {
+ if wiredIface == nil || link.opState == "routable" || link.opState == "carrier" {
+ wiredIface = link
+ }
+ }
+ }
+
+ var wiredConns []WiredConnection
+ var ethernetDevices []EthernetDevice
+ for name, link := range b.links {
+ if b.isVirtualInterface(name) || strings.HasPrefix(name, "wlan") || strings.HasPrefix(name, "wlp") {
+ continue
+ }
+
+ active := link.opState == "routable" || link.opState == "carrier"
+ wiredConns = append(wiredConns, WiredConnection{
+ Path: link.path,
+ ID: name,
+ UUID: "wired:" + name,
+ Type: "ethernet",
+ IsActive: active,
+ })
+
+ var ip string
+ var hwAddr string
+ if iface, err := net.InterfaceByName(name); err == nil {
+ hwAddr = iface.HardwareAddr.String()
+ if addrs := b.getAddresses(name); len(addrs) > 0 {
+ ip = addrs[0]
+ }
+ }
+
+ stateStr := "disconnected"
+ switch link.opState {
+ case "routable":
+ stateStr = "routable"
+ case "carrier":
+ stateStr = "carrier"
+ case "degraded":
+ stateStr = "degraded"
+ case "no-carrier":
+ stateStr = "no-carrier"
+ case "off":
+ stateStr = "off"
+ }
+
+ ethernetDevices = append(ethernetDevices, EthernetDevice{
+ Name: name,
+ HwAddress: hwAddr,
+ State: stateStr,
+ Connected: active,
+ IP: ip,
+ })
+ }
+
+ b.stateMutex.Lock()
+ defer b.stateMutex.Unlock()
+
+ b.state.NetworkStatus = StatusDisconnected
+ b.state.EthernetConnected = false
+ b.state.EthernetIP = ""
+ b.state.WiFiConnected = false
+ b.state.WiFiIP = ""
+ b.state.WiredConnections = wiredConns
+ b.state.EthernetDevices = ethernetDevices
+
+ if wiredIface != nil {
+ b.state.EthernetDevice = wiredIface.name
+ log.Debugf("networkd: wired interface %s opState=%s", wiredIface.name, wiredIface.opState)
+ if wiredIface.opState == "routable" || wiredIface.opState == "carrier" {
+ b.state.EthernetConnected = true
+ b.state.NetworkStatus = StatusEthernet
+
+ if addrs := b.getAddresses(wiredIface.name); len(addrs) > 0 {
+ b.state.EthernetIP = addrs[0]
+ log.Debugf("networkd: ethernet IP %s on %s", addrs[0], wiredIface.name)
+ }
+ }
+ }
+
+ if wifiIface != nil {
+ b.state.WiFiDevice = wifiIface.name
+ log.Debugf("networkd: wifi interface %s opState=%s", wifiIface.name, wifiIface.opState)
+ if wifiIface.opState == "routable" || wifiIface.opState == "carrier" {
+ b.state.WiFiConnected = true
+
+ if addrs := b.getAddresses(wifiIface.name); len(addrs) > 0 {
+ b.state.WiFiIP = addrs[0]
+ log.Debugf("networkd: wifi IP %s on %s", addrs[0], wifiIface.name)
+ if b.state.NetworkStatus == StatusDisconnected {
+ b.state.NetworkStatus = StatusWiFi
+ }
+ }
+ }
+ }
+
+ return nil
+}
+
+func (b *SystemdNetworkdBackend) isVirtualInterface(name string) bool {
+ virtualPrefixes := []string{
+ "lo", "docker", "veth", "virbr", "br-", "vnet", "tun", "tap",
+ "vboxnet", "vmnet", "kube", "cni", "flannel", "cali",
+ }
+ for _, prefix := range virtualPrefixes {
+ if strings.HasPrefix(name, prefix) {
+ return true
+ }
+ }
+ return false
+}
+
+func (b *SystemdNetworkdBackend) getAddresses(ifname string) []string {
+ iface, err := net.InterfaceByName(ifname)
+ if err != nil {
+ return nil
+ }
+
+ addrs, err := iface.Addrs()
+ if err != nil {
+ return nil
+ }
+
+ var result []string
+ for _, addr := range addrs {
+ if ipnet, ok := addr.(*net.IPNet); ok {
+ if ipv4 := ipnet.IP.To4(); ipv4 != nil {
+ result = append(result, ipv4.String())
+ }
+ }
+ }
+ return result
+}
+
+func (b *SystemdNetworkdBackend) GetCurrentState() (*BackendState, error) {
+ b.stateMutex.RLock()
+ defer b.stateMutex.RUnlock()
+ s := *b.state
+ return &s, nil
+}
+
+func (b *SystemdNetworkdBackend) GetPromptBroker() PromptBroker {
+ return nil
+}
+
+func (b *SystemdNetworkdBackend) SetPromptBroker(broker PromptBroker) error {
+ return nil
+}
+
+func (b *SystemdNetworkdBackend) SubmitCredentials(token string, secrets map[string]string, save bool) error {
+ return fmt.Errorf("credentials not needed by networkd backend")
+}
+
+func (b *SystemdNetworkdBackend) CancelCredentials(token string) error {
+ return fmt.Errorf("credentials not needed by networkd backend")
+}
+
+func (b *SystemdNetworkdBackend) EnsureDhcpUp(ifname string) error {
+ b.linksMutex.RLock()
+ link, exists := b.links[ifname]
+ b.linksMutex.RUnlock()
+
+ if !exists {
+ return fmt.Errorf("interface %s not found", ifname)
+ }
+
+ linkObj := b.conn.Object(networkdBusName, link.path)
+ return linkObj.Call(networkdLinkIface+".Reconfigure", 0).Err
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkd_ethernet.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkd_ethernet.go
new file mode 100644
index 0000000..8f9b225
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkd_ethernet.go
@@ -0,0 +1,120 @@
+package network
+
+import (
+ "fmt"
+ "net"
+ "strings"
+)
+
+func (b *SystemdNetworkdBackend) GetWiredConnections() ([]WiredConnection, error) {
+ b.linksMutex.RLock()
+ defer b.linksMutex.RUnlock()
+
+ var conns []WiredConnection
+ for name, link := range b.links {
+ if b.isVirtualInterface(name) || strings.HasPrefix(name, "wlan") || strings.HasPrefix(name, "wlp") {
+ continue
+ }
+
+ active := link.opState == "routable" || link.opState == "carrier"
+ conns = append(conns, WiredConnection{
+ Path: link.path,
+ ID: name,
+ UUID: "wired:" + name,
+ Type: "ethernet",
+ IsActive: active,
+ })
+ }
+
+ return conns, nil
+}
+
+func (b *SystemdNetworkdBackend) GetWiredNetworkDetails(id string) (*WiredNetworkInfoResponse, error) {
+ ifname := strings.TrimPrefix(id, "wired:")
+
+ b.linksMutex.RLock()
+ _, exists := b.links[ifname]
+ b.linksMutex.RUnlock()
+
+ if !exists {
+ return nil, fmt.Errorf("interface %s not found", ifname)
+ }
+
+ iface, err := net.InterfaceByName(ifname)
+ if err != nil {
+ return nil, fmt.Errorf("get interface: %w", err)
+ }
+
+ addrs, _ := iface.Addrs()
+ var ipv4s, ipv6s []string
+ for _, addr := range addrs {
+ if ipnet, ok := addr.(*net.IPNet); ok {
+ if ipv4 := ipnet.IP.To4(); ipv4 != nil {
+ ipv4s = append(ipv4s, ipnet.String())
+ } else if ipv6 := ipnet.IP.To16(); ipv6 != nil {
+ ipv6s = append(ipv6s, ipnet.String())
+ }
+ }
+ }
+
+ return &WiredNetworkInfoResponse{
+ UUID: id,
+ IFace: ifname,
+ HwAddr: iface.HardwareAddr.String(),
+ IPv4: WiredIPConfig{
+ IPs: ipv4s,
+ },
+ IPv6: WiredIPConfig{
+ IPs: ipv6s,
+ },
+ }, nil
+}
+
+func (b *SystemdNetworkdBackend) ConnectEthernet() error {
+ b.linksMutex.RLock()
+ var primaryWired *linkInfo
+ for name, l := range b.links {
+ if strings.HasPrefix(name, "lo") || strings.HasPrefix(name, "wlan") || strings.HasPrefix(name, "wlp") {
+ continue
+ }
+ primaryWired = l
+ break
+ }
+ b.linksMutex.RUnlock()
+
+ if primaryWired == nil {
+ return fmt.Errorf("no wired interface found")
+ }
+
+ linkObj := b.conn.Object(networkdBusName, primaryWired.path)
+ return linkObj.Call(networkdLinkIface+".Reconfigure", 0).Err
+}
+
+func (b *SystemdNetworkdBackend) DisconnectEthernet() error {
+ return fmt.Errorf("not supported by networkd backend")
+}
+
+func (b *SystemdNetworkdBackend) ActivateWiredConnection(id string) error {
+ ifname := strings.TrimPrefix(id, "wired:")
+
+ b.linksMutex.RLock()
+ link, exists := b.links[ifname]
+ b.linksMutex.RUnlock()
+
+ if !exists {
+ return fmt.Errorf("interface %s not found", ifname)
+ }
+
+ linkObj := b.conn.Object(networkdBusName, link.path)
+ return linkObj.Call(networkdLinkIface+".Reconfigure", 0).Err
+}
+
+func (b *SystemdNetworkdBackend) GetEthernetDevices() []EthernetDevice {
+ b.stateMutex.RLock()
+ defer b.stateMutex.RUnlock()
+ return append([]EthernetDevice(nil), b.state.EthernetDevices...)
+}
+
+func (b *SystemdNetworkdBackend) DisconnectEthernetDevice(device string) error {
+ return fmt.Errorf("not supported by networkd backend")
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkd_signals.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkd_signals.go
new file mode 100644
index 0000000..dc642ed
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkd_signals.go
@@ -0,0 +1,68 @@
+package network
+
+import (
+ "fmt"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "github.com/godbus/dbus/v5"
+)
+
+func (b *SystemdNetworkdBackend) StartMonitoring(onStateChange func()) error {
+ b.onStateChange = onStateChange
+
+ b.signals = make(chan *dbus.Signal, 64)
+ b.conn.Signal(b.signals)
+
+ matchRules := []string{
+ "type='signal',interface='org.freedesktop.DBus.Properties',member='PropertiesChanged',path_namespace='/org/freedesktop/network1'",
+ "type='signal',interface='org.freedesktop.network1.Manager'",
+ }
+
+ for _, rule := range matchRules {
+ if err := b.conn.BusObject().Call("org.freedesktop.DBus.AddMatch", 0, rule).Err; err != nil {
+ return fmt.Errorf("add match %q: %w", rule, err)
+ }
+ }
+
+ b.sigWG.Add(1)
+ go b.signalLoop()
+
+ return nil
+}
+
+func (b *SystemdNetworkdBackend) StopMonitoring() {
+ b.sigWG.Wait()
+}
+
+func (b *SystemdNetworkdBackend) signalLoop() {
+ defer b.sigWG.Done()
+
+ for {
+ select {
+ case <-b.stopChan:
+ return
+ case sig := <-b.signals:
+ if sig == nil {
+ continue
+ }
+
+ if sig.Name == "org.freedesktop.DBus.Properties.PropertiesChanged" {
+ if len(sig.Body) < 2 {
+ continue
+ }
+ iface, ok := sig.Body[0].(string)
+ if !ok || iface != networkdLinkIface {
+ continue
+ }
+
+ b.enumerateLinks()
+ if err := b.updateState(); err != nil {
+ log.Warnf("networkd state update failed: %v", err)
+ }
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+ }
+ }
+ }
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkd_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkd_test.go
new file mode 100644
index 0000000..18c5281
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkd_test.go
@@ -0,0 +1,147 @@
+package network
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestSystemdNetworkdBackend_New(t *testing.T) {
+ backend, err := NewSystemdNetworkdBackend()
+ assert.NoError(t, err)
+ assert.NotNil(t, backend)
+ assert.Equal(t, "networkd", backend.state.Backend)
+ assert.NotNil(t, backend.links)
+ assert.NotNil(t, backend.stopChan)
+}
+
+func TestSystemdNetworkdBackend_GetCurrentState(t *testing.T) {
+ backend, _ := NewSystemdNetworkdBackend()
+ backend.state.NetworkStatus = StatusEthernet
+ backend.state.EthernetConnected = true
+ backend.state.EthernetIP = "192.168.1.100"
+
+ state, err := backend.GetCurrentState()
+ assert.NoError(t, err)
+ assert.NotNil(t, state)
+ assert.Equal(t, StatusEthernet, state.NetworkStatus)
+ assert.True(t, state.EthernetConnected)
+ assert.Equal(t, "192.168.1.100", state.EthernetIP)
+}
+
+func TestSystemdNetworkdBackend_WiFiNotSupported(t *testing.T) {
+ backend, _ := NewSystemdNetworkdBackend()
+
+ err := backend.ScanWiFi()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not supported")
+
+ req := ConnectionRequest{SSID: "test"}
+ err = backend.ConnectWiFi(req)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not supported")
+
+ err = backend.DisconnectWiFi()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not supported")
+
+ err = backend.ForgetWiFiNetwork("test")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not supported")
+
+ _, err = backend.GetWiFiNetworkDetails("test")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not supported")
+}
+
+func TestSystemdNetworkdBackend_VPNNotSupported(t *testing.T) {
+ backend, _ := NewSystemdNetworkdBackend()
+
+ profiles, err := backend.ListVPNProfiles()
+ assert.NoError(t, err)
+ assert.Empty(t, profiles)
+
+ active, err := backend.ListActiveVPN()
+ assert.NoError(t, err)
+ assert.Empty(t, active)
+
+ err = backend.ConnectVPN("test", false)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not supported")
+
+ err = backend.DisconnectVPN("test")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not supported")
+
+ err = backend.DisconnectAllVPN()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not supported")
+
+ err = backend.ClearVPNCredentials("test")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not supported")
+}
+
+func TestSystemdNetworkdBackend_PromptBroker(t *testing.T) {
+ backend, _ := NewSystemdNetworkdBackend()
+
+ broker := backend.GetPromptBroker()
+ assert.Nil(t, broker)
+
+ err := backend.SetPromptBroker(nil)
+ assert.NoError(t, err)
+
+ err = backend.SubmitCredentials("token", nil, false)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not needed")
+
+ err = backend.CancelCredentials("token")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not needed")
+}
+
+func TestSystemdNetworkdBackend_GetWiFiEnabled(t *testing.T) {
+ backend, _ := NewSystemdNetworkdBackend()
+
+ enabled, err := backend.GetWiFiEnabled()
+ assert.NoError(t, err)
+ assert.True(t, enabled)
+}
+
+func TestSystemdNetworkdBackend_SetWiFiEnabled(t *testing.T) {
+ backend, _ := NewSystemdNetworkdBackend()
+
+ err := backend.SetWiFiEnabled(false)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not supported")
+}
+
+func TestSystemdNetworkdBackend_DisconnectEthernet(t *testing.T) {
+ backend, _ := NewSystemdNetworkdBackend()
+
+ err := backend.DisconnectEthernet()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not supported")
+}
+
+func TestSystemdNetworkdBackend_GetEthernetDevices(t *testing.T) {
+ backend, _ := NewSystemdNetworkdBackend()
+
+ backend.state.EthernetDevices = []EthernetDevice{
+ {Name: "enp0s3", State: "routable", Connected: true},
+ {Name: "enp0s8", State: "no-carrier", Connected: false},
+ }
+
+ devices := backend.GetEthernetDevices()
+ assert.Len(t, devices, 2)
+ assert.Equal(t, "enp0s3", devices[0].Name)
+ assert.True(t, devices[0].Connected)
+}
+
+func TestSystemdNetworkdBackend_DisconnectEthernetDevice(t *testing.T) {
+ backend, _ := NewSystemdNetworkdBackend()
+
+ err := backend.DisconnectEthernetDevice("enp0s3")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not supported")
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkd_unimplemented.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkd_unimplemented.go
new file mode 100644
index 0000000..695c3c5
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkd_unimplemented.go
@@ -0,0 +1,99 @@
+package network
+
+import "fmt"
+
+func (b *SystemdNetworkdBackend) GetWiFiEnabled() (bool, error) {
+ return true, nil
+}
+
+func (b *SystemdNetworkdBackend) SetWiFiEnabled(enabled bool) error {
+ return fmt.Errorf("WiFi control not supported by networkd backend")
+}
+
+func (b *SystemdNetworkdBackend) ScanWiFi() error {
+ return fmt.Errorf("WiFi scan not supported by networkd backend")
+}
+
+func (b *SystemdNetworkdBackend) GetWiFiNetworkDetails(ssid string) (*NetworkInfoResponse, error) {
+ return nil, fmt.Errorf("WiFi details not supported by networkd backend")
+}
+
+func (b *SystemdNetworkdBackend) GetWiFiQRCodeContent(ssid string) (string, error) {
+ return "", fmt.Errorf("WiFi QR Code not supported by networkd backend")
+}
+
+func (b *SystemdNetworkdBackend) ConnectWiFi(req ConnectionRequest) error {
+ return fmt.Errorf("WiFi connect not supported by networkd backend")
+}
+
+func (b *SystemdNetworkdBackend) DisconnectWiFi() error {
+ return fmt.Errorf("WiFi disconnect not supported by networkd backend")
+}
+
+func (b *SystemdNetworkdBackend) ForgetWiFiNetwork(ssid string) error {
+ return fmt.Errorf("WiFi forget not supported by networkd backend")
+}
+
+func (b *SystemdNetworkdBackend) ListVPNProfiles() ([]VPNProfile, error) {
+ return []VPNProfile{}, nil
+}
+
+func (b *SystemdNetworkdBackend) ListActiveVPN() ([]VPNActive, error) {
+ return []VPNActive{}, nil
+}
+
+func (b *SystemdNetworkdBackend) ConnectVPN(uuidOrName string, singleActive bool) error {
+ return fmt.Errorf("VPN not supported by networkd backend")
+}
+
+func (b *SystemdNetworkdBackend) DisconnectVPN(uuidOrName string) error {
+ return fmt.Errorf("VPN not supported by networkd backend")
+}
+
+func (b *SystemdNetworkdBackend) DisconnectAllVPN() error {
+ return fmt.Errorf("VPN not supported by networkd backend")
+}
+
+func (b *SystemdNetworkdBackend) ClearVPNCredentials(uuidOrName string) error {
+ return fmt.Errorf("VPN not supported by networkd backend")
+}
+
+func (b *SystemdNetworkdBackend) ListVPNPlugins() ([]VPNPlugin, error) {
+ return []VPNPlugin{}, nil
+}
+
+func (b *SystemdNetworkdBackend) ImportVPN(filePath string, name string) (*VPNImportResult, error) {
+ return nil, fmt.Errorf("VPN not supported by networkd backend")
+}
+
+func (b *SystemdNetworkdBackend) GetVPNConfig(uuidOrName string) (*VPNConfig, error) {
+ return nil, fmt.Errorf("VPN not supported by networkd backend")
+}
+
+func (b *SystemdNetworkdBackend) UpdateVPNConfig(uuid string, updates map[string]any) error {
+ return fmt.Errorf("VPN not supported by networkd backend")
+}
+
+func (b *SystemdNetworkdBackend) DeleteVPN(uuidOrName string) error {
+ return fmt.Errorf("VPN not supported by networkd backend")
+}
+
+func (b *SystemdNetworkdBackend) SetVPNCredentials(uuid, username, password string, save bool) error {
+ return fmt.Errorf("VPN not supported by networkd backend")
+}
+
+func (b *SystemdNetworkdBackend) SetWiFiAutoconnect(ssid string, autoconnect bool) error {
+ return fmt.Errorf("WiFi autoconnect not supported by networkd backend")
+}
+
+func (b *SystemdNetworkdBackend) ScanWiFiDevice(device string) error {
+ return fmt.Errorf("WiFi scan not supported by networkd backend")
+}
+
+func (b *SystemdNetworkdBackend) DisconnectWiFiDevice(device string) error {
+ return fmt.Errorf("WiFi disconnect not supported by networkd backend")
+}
+
+func (b *SystemdNetworkdBackend) GetWiFiDevices() []WiFiDevice {
+ return nil
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager.go
new file mode 100644
index 0000000..2233975
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager.go
@@ -0,0 +1,405 @@
+package network
+
+import (
+ "fmt"
+ "sync"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "github.com/Wifx/gonetworkmanager/v2"
+ "github.com/godbus/dbus/v5"
+)
+
+const (
+ dbusNMPath = "/org/freedesktop/NetworkManager"
+ dbusNMInterface = "org.freedesktop.NetworkManager"
+ dbusNMDeviceInterface = "org.freedesktop.NetworkManager.Device"
+ dbusNMWiredInterface = "org.freedesktop.NetworkManager.Device.Wired"
+ dbusNMWirelessInterface = "org.freedesktop.NetworkManager.Device.Wireless"
+ dbusNMAccessPointInterface = "org.freedesktop.NetworkManager.AccessPoint"
+ dbusPropsInterface = "org.freedesktop.DBus.Properties"
+
+ NmDeviceStateReasonWrongPassword = 8
+ NmDeviceStateReasonSupplicantTimeout = 24
+ NmDeviceStateReasonSupplicantFailed = 25
+ NmDeviceStateReasonSecretsRequired = 7
+ NmDeviceStateReasonNoSecrets = 6
+ NmDeviceStateReasonNoSsid = 10
+ NmDeviceStateReasonDhcpClientFailed = 14
+ NmDeviceStateReasonIpConfigUnavailable = 18
+ NmDeviceStateReasonSupplicantDisconnect = 23
+ NmDeviceStateReasonCarrier = 40
+ NmDeviceStateReasonNewActivation = 60
+)
+
+type wifiDeviceInfo struct {
+ device gonetworkmanager.Device
+ wireless gonetworkmanager.DeviceWireless
+ name string
+ hwAddress string
+}
+
+type ethernetDeviceInfo struct {
+ device gonetworkmanager.Device
+ wired gonetworkmanager.DeviceWired
+ name string
+ hwAddress string
+}
+
+type NetworkManagerBackend struct {
+ nmConn any
+ ethernetDevice any
+ ethernetDevices map[string]*ethernetDeviceInfo
+ wifiDevice any
+ settings any
+ wifiDev any
+ wifiDevices map[string]*wifiDeviceInfo
+
+ dbusConn *dbus.Conn
+ signals chan *dbus.Signal
+ sigWG sync.WaitGroup
+ stopChan chan struct{}
+
+ secretAgent *SecretAgent
+ promptBroker PromptBroker
+
+ state *BackendState
+ stateMutex sync.RWMutex
+
+ lastFailedSSID string
+ lastFailedTime int64
+ failedMutex sync.RWMutex
+
+ pendingVPNSave *pendingVPNCredentials
+ pendingVPNSaveMu sync.Mutex
+ cachedVPNCreds *cachedVPNCredentials
+ cachedVPNCredsMu sync.Mutex
+ cachedPKCS11PIN *cachedPKCS11PIN
+ cachedPKCS11Mu sync.Mutex
+ cachedGPSamlCookie *cachedGPSamlCookie
+ cachedGPSamlMu sync.Mutex
+
+ onStateChange func()
+}
+
+type pendingVPNCredentials struct {
+ ConnectionPath string
+ Username string
+ Password string
+ SavePassword bool
+}
+
+type cachedVPNCredentials struct {
+ ConnectionUUID string
+ Password string
+ SavePassword bool
+}
+
+type cachedPKCS11PIN struct {
+ ConnectionUUID string
+ PIN string
+}
+
+type cachedGPSamlCookie struct {
+ ConnectionUUID string
+ Cookie string
+ Host string
+ User string
+ Fingerprint string
+}
+
+func NewNetworkManagerBackend(nmConn ...gonetworkmanager.NetworkManager) (*NetworkManagerBackend, error) {
+ var nm gonetworkmanager.NetworkManager
+ var err error
+
+ if len(nmConn) > 0 && nmConn[0] != nil {
+ // Use injected connection (for testing)
+ nm = nmConn[0]
+ } else {
+ // Create real connection
+ nm, err = gonetworkmanager.NewNetworkManager()
+ if err != nil {
+ return nil, fmt.Errorf("failed to connect to NetworkManager: %w", err)
+ }
+ }
+
+ backend := &NetworkManagerBackend{
+ nmConn: nm,
+ stopChan: make(chan struct{}),
+ ethernetDevices: make(map[string]*ethernetDeviceInfo),
+ wifiDevices: make(map[string]*wifiDeviceInfo),
+ state: &BackendState{
+ Backend: "networkmanager",
+ },
+ }
+
+ return backend, nil
+}
+
+func (b *NetworkManagerBackend) Initialize() error {
+ nm := b.nmConn.(gonetworkmanager.NetworkManager)
+
+ if s, err := gonetworkmanager.NewSettings(); err == nil {
+ b.settings = s
+ }
+
+ devices, err := nm.GetDevices()
+ if err != nil {
+ return fmt.Errorf("failed to get devices: %w", err)
+ }
+
+ for _, dev := range devices {
+ devType, err := dev.GetPropertyDeviceType()
+ if err != nil {
+ continue
+ }
+
+ switch devType {
+ case gonetworkmanager.NmDeviceTypeEthernet:
+ if managed, _ := dev.GetPropertyManaged(); !managed {
+ continue
+ }
+ iface, err := dev.GetPropertyInterface()
+ if err != nil {
+ continue
+ }
+ w, err := gonetworkmanager.NewDeviceWired(dev.GetPath())
+ if err != nil {
+ continue
+ }
+ hwAddr, _ := w.GetPropertyHwAddress()
+
+ b.ethernetDevices[iface] = &ethernetDeviceInfo{
+ device: dev,
+ wired: w,
+ name: iface,
+ hwAddress: hwAddr,
+ }
+
+ if b.ethernetDevice == nil {
+ b.ethernetDevice = dev
+ }
+ if err := b.updateEthernetState(); err != nil {
+ continue
+ }
+ _, err = b.listEthernetConnections()
+ if err != nil {
+ return fmt.Errorf("failed to get wired configurations: %w", err)
+ }
+
+ case gonetworkmanager.NmDeviceTypeWifi:
+ iface, err := dev.GetPropertyInterface()
+ if err != nil {
+ continue
+ }
+ w, err := gonetworkmanager.NewDeviceWireless(dev.GetPath())
+ if err != nil {
+ continue
+ }
+ hwAddr, _ := w.GetPropertyHwAddress()
+
+ b.wifiDevices[iface] = &wifiDeviceInfo{
+ device: dev,
+ wireless: w,
+ name: iface,
+ hwAddress: hwAddr,
+ }
+
+ if b.wifiDevice == nil {
+ b.wifiDevice = dev
+ b.wifiDev = w
+ }
+ }
+ }
+
+ wifiEnabled, err := nm.GetPropertyWirelessEnabled()
+ if err == nil {
+ b.stateMutex.Lock()
+ b.state.WiFiEnabled = wifiEnabled
+ b.stateMutex.Unlock()
+ }
+
+ if err := b.updateWiFiState(); err != nil {
+ log.Warnf("Failed to update WiFi state: %v", err)
+ }
+
+ if wifiEnabled {
+ if _, err := b.updateWiFiNetworks(); err != nil {
+ log.Warnf("Failed to get initial networks: %v", err)
+ }
+ b.updateAllWiFiDevices()
+ }
+
+ b.updateAllEthernetDevices()
+
+ if err := b.updatePrimaryConnection(); err != nil {
+ return err
+ }
+
+ if _, err := b.ListVPNProfiles(); err != nil {
+ log.Warnf("Failed to get initial VPN profiles: %v", err)
+ }
+
+ if _, err := b.ListActiveVPN(); err != nil {
+ log.Warnf("Failed to get initial active VPNs: %v", err)
+ }
+
+ return nil
+}
+
+func (b *NetworkManagerBackend) Close() {
+ close(b.stopChan)
+ b.StopMonitoring()
+
+ if b.secretAgent != nil {
+ b.secretAgent.Close()
+ }
+}
+
+func (b *NetworkManagerBackend) GetCurrentState() (*BackendState, error) {
+ b.stateMutex.RLock()
+ defer b.stateMutex.RUnlock()
+
+ state := *b.state
+ state.WiFiNetworks = append([]WiFiNetwork(nil), b.state.WiFiNetworks...)
+ state.WiFiDevices = append([]WiFiDevice(nil), b.state.WiFiDevices...)
+ state.WiredConnections = append([]WiredConnection(nil), b.state.WiredConnections...)
+ state.EthernetDevices = append([]EthernetDevice(nil), b.state.EthernetDevices...)
+ state.VPNProfiles = append([]VPNProfile(nil), b.state.VPNProfiles...)
+ state.VPNActive = append([]VPNActive(nil), b.state.VPNActive...)
+
+ return &state, nil
+}
+
+func (b *NetworkManagerBackend) StartMonitoring(onStateChange func()) error {
+ b.onStateChange = onStateChange
+
+ if err := b.startSecretAgent(); err != nil {
+ return fmt.Errorf("failed to start secret agent: %w", err)
+ }
+
+ if err := b.startSignalPump(); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (b *NetworkManagerBackend) StopMonitoring() {
+ b.stopSignalPump()
+}
+
+func (b *NetworkManagerBackend) GetPromptBroker() PromptBroker {
+ return b.promptBroker
+}
+
+func (b *NetworkManagerBackend) SetPromptBroker(broker PromptBroker) error {
+ if broker == nil {
+ return fmt.Errorf("broker cannot be nil")
+ }
+
+ hadAgent := b.secretAgent != nil
+
+ b.promptBroker = broker
+
+ if b.secretAgent != nil {
+ b.secretAgent.Close()
+ b.secretAgent = nil
+ }
+
+ if hadAgent {
+ return b.startSecretAgent()
+ }
+
+ return nil
+}
+
+func (b *NetworkManagerBackend) SubmitCredentials(token string, secrets map[string]string, save bool) error {
+ if b.promptBroker == nil {
+ return fmt.Errorf("prompt broker not initialized")
+ }
+
+ return b.promptBroker.Resolve(token, PromptReply{
+ Secrets: secrets,
+ Save: save,
+ Cancel: false,
+ })
+}
+
+func (b *NetworkManagerBackend) CancelCredentials(token string) error {
+ if b.promptBroker == nil {
+ return fmt.Errorf("prompt broker not initialized")
+ }
+
+ return b.promptBroker.Resolve(token, PromptReply{
+ Cancel: true,
+ })
+}
+
+func (b *NetworkManagerBackend) ensureWiFiDevice() error {
+ if b.wifiDev != nil {
+ return nil
+ }
+
+ if b.wifiDevice == nil {
+ return fmt.Errorf("no WiFi device available")
+ }
+
+ dev := b.wifiDevice.(gonetworkmanager.Device)
+ wifiDev, err := gonetworkmanager.NewDeviceWireless(dev.GetPath())
+ if err != nil {
+ return fmt.Errorf("failed to get wireless device: %w", err)
+ }
+ b.wifiDev = wifiDev
+ return nil
+}
+
+func (b *NetworkManagerBackend) startSecretAgent() error {
+ if b.promptBroker == nil {
+ return fmt.Errorf("prompt broker not set")
+ }
+
+ agent, err := NewSecretAgent(b.promptBroker, nil, b)
+ if err != nil {
+ return err
+ }
+
+ b.secretAgent = agent
+ return nil
+}
+
+func (b *NetworkManagerBackend) getActiveConnections() (map[string]bool, error) {
+ nm := b.nmConn.(gonetworkmanager.NetworkManager)
+
+ activeUUIDs := make(map[string]bool)
+
+ activeConns, err := nm.GetPropertyActiveConnections()
+ if err != nil {
+ return activeUUIDs, fmt.Errorf("failed to get active connections: %w", err)
+ }
+
+ for _, activeConn := range activeConns {
+ connType, err := activeConn.GetPropertyType()
+ if err != nil {
+ continue
+ }
+
+ if connType != "802-3-ethernet" {
+ continue
+ }
+
+ state, err := activeConn.GetPropertyState()
+ if err != nil {
+ continue
+ }
+ if state < 1 || state > 2 {
+ continue
+ }
+
+ uuid, err := activeConn.GetPropertyUUID()
+ if err != nil {
+ continue
+ }
+ activeUUIDs[uuid] = true
+ }
+ return activeUUIDs, nil
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_ethernet.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_ethernet.go
new file mode 100644
index 0000000..47f785b
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_ethernet.go
@@ -0,0 +1,402 @@
+package network
+
+import (
+ "fmt"
+ "net"
+ "strconv"
+ "strings"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "github.com/Wifx/gonetworkmanager/v2"
+)
+
+func (b *NetworkManagerBackend) GetWiredConnections() ([]WiredConnection, error) {
+ return b.listEthernetConnections()
+}
+
+func (b *NetworkManagerBackend) GetWiredNetworkDetails(uuid string) (*WiredNetworkInfoResponse, error) {
+ if b.ethernetDevice == nil {
+ return nil, fmt.Errorf("no ethernet device available")
+ }
+
+ dev := b.ethernetDevice.(gonetworkmanager.Device)
+
+ iface, _ := dev.GetPropertyInterface()
+ driver, _ := dev.GetPropertyDriver()
+
+ hwAddr := "Not available"
+ var speed uint32 = 0
+ wiredDevice, err := gonetworkmanager.NewDeviceWired(dev.GetPath())
+ if err == nil {
+ hwAddr, _ = wiredDevice.GetPropertyHwAddress()
+ speed, _ = wiredDevice.GetPropertySpeed()
+ }
+ var ipv4Config WiredIPConfig
+ var ipv6Config WiredIPConfig
+
+ activeConn, err := dev.GetPropertyActiveConnection()
+ if err == nil && activeConn != nil {
+ ip4Config, err := activeConn.GetPropertyIP4Config()
+ if err == nil && ip4Config != nil {
+ var ips []string
+ addresses, err := ip4Config.GetPropertyAddressData()
+ if err == nil && len(addresses) > 0 {
+ for _, addr := range addresses {
+ ips = append(ips, fmt.Sprintf("%s/%s", addr.Address, strconv.Itoa(int(addr.Prefix))))
+ }
+ }
+
+ gateway, _ := ip4Config.GetPropertyGateway()
+ dnsAddrs := ""
+ dns, err := ip4Config.GetPropertyNameserverData()
+ if err == nil && len(dns) > 0 {
+ for _, d := range dns {
+ if len(dnsAddrs) > 0 {
+ dnsAddrs = strings.Join([]string{dnsAddrs, d.Address}, "; ")
+ } else {
+ dnsAddrs = d.Address
+ }
+ }
+ }
+
+ ipv4Config = WiredIPConfig{
+ IPs: ips,
+ Gateway: gateway,
+ DNS: dnsAddrs,
+ }
+ }
+
+ ip6Config, err := activeConn.GetPropertyIP6Config()
+ if err == nil && ip6Config != nil {
+ var ips []string
+ addresses, err := ip6Config.GetPropertyAddressData()
+ if err == nil && len(addresses) > 0 {
+ for _, addr := range addresses {
+ ips = append(ips, fmt.Sprintf("%s/%s", addr.Address, strconv.Itoa(int(addr.Prefix))))
+ }
+ }
+
+ gateway, _ := ip6Config.GetPropertyGateway()
+ dnsAddrs := ""
+ dns, err := ip6Config.GetPropertyNameservers()
+ if err == nil && len(dns) > 0 {
+ for _, d := range dns {
+ if len(d) == 16 {
+ ip := net.IP(d)
+ if len(dnsAddrs) > 0 {
+ dnsAddrs = strings.Join([]string{dnsAddrs, ip.String()}, "; ")
+ } else {
+ dnsAddrs = ip.String()
+ }
+ }
+ }
+ }
+
+ ipv6Config = WiredIPConfig{
+ IPs: ips,
+ Gateway: gateway,
+ DNS: dnsAddrs,
+ }
+ }
+ }
+
+ return &WiredNetworkInfoResponse{
+ UUID: uuid,
+ IFace: iface,
+ Driver: driver,
+ HwAddr: hwAddr,
+ Speed: strconv.Itoa(int(speed)),
+ IPv4: ipv4Config,
+ IPv6: ipv6Config,
+ }, nil
+}
+
+func (b *NetworkManagerBackend) ConnectEthernet() error {
+ if b.ethernetDevice == nil {
+ return fmt.Errorf("no ethernet device available")
+ }
+
+ nm := b.nmConn.(gonetworkmanager.NetworkManager)
+ dev := b.ethernetDevice.(gonetworkmanager.Device)
+
+ settingsMgr, err := gonetworkmanager.NewSettings()
+ if err != nil {
+ return fmt.Errorf("failed to get settings: %w", err)
+ }
+
+ connections, err := settingsMgr.ListConnections()
+ if err != nil {
+ return fmt.Errorf("failed to get connections: %w", err)
+ }
+
+ for _, conn := range connections {
+ connSettings, err := conn.GetSettings()
+ if err != nil {
+ continue
+ }
+
+ if connMeta, ok := connSettings["connection"]; ok {
+ if connType, ok := connMeta["type"].(string); ok && connType == "802-3-ethernet" {
+ _, err := nm.ActivateConnection(conn, dev, nil)
+ if err != nil {
+ return fmt.Errorf("failed to activate ethernet: %w", err)
+ }
+
+ b.updateEthernetState()
+ b.listEthernetConnections()
+ b.updatePrimaryConnection()
+
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+
+ return nil
+ }
+ }
+ }
+
+ settings := make(map[string]map[string]any)
+ settings["connection"] = map[string]any{
+ "id": "Wired connection",
+ "type": "802-3-ethernet",
+ }
+
+ _, err = nm.AddAndActivateConnection(settings, dev)
+ if err != nil {
+ return fmt.Errorf("failed to create and activate ethernet: %w", err)
+ }
+
+ b.updateEthernetState()
+ b.listEthernetConnections()
+ b.updatePrimaryConnection()
+
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+
+ return nil
+}
+
+func (b *NetworkManagerBackend) DisconnectEthernet() error {
+ if b.ethernetDevice == nil {
+ return fmt.Errorf("no ethernet device available")
+ }
+
+ dev := b.ethernetDevice.(gonetworkmanager.Device)
+
+ err := dev.Disconnect()
+ if err != nil {
+ return fmt.Errorf("failed to disconnect: %w", err)
+ }
+
+ b.updateEthernetState()
+ b.listEthernetConnections()
+ b.updatePrimaryConnection()
+
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+
+ return nil
+}
+
+func (b *NetworkManagerBackend) ActivateWiredConnection(uuid string) error {
+ if b.ethernetDevice == nil {
+ return fmt.Errorf("no ethernet device available")
+ }
+
+ nm := b.nmConn.(gonetworkmanager.NetworkManager)
+ dev := b.ethernetDevice.(gonetworkmanager.Device)
+
+ settingsMgr, err := gonetworkmanager.NewSettings()
+ if err != nil {
+ return fmt.Errorf("failed to get settings: %w", err)
+ }
+
+ connections, err := settingsMgr.ListConnections()
+ if err != nil {
+ return fmt.Errorf("failed to get connections: %w", err)
+ }
+
+ var targetConnection gonetworkmanager.Connection
+ for _, conn := range connections {
+ settings, err := conn.GetSettings()
+ if err != nil {
+ continue
+ }
+
+ if connectionSettings, ok := settings["connection"]; ok {
+ if connUUID, ok := connectionSettings["uuid"].(string); ok && connUUID == uuid {
+ targetConnection = conn
+ break
+ }
+ }
+ }
+
+ if targetConnection == nil {
+ return fmt.Errorf("connection with UUID %s not found", uuid)
+ }
+
+ _, err = nm.ActivateConnection(targetConnection, dev, nil)
+ if err != nil {
+ return fmt.Errorf("error activation connection: %w", err)
+ }
+
+ b.updateEthernetState()
+ b.listEthernetConnections()
+ b.updatePrimaryConnection()
+
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+
+ return nil
+}
+
+func (b *NetworkManagerBackend) listEthernetConnections() ([]WiredConnection, error) {
+ if b.ethernetDevice == nil {
+ return nil, fmt.Errorf("no ethernet device available")
+ }
+
+ s := b.settings
+ if s == nil {
+ s, err := gonetworkmanager.NewSettings()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get settings: %w", err)
+ }
+ b.settings = s
+ }
+
+ settingsMgr := s.(gonetworkmanager.Settings)
+ connections, err := settingsMgr.ListConnections()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get connections: %w", err)
+ }
+
+ wiredConfigs := make([]WiredConnection, 0)
+ activeUUIDs, err := b.getActiveConnections()
+
+ if err != nil {
+ return nil, fmt.Errorf("failed to get active wired connections: %w", err)
+ }
+
+ currentUuid := ""
+ for _, connection := range connections {
+ path := connection.GetPath()
+ settings, err := connection.GetSettings()
+ if err != nil {
+ log.Errorf("unable to get settings for %s: %v", path, err)
+ continue
+ }
+
+ connectionSettings := settings["connection"]
+ connType, _ := connectionSettings["type"].(string)
+ connID, _ := connectionSettings["id"].(string)
+ connUUID, _ := connectionSettings["uuid"].(string)
+
+ if connType == "802-3-ethernet" {
+ wiredConfigs = append(wiredConfigs, WiredConnection{
+ Path: path,
+ ID: connID,
+ UUID: connUUID,
+ Type: connType,
+ IsActive: activeUUIDs[connUUID],
+ })
+ if activeUUIDs[connUUID] {
+ currentUuid = connUUID
+ }
+ }
+ }
+
+ b.stateMutex.Lock()
+ b.state.EthernetConnectionUuid = currentUuid
+ b.state.WiredConnections = wiredConfigs
+ b.stateMutex.Unlock()
+
+ return wiredConfigs, nil
+}
+
+func (b *NetworkManagerBackend) GetEthernetDevices() []EthernetDevice {
+ b.stateMutex.RLock()
+ defer b.stateMutex.RUnlock()
+ return append([]EthernetDevice(nil), b.state.EthernetDevices...)
+}
+
+func (b *NetworkManagerBackend) DisconnectEthernetDevice(device string) error {
+ info, ok := b.ethernetDevices[device]
+ if !ok {
+ return fmt.Errorf("ethernet device %s not found", device)
+ }
+
+ if err := info.device.Disconnect(); err != nil {
+ return fmt.Errorf("failed to disconnect %s: %w", device, err)
+ }
+
+ b.updateAllEthernetDevices()
+ b.updateEthernetState()
+ b.listEthernetConnections()
+ b.updatePrimaryConnection()
+
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+
+ return nil
+}
+
+func (b *NetworkManagerBackend) updateAllEthernetDevices() {
+ devices := make([]EthernetDevice, 0, len(b.ethernetDevices))
+
+ for name, info := range b.ethernetDevices {
+ state, _ := info.device.GetPropertyState()
+ connected := state == gonetworkmanager.NmDeviceStateActivated
+ driver, _ := info.device.GetPropertyDriver()
+
+ var ip string
+ var speed uint32 = 0
+ if connected {
+ ip = b.getDeviceIP(info.device)
+ }
+ if info.wired != nil {
+ speed, _ = info.wired.GetPropertySpeed()
+ }
+
+ stateStr := "disconnected"
+ switch state {
+ case gonetworkmanager.NmDeviceStateActivated:
+ stateStr = "activated"
+ case gonetworkmanager.NmDeviceStatePrepare:
+ stateStr = "preparing"
+ case gonetworkmanager.NmDeviceStateConfig:
+ stateStr = "configuring"
+ case gonetworkmanager.NmDeviceStateIpConfig:
+ stateStr = "ip-config"
+ case gonetworkmanager.NmDeviceStateIpCheck:
+ stateStr = "ip-check"
+ case gonetworkmanager.NmDeviceStateSecondaries:
+ stateStr = "secondaries"
+ case gonetworkmanager.NmDeviceStateDeactivating:
+ stateStr = "deactivating"
+ case gonetworkmanager.NmDeviceStateFailed:
+ stateStr = "failed"
+ case gonetworkmanager.NmDeviceStateUnavailable:
+ stateStr = "unavailable"
+ case gonetworkmanager.NmDeviceStateUnmanaged:
+ stateStr = "unmanaged"
+ }
+
+ devices = append(devices, EthernetDevice{
+ Name: name,
+ HwAddress: info.hwAddress,
+ State: stateStr,
+ Connected: connected,
+ IP: ip,
+ Speed: speed,
+ Driver: driver,
+ })
+ }
+
+ b.stateMutex.Lock()
+ b.state.EthernetDevices = devices
+ b.stateMutex.Unlock()
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_ethernet_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_ethernet_test.go
new file mode 100644
index 0000000..0901913
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_ethernet_test.go
@@ -0,0 +1,134 @@
+package network
+
+import (
+ "testing"
+
+ mock_gonetworkmanager "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestNetworkManagerBackend_GetWiredConnections_NoDevice(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ backend.ethernetDevice = nil
+ _, err = backend.GetWiredConnections()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "no ethernet device available")
+}
+
+func TestNetworkManagerBackend_GetWiredNetworkDetails_NoDevice(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ backend.ethernetDevice = nil
+ _, err = backend.GetWiredNetworkDetails("test-uuid")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "no ethernet device available")
+}
+
+func TestNetworkManagerBackend_ConnectEthernet_NoDevice(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ backend.ethernetDevice = nil
+ err = backend.ConnectEthernet()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "no ethernet device available")
+}
+
+func TestNetworkManagerBackend_DisconnectEthernet_NoDevice(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ backend.ethernetDevice = nil
+ err = backend.DisconnectEthernet()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "no ethernet device available")
+}
+
+func TestNetworkManagerBackend_ActivateWiredConnection_NoDevice(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ backend.ethernetDevice = nil
+ err = backend.ActivateWiredConnection("test-uuid")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "no ethernet device available")
+}
+
+func TestNetworkManagerBackend_ActivateWiredConnection_NotFound(t *testing.T) {
+ t.Skip("ActivateWiredConnection creates a new Settings instance internally, cannot be fully mocked")
+}
+
+func TestNetworkManagerBackend_ListEthernetConnections_NoDevice(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ backend.ethernetDevice = nil
+ _, err = backend.listEthernetConnections()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "no ethernet device available")
+}
+
+func TestNetworkManagerBackend_GetEthernetDevices_Empty(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ devices := backend.GetEthernetDevices()
+ assert.Empty(t, devices)
+}
+
+func TestNetworkManagerBackend_GetEthernetDevices_WithState(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ backend.state.EthernetDevices = []EthernetDevice{
+ {Name: "enp0s3", HwAddress: "00:11:22:33:44:55", State: "activated", Connected: true, IP: "192.168.1.100"},
+ {Name: "enp0s8", HwAddress: "00:11:22:33:44:66", State: "disconnected", Connected: false},
+ }
+
+ devices := backend.GetEthernetDevices()
+ assert.Len(t, devices, 2)
+ assert.Equal(t, "enp0s3", devices[0].Name)
+ assert.True(t, devices[0].Connected)
+ assert.Equal(t, "enp0s8", devices[1].Name)
+ assert.False(t, devices[1].Connected)
+}
+
+func TestNetworkManagerBackend_DisconnectEthernetDevice_NotFound(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ err = backend.DisconnectEthernetDevice("nonexistent")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not found")
+}
+
+func TestNetworkManagerBackend_UpdateAllEthernetDevices_Empty(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ backend.updateAllEthernetDevices()
+ assert.Empty(t, backend.state.EthernetDevices)
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_gp_saml.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_gp_saml.go
new file mode 100644
index 0000000..b1fa7fb
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_gp_saml.go
@@ -0,0 +1,203 @@
+package network
+
+import (
+ "bufio"
+ "context"
+ "fmt"
+ "os/exec"
+ "strings"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+)
+
+type gpSamlAuthResult struct {
+ Cookie string
+ Host string
+ User string
+ Fingerprint string
+}
+
+// runGlobalProtectSAMLAuth handles GlobalProtect SAML/SSO authentication using gp-saml-gui.
+// Only supports protocol=gp. Other protocols need their own implementations.
+func (b *NetworkManagerBackend) runGlobalProtectSAMLAuth(ctx context.Context, gateway, protocol string) (*gpSamlAuthResult, error) {
+ if gateway == "" {
+ return nil, fmt.Errorf("GP SAML auth: gateway is empty")
+ }
+ if protocol != "gp" {
+ return nil, fmt.Errorf("only GlobalProtect (protocol=gp) SAML is supported, got: %s", protocol)
+ }
+
+ log.Infof("[GP-SAML] Starting GlobalProtect SAML authentication with gp-saml-gui for gateway=%s", gateway)
+
+ gpSamlPath, err := exec.LookPath("gp-saml-gui")
+ if err != nil {
+ return nil, fmt.Errorf("GlobalProtect SAML requires gp-saml-gui (install: pip install gp-saml-gui): %w", err)
+ }
+
+ args := []string{
+ "--gateway",
+ "--allow-insecure-crypto",
+ gateway,
+ }
+
+ cmd := exec.CommandContext(ctx, gpSamlPath, args...)
+
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ return nil, fmt.Errorf("GP SAML auth: failed to create stdout pipe: %w", err)
+ }
+
+ stderr, err := cmd.StderrPipe()
+ if err != nil {
+ return nil, fmt.Errorf("GP SAML auth: failed to create stderr pipe: %w", err)
+ }
+
+ if err := cmd.Start(); err != nil {
+ return nil, fmt.Errorf("GP SAML auth: failed to start gp-saml-gui: %w", err)
+ }
+
+ go func() {
+ scanner := bufio.NewScanner(stderr)
+ for scanner.Scan() {
+ log.Debugf("[GP-SAML] gp-saml-gui: %s", scanner.Text())
+ }
+ }()
+
+ result := &gpSamlAuthResult{Host: gateway}
+ var allOutput []string
+
+ scanner := bufio.NewScanner(stdout)
+ for scanner.Scan() {
+ line := scanner.Text()
+ allOutput = append(allOutput, line)
+ log.Infof("[GP-SAML] stdout: %s", line)
+
+ switch {
+ case strings.HasPrefix(line, "COOKIE="):
+ result.Cookie = unshellQuote(strings.TrimPrefix(line, "COOKIE="))
+ case strings.HasPrefix(line, "HOST="):
+ result.Host = unshellQuote(strings.TrimPrefix(line, "HOST="))
+ case strings.HasPrefix(line, "USER="):
+ result.User = unshellQuote(strings.TrimPrefix(line, "USER="))
+ case strings.HasPrefix(line, "FINGERPRINT="):
+ result.Fingerprint = unshellQuote(strings.TrimPrefix(line, "FINGERPRINT="))
+ default:
+ parseGPSamlFromCommandLine(line, result)
+ }
+ }
+
+ if err := cmd.Wait(); err != nil {
+ if ctx.Err() != nil {
+ return nil, fmt.Errorf("GP SAML auth timed out or was cancelled: %w", ctx.Err())
+ }
+ if result.Cookie == "" {
+ return nil, fmt.Errorf("GP SAML auth failed: %w (output: %s)", err, strings.Join(allOutput, "\n"))
+ }
+ log.Warnf("[GP-SAML] gp-saml-gui exited with error but cookie was captured: %v", err)
+ }
+
+ if result.Cookie == "" {
+ return nil, fmt.Errorf("GP SAML auth: no cookie in gp-saml-gui output")
+ }
+
+ log.Infof("[GP-SAML] Got prelogin-cookie from gp-saml-gui, converting to openconnect cookie via --authenticate")
+
+ // Convert prelogin-cookie to full openconnect cookie format
+ ocResult, err := convertGPPreloginCookie(ctx, gateway, result.Cookie, result.User)
+ if err != nil {
+ return nil, fmt.Errorf("GP SAML auth: failed to convert prelogin-cookie: %w", err)
+ }
+
+ result.Cookie = ocResult.Cookie
+ result.Host = ocResult.Host
+ result.Fingerprint = ocResult.Fingerprint
+
+ log.Infof("[GP-SAML] Authentication successful: user=%s, host=%s, cookie_len=%d, has_fingerprint=%v",
+ result.User, result.Host, len(result.Cookie), result.Fingerprint != "")
+ return result, nil
+}
+
+func convertGPPreloginCookie(ctx context.Context, gateway, preloginCookie, user string) (*gpSamlAuthResult, error) {
+ ocPath, err := exec.LookPath("openconnect")
+ if err != nil {
+ return nil, fmt.Errorf("openconnect not found: %w", err)
+ }
+
+ args := []string{
+ "--protocol=gp",
+ "--usergroup=gateway:prelogin-cookie",
+ "--user=" + user,
+ "--passwd-on-stdin",
+ "--allow-insecure-crypto",
+ "--authenticate",
+ gateway,
+ }
+
+ cmd := exec.CommandContext(ctx, ocPath, args...)
+ cmd.Stdin = strings.NewReader(preloginCookie)
+
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ return nil, fmt.Errorf("openconnect --authenticate failed: %w\noutput: %s", err, string(output))
+ }
+
+ result := &gpSamlAuthResult{}
+ for _, line := range strings.Split(string(output), "\n") {
+ line = strings.TrimSpace(line)
+ switch {
+ case strings.HasPrefix(line, "COOKIE="):
+ result.Cookie = unshellQuote(strings.TrimPrefix(line, "COOKIE="))
+ case strings.HasPrefix(line, "HOST="):
+ result.Host = unshellQuote(strings.TrimPrefix(line, "HOST="))
+ case strings.HasPrefix(line, "FINGERPRINT="):
+ result.Fingerprint = unshellQuote(strings.TrimPrefix(line, "FINGERPRINT="))
+ case strings.HasPrefix(line, "CONNECT_URL="):
+ connectURL := unshellQuote(strings.TrimPrefix(line, "CONNECT_URL="))
+ if connectURL != "" && result.Host == "" {
+ result.Host = connectURL
+ }
+ }
+ }
+
+ if result.Cookie == "" {
+ return nil, fmt.Errorf("no COOKIE in openconnect --authenticate output: %s", string(output))
+ }
+
+ log.Infof("[GP-SAML] openconnect --authenticate: cookie_len=%d, host=%s, fingerprint=%s",
+ len(result.Cookie), result.Host, result.Fingerprint)
+
+ return result, nil
+}
+
+func unshellQuote(s string) string {
+ if len(s) >= 2 {
+ if (s[0] == '\'' && s[len(s)-1] == '\'') ||
+ (s[0] == '"' && s[len(s)-1] == '"') {
+ return s[1 : len(s)-1]
+ }
+ }
+ return s
+}
+
+func parseGPSamlFromCommandLine(line string, result *gpSamlAuthResult) {
+ if !strings.Contains(line, "openconnect") {
+ return
+ }
+
+ for _, part := range strings.Fields(line) {
+ switch {
+ case strings.HasPrefix(part, "--cookie="):
+ if result.Cookie == "" {
+ result.Cookie = strings.TrimPrefix(part, "--cookie=")
+ }
+ case strings.HasPrefix(part, "--servercert="):
+ if result.Fingerprint == "" {
+ result.Fingerprint = strings.TrimPrefix(part, "--servercert=")
+ }
+ case strings.HasPrefix(part, "--user="):
+ if result.User == "" {
+ result.User = strings.TrimPrefix(part, "--user=")
+ }
+ }
+ }
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_gp_saml_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_gp_saml_test.go
new file mode 100644
index 0000000..5655815
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_gp_saml_test.go
@@ -0,0 +1,169 @@
+package network
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestUnshellQuote(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected string
+ }{
+ {
+ name: "single quoted",
+ input: "'hello world'",
+ expected: "hello world",
+ },
+ {
+ name: "double quoted",
+ input: `"hello world"`,
+ expected: "hello world",
+ },
+ {
+ name: "unquoted",
+ input: "hello",
+ expected: "hello",
+ },
+ {
+ name: "empty single quotes",
+ input: "''",
+ expected: "",
+ },
+ {
+ name: "empty double quotes",
+ input: `""`,
+ expected: "",
+ },
+ {
+ name: "single quote only",
+ input: "'",
+ expected: "'",
+ },
+ {
+ name: "mismatched quotes",
+ input: "'hello\"",
+ expected: "'hello\"",
+ },
+ {
+ name: "with special chars",
+ input: "'cookie=abc123&user=john'",
+ expected: "cookie=abc123&user=john",
+ },
+ {
+ name: "complex cookie",
+ input: `'authcookie=077058d3bc81&portal=PANGP_GW_01-N&user=john.doe@example.com&domain=Default&preferred-ip=192.168.1.100'`,
+ expected: "authcookie=077058d3bc81&portal=PANGP_GW_01-N&user=john.doe@example.com&domain=Default&preferred-ip=192.168.1.100",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := unshellQuote(tt.input)
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
+
+func TestParseGPSamlFromCommandLine(t *testing.T) {
+ tests := []struct {
+ name string
+ line string
+ initialResult *gpSamlAuthResult
+ expectedCookie string
+ expectedUser string
+ expectedFP string
+ }{
+ {
+ name: "full openconnect command",
+ line: "openconnect --protocol=gp --cookie=AUTH123 --servercert=pin-sha256:ABC --user=john",
+ initialResult: &gpSamlAuthResult{},
+ expectedCookie: "AUTH123",
+ expectedUser: "john",
+ expectedFP: "pin-sha256:ABC",
+ },
+ {
+ name: "with equals signs in cookie",
+ line: "openconnect --cookie=authcookie=xyz123&portal=GATE --user=jane",
+ initialResult: &gpSamlAuthResult{},
+ expectedCookie: "authcookie=xyz123&portal=GATE",
+ expectedUser: "jane",
+ expectedFP: "",
+ },
+ {
+ name: "non-openconnect line",
+ line: "some other output",
+ initialResult: &gpSamlAuthResult{},
+ expectedCookie: "",
+ expectedUser: "",
+ expectedFP: "",
+ },
+ {
+ name: "preserves existing values",
+ line: "openconnect --user=newuser",
+ initialResult: &gpSamlAuthResult{Cookie: "existing", Fingerprint: "existing-fp"},
+ expectedCookie: "existing",
+ expectedUser: "newuser",
+ expectedFP: "existing-fp",
+ },
+ {
+ name: "only updates empty fields",
+ line: "openconnect --cookie=NEW --user=NEW",
+ initialResult: &gpSamlAuthResult{Cookie: "OLD"},
+ expectedCookie: "OLD",
+ expectedUser: "NEW",
+ expectedFP: "",
+ },
+ {
+ name: "real gp-saml-gui output",
+ line: "openconnect --protocol=gp --user=john.doe@example.com --os=linux-64 --usergroup=gateway:prelogin-cookie --passwd-on-stdin",
+ initialResult: &gpSamlAuthResult{},
+ expectedCookie: "",
+ expectedUser: "john.doe@example.com",
+ expectedFP: "",
+ },
+ {
+ name: "with server cert flag",
+ line: "openconnect --servercert=pin-sha256:xp3scfzy3rOgQEXnfPiYKrUk7D66a8b8O+gEXaMPleE= vpn.example.com",
+ initialResult: &gpSamlAuthResult{},
+ expectedCookie: "",
+ expectedUser: "",
+ expectedFP: "pin-sha256:xp3scfzy3rOgQEXnfPiYKrUk7D66a8b8O+gEXaMPleE=",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := tt.initialResult
+ parseGPSamlFromCommandLine(tt.line, result)
+
+ assert.Equal(t, tt.expectedCookie, result.Cookie, "cookie mismatch")
+ assert.Equal(t, tt.expectedUser, result.User, "user mismatch")
+ assert.Equal(t, tt.expectedFP, result.Fingerprint, "fingerprint mismatch")
+ })
+ }
+}
+
+func TestParseGPSamlFromCommandLine_MultipleLines(t *testing.T) {
+ // Simulate gp-saml-gui output with command line suggestion
+ lines := []string{
+ "",
+ "SAML REDIRECT",
+ "Got SAML Login URL",
+ "POST to ACS endpoint...",
+ "Got 'prelogin-cookie': 'FAKE_cookie_12345'",
+ "openconnect --protocol=gp --user=john.doe@example.com --usergroup=gateway:prelogin-cookie --passwd-on-stdin vpn.example.com",
+ "",
+ }
+
+ result := &gpSamlAuthResult{}
+ for _, line := range lines {
+ parseGPSamlFromCommandLine(line, result)
+ }
+
+ assert.Equal(t, "john.doe@example.com", result.User)
+ assert.Empty(t, result.Cookie, "cookie should not be parsed from command line")
+ assert.Empty(t, result.Fingerprint)
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_signals.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_signals.go
new file mode 100644
index 0000000..5274ed0
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_signals.go
@@ -0,0 +1,530 @@
+package network
+
+import (
+ "github.com/Wifx/gonetworkmanager/v2"
+ "github.com/godbus/dbus/v5"
+)
+
+func (b *NetworkManagerBackend) startSignalPump() error {
+ conn, err := dbus.ConnectSystemBus()
+ if err != nil {
+ return err
+ }
+ b.dbusConn = conn
+
+ signals := make(chan *dbus.Signal, 256)
+ b.signals = signals
+ conn.Signal(signals)
+
+ if err := conn.AddMatchSignal(
+ dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMPath)),
+ dbus.WithMatchInterface(dbusPropsInterface),
+ dbus.WithMatchMember("PropertiesChanged"),
+ ); err != nil {
+ conn.RemoveSignal(signals)
+ conn.Close()
+ return err
+ }
+
+ if err := conn.AddMatchSignal(
+ dbus.WithMatchObjectPath(dbus.ObjectPath("/org/freedesktop/NetworkManager/Settings")),
+ dbus.WithMatchInterface("org.freedesktop.NetworkManager.Settings"),
+ dbus.WithMatchMember("NewConnection"),
+ ); err != nil {
+ conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMPath)),
+ dbus.WithMatchInterface(dbusPropsInterface),
+ dbus.WithMatchMember("PropertiesChanged"),
+ )
+ conn.RemoveSignal(signals)
+ conn.Close()
+ return err
+ }
+
+ if err := conn.AddMatchSignal(
+ dbus.WithMatchObjectPath(dbus.ObjectPath("/org/freedesktop/NetworkManager/Settings")),
+ dbus.WithMatchInterface("org.freedesktop.NetworkManager.Settings"),
+ dbus.WithMatchMember("ConnectionRemoved"),
+ ); err != nil {
+ conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMPath)),
+ dbus.WithMatchInterface(dbusPropsInterface),
+ dbus.WithMatchMember("PropertiesChanged"),
+ )
+ conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(dbus.ObjectPath("/org/freedesktop/NetworkManager/Settings")),
+ dbus.WithMatchInterface("org.freedesktop.NetworkManager.Settings"),
+ dbus.WithMatchMember("NewConnection"),
+ )
+ conn.RemoveSignal(signals)
+ conn.Close()
+ return err
+ }
+
+ if err := conn.AddMatchSignal(
+ dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMPath)),
+ dbus.WithMatchInterface(dbusNMInterface),
+ dbus.WithMatchMember("DeviceAdded"),
+ ); err != nil {
+ conn.RemoveSignal(signals)
+ conn.Close()
+ return err
+ }
+
+ if err := conn.AddMatchSignal(
+ dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMPath)),
+ dbus.WithMatchInterface(dbusNMInterface),
+ dbus.WithMatchMember("DeviceRemoved"),
+ ); err != nil {
+ conn.RemoveSignal(signals)
+ conn.Close()
+ return err
+ }
+
+ for _, info := range b.wifiDevices {
+ if err := conn.AddMatchSignal(
+ dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())),
+ dbus.WithMatchInterface(dbusPropsInterface),
+ dbus.WithMatchMember("PropertiesChanged"),
+ ); err != nil {
+ conn.RemoveSignal(signals)
+ conn.Close()
+ return err
+ }
+ }
+
+ for _, info := range b.ethernetDevices {
+ if err := conn.AddMatchSignal(
+ dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())),
+ dbus.WithMatchInterface(dbusPropsInterface),
+ dbus.WithMatchMember("PropertiesChanged"),
+ ); err != nil {
+ conn.RemoveSignal(signals)
+ conn.Close()
+ return err
+ }
+ }
+
+ b.sigWG.Add(1)
+ go func() {
+ defer b.sigWG.Done()
+ for {
+ select {
+ case <-b.stopChan:
+ return
+ case sig, ok := <-signals:
+ if !ok {
+ return
+ }
+ if sig == nil {
+ continue
+ }
+ b.handleDBusSignal(sig)
+ }
+ }
+ }()
+ return nil
+}
+
+func (b *NetworkManagerBackend) stopSignalPump() {
+ if b.dbusConn == nil {
+ return
+ }
+
+ b.dbusConn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMPath)),
+ dbus.WithMatchInterface(dbusPropsInterface),
+ dbus.WithMatchMember("PropertiesChanged"),
+ )
+
+ for _, info := range b.wifiDevices {
+ b.dbusConn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())),
+ dbus.WithMatchInterface(dbusPropsInterface),
+ dbus.WithMatchMember("PropertiesChanged"),
+ )
+ }
+
+ for _, info := range b.ethernetDevices {
+ b.dbusConn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())),
+ dbus.WithMatchInterface(dbusPropsInterface),
+ dbus.WithMatchMember("PropertiesChanged"),
+ )
+ }
+
+ if b.signals != nil {
+ b.dbusConn.RemoveSignal(b.signals)
+ close(b.signals)
+ }
+
+ b.sigWG.Wait()
+
+ b.dbusConn.Close()
+}
+
+func (b *NetworkManagerBackend) handleDBusSignal(sig *dbus.Signal) {
+ if sig.Name == "org.freedesktop.NetworkManager.Settings.NewConnection" ||
+ sig.Name == "org.freedesktop.NetworkManager.Settings.ConnectionRemoved" {
+ b.ListVPNProfiles()
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+ return
+ }
+
+ if sig.Name == "org.freedesktop.NetworkManager.DeviceAdded" {
+ if len(sig.Body) >= 1 {
+ if devicePath, ok := sig.Body[0].(dbus.ObjectPath); ok {
+ b.handleDeviceAdded(devicePath)
+ }
+ }
+ return
+ }
+
+ if sig.Name == "org.freedesktop.NetworkManager.DeviceRemoved" {
+ if len(sig.Body) >= 1 {
+ if devicePath, ok := sig.Body[0].(dbus.ObjectPath); ok {
+ b.handleDeviceRemoved(devicePath)
+ }
+ }
+ return
+ }
+
+ if len(sig.Body) < 2 {
+ return
+ }
+
+ iface, ok := sig.Body[0].(string)
+ if !ok {
+ return
+ }
+
+ changes, ok := sig.Body[1].(map[string]dbus.Variant)
+ if !ok {
+ return
+ }
+
+ switch iface {
+ case dbusNMInterface:
+ b.handleNetworkManagerChange(changes)
+
+ case dbusNMDeviceInterface:
+ b.handleDeviceChange(sig.Path, changes)
+
+ case dbusNMWiredInterface:
+ b.handleWiredChange(changes)
+
+ case dbusNMWirelessInterface:
+ b.handleWiFiChange(changes)
+
+ case dbusNMAccessPointInterface:
+ b.handleAccessPointChange(changes)
+ }
+}
+
+func (b *NetworkManagerBackend) handleNetworkManagerChange(changes map[string]dbus.Variant) {
+ var needsUpdate bool
+
+ for key := range changes {
+ switch key {
+ case "PrimaryConnection", "State", "ActiveConnections":
+ needsUpdate = true
+ case "WirelessEnabled":
+ nm := b.nmConn.(gonetworkmanager.NetworkManager)
+ if enabled, err := nm.GetPropertyWirelessEnabled(); err == nil {
+ b.stateMutex.Lock()
+ b.state.WiFiEnabled = enabled
+ b.stateMutex.Unlock()
+ needsUpdate = true
+ }
+ default:
+ continue
+ }
+ }
+
+ if needsUpdate {
+ b.updatePrimaryConnection()
+ if _, exists := changes["State"]; exists {
+ b.updateEthernetState()
+ b.updateWiFiState()
+ }
+ if _, exists := changes["ActiveConnections"]; exists {
+ b.updateVPNConnectionState()
+ b.ListActiveVPN()
+ }
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+ }
+}
+
+func (b *NetworkManagerBackend) handleDeviceChange(devicePath dbus.ObjectPath, changes map[string]dbus.Variant) {
+ var needsUpdate bool
+ var stateChanged bool
+ var managedChanged bool
+
+ for key := range changes {
+ switch key {
+ case "State":
+ stateChanged = true
+ needsUpdate = true
+ case "Ip4Config":
+ needsUpdate = true
+ case "Managed":
+ managedChanged = true
+ default:
+ continue
+ }
+ }
+
+ if managedChanged {
+ if managedVariant, ok := changes["Managed"]; ok {
+ if managed, ok := managedVariant.Value().(bool); ok && managed {
+ b.handleDeviceAdded(devicePath)
+ return
+ }
+ }
+ }
+
+ if !needsUpdate {
+ return
+ }
+
+ b.updateAllEthernetDevices()
+ b.updateEthernetState()
+ b.updateAllWiFiDevices()
+ b.updateWiFiState()
+ if stateChanged {
+ b.listEthernetConnections()
+ b.updatePrimaryConnection()
+ }
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+}
+
+func (b *NetworkManagerBackend) handleWiredChange(changes map[string]dbus.Variant) {
+ var needsUpdate bool
+
+ for key := range changes {
+ switch key {
+ case "Carrier", "Speed", "HwAddress":
+ needsUpdate = true
+ default:
+ continue
+ }
+ }
+
+ if !needsUpdate {
+ return
+ }
+
+ b.updateAllEthernetDevices()
+ b.updateEthernetState()
+ b.updatePrimaryConnection()
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+}
+
+func (b *NetworkManagerBackend) handleWiFiChange(changes map[string]dbus.Variant) {
+ var needsStateUpdate bool
+ var needsNetworkUpdate bool
+
+ for key := range changes {
+ switch key {
+ case "ActiveAccessPoint":
+ needsStateUpdate = true
+ needsNetworkUpdate = true
+ case "AccessPoints":
+ needsNetworkUpdate = true
+ default:
+ continue
+ }
+ }
+
+ if needsStateUpdate {
+ b.updateWiFiState()
+ }
+ if needsNetworkUpdate {
+ b.updateWiFiNetworks()
+ }
+ if needsStateUpdate || needsNetworkUpdate {
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+ }
+}
+
+func (b *NetworkManagerBackend) handleAccessPointChange(changes map[string]dbus.Variant) {
+ _, hasStrength := changes["Strength"]
+ if !hasStrength {
+ return
+ }
+
+ b.stateMutex.RLock()
+ oldSignal := b.state.WiFiSignal
+ b.stateMutex.RUnlock()
+
+ b.updateWiFiState()
+
+ b.stateMutex.RLock()
+ newSignal := b.state.WiFiSignal
+ b.stateMutex.RUnlock()
+
+ if signalChangeSignificant(oldSignal, newSignal) {
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+ }
+}
+
+func (b *NetworkManagerBackend) handleDeviceAdded(devicePath dbus.ObjectPath) {
+ dev, err := gonetworkmanager.NewDevice(devicePath)
+ if err != nil {
+ return
+ }
+
+ devType, err := dev.GetPropertyDeviceType()
+ if err != nil {
+ return
+ }
+
+ if devType != gonetworkmanager.NmDeviceTypeEthernet && devType != gonetworkmanager.NmDeviceTypeWifi {
+ return
+ }
+
+ if b.dbusConn != nil {
+ b.dbusConn.AddMatchSignal(
+ dbus.WithMatchObjectPath(devicePath),
+ dbus.WithMatchInterface(dbusPropsInterface),
+ dbus.WithMatchMember("PropertiesChanged"),
+ )
+ }
+
+ managed, _ := dev.GetPropertyManaged()
+ if !managed {
+ return
+ }
+
+ iface, err := dev.GetPropertyInterface()
+ if err != nil {
+ return
+ }
+
+ switch devType {
+ case gonetworkmanager.NmDeviceTypeEthernet:
+ w, err := gonetworkmanager.NewDeviceWired(devicePath)
+ if err != nil {
+ return
+ }
+ hwAddr, _ := w.GetPropertyHwAddress()
+
+ b.ethernetDevices[iface] = &ethernetDeviceInfo{
+ device: dev,
+ wired: w,
+ name: iface,
+ hwAddress: hwAddr,
+ }
+
+ if b.ethernetDevice == nil {
+ b.ethernetDevice = dev
+ }
+
+ b.updateAllEthernetDevices()
+ b.updateEthernetState()
+ b.listEthernetConnections()
+ b.updatePrimaryConnection()
+
+ case gonetworkmanager.NmDeviceTypeWifi:
+ w, err := gonetworkmanager.NewDeviceWireless(devicePath)
+ if err != nil {
+ return
+ }
+ hwAddr, _ := w.GetPropertyHwAddress()
+
+ b.wifiDevices[iface] = &wifiDeviceInfo{
+ device: dev,
+ wireless: w,
+ name: iface,
+ hwAddress: hwAddr,
+ }
+
+ if b.wifiDevice == nil {
+ b.wifiDevice = dev
+ b.wifiDev = w
+ }
+
+ b.updateAllWiFiDevices()
+ b.updateWiFiState()
+ }
+
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+}
+
+func (b *NetworkManagerBackend) handleDeviceRemoved(devicePath dbus.ObjectPath) {
+ if b.dbusConn != nil {
+ b.dbusConn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(devicePath),
+ dbus.WithMatchInterface(dbusPropsInterface),
+ dbus.WithMatchMember("PropertiesChanged"),
+ )
+ }
+
+ for iface, info := range b.ethernetDevices {
+ if info.device.GetPath() == devicePath {
+ delete(b.ethernetDevices, iface)
+
+ if b.ethernetDevice != nil {
+ dev := b.ethernetDevice.(gonetworkmanager.Device)
+ if dev.GetPath() == devicePath {
+ b.ethernetDevice = nil
+ for _, remaining := range b.ethernetDevices {
+ b.ethernetDevice = remaining.device
+ break
+ }
+ }
+ }
+
+ b.updateAllEthernetDevices()
+ b.updateEthernetState()
+ b.listEthernetConnections()
+ b.updatePrimaryConnection()
+
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+ return
+ }
+ }
+
+ for iface, info := range b.wifiDevices {
+ if info.device.GetPath() == devicePath {
+ delete(b.wifiDevices, iface)
+
+ if b.wifiDevice != nil {
+ dev := b.wifiDevice.(gonetworkmanager.Device)
+ if dev.GetPath() == devicePath {
+ b.wifiDevice = nil
+ b.wifiDev = nil
+ for _, remaining := range b.wifiDevices {
+ b.wifiDevice = remaining.device
+ b.wifiDev = remaining.wireless
+ break
+ }
+ }
+ }
+
+ b.updateAllWiFiDevices()
+ b.updateWiFiState()
+
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+ return
+ }
+ }
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_signals_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_signals_test.go
new file mode 100644
index 0000000..6b58684
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_signals_test.go
@@ -0,0 +1,255 @@
+package network
+
+import (
+ "testing"
+
+ mock_gonetworkmanager "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2"
+ "github.com/Wifx/gonetworkmanager/v2"
+ "github.com/godbus/dbus/v5"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestNetworkManagerBackend_HandleDBusSignal_NewConnection(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ sig := &dbus.Signal{
+ Name: "org.freedesktop.NetworkManager.Settings.NewConnection",
+ Body: []any{"/org/freedesktop/NetworkManager/Settings/1"},
+ }
+
+ assert.NotPanics(t, func() {
+ backend.handleDBusSignal(sig)
+ })
+}
+
+func TestNetworkManagerBackend_HandleDBusSignal_ConnectionRemoved(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ sig := &dbus.Signal{
+ Name: "org.freedesktop.NetworkManager.Settings.ConnectionRemoved",
+ Body: []any{"/org/freedesktop/NetworkManager/Settings/1"},
+ }
+
+ assert.NotPanics(t, func() {
+ backend.handleDBusSignal(sig)
+ })
+}
+
+func TestNetworkManagerBackend_HandleDBusSignal_InvalidBody(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ sig := &dbus.Signal{
+ Name: "org.freedesktop.DBus.Properties.PropertiesChanged",
+ Body: []any{"only-one-element"},
+ }
+
+ assert.NotPanics(t, func() {
+ backend.handleDBusSignal(sig)
+ })
+}
+
+func TestNetworkManagerBackend_HandleDBusSignal_InvalidInterface(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ sig := &dbus.Signal{
+ Name: "org.freedesktop.DBus.Properties.PropertiesChanged",
+ Body: []any{123, map[string]dbus.Variant{}},
+ }
+
+ assert.NotPanics(t, func() {
+ backend.handleDBusSignal(sig)
+ })
+}
+
+func TestNetworkManagerBackend_HandleDBusSignal_InvalidChanges(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ sig := &dbus.Signal{
+ Name: "org.freedesktop.DBus.Properties.PropertiesChanged",
+ Body: []any{dbusNMInterface, "not-a-map"},
+ }
+
+ assert.NotPanics(t, func() {
+ backend.handleDBusSignal(sig)
+ })
+}
+
+func TestNetworkManagerBackend_HandleNetworkManagerChange(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ mockNM.EXPECT().GetPropertyActiveConnections().Return([]gonetworkmanager.ActiveConnection{}, nil).Maybe()
+ mockNM.EXPECT().GetPropertyPrimaryConnection().Return(nil, nil).Maybe()
+
+ changes := map[string]dbus.Variant{
+ "PrimaryConnection": dbus.MakeVariant("/"),
+ "State": dbus.MakeVariant(uint32(70)),
+ }
+
+ assert.NotPanics(t, func() {
+ backend.handleNetworkManagerChange(changes)
+ })
+}
+
+func TestNetworkManagerBackend_HandleNetworkManagerChange_WirelessEnabled(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ mockNM.EXPECT().GetPropertyWirelessEnabled().Return(true, nil)
+ mockNM.EXPECT().GetPropertyActiveConnections().Return([]gonetworkmanager.ActiveConnection{}, nil).Maybe()
+ mockNM.EXPECT().GetPropertyPrimaryConnection().Return(nil, nil).Maybe()
+
+ changes := map[string]dbus.Variant{
+ "WirelessEnabled": dbus.MakeVariant(true),
+ }
+
+ assert.NotPanics(t, func() {
+ backend.handleNetworkManagerChange(changes)
+ })
+}
+
+func TestNetworkManagerBackend_HandleNetworkManagerChange_ActiveConnections(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ mockNM.EXPECT().GetPropertyActiveConnections().Return([]gonetworkmanager.ActiveConnection{}, nil)
+ mockNM.EXPECT().GetPropertyPrimaryConnection().Return(nil, nil).Maybe()
+
+ changes := map[string]dbus.Variant{
+ "ActiveConnections": dbus.MakeVariant([]any{}),
+ }
+
+ assert.NotPanics(t, func() {
+ backend.handleNetworkManagerChange(changes)
+ })
+}
+
+func TestNetworkManagerBackend_HandleDeviceChange(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ mockNM.EXPECT().GetPropertyActiveConnections().Return([]gonetworkmanager.ActiveConnection{}, nil).Maybe()
+ mockNM.EXPECT().GetPropertyPrimaryConnection().Return(nil, nil).Maybe()
+
+ changes := map[string]dbus.Variant{
+ "State": dbus.MakeVariant(uint32(100)),
+ }
+
+ assert.NotPanics(t, func() {
+ backend.handleDeviceChange("/org/freedesktop/NetworkManager/Devices/1", changes)
+ })
+}
+
+func TestNetworkManagerBackend_HandleDeviceChange_Ip4Config(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ changes := map[string]dbus.Variant{
+ "Ip4Config": dbus.MakeVariant("/"),
+ }
+
+ assert.NotPanics(t, func() {
+ backend.handleDeviceChange("/org/freedesktop/NetworkManager/Devices/1", changes)
+ })
+}
+
+func TestNetworkManagerBackend_HandleWiFiChange_ActiveAccessPoint(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ changes := map[string]dbus.Variant{
+ "ActiveAccessPoint": dbus.MakeVariant("/"),
+ }
+
+ assert.NotPanics(t, func() {
+ backend.handleWiFiChange(changes)
+ })
+}
+
+func TestNetworkManagerBackend_HandleWiFiChange_AccessPoints(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ changes := map[string]dbus.Variant{
+ "AccessPoints": dbus.MakeVariant([]any{}),
+ }
+
+ assert.NotPanics(t, func() {
+ backend.handleWiFiChange(changes)
+ })
+}
+
+func TestNetworkManagerBackend_HandleAccessPointChange_NoStrength(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ changes := map[string]dbus.Variant{
+ "SomeOtherProperty": dbus.MakeVariant("value"),
+ }
+
+ assert.NotPanics(t, func() {
+ backend.handleAccessPointChange(changes)
+ })
+}
+
+func TestNetworkManagerBackend_HandleAccessPointChange_WithStrength(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ backend.stateMutex.Lock()
+ backend.state.WiFiSignal = 50
+ backend.stateMutex.Unlock()
+
+ changes := map[string]dbus.Variant{
+ "Strength": dbus.MakeVariant(uint8(80)),
+ }
+
+ assert.NotPanics(t, func() {
+ backend.handleAccessPointChange(changes)
+ })
+}
+
+func TestNetworkManagerBackend_StopSignalPump_NoConnection(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ backend.dbusConn = nil
+ assert.NotPanics(t, func() {
+ backend.stopSignalPump()
+ })
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_state.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_state.go
new file mode 100644
index 0000000..ba77f6d
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_state.go
@@ -0,0 +1,277 @@
+package network
+
+import (
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "github.com/Wifx/gonetworkmanager/v2"
+)
+
+func (b *NetworkManagerBackend) updatePrimaryConnection() error {
+ nm := b.nmConn.(gonetworkmanager.NetworkManager)
+
+ activeConns, err := nm.GetPropertyActiveConnections()
+ if err != nil {
+ return err
+ }
+
+ hasActiveVPN := false
+ for _, activeConn := range activeConns {
+ connType, err := activeConn.GetPropertyType()
+ if err != nil {
+ continue
+ }
+ if connType == "vpn" || connType == "wireguard" {
+ state, _ := activeConn.GetPropertyState()
+ if state == 2 {
+ hasActiveVPN = true
+ break
+ }
+ }
+ }
+
+ if hasActiveVPN {
+ b.stateMutex.Lock()
+ b.state.NetworkStatus = StatusVPN
+ b.stateMutex.Unlock()
+ return nil
+ }
+
+ primaryConn, err := nm.GetPropertyPrimaryConnection()
+ if err != nil {
+ return err
+ }
+
+ if primaryConn == nil || primaryConn.GetPath() == "/" {
+ b.stateMutex.Lock()
+ b.state.NetworkStatus = StatusDisconnected
+ b.stateMutex.Unlock()
+ return nil
+ }
+
+ connType, err := primaryConn.GetPropertyType()
+ if err != nil {
+ return err
+ }
+
+ b.stateMutex.Lock()
+ switch connType {
+ case "802-3-ethernet":
+ b.state.NetworkStatus = StatusEthernet
+ case "802-11-wireless":
+ b.state.NetworkStatus = StatusWiFi
+ case "vpn", "wireguard":
+ b.state.NetworkStatus = StatusVPN
+ default:
+ b.state.NetworkStatus = StatusDisconnected
+ }
+ b.stateMutex.Unlock()
+
+ return nil
+}
+
+func (b *NetworkManagerBackend) updateEthernetState() error {
+ var connectedDevice string
+ var connectedIP string
+ var anyConnected bool
+
+ for name, info := range b.ethernetDevices {
+ state, err := info.device.GetPropertyState()
+ if err != nil {
+ continue
+ }
+
+ if state == gonetworkmanager.NmDeviceStateActivated {
+ anyConnected = true
+ connectedDevice = name
+ connectedIP = b.getDeviceIP(info.device)
+ break
+ }
+ }
+
+ if !anyConnected && b.ethernetDevice != nil {
+ dev := b.ethernetDevice.(gonetworkmanager.Device)
+ iface, _ := dev.GetPropertyInterface()
+ connectedDevice = iface
+ }
+
+ b.stateMutex.Lock()
+ b.state.EthernetDevice = connectedDevice
+ b.state.EthernetConnected = anyConnected
+ b.state.EthernetIP = connectedIP
+ b.stateMutex.Unlock()
+
+ return nil
+}
+
+func (b *NetworkManagerBackend) getDeviceStateReason(dev gonetworkmanager.Device) uint32 {
+ path := dev.GetPath()
+ obj := b.dbusConn.Object("org.freedesktop.NetworkManager", path)
+
+ variant, err := obj.GetProperty(dbusNMDeviceInterface + ".StateReason")
+ if err != nil {
+ return 0
+ }
+
+ if stateReasonStruct, ok := variant.Value().([]any); ok && len(stateReasonStruct) >= 2 {
+ if reason, ok := stateReasonStruct[1].(uint32); ok {
+ return reason
+ }
+ }
+
+ return 0
+}
+
+func (b *NetworkManagerBackend) classifyNMStateReason(reason uint32) string {
+ switch reason {
+ case NmDeviceStateReasonWrongPassword,
+ NmDeviceStateReasonSupplicantTimeout,
+ NmDeviceStateReasonSupplicantFailed,
+ NmDeviceStateReasonSecretsRequired:
+ return errdefs.ErrBadCredentials
+ case NmDeviceStateReasonNoSecrets:
+ return errdefs.ErrUserCanceled
+ case NmDeviceStateReasonNoSsid:
+ return errdefs.ErrNoSuchSSID
+ case NmDeviceStateReasonDhcpClientFailed,
+ NmDeviceStateReasonIpConfigUnavailable:
+ return errdefs.ErrDhcpTimeout
+ case NmDeviceStateReasonSupplicantDisconnect,
+ NmDeviceStateReasonCarrier:
+ return errdefs.ErrAssocTimeout
+ default:
+ return errdefs.ErrConnectionFailed
+ }
+}
+
+func (b *NetworkManagerBackend) updateWiFiState() error {
+ if b.wifiDevice == nil {
+ return nil
+ }
+
+ dev := b.wifiDevice.(gonetworkmanager.Device)
+
+ iface, err := dev.GetPropertyInterface()
+ if err != nil {
+ return err
+ }
+
+ state, err := dev.GetPropertyState()
+ if err != nil {
+ return err
+ }
+
+ connected := state == gonetworkmanager.NmDeviceStateActivated
+ failed := state == gonetworkmanager.NmDeviceStateFailed
+ disconnected := state == gonetworkmanager.NmDeviceStateDisconnected
+
+ var ip, ssid, bssid string
+ var signal uint8
+
+ if connected {
+ if err := b.ensureWiFiDevice(); err == nil && b.wifiDev != nil {
+ w := b.wifiDev.(gonetworkmanager.DeviceWireless)
+ activeAP, err := w.GetPropertyActiveAccessPoint()
+ if err == nil && activeAP != nil && activeAP.GetPath() != "/" {
+ ssid, _ = activeAP.GetPropertySSID()
+ signal, _ = activeAP.GetPropertyStrength()
+ bssid, _ = activeAP.GetPropertyHWAddress()
+ }
+ }
+
+ ip = b.getDeviceIP(dev)
+ }
+
+ b.stateMutex.RLock()
+ wasConnecting := b.state.IsConnecting
+ connectingSSID := b.state.ConnectingSSID
+ b.stateMutex.RUnlock()
+
+ var reasonCode string
+ if wasConnecting && connectingSSID != "" && (failed || (disconnected && !connected)) {
+ reason := b.getDeviceStateReason(dev)
+
+ if reason == NmDeviceStateReasonNewActivation || reason == 0 {
+ return nil
+ }
+
+ log.Warnf("[updateWiFiState] Connection failed: SSID=%s, state=%d, reason=%d", connectingSSID, state, reason)
+
+ reasonCode = b.classifyNMStateReason(reason)
+
+ if reasonCode == errdefs.ErrConnectionFailed {
+ b.failedMutex.RLock()
+ if b.lastFailedSSID == connectingSSID {
+ elapsed := time.Now().Unix() - b.lastFailedTime
+ if elapsed < 5 {
+ reasonCode = errdefs.ErrBadCredentials
+ }
+ }
+ b.failedMutex.RUnlock()
+ }
+ }
+
+ var forgetSSID string
+
+ b.stateMutex.Lock()
+
+ wasConnecting = b.state.IsConnecting
+ connectingSSID = b.state.ConnectingSSID
+
+ if wasConnecting && connectingSSID != "" {
+ switch {
+ case connected && ssid == connectingSSID:
+ log.Infof("[updateWiFiState] Connection successful: %s", ssid)
+ b.state.IsConnecting = false
+ b.state.ConnectingSSID = ""
+ b.state.LastError = ""
+ case failed || (disconnected && !connected):
+ log.Warnf("[updateWiFiState] Connection failed: SSID=%s, state=%d", connectingSSID, state)
+ b.state.IsConnecting = false
+ b.state.ConnectingSSID = ""
+ b.state.LastError = reasonCode
+
+ if reasonCode == errdefs.ErrUserCanceled {
+ forgetSSID = connectingSSID
+ }
+
+ b.failedMutex.Lock()
+ b.lastFailedSSID = connectingSSID
+ b.lastFailedTime = time.Now().Unix()
+ b.failedMutex.Unlock()
+ }
+ }
+
+ b.state.WiFiDevice = iface
+ b.state.WiFiConnected = connected
+ b.state.WiFiIP = ip
+ b.state.WiFiSSID = ssid
+ b.state.WiFiBSSID = bssid
+ b.state.WiFiSignal = signal
+
+ b.stateMutex.Unlock()
+
+ if forgetSSID != "" {
+ log.Infof("[updateWiFiState] User cancelled authentication, removing connection profile for %s", forgetSSID)
+ if err := b.ForgetWiFiNetwork(forgetSSID); err != nil {
+ log.Warnf("[updateWiFiState] Failed to remove cancelled connection: %v", err)
+ }
+ }
+
+ return nil
+}
+
+func (b *NetworkManagerBackend) getDeviceIP(dev gonetworkmanager.Device) string {
+ ip4Config, err := dev.GetPropertyIP4Config()
+ if err != nil || ip4Config == nil {
+ return ""
+ }
+
+ addresses, err := ip4Config.GetPropertyAddressData()
+ if err != nil || len(addresses) == 0 {
+ return ""
+ }
+
+ return addresses[0].Address
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_state_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_state_test.go
new file mode 100644
index 0000000..86aa48f
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_state_test.go
@@ -0,0 +1,82 @@
+package network
+
+import (
+ "testing"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
+ mock_gonetworkmanager "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2"
+ "github.com/Wifx/gonetworkmanager/v2"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestNetworkManagerBackend_UpdatePrimaryConnection(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ mockNM.EXPECT().GetPropertyActiveConnections().Return([]gonetworkmanager.ActiveConnection{}, nil)
+ mockNM.EXPECT().GetPropertyPrimaryConnection().Return(nil, nil)
+
+ err = backend.updatePrimaryConnection()
+ assert.NoError(t, err)
+}
+
+func TestNetworkManagerBackend_UpdateEthernetState_NoDevice(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ backend.ethernetDevice = nil
+ err = backend.updateEthernetState()
+ assert.NoError(t, err)
+}
+
+func TestNetworkManagerBackend_UpdateWiFiState_NoDevice(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ backend.wifiDevice = nil
+ err = backend.updateWiFiState()
+ assert.NoError(t, err)
+}
+
+func TestNetworkManagerBackend_ClassifyNMStateReason(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ testCases := []struct {
+ reason uint32
+ expected string
+ }{
+ {NmDeviceStateReasonWrongPassword, errdefs.ErrBadCredentials},
+ {NmDeviceStateReasonNoSecrets, errdefs.ErrUserCanceled},
+ {NmDeviceStateReasonSupplicantTimeout, errdefs.ErrBadCredentials},
+ {NmDeviceStateReasonDhcpClientFailed, errdefs.ErrDhcpTimeout},
+ {NmDeviceStateReasonNoSsid, errdefs.ErrNoSuchSSID},
+ {999, errdefs.ErrConnectionFailed},
+ }
+
+ for _, tc := range testCases {
+ result := backend.classifyNMStateReason(tc.reason)
+ assert.Equal(t, tc.expected, result)
+ }
+}
+
+func TestNetworkManagerBackend_GetDeviceIP_NoConfig(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+ mockDevice := mock_gonetworkmanager.NewMockDevice(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ mockDevice.EXPECT().GetPropertyIP4Config().Return(nil, nil)
+
+ ip := backend.getDeviceIP(mockDevice)
+ assert.Empty(t, ip)
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_test.go
new file mode 100644
index 0000000..1391e4a
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_test.go
@@ -0,0 +1,154 @@
+package network
+
+import (
+ "testing"
+
+ mock_gonetworkmanager "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestNetworkManagerBackend_New(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+ assert.NotNil(t, backend)
+ assert.Equal(t, "networkmanager", backend.state.Backend)
+ assert.NotNil(t, backend.stopChan)
+ assert.NotNil(t, backend.state)
+}
+
+func TestNetworkManagerBackend_GetCurrentState(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ backend.state.NetworkStatus = StatusWiFi
+ backend.state.WiFiConnected = true
+ backend.state.WiFiSSID = "TestNetwork"
+ backend.state.WiFiIP = "192.168.1.100"
+ backend.state.WiFiNetworks = []WiFiNetwork{
+ {SSID: "TestNetwork", Signal: 80, Connected: true},
+ }
+ backend.state.WiredConnections = []WiredConnection{
+ {ID: "Wired connection 1", UUID: "test-uuid"},
+ }
+
+ state, err := backend.GetCurrentState()
+ assert.NoError(t, err)
+ assert.NotNil(t, state)
+ assert.Equal(t, StatusWiFi, state.NetworkStatus)
+ assert.True(t, state.WiFiConnected)
+ assert.Equal(t, "TestNetwork", state.WiFiSSID)
+ assert.Len(t, state.WiFiNetworks, 1)
+ assert.Len(t, state.WiredConnections, 1)
+
+ assert.NotSame(t, &backend.state.WiFiNetworks, &state.WiFiNetworks)
+ assert.NotSame(t, &backend.state.WiredConnections, &state.WiredConnections)
+}
+
+func TestNetworkManagerBackend_SetPromptBroker_Nil(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ err = backend.SetPromptBroker(nil)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "cannot be nil")
+}
+
+func TestNetworkManagerBackend_SubmitCredentials_NoBroker(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ backend.promptBroker = nil
+ err = backend.SubmitCredentials("token", map[string]string{"password": "test"}, false)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not initialized")
+}
+
+func TestNetworkManagerBackend_CancelCredentials_NoBroker(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ backend.promptBroker = nil
+ err = backend.CancelCredentials("token")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not initialized")
+}
+
+func TestNetworkManagerBackend_EnsureWiFiDevice_NoDevice(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ backend.wifiDevice = nil
+ backend.wifiDev = nil
+
+ err = backend.ensureWiFiDevice()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "no WiFi device available")
+}
+
+func TestNetworkManagerBackend_EnsureWiFiDevice_AlreadySet(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ backend.wifiDev = "dummy-device"
+
+ err = backend.ensureWiFiDevice()
+ assert.NoError(t, err)
+}
+
+func TestNetworkManagerBackend_StartSecretAgent_NoBroker(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ backend.promptBroker = nil
+ err = backend.startSecretAgent()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "prompt broker not set")
+}
+
+func TestNetworkManagerBackend_Close(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ assert.NotPanics(t, func() {
+ backend.Close()
+ })
+}
+
+func TestNetworkManagerBackend_GetPromptBroker(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ broker := backend.GetPromptBroker()
+ assert.Nil(t, broker)
+}
+
+func TestNetworkManagerBackend_StopMonitoring_NoSignals(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ assert.NotPanics(t, func() {
+ backend.StopMonitoring()
+ })
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_vpn.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_vpn.go
new file mode 100644
index 0000000..261fcef
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_vpn.go
@@ -0,0 +1,1394 @@
+package network
+
+import (
+ "bufio"
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "github.com/Wifx/gonetworkmanager/v2"
+ "github.com/godbus/dbus/v5"
+)
+
+func (b *NetworkManagerBackend) ListVPNProfiles() ([]VPNProfile, error) {
+ s := b.settings
+ if s == nil {
+ var err error
+ s, err = gonetworkmanager.NewSettings()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get settings: %w", err)
+ }
+ b.settings = s
+ }
+
+ settingsMgr := s.(gonetworkmanager.Settings)
+ connections, err := settingsMgr.ListConnections()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get connections: %w", err)
+ }
+
+ profiles := []VPNProfile{}
+ for _, conn := range connections {
+ settings, err := conn.GetSettings()
+ if err != nil {
+ continue
+ }
+
+ connMeta, ok := settings["connection"]
+ if !ok {
+ continue
+ }
+
+ connType, _ := connMeta["type"].(string)
+ if connType != "vpn" && connType != "wireguard" {
+ continue
+ }
+
+ connID, _ := connMeta["id"].(string)
+ connUUID, _ := connMeta["uuid"].(string)
+ autoconnect, _ := connMeta["autoconnect"].(bool)
+
+ profile := VPNProfile{
+ Name: connID,
+ UUID: connUUID,
+ Type: connType,
+ Autoconnect: autoconnect,
+ }
+
+ if connType == "vpn" {
+ if vpnSettings, ok := settings["vpn"]; ok {
+ if svcType, ok := vpnSettings["service-type"].(string); ok {
+ profile.ServiceType = svcType
+ }
+ // Get full data map
+ if data, ok := vpnSettings["data"].(map[string]string); ok {
+ profile.Data = data
+ if remote, ok := data["remote"]; ok {
+ profile.RemoteHost = remote
+ }
+ if username, ok := data["username"]; ok {
+ profile.Username = username
+ }
+ }
+ }
+ }
+
+ profiles = append(profiles, profile)
+ }
+
+ sort.Slice(profiles, func(i, j int) bool {
+ return strings.ToLower(profiles[i].Name) < strings.ToLower(profiles[j].Name)
+ })
+
+ b.stateMutex.Lock()
+ b.state.VPNProfiles = profiles
+ b.stateMutex.Unlock()
+
+ return profiles, nil
+}
+
+func (b *NetworkManagerBackend) ListActiveVPN() ([]VPNActive, error) {
+ nm := b.nmConn.(gonetworkmanager.NetworkManager)
+
+ activeConns, err := nm.GetPropertyActiveConnections()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get active connections: %w", err)
+ }
+
+ active := []VPNActive{}
+ for _, activeConn := range activeConns {
+ connType, err := activeConn.GetPropertyType()
+ if err != nil {
+ continue
+ }
+
+ if connType != "vpn" && connType != "wireguard" {
+ continue
+ }
+
+ uuid, _ := activeConn.GetPropertyUUID()
+ id, _ := activeConn.GetPropertyID()
+ state, _ := activeConn.GetPropertyState()
+
+ var stateStr string
+ switch state {
+ case 0:
+ stateStr = "unknown"
+ case 1:
+ stateStr = "activating"
+ case 2:
+ stateStr = "activated"
+ case 3:
+ stateStr = "deactivating"
+ case 4:
+ stateStr = "deactivated"
+ }
+
+ vpnActive := VPNActive{
+ Name: id,
+ UUID: uuid,
+ State: stateStr,
+ Type: connType,
+ Plugin: "",
+ }
+
+ // Get VPN device
+ devices, _ := activeConn.GetPropertyDevices()
+ if len(devices) > 0 {
+ if iface, err := devices[0].GetPropertyInterface(); err == nil {
+ vpnActive.Device = iface
+ }
+ }
+
+ // Get VPN IP from IP4Config
+ if ip4Config, err := activeConn.GetPropertyIP4Config(); err == nil && ip4Config != nil {
+ if addrData, err := ip4Config.GetPropertyAddressData(); err == nil && len(addrData) > 0 {
+ vpnActive.IP = addrData[0].Address
+ }
+ if gw, err := ip4Config.GetPropertyGateway(); err == nil {
+ vpnActive.Gateway = gw
+ }
+ }
+
+ // Get MTU from device
+ if len(devices) > 0 {
+ if mtu, err := devices[0].GetPropertyMtu(); err == nil {
+ vpnActive.MTU = mtu
+ }
+ }
+
+ if connType == "vpn" {
+ conn, _ := activeConn.GetPropertyConnection()
+ if conn != nil {
+ connSettings, err := conn.GetSettings()
+ if err == nil {
+ if vpnSettings, ok := connSettings["vpn"]; ok {
+ if svcType, ok := vpnSettings["service-type"].(string); ok {
+ vpnActive.Plugin = svcType
+ }
+ // Get full data map
+ if data, ok := vpnSettings["data"].(map[string]string); ok {
+ vpnActive.Data = data
+ if remote, ok := data["remote"]; ok {
+ vpnActive.RemoteHost = remote
+ }
+ if username, ok := data["username"]; ok {
+ vpnActive.Username = username
+ }
+ }
+ }
+ }
+ }
+ }
+
+ active = append(active, vpnActive)
+ }
+
+ b.stateMutex.Lock()
+ b.state.VPNActive = active
+ b.stateMutex.Unlock()
+
+ return active, nil
+}
+
+func (b *NetworkManagerBackend) ConnectVPN(uuidOrName string, singleActive bool) error {
+ if singleActive {
+ active, err := b.ListActiveVPN()
+ if err == nil && len(active) > 0 {
+ alreadyConnected := false
+ for _, vpn := range active {
+ if vpn.UUID == uuidOrName || vpn.Name == uuidOrName {
+ alreadyConnected = true
+ break
+ }
+ }
+
+ if !alreadyConnected {
+ if err := b.DisconnectAllVPN(); err != nil {
+ log.Warnf("Failed to disconnect existing VPNs: %v", err)
+ }
+ time.Sleep(500 * time.Millisecond)
+ } else {
+ return nil
+ }
+ }
+ }
+
+ s := b.settings
+ if s == nil {
+ var err error
+ s, err = gonetworkmanager.NewSettings()
+ if err != nil {
+ return fmt.Errorf("failed to get settings: %w", err)
+ }
+ b.settings = s
+ }
+
+ settingsMgr := s.(gonetworkmanager.Settings)
+ connections, err := settingsMgr.ListConnections()
+ if err != nil {
+ return fmt.Errorf("failed to get connections: %w", err)
+ }
+
+ var targetConn gonetworkmanager.Connection
+ for _, conn := range connections {
+ settings, err := conn.GetSettings()
+ if err != nil {
+ continue
+ }
+
+ connMeta, ok := settings["connection"]
+ if !ok {
+ continue
+ }
+
+ connType, _ := connMeta["type"].(string)
+ if connType != "vpn" && connType != "wireguard" {
+ continue
+ }
+
+ connID, _ := connMeta["id"].(string)
+ connUUID, _ := connMeta["uuid"].(string)
+
+ if connUUID == uuidOrName || connID == uuidOrName {
+ targetConn = conn
+ break
+ }
+ }
+
+ if targetConn == nil {
+ return fmt.Errorf("VPN connection not found: %s", uuidOrName)
+ }
+
+ targetSettings, err := targetConn.GetSettings()
+ if err != nil {
+ return fmt.Errorf("failed to get connection settings: %w", err)
+ }
+
+ var targetUUID string
+ var connName string
+ if connMeta, ok := targetSettings["connection"]; ok {
+ if uuid, ok := connMeta["uuid"].(string); ok {
+ targetUUID = uuid
+ }
+ if id, ok := connMeta["id"].(string); ok {
+ connName = id
+ }
+ }
+
+ var vpnServiceType string
+ var vpnData map[string]string
+ if vpnSettings, ok := targetSettings["vpn"]; ok {
+ if svc, ok := vpnSettings["service-type"].(string); ok {
+ vpnServiceType = svc
+ }
+ if data, ok := vpnSettings["data"].(map[string]string); ok {
+ vpnData = data
+ }
+ }
+
+ authAction := detectVPNAuthAction(vpnServiceType, vpnData)
+
+ switch authAction {
+ case "openvpn_username":
+ if b.promptBroker == nil {
+ return fmt.Errorf("OpenVPN password authentication requires interactive prompt")
+ }
+ if err := b.handleOpenVPNUsernameAuth(targetConn, connName, targetUUID, vpnServiceType); err != nil {
+ return err
+ }
+ case "gp_saml":
+ gateway := vpnData["gateway"]
+ protocol := vpnData["protocol"]
+ if protocol != "gp" {
+ return fmt.Errorf("GlobalProtect SAML authentication only supported for protocol=gp, got: %s", protocol)
+ }
+
+ log.Infof("[ConnectVPN] GlobalProtect SAML/SSO authentication required for %s (gateway=%s)", connName, gateway)
+
+ samlCtx, samlCancel := context.WithTimeout(context.Background(), 5*time.Minute)
+ authResult, err := b.runGlobalProtectSAMLAuth(samlCtx, gateway, protocol)
+ samlCancel()
+ if err != nil {
+ errMsg := err.Error()
+ switch {
+ case strings.Contains(errMsg, "not installed"):
+ return fmt.Errorf("gp-saml-gui is not installed (required for GlobalProtect SAML/SSO VPN)")
+ case strings.Contains(errMsg, "timed out") || strings.Contains(errMsg, "cancelled"):
+ return fmt.Errorf("GlobalProtect SAML authentication timed out — please try again")
+ case strings.Contains(errMsg, "no cookie"):
+ return fmt.Errorf("GlobalProtect SAML login did not complete — browser was closed before authentication finished")
+ case strings.Contains(errMsg, "convert prelogin-cookie"):
+ return fmt.Errorf("GlobalProtect VPN authentication succeeded but cookie exchange failed: %w", err)
+ default:
+ return fmt.Errorf("GlobalProtect SAML authentication failed: %w", err)
+ }
+ }
+
+ b.cachedGPSamlMu.Lock()
+ b.cachedGPSamlCookie = &cachedGPSamlCookie{
+ ConnectionUUID: targetUUID,
+ Cookie: authResult.Cookie,
+ Host: authResult.Host,
+ User: authResult.User,
+ Fingerprint: authResult.Fingerprint,
+ }
+ b.cachedGPSamlMu.Unlock()
+
+ if err := targetConn.ClearSecrets(); err != nil {
+ log.Warnf("[ConnectVPN] ClearSecrets failed (non-fatal): %v", err)
+ } else {
+ log.Infof("[ConnectVPN] Cleared stale stored secrets for %s", connName)
+ }
+
+ log.Infof("[ConnectVPN] GlobalProtect SAML cookie cached for %s, proceeding with activation", connName)
+ }
+
+ b.stateMutex.Lock()
+ b.state.IsConnectingVPN = true
+ b.state.ConnectingVPNUUID = targetUUID
+ b.stateMutex.Unlock()
+
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+
+ nm := b.nmConn.(gonetworkmanager.NetworkManager)
+ _, err = nm.ActivateConnection(targetConn, nil, nil)
+ if err != nil {
+ b.stateMutex.Lock()
+ b.state.IsConnectingVPN = false
+ b.state.ConnectingVPNUUID = ""
+ b.stateMutex.Unlock()
+
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+
+ return fmt.Errorf("failed to activate VPN: %w", err)
+ }
+
+ return nil
+}
+
+func detectVPNAuthAction(serviceType string, data map[string]string) string {
+ if data == nil {
+ return ""
+ }
+
+ switch {
+ case strings.Contains(serviceType, "openconnect"):
+ protocol := data["protocol"]
+ if needsExternalBrowserAuth(protocol, data["authtype"], data["username"], data) {
+ switch protocol {
+ case "gp":
+ return "gp_saml"
+ default:
+ log.Infof("[VPN] External browser auth detected for protocol '%s' but only GlobalProtect (gp) is currently supported", protocol)
+ }
+ }
+ case strings.Contains(serviceType, "openvpn"):
+ connType := data["connection-type"]
+ username := data["username"]
+ if (connType == "password" || connType == "password-tls") && username == "" {
+ return "openvpn_username"
+ }
+ }
+ return ""
+}
+
+func (b *NetworkManagerBackend) handleOpenVPNUsernameAuth(targetConn gonetworkmanager.Connection, connName, targetUUID, vpnServiceType string) error {
+ log.Infof("[ConnectVPN] OpenVPN requires username in vpn.data - prompting before activation")
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+ defer cancel()
+
+ token, err := b.promptBroker.Ask(ctx, PromptRequest{
+ Name: connName,
+ ConnType: "vpn",
+ VpnService: vpnServiceType,
+ SettingName: "vpn",
+ Fields: []string{"username", "password"},
+ FieldsInfo: []FieldInfo{{Name: "username", Label: "Username", IsSecret: false}, {Name: "password", Label: "Password", IsSecret: true}},
+ Reason: "required",
+ ConnectionId: connName,
+ ConnectionUuid: targetUUID,
+ ConnectionPath: string(targetConn.GetPath()),
+ })
+ if err != nil {
+ return fmt.Errorf("failed to request credentials: %w", err)
+ }
+
+ reply, err := b.promptBroker.Wait(ctx, token)
+ if err != nil {
+ return fmt.Errorf("credentials prompt failed: %w", err)
+ }
+
+ if reply.Cancel {
+ return fmt.Errorf("user cancelled authentication")
+ }
+
+ username := reply.Secrets["username"]
+ password := reply.Secrets["password"]
+ if username == "" {
+ return nil
+ }
+
+ connObj := b.dbusConn.Object("org.freedesktop.NetworkManager", targetConn.GetPath())
+ var existingSettings map[string]map[string]dbus.Variant
+ if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.GetSettings", 0).Store(&existingSettings); err != nil {
+ return fmt.Errorf("failed to get settings for username save: %w", err)
+ }
+
+ settings := make(map[string]map[string]dbus.Variant)
+ if connSection, ok := existingSettings["connection"]; ok {
+ settings["connection"] = connSection
+ }
+ vpn := existingSettings["vpn"]
+ var data map[string]string
+ if dataVariant, ok := vpn["data"]; ok {
+ if dm, ok := dataVariant.Value().(map[string]string); ok {
+ data = make(map[string]string)
+ for k, v := range dm {
+ data[k] = v
+ }
+ } else {
+ data = make(map[string]string)
+ }
+ } else {
+ data = make(map[string]string)
+ }
+ data["username"] = username
+
+ vpn["data"] = dbus.MakeVariant(data)
+ settings["vpn"] = vpn
+
+ var result map[string]dbus.Variant
+ if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.Update2", 0,
+ settings, uint32(0x1), map[string]dbus.Variant{}).Store(&result); err != nil {
+ return fmt.Errorf("failed to save username: %w", err)
+ }
+ log.Infof("[ConnectVPN] Username saved to connection")
+
+ if password != "" {
+ b.cachedVPNCredsMu.Lock()
+ b.cachedVPNCreds = &cachedVPNCredentials{
+ ConnectionUUID: targetUUID,
+ Password: password,
+ SavePassword: reply.Save,
+ }
+ b.cachedVPNCredsMu.Unlock()
+ log.Infof("[ConnectVPN] Cached password for GetSecrets")
+ }
+
+ return nil
+}
+
+func (b *NetworkManagerBackend) DisconnectVPN(uuidOrName string) error {
+ nm := b.nmConn.(gonetworkmanager.NetworkManager)
+
+ activeConns, err := nm.GetPropertyActiveConnections()
+ if err != nil {
+ return fmt.Errorf("failed to get active connections: %w", err)
+ }
+
+ log.Debugf("[DisconnectVPN] Looking for VPN: %s", uuidOrName)
+
+ for _, activeConn := range activeConns {
+ connType, err := activeConn.GetPropertyType()
+ if err != nil {
+ continue
+ }
+
+ if connType != "vpn" && connType != "wireguard" {
+ continue
+ }
+
+ uuid, _ := activeConn.GetPropertyUUID()
+ id, _ := activeConn.GetPropertyID()
+ state, _ := activeConn.GetPropertyState()
+
+ log.Debugf("[DisconnectVPN] Found active VPN: uuid=%s id=%s state=%d", uuid, id, state)
+
+ if uuid == uuidOrName || id == uuidOrName {
+ log.Infof("[DisconnectVPN] Deactivating VPN: %s (state=%d)", id, state)
+ if err := nm.DeactivateConnection(activeConn); err != nil {
+ return fmt.Errorf("failed to deactivate VPN: %w", err)
+ }
+ b.ListActiveVPN()
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+ return nil
+ }
+ }
+
+ log.Warnf("[DisconnectVPN] VPN not found in active connections: %s", uuidOrName)
+
+ s := b.settings
+ if s == nil {
+ var err error
+ s, err = gonetworkmanager.NewSettings()
+ if err != nil {
+ return fmt.Errorf("VPN connection not active and cannot access settings: %w", err)
+ }
+ b.settings = s
+ }
+
+ settingsMgr := s.(gonetworkmanager.Settings)
+ connections, err := settingsMgr.ListConnections()
+ if err != nil {
+ return fmt.Errorf("VPN connection not active: %s", uuidOrName)
+ }
+
+ for _, conn := range connections {
+ settings, err := conn.GetSettings()
+ if err != nil {
+ continue
+ }
+
+ connMeta, ok := settings["connection"]
+ if !ok {
+ continue
+ }
+
+ connType, _ := connMeta["type"].(string)
+ if connType != "vpn" && connType != "wireguard" {
+ continue
+ }
+
+ connID, _ := connMeta["id"].(string)
+ connUUID, _ := connMeta["uuid"].(string)
+
+ if connUUID == uuidOrName || connID == uuidOrName {
+ log.Infof("[DisconnectVPN] VPN connection exists but not active: %s", connID)
+ return nil
+ }
+ }
+
+ return fmt.Errorf("VPN connection not found: %s", uuidOrName)
+}
+
+func (b *NetworkManagerBackend) DisconnectAllVPN() error {
+ nm := b.nmConn.(gonetworkmanager.NetworkManager)
+
+ activeConns, err := nm.GetPropertyActiveConnections()
+ if err != nil {
+ return fmt.Errorf("failed to get active connections: %w", err)
+ }
+
+ var lastErr error
+ var disconnected bool
+ for _, activeConn := range activeConns {
+ connType, err := activeConn.GetPropertyType()
+ if err != nil {
+ continue
+ }
+
+ if connType != "vpn" && connType != "wireguard" {
+ continue
+ }
+
+ if err := nm.DeactivateConnection(activeConn); err != nil {
+ lastErr = err
+ log.Warnf("Failed to deactivate VPN connection: %v", err)
+ } else {
+ disconnected = true
+ }
+ }
+
+ if disconnected {
+ b.ListActiveVPN()
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+ }
+
+ return lastErr
+}
+
+func (b *NetworkManagerBackend) ClearVPNCredentials(uuidOrName string) error {
+ s := b.settings
+ if s == nil {
+ var err error
+ s, err = gonetworkmanager.NewSettings()
+ if err != nil {
+ return fmt.Errorf("failed to get settings: %w", err)
+ }
+ b.settings = s
+ }
+
+ settingsMgr := s.(gonetworkmanager.Settings)
+ connections, err := settingsMgr.ListConnections()
+ if err != nil {
+ return fmt.Errorf("failed to get connections: %w", err)
+ }
+
+ for _, conn := range connections {
+ settings, err := conn.GetSettings()
+ if err != nil {
+ continue
+ }
+
+ connMeta, ok := settings["connection"]
+ if !ok {
+ continue
+ }
+
+ connType, _ := connMeta["type"].(string)
+ if connType != "vpn" && connType != "wireguard" {
+ continue
+ }
+
+ connID, _ := connMeta["id"].(string)
+ connUUID, _ := connMeta["uuid"].(string)
+
+ if connUUID == uuidOrName || connID == uuidOrName {
+ if connType == "vpn" {
+ if vpnSettings, ok := settings["vpn"]; ok {
+ delete(vpnSettings, "secrets")
+
+ if dataMap, ok := vpnSettings["data"].(map[string]string); ok {
+ dataMap["password-flags"] = "1"
+ vpnSettings["data"] = dataMap
+ }
+ }
+ }
+
+ if err := conn.Update(settings); err != nil {
+ return fmt.Errorf("failed to update connection: %w", err)
+ }
+
+ if err := conn.ClearSecrets(); err != nil {
+ log.Warnf("ClearSecrets call failed (may not be critical): %v", err)
+ }
+
+ log.Infof("Cleared credentials for VPN: %s", connID)
+ return nil
+ }
+ }
+
+ return fmt.Errorf("VPN connection not found: %s", uuidOrName)
+}
+
+func (b *NetworkManagerBackend) updateVPNConnectionState() {
+ b.stateMutex.RLock()
+ isConnectingVPN := b.state.IsConnectingVPN
+ connectingVPNUUID := b.state.ConnectingVPNUUID
+ b.stateMutex.RUnlock()
+
+ if !isConnectingVPN || connectingVPNUUID == "" {
+ return
+ }
+
+ nm := b.nmConn.(gonetworkmanager.NetworkManager)
+ activeConns, err := nm.GetPropertyActiveConnections()
+ if err != nil {
+ return
+ }
+
+ foundConnection := false
+ for _, activeConn := range activeConns {
+ connType, err := activeConn.GetPropertyType()
+ if err != nil {
+ continue
+ }
+
+ if connType != "vpn" && connType != "wireguard" {
+ continue
+ }
+
+ connUUID, err := activeConn.GetPropertyUUID()
+ if err != nil {
+ continue
+ }
+
+ state, _ := activeConn.GetPropertyState()
+ stateReason, _ := activeConn.GetPropertyStateFlags()
+
+ if connUUID == connectingVPNUUID {
+ foundConnection = true
+
+ switch state {
+ case 2:
+ log.Infof("[updateVPNConnectionState] VPN connection successful: %s", connUUID)
+ b.stateMutex.Lock()
+ b.state.IsConnectingVPN = false
+ b.state.ConnectingVPNUUID = ""
+ b.state.LastError = ""
+ b.stateMutex.Unlock()
+
+ // Clear cached PKCS11 PIN and SAML cookie on success
+ b.cachedPKCS11Mu.Lock()
+ b.cachedPKCS11PIN = nil
+ b.cachedPKCS11Mu.Unlock()
+ b.cachedGPSamlMu.Lock()
+ b.cachedGPSamlCookie = nil
+ b.cachedGPSamlMu.Unlock()
+
+ b.pendingVPNSaveMu.Lock()
+ pending := b.pendingVPNSave
+ b.pendingVPNSave = nil
+ b.pendingVPNSaveMu.Unlock()
+
+ if pending != nil {
+ go b.saveVPNCredentials(pending)
+ }
+ return
+ case 4:
+ log.Warnf("[updateVPNConnectionState] VPN connection failed/deactivated: %s (state=%d, flags=%d)", connUUID, state, stateReason)
+ b.stateMutex.Lock()
+ b.state.IsConnectingVPN = false
+ b.state.ConnectingVPNUUID = ""
+ b.state.LastError = "VPN connection failed"
+ b.stateMutex.Unlock()
+
+ // Clear cached PKCS11 PIN and SAML cookie on failure
+ b.cachedPKCS11Mu.Lock()
+ b.cachedPKCS11PIN = nil
+ b.cachedPKCS11Mu.Unlock()
+ b.cachedGPSamlMu.Lock()
+ b.cachedGPSamlCookie = nil
+ b.cachedGPSamlMu.Unlock()
+ return
+ }
+ }
+ }
+
+ if !foundConnection {
+ log.Warnf("[updateVPNConnectionState] VPN connection no longer exists: %s", connectingVPNUUID)
+ b.stateMutex.Lock()
+ b.state.IsConnectingVPN = false
+ b.state.ConnectingVPNUUID = ""
+ b.state.LastError = "VPN connection failed"
+ b.stateMutex.Unlock()
+
+ // Clear cached PKCS11 PIN and SAML cookie
+ b.cachedPKCS11Mu.Lock()
+ b.cachedPKCS11PIN = nil
+ b.cachedPKCS11Mu.Unlock()
+ b.cachedGPSamlMu.Lock()
+ b.cachedGPSamlCookie = nil
+ b.cachedGPSamlMu.Unlock()
+ }
+}
+
+func (b *NetworkManagerBackend) saveVPNCredentials(creds *pendingVPNCredentials) {
+ log.Infof("[saveVPNCredentials] Saving credentials for %s (username=%v, savePassword=%v)",
+ creds.ConnectionPath, creds.Username != "", creds.SavePassword)
+
+ connObj := b.dbusConn.Object("org.freedesktop.NetworkManager", dbus.ObjectPath(creds.ConnectionPath))
+ var existingSettings map[string]map[string]dbus.Variant
+ if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.GetSettings", 0).Store(&existingSettings); err != nil {
+ log.Warnf("[saveVPNCredentials] GetSettings failed: %v", err)
+ return
+ }
+
+ settings := make(map[string]map[string]dbus.Variant)
+ if connSection, ok := existingSettings["connection"]; ok {
+ settings["connection"] = connSection
+ }
+
+ vpn, ok := existingSettings["vpn"]
+ if !ok {
+ vpn = make(map[string]dbus.Variant)
+ }
+
+ // Get existing data map
+ var data map[string]string
+ if dataVariant, ok := vpn["data"]; ok {
+ if dm, ok := dataVariant.Value().(map[string]string); ok {
+ data = make(map[string]string)
+ for k, v := range dm {
+ data[k] = v
+ }
+ } else {
+ data = make(map[string]string)
+ }
+ } else {
+ data = make(map[string]string)
+ }
+
+ // Always save username if provided
+ if creds.Username != "" {
+ data["username"] = creds.Username
+ log.Infof("[saveVPNCredentials] Saving username")
+ }
+
+ // Save password if requested
+ if creds.SavePassword {
+ data["password-flags"] = "0"
+ secs := make(map[string]string)
+ secs["password"] = creds.Password
+ vpn["secrets"] = dbus.MakeVariant(secs)
+ log.Infof("[saveVPNCredentials] Saving password with password-flags=0")
+ }
+
+ vpn["data"] = dbus.MakeVariant(data)
+ settings["vpn"] = vpn
+
+ var result map[string]dbus.Variant
+ if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.Update2", 0,
+ settings, uint32(0x1), map[string]dbus.Variant{}).Store(&result); err != nil {
+ log.Warnf("[saveVPNCredentials] Update2 failed: %v", err)
+ } else {
+ log.Infof("[saveVPNCredentials] Successfully saved credentials")
+ }
+}
+
+func (b *NetworkManagerBackend) ListVPNPlugins() ([]VPNPlugin, error) {
+ plugins := []VPNPlugin{}
+ pluginDirs := []string{
+ "/usr/lib/NetworkManager/VPN",
+ "/usr/lib64/NetworkManager/VPN",
+ "/etc/NetworkManager/VPN",
+ }
+
+ seen := make(map[string]bool)
+
+ for _, dir := range pluginDirs {
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ continue
+ }
+
+ for _, entry := range entries {
+ if !strings.HasSuffix(entry.Name(), ".name") {
+ continue
+ }
+
+ filePath := filepath.Join(dir, entry.Name())
+ plugin, err := parseVPNPluginFile(filePath)
+ if err != nil {
+ log.Debugf("Failed to parse VPN plugin file %s: %v", filePath, err)
+ continue
+ }
+
+ if seen[plugin.ServiceType] {
+ continue
+ }
+ seen[plugin.ServiceType] = true
+ plugins = append(plugins, *plugin)
+ }
+ }
+
+ sort.Slice(plugins, func(i, j int) bool {
+ return strings.ToLower(plugins[i].Name) < strings.ToLower(plugins[j].Name)
+ })
+
+ return plugins, nil
+}
+
+func parseVPNPluginFile(path string) (*VPNPlugin, error) {
+ file, err := os.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ defer file.Close()
+
+ plugin := &VPNPlugin{}
+ scanner := bufio.NewScanner(file)
+
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "[") {
+ continue
+ }
+
+ parts := strings.SplitN(line, "=", 2)
+ if len(parts) != 2 {
+ continue
+ }
+
+ key := strings.TrimSpace(parts[0])
+ value := strings.TrimSpace(parts[1])
+
+ switch key {
+ case "name":
+ plugin.Name = value
+ case "service":
+ plugin.ServiceType = value
+ case "program":
+ plugin.Program = value
+ case "supports":
+ plugin.Supports = strings.Split(value, ",")
+ for i := range plugin.Supports {
+ plugin.Supports[i] = strings.TrimSpace(plugin.Supports[i])
+ }
+ }
+ }
+
+ if plugin.ServiceType == "" {
+ return nil, fmt.Errorf("plugin file missing service type")
+ }
+
+ plugin.FileExtensions = getVPNFileExtensions(plugin.ServiceType)
+
+ return plugin, nil
+}
+
+func getVPNFileExtensions(serviceType string) []string {
+ switch {
+ case strings.Contains(serviceType, "openvpn"):
+ return []string{".ovpn", ".conf"}
+ case strings.Contains(serviceType, "wireguard"):
+ return []string{".conf"}
+ case strings.Contains(serviceType, "vpnc"), strings.Contains(serviceType, "cisco"):
+ return []string{".pcf", ".conf"}
+ case strings.Contains(serviceType, "openconnect"):
+ return []string{".conf"}
+ case strings.Contains(serviceType, "pptp"):
+ return []string{".conf"}
+ case strings.Contains(serviceType, "l2tp"):
+ return []string{".conf"}
+ case strings.Contains(serviceType, "strongswan"), strings.Contains(serviceType, "ipsec"):
+ return []string{".conf", ".sswan"}
+ default:
+ return []string{".conf"}
+ }
+}
+
+func (b *NetworkManagerBackend) ImportVPN(filePath string, name string) (*VPNImportResult, error) {
+ if _, err := os.Stat(filePath); os.IsNotExist(err) {
+ return &VPNImportResult{
+ Success: false,
+ Error: fmt.Sprintf("file not found: %s", filePath),
+ }, nil
+ }
+
+ ext := strings.ToLower(filepath.Ext(filePath))
+
+ switch ext {
+ case ".ovpn", ".conf":
+ return b.importVPNWithNmcli(filePath, name)
+ default:
+ return b.importVPNWithNmcli(filePath, name)
+ }
+}
+
+func (b *NetworkManagerBackend) importVPNWithNmcli(filePath string, name string) (*VPNImportResult, error) {
+ vpnTypes := []string{"openvpn", "wireguard", "vpnc", "pptp", "l2tp", "openconnect", "strongswan"}
+
+ var allErrors []error
+ var outputStr string
+ for _, vpnType := range vpnTypes {
+ cmd := exec.Command("nmcli", "connection", "import", "type", vpnType, "file", filePath)
+ output, err := cmd.CombinedOutput()
+ if err == nil {
+ outputStr = string(output)
+ break
+ }
+ allErrors = append(allErrors, fmt.Errorf("%s: %s", vpnType, strings.TrimSpace(string(output))))
+ }
+
+ if len(allErrors) == len(vpnTypes) {
+ return &VPNImportResult{
+ Success: false,
+ Error: errors.Join(allErrors...).Error(),
+ }, nil
+ }
+ var connUUID, connName string
+
+ lines := strings.Split(outputStr, "\n")
+ for _, line := range lines {
+ if strings.Contains(line, "successfully added") {
+ parts := strings.Fields(line)
+ for i, part := range parts {
+ if part == "(" && i+1 < len(parts) {
+ connUUID = strings.TrimSuffix(parts[i+1], ")")
+ break
+ }
+ }
+ }
+ }
+
+ if name != "" && connUUID != "" {
+ renameCmd := exec.Command("nmcli", "connection", "modify", connUUID, "connection.id", name)
+ if err := renameCmd.Run(); err != nil {
+ log.Warnf("Failed to rename imported VPN: %v", err)
+ } else {
+ connName = name
+ }
+ }
+
+ if connUUID == "" {
+ s := b.settings
+ if s == nil {
+ var settingsErr error
+ s, settingsErr = gonetworkmanager.NewSettings()
+ if settingsErr == nil {
+ b.settings = s
+ }
+ }
+
+ if s != nil {
+ settingsMgr := s.(gonetworkmanager.Settings)
+ connections, _ := settingsMgr.ListConnections()
+ baseName := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
+
+ for _, conn := range connections {
+ settings, err := conn.GetSettings()
+ if err != nil {
+ continue
+ }
+ connMeta, ok := settings["connection"]
+ if !ok {
+ continue
+ }
+ connType, _ := connMeta["type"].(string)
+ if connType != "vpn" && connType != "wireguard" {
+ continue
+ }
+ connID, _ := connMeta["id"].(string)
+ if strings.Contains(connID, baseName) || (name != "" && connID == name) {
+ connUUID, _ = connMeta["uuid"].(string)
+ connName = connID
+ break
+ }
+ }
+ }
+ }
+
+ b.ListVPNProfiles()
+
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+
+ return &VPNImportResult{
+ Success: true,
+ UUID: connUUID,
+ Name: connName,
+ }, nil
+}
+
+func (b *NetworkManagerBackend) GetVPNConfig(uuidOrName string) (*VPNConfig, error) {
+ s := b.settings
+ if s == nil {
+ var err error
+ s, err = gonetworkmanager.NewSettings()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get settings: %w", err)
+ }
+ b.settings = s
+ }
+
+ settingsMgr := s.(gonetworkmanager.Settings)
+ connections, err := settingsMgr.ListConnections()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get connections: %w", err)
+ }
+
+ for _, conn := range connections {
+ settings, err := conn.GetSettings()
+ if err != nil {
+ continue
+ }
+
+ connMeta, ok := settings["connection"]
+ if !ok {
+ continue
+ }
+
+ connType, _ := connMeta["type"].(string)
+ if connType != "vpn" && connType != "wireguard" {
+ continue
+ }
+
+ connID, _ := connMeta["id"].(string)
+ connUUID, _ := connMeta["uuid"].(string)
+
+ if connUUID != uuidOrName && connID != uuidOrName {
+ continue
+ }
+
+ autoconnect := true
+ if ac, ok := connMeta["autoconnect"].(bool); ok {
+ autoconnect = ac
+ }
+
+ config := &VPNConfig{
+ UUID: connUUID,
+ Name: connID,
+ Type: connType,
+ Autoconnect: autoconnect,
+ Data: make(map[string]string),
+ }
+
+ if connType == "vpn" {
+ if vpnSettings, ok := settings["vpn"]; ok {
+ if svcType, ok := vpnSettings["service-type"].(string); ok {
+ config.ServiceType = svcType
+ }
+ if dataMap, ok := vpnSettings["data"].(map[string]string); ok {
+ for k, v := range dataMap {
+ if !strings.Contains(strings.ToLower(k), "password") &&
+ !strings.Contains(strings.ToLower(k), "secret") &&
+ !strings.Contains(strings.ToLower(k), "key") {
+ config.Data[k] = v
+ }
+ }
+ }
+ }
+ }
+
+ return config, nil
+ }
+
+ return nil, fmt.Errorf("VPN connection not found: %s", uuidOrName)
+}
+
+func (b *NetworkManagerBackend) UpdateVPNConfig(connUUID string, updates map[string]any) error {
+ s := b.settings
+ if s == nil {
+ var err error
+ s, err = gonetworkmanager.NewSettings()
+ if err != nil {
+ return fmt.Errorf("failed to get settings: %w", err)
+ }
+ b.settings = s
+ }
+
+ settingsMgr := s.(gonetworkmanager.Settings)
+ connections, err := settingsMgr.ListConnections()
+ if err != nil {
+ return fmt.Errorf("failed to get connections: %w", err)
+ }
+
+ for _, conn := range connections {
+ settings, err := conn.GetSettings()
+ if err != nil {
+ continue
+ }
+
+ connMeta, ok := settings["connection"]
+ if !ok {
+ continue
+ }
+
+ connType, _ := connMeta["type"].(string)
+ if connType != "vpn" && connType != "wireguard" {
+ continue
+ }
+
+ existingUUID, _ := connMeta["uuid"].(string)
+ if existingUUID != connUUID {
+ continue
+ }
+
+ if name, ok := updates["name"].(string); ok && name != "" {
+ connMeta["id"] = name
+ }
+
+ if autoconnect, ok := updates["autoconnect"].(bool); ok {
+ connMeta["autoconnect"] = autoconnect
+ }
+
+ if data, ok := updates["data"].(map[string]any); ok {
+ if vpnSettings, ok := settings["vpn"]; ok {
+ existingData, _ := vpnSettings["data"].(map[string]string)
+ if existingData == nil {
+ existingData = make(map[string]string)
+ }
+ for k, v := range data {
+ if strVal, ok := v.(string); ok {
+ existingData[k] = strVal
+ }
+ }
+ vpnSettings["data"] = existingData
+ }
+ }
+
+ if ipv4, ok := settings["ipv4"]; ok {
+ delete(ipv4, "addresses")
+ delete(ipv4, "routes")
+ delete(ipv4, "dns")
+ }
+ if ipv6, ok := settings["ipv6"]; ok {
+ delete(ipv6, "addresses")
+ delete(ipv6, "routes")
+ delete(ipv6, "dns")
+ }
+
+ if err := conn.Update(settings); err != nil {
+ return fmt.Errorf("failed to update connection: %w", err)
+ }
+
+ b.ListVPNProfiles()
+
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+
+ return nil
+ }
+
+ return fmt.Errorf("VPN connection not found: %s", connUUID)
+}
+
+func (b *NetworkManagerBackend) SetVPNCredentials(connUUID string, username string, password string, saveToKeyring bool) error {
+ s := b.settings
+ if s == nil {
+ var err error
+ s, err = gonetworkmanager.NewSettings()
+ if err != nil {
+ return fmt.Errorf("failed to get settings: %w", err)
+ }
+ b.settings = s
+ }
+
+ settingsMgr := s.(gonetworkmanager.Settings)
+ connections, err := settingsMgr.ListConnections()
+ if err != nil {
+ return fmt.Errorf("failed to get connections: %w", err)
+ }
+
+ for _, conn := range connections {
+ settings, err := conn.GetSettings()
+ if err != nil {
+ continue
+ }
+
+ connMeta, ok := settings["connection"]
+ if !ok {
+ continue
+ }
+
+ connType, _ := connMeta["type"].(string)
+ if connType != "vpn" && connType != "wireguard" {
+ continue
+ }
+
+ existingUUID, _ := connMeta["uuid"].(string)
+ if existingUUID != connUUID {
+ continue
+ }
+
+ vpnSettings, ok := settings["vpn"]
+ if !ok {
+ vpnSettings = make(map[string]any)
+ settings["vpn"] = vpnSettings
+ }
+
+ existingData, _ := vpnSettings["data"].(map[string]string)
+ if existingData == nil {
+ existingData = make(map[string]string)
+ }
+
+ if username != "" {
+ existingData["username"] = username
+ }
+
+ if saveToKeyring {
+ existingData["password-flags"] = "0"
+ } else {
+ existingData["password-flags"] = "2"
+ }
+
+ vpnSettings["data"] = existingData
+
+ if password != "" {
+ secrets := make(map[string]string)
+ secrets["password"] = password
+ vpnSettings["secrets"] = secrets
+ }
+
+ if ipv4, ok := settings["ipv4"]; ok {
+ delete(ipv4, "addresses")
+ delete(ipv4, "routes")
+ delete(ipv4, "dns")
+ }
+ if ipv6, ok := settings["ipv6"]; ok {
+ delete(ipv6, "addresses")
+ delete(ipv6, "routes")
+ delete(ipv6, "dns")
+ }
+
+ if err := conn.Update(settings); err != nil {
+ return fmt.Errorf("failed to update connection: %w", err)
+ }
+
+ log.Infof("Updated VPN credentials for %s (save=%v)", connUUID, saveToKeyring)
+
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+
+ return nil
+ }
+
+ return fmt.Errorf("VPN connection not found: %s", connUUID)
+}
+
+func (b *NetworkManagerBackend) DeleteVPN(uuidOrName string) error {
+ active, _ := b.ListActiveVPN()
+ for _, vpn := range active {
+ if vpn.UUID == uuidOrName || vpn.Name == uuidOrName {
+ if err := b.DisconnectVPN(uuidOrName); err != nil {
+ log.Warnf("Failed to disconnect VPN before deletion: %v", err)
+ }
+ time.Sleep(200 * time.Millisecond)
+ break
+ }
+ }
+
+ s := b.settings
+ if s == nil {
+ var err error
+ s, err = gonetworkmanager.NewSettings()
+ if err != nil {
+ return fmt.Errorf("failed to get settings: %w", err)
+ }
+ b.settings = s
+ }
+
+ settingsMgr := s.(gonetworkmanager.Settings)
+ connections, err := settingsMgr.ListConnections()
+ if err != nil {
+ return fmt.Errorf("failed to get connections: %w", err)
+ }
+
+ for _, conn := range connections {
+ settings, err := conn.GetSettings()
+ if err != nil {
+ continue
+ }
+
+ connMeta, ok := settings["connection"]
+ if !ok {
+ continue
+ }
+
+ connType, _ := connMeta["type"].(string)
+ if connType != "vpn" && connType != "wireguard" {
+ continue
+ }
+
+ connID, _ := connMeta["id"].(string)
+ connUUID, _ := connMeta["uuid"].(string)
+
+ if connUUID == uuidOrName || connID == uuidOrName {
+ if err := conn.Delete(); err != nil {
+ return fmt.Errorf("failed to delete VPN: %w", err)
+ }
+
+ b.ListVPNProfiles()
+
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+
+ log.Infof("Deleted VPN connection: %s (%s)", connID, connUUID)
+ return nil
+ }
+ }
+
+ return fmt.Errorf("VPN connection not found: %s", uuidOrName)
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_vpn_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_vpn_test.go
new file mode 100644
index 0000000..eebd433
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_vpn_test.go
@@ -0,0 +1,138 @@
+package network
+
+import (
+ "testing"
+
+ mock_gonetworkmanager "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2"
+ "github.com/Wifx/gonetworkmanager/v2"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestNetworkManagerBackend_ListVPNProfiles(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+ mockSettings := mock_gonetworkmanager.NewMockSettings(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+ backend.settings = mockSettings
+
+ mockSettings.EXPECT().ListConnections().Return([]gonetworkmanager.Connection{}, nil)
+
+ profiles, err := backend.ListVPNProfiles()
+ assert.NoError(t, err)
+ assert.Empty(t, profiles)
+}
+
+func TestNetworkManagerBackend_ListActiveVPN(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ mockNM.EXPECT().GetPropertyActiveConnections().Return([]gonetworkmanager.ActiveConnection{}, nil)
+
+ active, err := backend.ListActiveVPN()
+ assert.NoError(t, err)
+ assert.Empty(t, active)
+}
+
+func TestNetworkManagerBackend_ConnectVPN_NotFound(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+ mockSettings := mock_gonetworkmanager.NewMockSettings(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+ backend.settings = mockSettings
+
+ mockSettings.EXPECT().ListConnections().Return([]gonetworkmanager.Connection{}, nil)
+
+ err = backend.ConnectVPN("non-existent-vpn-12345", false)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not found")
+}
+
+func TestNetworkManagerBackend_ConnectVPN_SingleActive_NoActiveVPN(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+ mockSettings := mock_gonetworkmanager.NewMockSettings(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+ backend.settings = mockSettings
+
+ mockSettings.EXPECT().ListConnections().Return([]gonetworkmanager.Connection{}, nil)
+ mockNM.EXPECT().GetPropertyActiveConnections().Return([]gonetworkmanager.ActiveConnection{}, nil)
+
+ err = backend.ConnectVPN("non-existent-vpn-12345", true)
+ assert.Error(t, err)
+}
+
+func TestNetworkManagerBackend_DisconnectVPN_NotActive(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ mockNM.EXPECT().GetPropertyActiveConnections().Return([]gonetworkmanager.ActiveConnection{}, nil)
+
+ err = backend.DisconnectVPN("non-existent-vpn-12345")
+ assert.Error(t, err)
+}
+
+func TestNetworkManagerBackend_DisconnectAllVPN(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ mockNM.EXPECT().GetPropertyActiveConnections().Return([]gonetworkmanager.ActiveConnection{}, nil)
+
+ err = backend.DisconnectAllVPN()
+ assert.NoError(t, err)
+}
+
+func TestNetworkManagerBackend_ClearVPNCredentials_NotFound(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+ mockSettings := mock_gonetworkmanager.NewMockSettings(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+ backend.settings = mockSettings
+
+ mockSettings.EXPECT().ListConnections().Return([]gonetworkmanager.Connection{}, nil)
+
+ err = backend.ClearVPNCredentials("non-existent-vpn-12345")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not found")
+}
+
+func TestNetworkManagerBackend_UpdateVPNConnectionState_NotConnecting(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ backend.stateMutex.Lock()
+ backend.state.IsConnectingVPN = false
+ backend.state.ConnectingVPNUUID = ""
+ backend.stateMutex.Unlock()
+
+ assert.NotPanics(t, func() {
+ backend.updateVPNConnectionState()
+ })
+}
+
+func TestNetworkManagerBackend_UpdateVPNConnectionState_EmptyUUID(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ backend.stateMutex.Lock()
+ backend.state.IsConnectingVPN = true
+ backend.state.ConnectingVPNUUID = ""
+ backend.stateMutex.Unlock()
+
+ assert.NotPanics(t, func() {
+ backend.updateVPNConnectionState()
+ })
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_wifi.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_wifi.go
new file mode 100644
index 0000000..6f593b1
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_wifi.go
@@ -0,0 +1,1173 @@
+package network
+
+import (
+ "bytes"
+ "fmt"
+ "sort"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "github.com/Wifx/gonetworkmanager/v2"
+)
+
+func (b *NetworkManagerBackend) GetWiFiEnabled() (bool, error) {
+ nm := b.nmConn.(gonetworkmanager.NetworkManager)
+ return nm.GetPropertyWirelessEnabled()
+}
+
+func (b *NetworkManagerBackend) SetWiFiEnabled(enabled bool) error {
+ nm := b.nmConn.(gonetworkmanager.NetworkManager)
+ err := nm.SetPropertyWirelessEnabled(enabled)
+ if err != nil {
+ return fmt.Errorf("failed to set WiFi enabled: %w", err)
+ }
+
+ b.stateMutex.Lock()
+ b.state.WiFiEnabled = enabled
+ b.stateMutex.Unlock()
+
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+
+ return nil
+}
+
+func (b *NetworkManagerBackend) ScanWiFi() error {
+ if b.wifiDevice == nil {
+ return fmt.Errorf("no WiFi device available")
+ }
+
+ b.stateMutex.RLock()
+ enabled := b.state.WiFiEnabled
+ b.stateMutex.RUnlock()
+
+ if !enabled {
+ return fmt.Errorf("WiFi is disabled")
+ }
+
+ if err := b.ensureWiFiDevice(); err != nil {
+ return err
+ }
+
+ w := b.wifiDev.(gonetworkmanager.DeviceWireless)
+ err := w.RequestScan()
+ if err != nil {
+ return fmt.Errorf("scan request failed: %w", err)
+ }
+
+ _, err = b.updateWiFiNetworks()
+ return err
+}
+
+func (b *NetworkManagerBackend) GetWiFiNetworkDetails(ssid string) (*NetworkInfoResponse, error) {
+ if b.wifiDevice == nil {
+ return nil, fmt.Errorf("no WiFi device available")
+ }
+
+ if err := b.ensureWiFiDevice(); err != nil {
+ return nil, err
+ }
+ wifiDev := b.wifiDev
+
+ w := wifiDev.(gonetworkmanager.DeviceWireless)
+ apPaths, err := w.GetAccessPoints()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get access points: %w", err)
+ }
+
+ s := b.settings
+ if s == nil {
+ s, err = gonetworkmanager.NewSettings()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get settings: %w", err)
+ }
+ b.settings = s
+ }
+
+ settingsMgr := s.(gonetworkmanager.Settings)
+ connections, err := settingsMgr.ListConnections()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get connections: %w", err)
+ }
+
+ savedSSIDs := make(map[string]bool)
+ autoconnectMap := make(map[string]bool)
+ for _, conn := range connections {
+ connSettings, err := conn.GetSettings()
+ if err != nil {
+ continue
+ }
+
+ if connMeta, ok := connSettings["connection"]; ok {
+ if connType, ok := connMeta["type"].(string); ok && connType == "802-11-wireless" {
+ if wifiSettings, ok := connSettings["802-11-wireless"]; ok {
+ if ssidBytes, ok := wifiSettings["ssid"].([]byte); ok {
+ savedSSID := string(ssidBytes)
+ savedSSIDs[savedSSID] = true
+ autoconnect := true
+ if ac, ok := connMeta["autoconnect"].(bool); ok {
+ autoconnect = ac
+ }
+ autoconnectMap[savedSSID] = autoconnect
+ }
+ }
+ }
+ }
+ }
+
+ b.stateMutex.RLock()
+ currentSSID := b.state.WiFiSSID
+ currentBSSID := b.state.WiFiBSSID
+ b.stateMutex.RUnlock()
+
+ var bands []WiFiNetwork
+
+ for _, ap := range apPaths {
+ apSSID, err := ap.GetPropertySSID()
+ if err != nil || apSSID != ssid {
+ continue
+ }
+
+ strength, _ := ap.GetPropertyStrength()
+ flags, _ := ap.GetPropertyFlags()
+ wpaFlags, _ := ap.GetPropertyWPAFlags()
+ rsnFlags, _ := ap.GetPropertyRSNFlags()
+ freq, _ := ap.GetPropertyFrequency()
+ maxBitrate, _ := ap.GetPropertyMaxBitrate()
+ bssid, _ := ap.GetPropertyHWAddress()
+ mode, _ := ap.GetPropertyMode()
+
+ secured := flags != uint32(gonetworkmanager.Nm80211APFlagsNone) ||
+ wpaFlags != uint32(gonetworkmanager.Nm80211APSecNone) ||
+ rsnFlags != uint32(gonetworkmanager.Nm80211APSecNone)
+
+ enterprise := (rsnFlags&uint32(gonetworkmanager.Nm80211APSecKeyMgmt8021X) != 0) ||
+ (wpaFlags&uint32(gonetworkmanager.Nm80211APSecKeyMgmt8021X) != 0)
+
+ var modeStr string
+ switch mode {
+ case gonetworkmanager.Nm80211ModeAdhoc:
+ modeStr = "adhoc"
+ case gonetworkmanager.Nm80211ModeInfra:
+ modeStr = "infrastructure"
+ case gonetworkmanager.Nm80211ModeAp:
+ modeStr = "ap"
+ default:
+ modeStr = "unknown"
+ }
+
+ channel := frequencyToChannel(freq)
+
+ isConnected := ssid == currentSSID && bssid == currentBSSID
+ rate := maxBitrate / 1000
+ if isConnected {
+ if devBitrate, err := w.GetPropertyBitrate(); err == nil && devBitrate > 0 {
+ rate = devBitrate / 1000
+ }
+ }
+
+ network := WiFiNetwork{
+ SSID: ssid,
+ BSSID: bssid,
+ Signal: strength,
+ Secured: secured,
+ Enterprise: enterprise,
+ Connected: isConnected,
+ Saved: savedSSIDs[ssid],
+ Autoconnect: autoconnectMap[ssid],
+ Frequency: freq,
+ Mode: modeStr,
+ Rate: rate,
+ Channel: channel,
+ }
+
+ bands = append(bands, network)
+ }
+
+ if len(bands) == 0 {
+ return nil, fmt.Errorf("network not found: %s", ssid)
+ }
+
+ sort.Slice(bands, func(i, j int) bool {
+ if bands[i].Connected && !bands[j].Connected {
+ return true
+ }
+ if !bands[i].Connected && bands[j].Connected {
+ return false
+ }
+ return bands[i].Signal > bands[j].Signal
+ })
+
+ return &NetworkInfoResponse{
+ SSID: ssid,
+ Bands: bands,
+ }, nil
+}
+
+func (b *NetworkManagerBackend) GetWiFiQRCodeContent(ssid string) (string, error) {
+ conn, err := b.findConnection(ssid)
+ if err != nil {
+ return "", fmt.Errorf("no saved connection for `%s`: %w", ssid, err)
+ }
+
+ connSettings, err := conn.GetSettings()
+ if err != nil {
+ return "", fmt.Errorf("failed to get settings for `%s`: %w", ssid, err)
+ }
+
+ secSettings, ok := connSettings["802-11-wireless-security"]
+ if !ok {
+ return "", fmt.Errorf("network `%s` has no security settings", ssid)
+ }
+
+ keyMgmt, ok := secSettings["key-mgmt"].(string)
+ if !ok {
+ return "", fmt.Errorf("failed to identify security type of network `%s`", ssid)
+ }
+
+ var securityType string
+ switch keyMgmt {
+ case "none":
+ authAlg, _ := secSettings["auth-alg"].(string)
+ switch authAlg {
+ case "open":
+ securityType = "nopass"
+ default:
+ securityType = "WEP"
+ }
+ case "ieee8021x":
+ securityType = "WEP"
+ default:
+ securityType = "WPA"
+ }
+
+ if securityType != "WPA" {
+ return "", fmt.Errorf("QR code generation only supports WPA connections, `%s` uses %s", ssid, securityType)
+ }
+
+ secrets, err := conn.GetSecrets("802-11-wireless-security")
+ if err != nil {
+ return "", fmt.Errorf("failed to retrieve connection secrets for `%s`: %w", ssid, err)
+ }
+
+ secSecrets, ok := secrets["802-11-wireless-security"]
+ if !ok {
+ return "", fmt.Errorf("failed to retrieve password for `%s`", ssid)
+ }
+
+ psk, ok := secSecrets["psk"].(string)
+ if !ok {
+ return "", fmt.Errorf("failed to retrieve password for `%s`", ssid)
+ }
+
+ return FormatWiFiQRString(securityType, ssid, psk), nil
+}
+
+func (b *NetworkManagerBackend) ConnectWiFi(req ConnectionRequest) error {
+ devInfo, err := b.getWifiDeviceForConnection(req.Device)
+ if err != nil {
+ return err
+ }
+
+ b.stateMutex.RLock()
+ alreadyConnected := b.state.WiFiConnected && b.state.WiFiSSID == req.SSID
+ b.stateMutex.RUnlock()
+
+ if alreadyConnected && !req.Interactive && req.Device == "" {
+ return nil
+ }
+
+ b.stateMutex.Lock()
+ b.state.IsConnecting = true
+ b.state.ConnectingSSID = req.SSID
+ b.state.ConnectingDevice = req.Device
+ b.state.LastError = ""
+ b.stateMutex.Unlock()
+
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+
+ nm := b.nmConn.(gonetworkmanager.NetworkManager)
+
+ existingConn, err := b.findConnection(req.SSID)
+ if err == nil && existingConn != nil {
+ _, err := nm.ActivateConnection(existingConn, devInfo.device, nil)
+ if err != nil {
+ log.Warnf("[ConnectWiFi] Failed to activate existing connection: %v", err)
+ b.stateMutex.Lock()
+ b.state.IsConnecting = false
+ b.state.ConnectingSSID = ""
+ b.state.ConnectingDevice = ""
+ b.state.LastError = fmt.Sprintf("failed to activate connection: %v", err)
+ b.stateMutex.Unlock()
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+ return fmt.Errorf("failed to activate connection: %w", err)
+ }
+
+ return nil
+ }
+
+ if err := b.createAndConnectWiFiOnDevice(req, devInfo); err != nil {
+ log.Warnf("[ConnectWiFi] Failed to create and connect: %v", err)
+ b.stateMutex.Lock()
+ b.state.IsConnecting = false
+ b.state.ConnectingSSID = ""
+ b.state.ConnectingDevice = ""
+ b.state.LastError = err.Error()
+ b.stateMutex.Unlock()
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+ return err
+ }
+
+ return nil
+}
+
+func (b *NetworkManagerBackend) DisconnectWiFi() error {
+ if b.wifiDevice == nil {
+ return fmt.Errorf("no WiFi device available")
+ }
+
+ dev := b.wifiDevice.(gonetworkmanager.Device)
+
+ err := dev.Disconnect()
+ if err != nil {
+ return fmt.Errorf("failed to disconnect: %w", err)
+ }
+
+ b.updateWiFiState()
+ b.updatePrimaryConnection()
+
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+
+ return nil
+}
+
+func (b *NetworkManagerBackend) ForgetWiFiNetwork(ssid string) error {
+ conn, err := b.findConnection(ssid)
+ if err != nil {
+ return fmt.Errorf("connection not found: %w", err)
+ }
+
+ b.stateMutex.RLock()
+ currentSSID := b.state.WiFiSSID
+ isConnected := b.state.WiFiConnected
+ b.stateMutex.RUnlock()
+
+ err = conn.Delete()
+ if err != nil {
+ return fmt.Errorf("failed to delete connection: %w", err)
+ }
+
+ if isConnected && currentSSID == ssid {
+ b.stateMutex.Lock()
+ b.state.WiFiConnected = false
+ b.state.WiFiSSID = ""
+ b.state.WiFiBSSID = ""
+ b.state.WiFiSignal = 0
+ b.state.WiFiIP = ""
+ b.state.NetworkStatus = StatusDisconnected
+ b.stateMutex.Unlock()
+ }
+
+ b.updateWiFiNetworks()
+
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+
+ return nil
+}
+
+func (b *NetworkManagerBackend) IsConnectingTo(ssid string) bool {
+ b.stateMutex.RLock()
+ defer b.stateMutex.RUnlock()
+ return b.state.IsConnecting && b.state.ConnectingSSID == ssid
+}
+
+func (b *NetworkManagerBackend) updateWiFiNetworks() ([]WiFiNetwork, error) {
+ if b.wifiDevice == nil {
+ return nil, fmt.Errorf("no WiFi device available")
+ }
+
+ if err := b.ensureWiFiDevice(); err != nil {
+ return nil, err
+ }
+ wifiDev := b.wifiDev
+
+ w := wifiDev.(gonetworkmanager.DeviceWireless)
+ apPaths, err := w.GetAccessPoints()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get access points: %w", err)
+ }
+
+ s := b.settings
+ if s == nil {
+ s, err = gonetworkmanager.NewSettings()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get settings: %w", err)
+ }
+ b.settings = s
+ }
+
+ settingsMgr := s.(gonetworkmanager.Settings)
+ connections, err := settingsMgr.ListConnections()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get connections: %w", err)
+ }
+
+ savedSSIDs := make(map[string]bool)
+ autoconnectMap := make(map[string]bool)
+ hiddenSSIDs := make(map[string]bool)
+ for _, conn := range connections {
+ connSettings, err := conn.GetSettings()
+ if err != nil {
+ continue
+ }
+
+ connMeta, ok := connSettings["connection"]
+ if !ok {
+ continue
+ }
+
+ connType, ok := connMeta["type"].(string)
+ if !ok || connType != "802-11-wireless" {
+ continue
+ }
+
+ wifiSettings, ok := connSettings["802-11-wireless"]
+ if !ok {
+ continue
+ }
+
+ ssidBytes, ok := wifiSettings["ssid"].([]byte)
+ if !ok {
+ continue
+ }
+
+ ssid := string(ssidBytes)
+ savedSSIDs[ssid] = true
+ autoconnect := true
+ if ac, ok := connMeta["autoconnect"].(bool); ok {
+ autoconnect = ac
+ }
+ autoconnectMap[ssid] = autoconnect
+
+ if hidden, ok := wifiSettings["hidden"].(bool); ok && hidden {
+ hiddenSSIDs[ssid] = true
+ }
+ }
+
+ b.stateMutex.RLock()
+ currentSSID := b.state.WiFiSSID
+ wifiConnected := b.state.WiFiConnected
+ wifiSignal := b.state.WiFiSignal
+ wifiBSSID := b.state.WiFiBSSID
+ b.stateMutex.RUnlock()
+
+ seenSSIDs := make(map[string]*WiFiNetwork)
+ networks := []WiFiNetwork{}
+
+ for _, ap := range apPaths {
+ ssid, err := ap.GetPropertySSID()
+ if err != nil || ssid == "" {
+ continue
+ }
+
+ if existing, exists := seenSSIDs[ssid]; exists {
+ strength, _ := ap.GetPropertyStrength()
+ if strength > existing.Signal {
+ existing.Signal = strength
+ freq, _ := ap.GetPropertyFrequency()
+ existing.Frequency = freq
+ bssid, _ := ap.GetPropertyHWAddress()
+ existing.BSSID = bssid
+ }
+ continue
+ }
+
+ strength, _ := ap.GetPropertyStrength()
+ flags, _ := ap.GetPropertyFlags()
+ wpaFlags, _ := ap.GetPropertyWPAFlags()
+ rsnFlags, _ := ap.GetPropertyRSNFlags()
+ freq, _ := ap.GetPropertyFrequency()
+ maxBitrate, _ := ap.GetPropertyMaxBitrate()
+ bssid, _ := ap.GetPropertyHWAddress()
+ mode, _ := ap.GetPropertyMode()
+
+ secured := flags != uint32(gonetworkmanager.Nm80211APFlagsNone) ||
+ wpaFlags != uint32(gonetworkmanager.Nm80211APSecNone) ||
+ rsnFlags != uint32(gonetworkmanager.Nm80211APSecNone)
+
+ enterprise := (rsnFlags&uint32(gonetworkmanager.Nm80211APSecKeyMgmt8021X) != 0) ||
+ (wpaFlags&uint32(gonetworkmanager.Nm80211APSecKeyMgmt8021X) != 0)
+
+ var modeStr string
+ switch mode {
+ case gonetworkmanager.Nm80211ModeAdhoc:
+ modeStr = "adhoc"
+ case gonetworkmanager.Nm80211ModeInfra:
+ modeStr = "infrastructure"
+ case gonetworkmanager.Nm80211ModeAp:
+ modeStr = "ap"
+ default:
+ modeStr = "unknown"
+ }
+
+ channel := frequencyToChannel(freq)
+
+ isConnected := ssid == currentSSID
+ rate := maxBitrate / 1000
+ if isConnected {
+ if devBitrate, err := w.GetPropertyBitrate(); err == nil && devBitrate > 0 {
+ rate = devBitrate / 1000
+ }
+ }
+
+ network := WiFiNetwork{
+ SSID: ssid,
+ BSSID: bssid,
+ Signal: strength,
+ Secured: secured,
+ Enterprise: enterprise,
+ Connected: isConnected,
+ Saved: savedSSIDs[ssid],
+ Autoconnect: autoconnectMap[ssid],
+ Hidden: hiddenSSIDs[ssid],
+ Frequency: freq,
+ Mode: modeStr,
+ Rate: rate,
+ Channel: channel,
+ }
+
+ seenSSIDs[ssid] = &network
+ networks = append(networks, network)
+ }
+
+ if wifiConnected && currentSSID != "" {
+ if _, exists := seenSSIDs[currentSSID]; !exists {
+ hiddenNetwork := WiFiNetwork{
+ SSID: currentSSID,
+ BSSID: wifiBSSID,
+ Signal: wifiSignal,
+ Secured: true,
+ Connected: true,
+ Saved: savedSSIDs[currentSSID],
+ Autoconnect: autoconnectMap[currentSSID],
+ Hidden: true,
+ Mode: "infrastructure",
+ }
+ networks = append(networks, hiddenNetwork)
+ }
+ }
+
+ sortWiFiNetworks(networks)
+
+ b.stateMutex.Lock()
+ b.state.WiFiNetworks = networks
+ b.stateMutex.Unlock()
+
+ return networks, nil
+}
+
+func (b *NetworkManagerBackend) findConnection(ssid string) (gonetworkmanager.Connection, error) {
+ s := b.settings
+ if s == nil {
+ var err error
+ s, err = gonetworkmanager.NewSettings()
+ if err != nil {
+ return nil, err
+ }
+ b.settings = s
+ }
+
+ settings := s.(gonetworkmanager.Settings)
+ connections, err := settings.ListConnections()
+ if err != nil {
+ return nil, err
+ }
+
+ ssidBytes := []byte(ssid)
+ for _, conn := range connections {
+ connSettings, err := conn.GetSettings()
+ if err != nil {
+ continue
+ }
+
+ if connMeta, ok := connSettings["connection"]; ok {
+ if connType, ok := connMeta["type"].(string); ok && connType == "802-11-wireless" {
+ if wifiSettings, ok := connSettings["802-11-wireless"]; ok {
+ if candidateSSID, ok := wifiSettings["ssid"].([]byte); ok {
+ if bytes.Equal(candidateSSID, ssidBytes) {
+ return conn, nil
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return nil, fmt.Errorf("connection not found")
+}
+
+func (b *NetworkManagerBackend) createAndConnectWiFi(req ConnectionRequest) error {
+ devInfo, err := b.getWifiDeviceForConnection(req.Device)
+ if err != nil {
+ return err
+ }
+ return b.createAndConnectWiFiOnDevice(req, devInfo)
+}
+
+func (b *NetworkManagerBackend) createAndConnectWiFiOnDevice(req ConnectionRequest, devInfo *wifiDeviceInfo) error {
+ nm := b.nmConn.(gonetworkmanager.NetworkManager)
+ dev := devInfo.device
+ w := devInfo.wireless
+
+ var targetAP gonetworkmanager.AccessPoint
+ var flags, wpaFlags, rsnFlags uint32
+
+ if !req.Hidden {
+ apPaths, err := w.GetAccessPoints()
+ if err != nil {
+ return fmt.Errorf("failed to get access points: %w", err)
+ }
+
+ for _, ap := range apPaths {
+ ssid, err := ap.GetPropertySSID()
+ if err != nil || ssid != req.SSID {
+ continue
+ }
+ targetAP = ap
+ break
+ }
+
+ if targetAP == nil {
+ return fmt.Errorf("access point not found: %s", req.SSID)
+ }
+
+ flags, _ = targetAP.GetPropertyFlags()
+ wpaFlags, _ = targetAP.GetPropertyWPAFlags()
+ rsnFlags, _ = targetAP.GetPropertyRSNFlags()
+ }
+
+ const KeyMgmt8021x = uint32(512)
+ const KeyMgmtPsk = uint32(256)
+ const KeyMgmtSae = uint32(1024)
+
+ var isEnterprise, isPsk, isSae, secured bool
+
+ switch {
+ case req.Hidden:
+ secured = req.Password != "" || req.Username != ""
+ isEnterprise = req.Username != ""
+ isPsk = req.Password != "" && !isEnterprise
+ default:
+ isEnterprise = (wpaFlags&KeyMgmt8021x) != 0 || (rsnFlags&KeyMgmt8021x) != 0
+ isPsk = (wpaFlags&KeyMgmtPsk) != 0 || (rsnFlags&KeyMgmtPsk) != 0
+ isSae = (wpaFlags&KeyMgmtSae) != 0 || (rsnFlags&KeyMgmtSae) != 0
+ secured = flags != uint32(gonetworkmanager.Nm80211APFlagsNone) ||
+ wpaFlags != uint32(gonetworkmanager.Nm80211APSecNone) ||
+ rsnFlags != uint32(gonetworkmanager.Nm80211APSecNone)
+ }
+
+ if isEnterprise {
+ log.Infof("[createAndConnectWiFi] Enterprise network detected (802.1x) - SSID: %s, interactive: %v",
+ req.SSID, req.Interactive)
+ }
+
+ settings := make(map[string]map[string]any)
+
+ settings["connection"] = map[string]any{
+ "id": req.SSID,
+ "type": "802-11-wireless",
+ "autoconnect": true,
+ }
+
+ settings["ipv4"] = map[string]any{"method": "auto"}
+ settings["ipv6"] = map[string]any{"method": "auto"}
+
+ if secured {
+ wifiSettings := map[string]any{
+ "ssid": []byte(req.SSID),
+ "mode": "infrastructure",
+ "security": "802-11-wireless-security",
+ }
+ if req.Hidden {
+ wifiSettings["hidden"] = true
+ }
+ settings["802-11-wireless"] = wifiSettings
+
+ switch {
+ case isEnterprise || req.Username != "":
+ settings["802-11-wireless-security"] = map[string]any{
+ "key-mgmt": "wpa-eap",
+ }
+
+ eapMethod := "peap"
+ if req.EAPMethod != "" {
+ eapMethod = req.EAPMethod
+ }
+
+ phase2Auth := "mschapv2"
+ if req.Phase2Auth != "" {
+ phase2Auth = req.Phase2Auth
+ }
+
+ useSystemCACerts := false
+ if req.UseSystemCACerts != nil {
+ useSystemCACerts = *req.UseSystemCACerts
+ }
+
+ x := map[string]any{
+ "eap": []string{eapMethod},
+ "system-ca-certs": useSystemCACerts,
+ "password-flags": uint32(0),
+ }
+
+ switch eapMethod {
+ case "peap", "ttls":
+ x["phase2-auth"] = phase2Auth
+ case "tls":
+ if req.ClientCertPath != "" {
+ x["client-cert"] = []byte("file://" + req.ClientCertPath)
+ }
+ if req.PrivateKeyPath != "" {
+ x["private-key"] = []byte("file://" + req.PrivateKeyPath)
+ }
+ }
+
+ if req.Username != "" {
+ x["identity"] = req.Username
+ }
+ if req.Password != "" {
+ x["password"] = req.Password
+ }
+ if req.AnonymousIdentity != "" {
+ x["anonymous-identity"] = req.AnonymousIdentity
+ }
+ if req.DomainSuffixMatch != "" {
+ x["domain-suffix-match"] = req.DomainSuffixMatch
+ }
+ if req.CACertPath != "" {
+ x["ca-cert"] = []byte("file://" + req.CACertPath)
+ }
+
+ settings["802-1x"] = x
+
+ log.Infof("[createAndConnectWiFi] WPA-EAP settings: eap=%s, phase2-auth=%s, identity=%s, interactive=%v, system-ca-certs=%v, domain-suffix-match=%q",
+ eapMethod, phase2Auth, req.Username, req.Interactive, useSystemCACerts, req.DomainSuffixMatch)
+
+ case isPsk:
+ sec := map[string]any{
+ "key-mgmt": "wpa-psk",
+ "psk-flags": uint32(0),
+ }
+ if !req.Interactive {
+ sec["psk"] = req.Password
+ }
+ settings["802-11-wireless-security"] = sec
+
+ case isSae:
+ sec := map[string]any{
+ "key-mgmt": "sae",
+ "pmf": int32(3),
+ "psk-flags": uint32(0),
+ }
+ if !req.Interactive {
+ sec["psk"] = req.Password
+ }
+ settings["802-11-wireless-security"] = sec
+
+ default:
+ return fmt.Errorf("secured network but not SAE/PSK/802.1X (rsn=0x%x wpa=0x%x)", rsnFlags, wpaFlags)
+ }
+ } else {
+ wifiSettings := map[string]any{
+ "ssid": []byte(req.SSID),
+ "mode": "infrastructure",
+ }
+ if req.Hidden {
+ wifiSettings["hidden"] = true
+ }
+ settings["802-11-wireless"] = wifiSettings
+ }
+
+ if req.Interactive {
+ s := b.settings
+ if s == nil {
+ var settingsErr error
+ s, settingsErr = gonetworkmanager.NewSettings()
+ if settingsErr != nil {
+ return fmt.Errorf("failed to get settings manager: %w", settingsErr)
+ }
+ b.settings = s
+ }
+
+ settingsMgr := s.(gonetworkmanager.Settings)
+ conn, err := settingsMgr.AddConnection(settings)
+ if err != nil {
+ return fmt.Errorf("failed to add connection: %w", err)
+ }
+
+ if isEnterprise {
+ log.Infof("[createAndConnectWiFi] Enterprise connection added, activating (secret agent will be called)")
+ }
+
+ if req.Hidden {
+ _, err = nm.ActivateConnection(conn, dev, nil)
+ } else {
+ _, err = nm.ActivateWirelessConnection(conn, dev, targetAP)
+ }
+ if err != nil {
+ return fmt.Errorf("failed to activate connection: %w", err)
+ }
+
+ log.Infof("[createAndConnectWiFi] Connection activation initiated, waiting for NetworkManager state changes...")
+ } else {
+ var err error
+ if req.Hidden {
+ _, err = nm.AddAndActivateConnection(settings, dev)
+ } else {
+ _, err = nm.AddAndActivateWirelessConnection(settings, dev, targetAP)
+ }
+ if err != nil {
+ return fmt.Errorf("failed to connect: %w", err)
+ }
+ log.Infof("[createAndConnectWiFi] Connection activation initiated, waiting for NetworkManager state changes...")
+ }
+
+ return nil
+}
+
+func (b *NetworkManagerBackend) SetWiFiAutoconnect(ssid string, autoconnect bool) error {
+ conn, err := b.findConnection(ssid)
+ if err != nil {
+ return fmt.Errorf("connection not found: %w", err)
+ }
+
+ settings, err := conn.GetSettings()
+ if err != nil {
+ return fmt.Errorf("failed to get connection settings: %w", err)
+ }
+
+ if connMeta, ok := settings["connection"]; ok {
+ connMeta["autoconnect"] = autoconnect
+ } else {
+ return fmt.Errorf("connection metadata not found")
+ }
+
+ if ipv4, ok := settings["ipv4"]; ok {
+ delete(ipv4, "addresses")
+ delete(ipv4, "routes")
+ delete(ipv4, "dns")
+ }
+
+ if ipv6, ok := settings["ipv6"]; ok {
+ delete(ipv6, "addresses")
+ delete(ipv6, "routes")
+ delete(ipv6, "dns")
+ }
+
+ err = conn.Update(settings)
+ if err != nil {
+ return fmt.Errorf("failed to update connection: %w", err)
+ }
+
+ b.updateWiFiNetworks()
+
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+
+ return nil
+}
+
+func (b *NetworkManagerBackend) ScanWiFiDevice(device string) error {
+ devInfo, ok := b.wifiDevices[device]
+ if !ok {
+ return fmt.Errorf("WiFi device not found: %s", device)
+ }
+
+ b.stateMutex.RLock()
+ enabled := b.state.WiFiEnabled
+ b.stateMutex.RUnlock()
+
+ if !enabled {
+ return fmt.Errorf("WiFi is disabled")
+ }
+
+ if err := devInfo.wireless.RequestScan(); err != nil {
+ return fmt.Errorf("scan request failed: %w", err)
+ }
+
+ b.updateAllWiFiDevices()
+ return nil
+}
+
+func (b *NetworkManagerBackend) DisconnectWiFiDevice(device string) error {
+ devInfo, ok := b.wifiDevices[device]
+ if !ok {
+ return fmt.Errorf("WiFi device not found: %s", device)
+ }
+
+ if err := devInfo.device.Disconnect(); err != nil {
+ return fmt.Errorf("failed to disconnect: %w", err)
+ }
+
+ b.updateWiFiState()
+ b.updateAllWiFiDevices()
+ b.updatePrimaryConnection()
+
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+
+ return nil
+}
+
+func (b *NetworkManagerBackend) GetWiFiDevices() []WiFiDevice {
+ b.stateMutex.RLock()
+ defer b.stateMutex.RUnlock()
+ return append([]WiFiDevice(nil), b.state.WiFiDevices...)
+}
+
+func (b *NetworkManagerBackend) updateAllWiFiDevices() {
+ s := b.settings
+ if s == nil {
+ var err error
+ s, err = gonetworkmanager.NewSettings()
+ if err != nil {
+ return
+ }
+ b.settings = s
+ }
+
+ settingsMgr := s.(gonetworkmanager.Settings)
+ connections, err := settingsMgr.ListConnections()
+ if err != nil {
+ return
+ }
+
+ savedSSIDs := make(map[string]bool)
+ autoconnectMap := make(map[string]bool)
+ hiddenSSIDs := make(map[string]bool)
+ for _, conn := range connections {
+ connSettings, err := conn.GetSettings()
+ if err != nil {
+ continue
+ }
+
+ connMeta, ok := connSettings["connection"]
+ if !ok {
+ continue
+ }
+
+ connType, ok := connMeta["type"].(string)
+ if !ok || connType != "802-11-wireless" {
+ continue
+ }
+
+ wifiSettings, ok := connSettings["802-11-wireless"]
+ if !ok {
+ continue
+ }
+
+ ssidBytes, ok := wifiSettings["ssid"].([]byte)
+ if !ok {
+ continue
+ }
+
+ ssid := string(ssidBytes)
+ savedSSIDs[ssid] = true
+ autoconnect := true
+ if ac, ok := connMeta["autoconnect"].(bool); ok {
+ autoconnect = ac
+ }
+ autoconnectMap[ssid] = autoconnect
+
+ if hidden, ok := wifiSettings["hidden"].(bool); ok && hidden {
+ hiddenSSIDs[ssid] = true
+ }
+ }
+
+ var devices []WiFiDevice
+
+ for name, devInfo := range b.wifiDevices {
+ state, _ := devInfo.device.GetPropertyState()
+ connected := state == gonetworkmanager.NmDeviceStateActivated
+
+ var ssid, bssid, ip string
+ var signal uint8
+
+ if connected {
+ if activeAP, err := devInfo.wireless.GetPropertyActiveAccessPoint(); err == nil && activeAP != nil && activeAP.GetPath() != "/" {
+ ssid, _ = activeAP.GetPropertySSID()
+ signal, _ = activeAP.GetPropertyStrength()
+ bssid, _ = activeAP.GetPropertyHWAddress()
+ }
+ ip = b.getDeviceIP(devInfo.device)
+ }
+
+ stateStr := "disconnected"
+ switch state {
+ case gonetworkmanager.NmDeviceStateActivated:
+ stateStr = "connected"
+ case gonetworkmanager.NmDeviceStateConfig, gonetworkmanager.NmDeviceStateIpConfig:
+ stateStr = "connecting"
+ case gonetworkmanager.NmDeviceStatePrepare:
+ stateStr = "preparing"
+ case gonetworkmanager.NmDeviceStateDeactivating:
+ stateStr = "disconnecting"
+ }
+
+ apPaths, err := devInfo.wireless.GetAccessPoints()
+ var networks []WiFiNetwork
+ if err == nil {
+ seenSSIDs := make(map[string]*WiFiNetwork)
+ for _, ap := range apPaths {
+ apSSID, err := ap.GetPropertySSID()
+ if err != nil || apSSID == "" {
+ continue
+ }
+
+ if existing, exists := seenSSIDs[apSSID]; exists {
+ strength, _ := ap.GetPropertyStrength()
+ if strength > existing.Signal {
+ existing.Signal = strength
+ freq, _ := ap.GetPropertyFrequency()
+ existing.Frequency = freq
+ apBSSID, _ := ap.GetPropertyHWAddress()
+ existing.BSSID = apBSSID
+ }
+ continue
+ }
+
+ strength, _ := ap.GetPropertyStrength()
+ flags, _ := ap.GetPropertyFlags()
+ wpaFlags, _ := ap.GetPropertyWPAFlags()
+ rsnFlags, _ := ap.GetPropertyRSNFlags()
+ freq, _ := ap.GetPropertyFrequency()
+ maxBitrate, _ := ap.GetPropertyMaxBitrate()
+ apBSSID, _ := ap.GetPropertyHWAddress()
+ mode, _ := ap.GetPropertyMode()
+
+ secured := flags != uint32(gonetworkmanager.Nm80211APFlagsNone) ||
+ wpaFlags != uint32(gonetworkmanager.Nm80211APSecNone) ||
+ rsnFlags != uint32(gonetworkmanager.Nm80211APSecNone)
+
+ enterprise := (rsnFlags&uint32(gonetworkmanager.Nm80211APSecKeyMgmt8021X) != 0) ||
+ (wpaFlags&uint32(gonetworkmanager.Nm80211APSecKeyMgmt8021X) != 0)
+
+ var modeStr string
+ switch mode {
+ case gonetworkmanager.Nm80211ModeAdhoc:
+ modeStr = "adhoc"
+ case gonetworkmanager.Nm80211ModeInfra:
+ modeStr = "infrastructure"
+ case gonetworkmanager.Nm80211ModeAp:
+ modeStr = "ap"
+ default:
+ modeStr = "unknown"
+ }
+
+ channel := frequencyToChannel(freq)
+
+ isConnected := connected && apSSID == ssid
+ rate := maxBitrate / 1000
+ if isConnected {
+ if devBitrate, err := devInfo.wireless.GetPropertyBitrate(); err == nil && devBitrate > 0 {
+ rate = devBitrate / 1000
+ }
+ }
+
+ network := WiFiNetwork{
+ SSID: apSSID,
+ BSSID: apBSSID,
+ Signal: strength,
+ Secured: secured,
+ Enterprise: enterprise,
+ Connected: isConnected,
+ Saved: savedSSIDs[apSSID],
+ Autoconnect: autoconnectMap[apSSID],
+ Hidden: hiddenSSIDs[apSSID],
+ Frequency: freq,
+ Mode: modeStr,
+ Rate: rate,
+ Channel: channel,
+ Device: name,
+ }
+
+ seenSSIDs[apSSID] = &network
+ networks = append(networks, network)
+ }
+
+ if connected && ssid != "" {
+ if _, exists := seenSSIDs[ssid]; !exists {
+ hiddenNetwork := WiFiNetwork{
+ SSID: ssid,
+ BSSID: bssid,
+ Signal: signal,
+ Secured: true,
+ Connected: true,
+ Saved: savedSSIDs[ssid],
+ Autoconnect: autoconnectMap[ssid],
+ Hidden: true,
+ Mode: "infrastructure",
+ Device: name,
+ }
+ networks = append(networks, hiddenNetwork)
+ }
+ }
+
+ sortWiFiNetworks(networks)
+ }
+
+ devices = append(devices, WiFiDevice{
+ Name: name,
+ HwAddress: devInfo.hwAddress,
+ State: stateStr,
+ Connected: connected,
+ SSID: ssid,
+ BSSID: bssid,
+ Signal: signal,
+ IP: ip,
+ Networks: networks,
+ })
+ }
+
+ sort.Slice(devices, func(i, j int) bool {
+ return devices[i].Name < devices[j].Name
+ })
+
+ b.stateMutex.Lock()
+ b.state.WiFiDevices = devices
+ b.stateMutex.Unlock()
+}
+
+func (b *NetworkManagerBackend) getWifiDeviceForConnection(deviceName string) (*wifiDeviceInfo, error) {
+ if deviceName != "" {
+ devInfo, ok := b.wifiDevices[deviceName]
+ if !ok {
+ return nil, fmt.Errorf("WiFi device not found: %s", deviceName)
+ }
+ return devInfo, nil
+ }
+
+ if b.wifiDevice == nil {
+ return nil, fmt.Errorf("no WiFi device available")
+ }
+
+ dev := b.wifiDevice.(gonetworkmanager.Device)
+ iface, _ := dev.GetPropertyInterface()
+ if devInfo, ok := b.wifiDevices[iface]; ok {
+ return devInfo, nil
+ }
+
+ return nil, fmt.Errorf("no WiFi device available")
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_wifi_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_wifi_test.go
new file mode 100644
index 0000000..79c2c5b
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_wifi_test.go
@@ -0,0 +1,202 @@
+package network
+
+import (
+ "testing"
+
+ mock_gonetworkmanager "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestNetworkManagerBackend_GetWiFiEnabled(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ mockNM.EXPECT().GetPropertyWirelessEnabled().Return(true, nil)
+
+ enabled, err := backend.GetWiFiEnabled()
+ assert.NoError(t, err)
+ assert.True(t, enabled)
+}
+
+func TestNetworkManagerBackend_SetWiFiEnabled(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ mockNM.EXPECT().SetPropertyWirelessEnabled(true).Return(nil)
+
+ err = backend.SetWiFiEnabled(true)
+ assert.NoError(t, err)
+
+ backend.stateMutex.RLock()
+ assert.True(t, backend.state.WiFiEnabled)
+ backend.stateMutex.RUnlock()
+}
+
+func TestNetworkManagerBackend_ScanWiFi_NoDevice(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ backend.wifiDevice = nil
+ err = backend.ScanWiFi()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "no WiFi device available")
+}
+
+func TestNetworkManagerBackend_ScanWiFi_Disabled(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+ mockDeviceWireless := mock_gonetworkmanager.NewMockDeviceWireless(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ backend.wifiDevice = mockDeviceWireless
+ backend.wifiDev = mockDeviceWireless
+
+ backend.stateMutex.Lock()
+ backend.state.WiFiEnabled = false
+ backend.stateMutex.Unlock()
+
+ err = backend.ScanWiFi()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "WiFi is disabled")
+}
+
+func TestNetworkManagerBackend_GetWiFiNetworkDetails_NoDevice(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ backend.wifiDevice = nil
+ _, err = backend.GetWiFiNetworkDetails("TestNetwork")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "no WiFi device available")
+}
+
+func TestNetworkManagerBackend_ConnectWiFi_NoDevice(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ backend.wifiDevice = nil
+ req := ConnectionRequest{SSID: "TestNetwork", Password: "password"}
+ err = backend.ConnectWiFi(req)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "no WiFi device available")
+}
+
+func TestNetworkManagerBackend_ConnectWiFi_AlreadyConnected(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+ mockDeviceWireless := mock_gonetworkmanager.NewMockDeviceWireless(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ backend.wifiDevice = mockDeviceWireless
+ backend.wifiDev = mockDeviceWireless
+ backend.wifiDevices = map[string]*wifiDeviceInfo{
+ "wlan0": {
+ device: nil,
+ wireless: mockDeviceWireless,
+ name: "wlan0",
+ hwAddress: "00:11:22:33:44:55",
+ },
+ }
+
+ mockDeviceWireless.EXPECT().GetPropertyInterface().Return("wlan0", nil)
+
+ backend.stateMutex.Lock()
+ backend.state.WiFiConnected = true
+ backend.state.WiFiSSID = "TestNetwork"
+ backend.state.WiFiDevice = "wlan0"
+ backend.stateMutex.Unlock()
+
+ req := ConnectionRequest{SSID: "TestNetwork", Password: "password"}
+ err = backend.ConnectWiFi(req)
+ assert.NoError(t, err)
+}
+
+func TestNetworkManagerBackend_DisconnectWiFi_NoDevice(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ backend.wifiDevice = nil
+ err = backend.DisconnectWiFi()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "no WiFi device available")
+}
+
+func TestNetworkManagerBackend_IsConnectingTo(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ backend.stateMutex.Lock()
+ backend.state.IsConnecting = true
+ backend.state.ConnectingSSID = "TestNetwork"
+ backend.stateMutex.Unlock()
+
+ assert.True(t, backend.IsConnectingTo("TestNetwork"))
+ assert.False(t, backend.IsConnectingTo("OtherNetwork"))
+}
+
+func TestNetworkManagerBackend_IsConnectingTo_NotConnecting(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ backend.stateMutex.Lock()
+ backend.state.IsConnecting = false
+ backend.state.ConnectingSSID = ""
+ backend.stateMutex.Unlock()
+
+ assert.False(t, backend.IsConnectingTo("TestNetwork"))
+}
+
+func TestNetworkManagerBackend_UpdateWiFiNetworks_NoDevice(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ backend.wifiDevice = nil
+ _, err = backend.updateWiFiNetworks()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "no WiFi device available")
+}
+
+func TestNetworkManagerBackend_FindConnection_NoSettings(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ backend.settings = nil
+ _, err = backend.findConnection("NonExistentNetwork")
+ assert.Error(t, err)
+}
+
+func TestNetworkManagerBackend_CreateAndConnectWiFi_NoDevice(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ backend.wifiDevice = nil
+ backend.wifiDev = nil
+ req := ConnectionRequest{SSID: "TestNetwork", Password: "password"}
+ err = backend.createAndConnectWiFi(req)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "no WiFi device available")
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/broker.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/broker.go
new file mode 100644
index 0000000..5290fb5
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/broker.go
@@ -0,0 +1,22 @@
+package network
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+)
+
+type PromptBroker interface {
+ Ask(ctx context.Context, req PromptRequest) (token string, err error)
+ Wait(ctx context.Context, token string) (PromptReply, error)
+ Resolve(token string, reply PromptReply) error
+ Cancel(path string, setting string) error
+}
+
+func generateToken() (string, error) {
+ bytes := make([]byte, 16)
+ if _, err := rand.Read(bytes); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(bytes), nil
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/connection_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/connection_test.go
new file mode 100644
index 0000000..7b37731
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/connection_test.go
@@ -0,0 +1,109 @@
+package network_test
+
+import (
+ "errors"
+ "testing"
+
+ mocks_network "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/network"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestConnectionRequest_Validation(t *testing.T) {
+ t.Run("basic WiFi connection", func(t *testing.T) {
+ req := network.ConnectionRequest{
+ SSID: "TestNetwork",
+ Password: "testpass123",
+ }
+
+ assert.NotEmpty(t, req.SSID)
+ assert.NotEmpty(t, req.Password)
+ assert.Empty(t, req.Username)
+ })
+
+ t.Run("enterprise WiFi connection", func(t *testing.T) {
+ req := network.ConnectionRequest{
+ SSID: "EnterpriseNetwork",
+ Password: "testpass123",
+ Username: "testuser",
+ }
+
+ assert.NotEmpty(t, req.SSID)
+ assert.NotEmpty(t, req.Password)
+ assert.NotEmpty(t, req.Username)
+ })
+
+ t.Run("open WiFi connection", func(t *testing.T) {
+ req := network.ConnectionRequest{
+ SSID: "OpenNetwork",
+ }
+
+ assert.NotEmpty(t, req.SSID)
+ assert.Empty(t, req.Password)
+ assert.Empty(t, req.Username)
+ })
+}
+
+func TestManager_ConnectWiFi_NoDevice(t *testing.T) {
+ backend := mocks_network.NewMockBackend(t)
+ req := network.ConnectionRequest{
+ SSID: "TestNetwork",
+ Password: "testpass123",
+ }
+ backend.EXPECT().ConnectWiFi(req).Return(errors.New("no WiFi device available"))
+
+ manager := network.NewTestManager(backend, &network.NetworkState{})
+
+ err := manager.ConnectWiFi(req)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "no WiFi device available")
+}
+
+func TestManager_DisconnectWiFi_NoDevice(t *testing.T) {
+ backend := mocks_network.NewMockBackend(t)
+ backend.EXPECT().DisconnectWiFi().Return(errors.New("no WiFi device available"))
+
+ manager := network.NewTestManager(backend, &network.NetworkState{})
+
+ err := manager.DisconnectWiFi()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "no WiFi device available")
+}
+
+func TestManager_ForgetWiFiNetwork_NotFound(t *testing.T) {
+ backend := mocks_network.NewMockBackend(t)
+ backend.EXPECT().ForgetWiFiNetwork("NonExistentNetwork").Return(errors.New("connection not found"))
+
+ manager := network.NewTestManager(backend, &network.NetworkState{})
+
+ err := manager.ForgetWiFiNetwork("NonExistentNetwork")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "connection not found")
+}
+
+func TestManager_ConnectEthernet_NoDevice(t *testing.T) {
+ backend := mocks_network.NewMockBackend(t)
+ backend.EXPECT().ConnectEthernet().Return(errors.New("no ethernet device available"))
+
+ manager := network.NewTestManager(backend, &network.NetworkState{})
+
+ err := manager.ConnectEthernet()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "no ethernet device available")
+}
+
+func TestManager_DisconnectEthernet_NoDevice(t *testing.T) {
+ backend := mocks_network.NewMockBackend(t)
+ backend.EXPECT().DisconnectEthernet().Return(errors.New("no ethernet device available"))
+
+ manager := network.NewTestManager(backend, &network.NetworkState{})
+
+ err := manager.DisconnectEthernet()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "no ethernet device available")
+}
+
+// Note: More comprehensive tests for connection operations would require
+// mocking the NetworkManager D-Bus interfaces, which is beyond the scope
+// of these unit tests. The tests above cover the basic error cases and
+// validation logic. Integration tests would be needed for full coverage.
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/detect.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/detect.go
new file mode 100644
index 0000000..6031e42
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/detect.go
@@ -0,0 +1,89 @@
+package network
+
+import (
+ "fmt"
+
+ "github.com/godbus/dbus/v5"
+)
+
+type BackendType int
+
+const (
+ BackendNone BackendType = iota
+ BackendNetworkManager
+ BackendIwd
+ BackendConnMan
+ BackendNetworkd
+)
+
+func nameHasOwner(bus *dbus.Conn, name string) (bool, error) {
+ obj := bus.Object("org.freedesktop.DBus", "/org/freedesktop/DBus")
+ var owned bool
+ if err := obj.Call("org.freedesktop.DBus.NameHasOwner", 0, name).Store(&owned); err != nil {
+ return false, err
+ }
+ return owned, nil
+}
+
+type DetectResult struct {
+ Backend BackendType
+ HasNM bool
+ HasIwd bool
+ HasConnMan bool
+ HasWpaSupp bool
+ HasNetworkd bool
+ ChosenReason string
+}
+
+func DetectNetworkStack() (*DetectResult, error) {
+ bus, err := dbus.ConnectSystemBus()
+ if err != nil {
+ return nil, fmt.Errorf("connect system bus: %w", err)
+ }
+ defer bus.Close()
+
+ hasNM, _ := nameHasOwner(bus, "org.freedesktop.NetworkManager")
+ hasIwd, _ := nameHasOwner(bus, "net.connman.iwd")
+ hasConn, _ := nameHasOwner(bus, "net.connman")
+ hasWpa, _ := nameHasOwner(bus, "fi.w1.wpa_supplicant1")
+ hasNetworkd, _ := nameHasOwner(bus, "org.freedesktop.network1")
+
+ res := &DetectResult{
+ HasNM: hasNM,
+ HasIwd: hasIwd,
+ HasConnMan: hasConn,
+ HasWpaSupp: hasWpa,
+ HasNetworkd: hasNetworkd,
+ }
+
+ switch {
+ case hasNM:
+ res.Backend = BackendNetworkManager
+ if hasIwd {
+ res.ChosenReason = "NetworkManager present; iwd also running (likely NM's Wi-Fi backend). Using NM API."
+ } else {
+ res.ChosenReason = "NetworkManager present. Using NM API."
+ }
+ case hasConn && hasIwd:
+ res.Backend = BackendConnMan
+ res.ChosenReason = "ConnMan + iwd detected. Use ConnMan API (iwd is its Wi-Fi daemon)."
+ case hasIwd && hasNetworkd:
+ res.Backend = BackendNetworkd
+ res.ChosenReason = "iwd + systemd-networkd detected. Using iwd for Wi-Fi association and networkd for IP/DHCP."
+ case hasIwd:
+ res.Backend = BackendIwd
+ res.ChosenReason = "iwd detected without NM/ConnMan. Using iwd API."
+ case hasNetworkd:
+ res.Backend = BackendNetworkd
+ res.ChosenReason = "systemd-networkd detected (no NM/ConnMan). Using networkd for L3 and wired."
+ default:
+ res.Backend = BackendNone
+ if hasWpa {
+ res.ChosenReason = "No NM/ConnMan/iwd; wpa_supplicant present. Consider a wpa_supplicant path."
+ } else {
+ res.ChosenReason = "No known network manager bus names found."
+ }
+ }
+
+ return res, nil
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/detect_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/detect_test.go
new file mode 100644
index 0000000..87e7635
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/detect_test.go
@@ -0,0 +1,41 @@
+package network
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestBackendType_Constants(t *testing.T) {
+ assert.Equal(t, BackendType(0), BackendNone)
+ assert.Equal(t, BackendType(1), BackendNetworkManager)
+ assert.Equal(t, BackendType(2), BackendIwd)
+ assert.Equal(t, BackendType(3), BackendConnMan)
+ assert.Equal(t, BackendType(4), BackendNetworkd)
+}
+
+func TestDetectResult_HasNetworkdField(t *testing.T) {
+ result := &DetectResult{
+ Backend: BackendNetworkd,
+ HasNetworkd: true,
+ HasIwd: true,
+ }
+
+ assert.True(t, result.HasNetworkd)
+ assert.True(t, result.HasIwd)
+ assert.Equal(t, BackendNetworkd, result.Backend)
+}
+
+func TestDetectNetworkStack_Integration(t *testing.T) {
+ result, err := DetectNetworkStack()
+
+ if err != nil && strings.Contains(err.Error(), "connect system bus") {
+ t.Skipf("system D-Bus unavailable: %v", err)
+ }
+
+ assert.NoError(t, err)
+ if assert.NotNil(t, result) {
+ assert.NotEmpty(t, result.ChosenReason)
+ }
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/handlers.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/handlers.go
new file mode 100644
index 0000000..2b3e75e
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/handlers.go
@@ -0,0 +1,630 @@
+package network
+
+import (
+ "encoding/json"
+ "fmt"
+ "net"
+ "os"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/params"
+)
+
+func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
+ switch req.Method {
+ case "network.getState":
+ handleGetState(conn, req, manager)
+ case "network.wifi.scan":
+ handleScanWiFi(conn, req, manager)
+ case "network.wifi.networks":
+ handleGetWiFiNetworks(conn, req, manager)
+ case "network.wifi.connect":
+ handleConnectWiFi(conn, req, manager)
+ case "network.wifi.disconnect":
+ handleDisconnectWiFi(conn, req, manager)
+ case "network.wifi.forget":
+ handleForgetWiFi(conn, req, manager)
+ case "network.wifi.toggle":
+ handleToggleWiFi(conn, req, manager)
+ case "network.wifi.enable":
+ handleEnableWiFi(conn, req, manager)
+ case "network.wifi.disable":
+ handleDisableWiFi(conn, req, manager)
+ case "network.ethernet.connect.config":
+ handleConnectEthernetSpecificConfig(conn, req, manager)
+ case "network.ethernet.connect":
+ handleConnectEthernet(conn, req, manager)
+ case "network.ethernet.disconnect":
+ handleDisconnectEthernet(conn, req, manager)
+ case "network.preference.set":
+ handleSetPreference(conn, req, manager)
+ case "network.info":
+ handleGetNetworkInfo(conn, req, manager)
+ case "network.qrcode":
+ handleGetNetworkQRCode(conn, req, manager)
+ case "network.delete-qrcode":
+ handleDeleteQRCode(conn, req, manager)
+ case "network.ethernet.info":
+ handleGetWiredNetworkInfo(conn, req, manager)
+ case "network.subscribe":
+ handleSubscribe(conn, req, manager)
+ case "network.credentials.submit":
+ handleCredentialsSubmit(conn, req, manager)
+ case "network.credentials.cancel":
+ handleCredentialsCancel(conn, req, manager)
+ case "network.vpn.profiles":
+ handleListVPNProfiles(conn, req, manager)
+ case "network.vpn.active":
+ handleListActiveVPN(conn, req, manager)
+ case "network.vpn.connect":
+ handleConnectVPN(conn, req, manager)
+ case "network.vpn.disconnect":
+ handleDisconnectVPN(conn, req, manager)
+ case "network.vpn.disconnectAll":
+ handleDisconnectAllVPN(conn, req, manager)
+ case "network.vpn.clearCredentials":
+ handleClearVPNCredentials(conn, req, manager)
+ case "network.vpn.plugins":
+ handleListVPNPlugins(conn, req, manager)
+ case "network.vpn.import":
+ handleImportVPN(conn, req, manager)
+ case "network.vpn.getConfig":
+ handleGetVPNConfig(conn, req, manager)
+ case "network.vpn.updateConfig":
+ handleUpdateVPNConfig(conn, req, manager)
+ case "network.vpn.delete":
+ handleDeleteVPN(conn, req, manager)
+ case "network.vpn.setCredentials":
+ handleSetVPNCredentials(conn, req, manager)
+ case "network.wifi.setAutoconnect":
+ handleSetWiFiAutoconnect(conn, req, manager)
+ default:
+ models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
+ }
+}
+
+func handleCredentialsSubmit(conn net.Conn, req models.Request, manager *Manager) {
+ token, err := params.String(req.Params, "token")
+ if err != nil {
+ log.Warnf("handleCredentialsSubmit: missing or invalid token parameter")
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ secrets, err := params.StringMap(req.Params, "secrets")
+ if err != nil {
+ log.Warnf("handleCredentialsSubmit: missing or invalid secrets parameter")
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ save := params.BoolOpt(req.Params, "save", true)
+
+ if err := manager.SubmitCredentials(token, secrets, save); err != nil {
+ log.Warnf("handleCredentialsSubmit: failed to submit credentials: %v", err)
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ log.Infof("handleCredentialsSubmit: credentials submitted successfully")
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "credentials submitted"})
+}
+
+func handleCredentialsCancel(conn net.Conn, req models.Request, manager *Manager) {
+ token, err := params.String(req.Params, "token")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.CancelCredentials(token); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "credentials cancelled"})
+}
+
+func handleGetState(conn net.Conn, req models.Request, manager *Manager) {
+ models.Respond(conn, req.ID, manager.GetState())
+}
+
+func handleScanWiFi(conn net.Conn, req models.Request, manager *Manager) {
+ device := params.StringOpt(req.Params, "device", "")
+ var err error
+ if device != "" {
+ err = manager.ScanWiFiDevice(device)
+ } else {
+ err = manager.ScanWiFi()
+ }
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "scanning"})
+}
+
+func handleGetWiFiNetworks(conn net.Conn, req models.Request, manager *Manager) {
+ models.Respond(conn, req.ID, manager.GetWiFiNetworks())
+}
+
+func handleConnectWiFi(conn net.Conn, req models.Request, manager *Manager) {
+ ssid, err := params.String(req.Params, "ssid")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ var connReq ConnectionRequest
+ connReq.SSID = ssid
+ connReq.Password = params.StringOpt(req.Params, "password", "")
+ connReq.Username = params.StringOpt(req.Params, "username", "")
+ connReq.Device = params.StringOpt(req.Params, "device", "")
+
+ if interactive, ok := models.Get[bool](req, "interactive"); ok {
+ connReq.Interactive = interactive
+ } else {
+ state := manager.GetState()
+ alreadyConnected := state.WiFiConnected && state.WiFiSSID == ssid
+
+ if alreadyConnected && connReq.Device == "" {
+ connReq.Interactive = false
+ } else {
+ networkInfo, err := manager.GetNetworkInfo(ssid)
+ isSaved := err == nil && networkInfo.Saved
+
+ if isSaved {
+ connReq.Interactive = false
+ } else if err == nil && networkInfo.Secured && connReq.Password == "" && connReq.Username == "" {
+ connReq.Interactive = true
+ }
+ }
+ }
+
+ connReq.AnonymousIdentity = params.StringOpt(req.Params, "anonymousIdentity", "")
+ connReq.DomainSuffixMatch = params.StringOpt(req.Params, "domainSuffixMatch", "")
+ connReq.EAPMethod = params.StringOpt(req.Params, "eapMethod", "")
+ connReq.Phase2Auth = params.StringOpt(req.Params, "phase2Auth", "")
+ connReq.CACertPath = params.StringOpt(req.Params, "caCertPath", "")
+ connReq.ClientCertPath = params.StringOpt(req.Params, "clientCertPath", "")
+ connReq.PrivateKeyPath = params.StringOpt(req.Params, "privateKeyPath", "")
+
+ if useSystemCACerts, ok := models.Get[bool](req, "useSystemCACerts"); ok {
+ connReq.UseSystemCACerts = &useSystemCACerts
+ }
+
+ if err := manager.ConnectWiFi(connReq); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "connecting"})
+}
+
+func handleDisconnectWiFi(conn net.Conn, req models.Request, manager *Manager) {
+ device := params.StringOpt(req.Params, "device", "")
+ var err error
+ if device != "" {
+ err = manager.DisconnectWiFiDevice(device)
+ } else {
+ err = manager.DisconnectWiFi()
+ }
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "disconnected"})
+}
+
+func handleForgetWiFi(conn net.Conn, req models.Request, manager *Manager) {
+ ssid, err := params.String(req.Params, "ssid")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.ForgetWiFiNetwork(ssid); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "forgotten"})
+}
+
+func handleToggleWiFi(conn net.Conn, req models.Request, manager *Manager) {
+ if err := manager.ToggleWiFi(); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ state := manager.GetState()
+ models.Respond(conn, req.ID, map[string]bool{"enabled": state.WiFiEnabled})
+}
+
+func handleEnableWiFi(conn net.Conn, req models.Request, manager *Manager) {
+ if err := manager.EnableWiFi(); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, map[string]bool{"enabled": true})
+}
+
+func handleDisableWiFi(conn net.Conn, req models.Request, manager *Manager) {
+ if err := manager.DisableWiFi(); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, map[string]bool{"enabled": false})
+}
+
+func handleConnectEthernetSpecificConfig(conn net.Conn, req models.Request, manager *Manager) {
+ uuid, err := params.String(req.Params, "uuid")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ if err := manager.activateConnection(uuid); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "connecting"})
+}
+
+func handleConnectEthernet(conn net.Conn, req models.Request, manager *Manager) {
+ if err := manager.ConnectEthernet(); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "connecting"})
+}
+
+func handleDisconnectEthernet(conn net.Conn, req models.Request, manager *Manager) {
+ device := params.StringOpt(req.Params, "device", "")
+ var err error
+ if device != "" {
+ err = manager.DisconnectEthernetDevice(device)
+ } else {
+ err = manager.DisconnectEthernet()
+ }
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "disconnected"})
+}
+
+func handleSetPreference(conn net.Conn, req models.Request, manager *Manager) {
+ preference, err := params.String(req.Params, "preference")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.SetConnectionPreference(ConnectionPreference(preference)); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, map[string]string{"preference": preference})
+}
+
+func handleGetNetworkInfo(conn net.Conn, req models.Request, manager *Manager) {
+ ssid, err := params.String(req.Params, "ssid")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ network, err := manager.GetNetworkInfoDetailed(ssid)
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, network)
+}
+
+func handleGetNetworkQRCode(conn net.Conn, req models.Request, manager *Manager) {
+ ssid, err := params.String(req.Params, "ssid")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ content, err := manager.GetNetworkQRCode(ssid)
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, content)
+}
+
+func handleDeleteQRCode(conn net.Conn, req models.Request, _ *Manager) {
+ path, err := params.String(req.Params, "path")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if !isValidQRCodePath(path) {
+ models.RespondError(conn, req.ID, "invalid QR code path")
+ return
+ }
+
+ if err := os.Remove(path); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "QR code file deleted"})
+}
+
+func handleGetWiredNetworkInfo(conn net.Conn, req models.Request, manager *Manager) {
+ uuid, err := params.String(req.Params, "uuid")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ network, err := manager.GetWiredNetworkInfoDetailed(uuid)
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, network)
+}
+
+func handleSubscribe(conn net.Conn, req models.Request, manager *Manager) {
+ clientID := fmt.Sprintf("client-%p", conn)
+ stateChan := manager.Subscribe(clientID)
+ defer manager.Unsubscribe(clientID)
+
+ initialState := manager.GetState()
+ event := NetworkEvent{
+ Type: EventStateChanged,
+ Data: initialState,
+ }
+ if err := json.NewEncoder(conn).Encode(models.Response[NetworkEvent]{
+ ID: req.ID,
+ Result: &event,
+ }); err != nil {
+ return
+ }
+
+ for state := range stateChan {
+ event := NetworkEvent{
+ Type: EventStateChanged,
+ Data: state,
+ }
+ if err := json.NewEncoder(conn).Encode(models.Response[NetworkEvent]{
+ Result: &event,
+ }); err != nil {
+ return
+ }
+ }
+}
+
+func handleListVPNProfiles(conn net.Conn, req models.Request, manager *Manager) {
+ profiles, err := manager.ListVPNProfiles()
+ if err != nil {
+ log.Warnf("handleListVPNProfiles: failed to list profiles: %v", err)
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to list VPN profiles: %v", err))
+ return
+ }
+
+ models.Respond(conn, req.ID, profiles)
+}
+
+func handleListActiveVPN(conn net.Conn, req models.Request, manager *Manager) {
+ active, err := manager.ListActiveVPN()
+ if err != nil {
+ log.Warnf("handleListActiveVPN: failed to list active VPNs: %v", err)
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to list active VPNs: %v", err))
+ return
+ }
+
+ models.Respond(conn, req.ID, active)
+}
+
+func handleConnectVPN(conn net.Conn, req models.Request, manager *Manager) {
+ uuidOrName, ok := params.StringAlt(req.Params, "uuidOrName", "name", "uuid")
+ if !ok {
+ log.Warnf("handleConnectVPN: missing uuidOrName/name/uuid parameter")
+ models.RespondError(conn, req.ID, "missing 'uuidOrName', 'name', or 'uuid' parameter")
+ return
+ }
+
+ singleActive := params.BoolOpt(req.Params, "singleActive", true)
+
+ if err := manager.ConnectVPN(uuidOrName, singleActive); err != nil {
+ log.Warnf("handleConnectVPN: failed to connect: %v", err)
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to connect VPN: %v", err))
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "VPN connection initiated"})
+}
+
+func handleDisconnectVPN(conn net.Conn, req models.Request, manager *Manager) {
+ uuidOrName, ok := params.StringAlt(req.Params, "uuidOrName", "name", "uuid")
+ if !ok {
+ log.Warnf("handleDisconnectVPN: missing uuidOrName/name/uuid parameter")
+ models.RespondError(conn, req.ID, "missing 'uuidOrName', 'name', or 'uuid' parameter")
+ return
+ }
+
+ if err := manager.DisconnectVPN(uuidOrName); err != nil {
+ log.Warnf("handleDisconnectVPN: failed to disconnect: %v", err)
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to disconnect VPN: %v", err))
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "VPN disconnected"})
+}
+
+func handleDisconnectAllVPN(conn net.Conn, req models.Request, manager *Manager) {
+ if err := manager.DisconnectAllVPN(); err != nil {
+ log.Warnf("handleDisconnectAllVPN: failed: %v", err)
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to disconnect all VPNs: %v", err))
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "All VPNs disconnected"})
+}
+
+func handleClearVPNCredentials(conn net.Conn, req models.Request, manager *Manager) {
+ uuidOrName, ok := params.StringAlt(req.Params, "uuid", "name", "uuidOrName")
+ if !ok {
+ log.Warnf("handleClearVPNCredentials: missing uuidOrName/name/uuid parameter")
+ models.RespondError(conn, req.ID, "missing uuidOrName/name/uuid parameter")
+ return
+ }
+
+ if err := manager.ClearVPNCredentials(uuidOrName); err != nil {
+ log.Warnf("handleClearVPNCredentials: failed: %v", err)
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to clear VPN credentials: %v", err))
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "VPN credentials cleared"})
+}
+
+func handleSetWiFiAutoconnect(conn net.Conn, req models.Request, manager *Manager) {
+ ssid, err := params.String(req.Params, "ssid")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ autoconnect, err := params.Bool(req.Params, "autoconnect")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.SetWiFiAutoconnect(ssid, autoconnect); err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to set autoconnect: %v", err))
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "autoconnect updated"})
+}
+
+func handleListVPNPlugins(conn net.Conn, req models.Request, manager *Manager) {
+ plugins, err := manager.ListVPNPlugins()
+ if err != nil {
+ log.Warnf("handleListVPNPlugins: failed to list plugins: %v", err)
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to list VPN plugins: %v", err))
+ return
+ }
+
+ models.Respond(conn, req.ID, plugins)
+}
+
+func handleImportVPN(conn net.Conn, req models.Request, manager *Manager) {
+ filePath, ok := params.StringAlt(req.Params, "file", "path")
+ if !ok {
+ models.RespondError(conn, req.ID, "missing 'file' or 'path' parameter")
+ return
+ }
+
+ name := params.StringOpt(req.Params, "name", "")
+
+ result, err := manager.ImportVPN(filePath, name)
+ if err != nil {
+ log.Warnf("handleImportVPN: failed to import: %v", err)
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to import VPN: %v", err))
+ return
+ }
+
+ models.Respond(conn, req.ID, result)
+}
+
+func handleGetVPNConfig(conn net.Conn, req models.Request, manager *Manager) {
+ uuidOrName, ok := params.StringAlt(req.Params, "uuid", "name", "uuidOrName")
+ if !ok {
+ models.RespondError(conn, req.ID, "missing 'uuid', 'name', or 'uuidOrName' parameter")
+ return
+ }
+
+ config, err := manager.GetVPNConfig(uuidOrName)
+ if err != nil {
+ log.Warnf("handleGetVPNConfig: failed to get config: %v", err)
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to get VPN config: %v", err))
+ return
+ }
+
+ models.Respond(conn, req.ID, config)
+}
+
+func handleUpdateVPNConfig(conn net.Conn, req models.Request, manager *Manager) {
+ connUUID, err := params.String(req.Params, "uuid")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ updates := make(map[string]any)
+
+ if name, ok := models.Get[string](req, "name"); ok {
+ updates["name"] = name
+ }
+ if autoconnect, ok := models.Get[bool](req, "autoconnect"); ok {
+ updates["autoconnect"] = autoconnect
+ }
+ if data, ok := models.Get[map[string]any](req, "data"); ok {
+ updates["data"] = data
+ }
+
+ if len(updates) == 0 {
+ models.RespondError(conn, req.ID, "no updates provided")
+ return
+ }
+
+ if err := manager.UpdateVPNConfig(connUUID, updates); err != nil {
+ log.Warnf("handleUpdateVPNConfig: failed to update: %v", err)
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to update VPN config: %v", err))
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "VPN config updated"})
+}
+
+func handleDeleteVPN(conn net.Conn, req models.Request, manager *Manager) {
+ uuidOrName, ok := params.StringAlt(req.Params, "uuid", "name", "uuidOrName")
+ if !ok {
+ models.RespondError(conn, req.ID, "missing 'uuid', 'name', or 'uuidOrName' parameter")
+ return
+ }
+
+ if err := manager.DeleteVPN(uuidOrName); err != nil {
+ log.Warnf("handleDeleteVPN: failed to delete: %v", err)
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to delete VPN: %v", err))
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "VPN deleted"})
+}
+
+func handleSetVPNCredentials(conn net.Conn, req models.Request, manager *Manager) {
+ connUUID, err := params.String(req.Params, "uuid")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ username := params.StringOpt(req.Params, "username", "")
+ password := params.StringOpt(req.Params, "password", "")
+ save := params.BoolOpt(req.Params, "save", true)
+
+ if err := manager.SetVPNCredentials(connUUID, username, password, save); err != nil {
+ log.Warnf("handleSetVPNCredentials: failed to set credentials: %v", err)
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to set VPN credentials: %v", err))
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "VPN credentials set"})
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/handlers_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/handlers_test.go
new file mode 100644
index 0000000..ea8ae33
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/handlers_test.go
@@ -0,0 +1,269 @@
+package network
+
+import (
+ "bytes"
+ "encoding/json"
+ "net"
+ "testing"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+type mockNetConn struct {
+ net.Conn
+ readBuf *bytes.Buffer
+ writeBuf *bytes.Buffer
+ closed bool
+}
+
+func newMockNetConn() *mockNetConn {
+ return &mockNetConn{
+ readBuf: &bytes.Buffer{},
+ writeBuf: &bytes.Buffer{},
+ }
+}
+
+func (m *mockNetConn) Read(b []byte) (n int, err error) {
+ return m.readBuf.Read(b)
+}
+
+func (m *mockNetConn) Write(b []byte) (n int, err error) {
+ return m.writeBuf.Write(b)
+}
+
+func (m *mockNetConn) Close() error {
+ m.closed = true
+ return nil
+}
+
+func TestRespondError_Network(t *testing.T) {
+ conn := newMockNetConn()
+ models.RespondError(conn, 123, "test error")
+
+ var resp models.Response[any]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Equal(t, "test error", resp.Error)
+ assert.Nil(t, resp.Result)
+}
+
+func TestRespond_Network(t *testing.T) {
+ conn := newMockNetConn()
+ result := models.SuccessResult{Success: true, Message: "test"}
+ models.Respond(conn, 123, result)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Empty(t, resp.Error)
+ require.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+ assert.Equal(t, "test", resp.Result.Message)
+}
+
+func TestHandleGetState(t *testing.T) {
+ manager := &Manager{
+ state: &NetworkState{
+ NetworkStatus: StatusWiFi,
+ WiFiSSID: "TestNetwork",
+ WiFiConnected: true,
+ },
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{ID: 123, Method: "network.getState"}
+
+ handleGetState(conn, req, manager)
+
+ var resp models.Response[NetworkState]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Empty(t, resp.Error)
+ require.NotNil(t, resp.Result)
+ assert.Equal(t, StatusWiFi, resp.Result.NetworkStatus)
+ assert.Equal(t, "TestNetwork", resp.Result.WiFiSSID)
+}
+
+func TestHandleGetWiFiNetworks(t *testing.T) {
+ manager := &Manager{
+ state: &NetworkState{
+ WiFiNetworks: []WiFiNetwork{
+ {SSID: "Network1", Signal: 90},
+ {SSID: "Network2", Signal: 80},
+ },
+ },
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{ID: 123, Method: "network.wifi.networks"}
+
+ handleGetWiFiNetworks(conn, req, manager)
+
+ var resp models.Response[[]WiFiNetwork]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Empty(t, resp.Error)
+ require.NotNil(t, resp.Result)
+ assert.Len(t, *resp.Result, 2)
+ assert.Equal(t, "Network1", (*resp.Result)[0].SSID)
+}
+
+func TestHandleConnectWiFi(t *testing.T) {
+ t.Run("missing ssid parameter", func(t *testing.T) {
+ manager := &Manager{
+ state: &NetworkState{},
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{
+ ID: 123,
+ Method: "network.wifi.connect",
+ Params: map[string]any{},
+ }
+
+ handleConnectWiFi(conn, req, manager)
+
+ var resp models.Response[any]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "missing or invalid 'ssid' parameter")
+ })
+}
+
+func TestHandleSetPreference(t *testing.T) {
+ t.Run("missing preference parameter", func(t *testing.T) {
+ manager := &Manager{
+ state: &NetworkState{},
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{
+ ID: 123,
+ Method: "network.preference.set",
+ Params: map[string]any{},
+ }
+
+ handleSetPreference(conn, req, manager)
+
+ var resp models.Response[any]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "missing or invalid 'preference' parameter")
+ })
+}
+
+func TestHandleGetNetworkInfo(t *testing.T) {
+ t.Run("missing ssid parameter", func(t *testing.T) {
+ manager := &Manager{
+ state: &NetworkState{},
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{
+ ID: 123,
+ Method: "network.info",
+ Params: map[string]any{},
+ }
+
+ handleGetNetworkInfo(conn, req, manager)
+
+ var resp models.Response[any]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "missing or invalid 'ssid' parameter")
+ })
+}
+
+func TestHandleRequest(t *testing.T) {
+ manager := &Manager{
+ state: &NetworkState{
+ NetworkStatus: StatusWiFi,
+ },
+ }
+
+ t.Run("unknown method", func(t *testing.T) {
+ conn := newMockNetConn()
+ req := models.Request{
+ ID: 123,
+ Method: "network.unknown",
+ }
+
+ HandleRequest(conn, req, manager)
+
+ var resp models.Response[any]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "unknown method")
+ })
+
+ t.Run("valid method - getState", func(t *testing.T) {
+ conn := newMockNetConn()
+ req := models.Request{
+ ID: 123,
+ Method: "network.getState",
+ }
+
+ HandleRequest(conn, req, manager)
+
+ var resp models.Response[NetworkState]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Empty(t, resp.Error)
+ })
+}
+
+func TestHandleSubscribe(t *testing.T) {
+ // This test is complex due to the streaming nature of subscriptions
+ // Better suited as an integration test
+ t.Skip("Subscription test requires connection lifecycle management - integration test needed")
+}
+
+func TestManager_Subscribe_Unsubscribe(t *testing.T) {
+ manager := &Manager{
+ state: &NetworkState{},
+ }
+
+ t.Run("subscribe creates channel", func(t *testing.T) {
+ ch := manager.Subscribe("client1")
+ assert.NotNil(t, ch)
+ count := 0
+ manager.subscribers.Range(func(key string, ch chan NetworkState) bool {
+ count++
+ return true
+ })
+ assert.Equal(t, 1, count)
+ })
+
+ t.Run("unsubscribe removes channel", func(t *testing.T) {
+ manager.Unsubscribe("client1")
+ count := 0
+ manager.subscribers.Range(func(key string, ch chan NetworkState) bool { count++; return true })
+ assert.Equal(t, 0, count)
+ })
+
+ t.Run("unsubscribe non-existent client is safe", func(t *testing.T) {
+ assert.NotPanics(t, func() {
+ manager.Unsubscribe("non-existent")
+ })
+ })
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/helpers.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/helpers.go
new file mode 100644
index 0000000..4669f52
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/helpers.go
@@ -0,0 +1,53 @@
+package network
+
+import "sort"
+
+func frequencyToChannel(freq uint32) uint32 {
+ if freq >= 2412 && freq <= 2484 {
+ if freq == 2484 {
+ return 14
+ }
+ return (freq-2412)/5 + 1
+ }
+
+ if freq >= 5170 && freq <= 5825 {
+ return (freq-5170)/5 + 34
+ }
+
+ if freq >= 5955 && freq <= 7115 {
+ return (freq-5955)/5 + 1
+ }
+
+ return 0
+}
+
+func sortWiFiNetworks(networks []WiFiNetwork) {
+ sort.Slice(networks, func(i, j int) bool {
+ if networks[i].Connected && !networks[j].Connected {
+ return true
+ }
+ if !networks[i].Connected && networks[j].Connected {
+ return false
+ }
+
+ if networks[i].Saved && !networks[j].Saved {
+ return true
+ }
+ if !networks[i].Saved && networks[j].Saved {
+ return false
+ }
+
+ if !networks[i].Secured && networks[j].Secured {
+ if networks[i].Signal >= 50 {
+ return true
+ }
+ }
+ if networks[i].Secured && !networks[j].Secured {
+ if networks[j].Signal >= 50 {
+ return false
+ }
+ }
+
+ return networks[i].Signal > networks[j].Signal
+ })
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/manager.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/manager.go
new file mode 100644
index 0000000..5c6657e
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/manager.go
@@ -0,0 +1,626 @@
+package network
+
+import (
+ "fmt"
+ "sync"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "github.com/yeqown/go-qrcode/v2"
+ "github.com/yeqown/go-qrcode/writer/standard"
+)
+
+func NewManager() (*Manager, error) {
+ detection, err := DetectNetworkStack()
+ if err != nil {
+ return nil, fmt.Errorf("failed to detect network stack: %w", err)
+ }
+
+ log.Infof("Network backend detection: %s", detection.ChosenReason)
+
+ var backend Backend
+ switch detection.Backend {
+ case BackendNetworkManager:
+ nm, err := NewNetworkManagerBackend()
+ if err != nil {
+ return nil, fmt.Errorf("failed to create NetworkManager backend: %w", err)
+ }
+ backend = nm
+
+ case BackendIwd:
+ iwd, err := NewIWDBackend()
+ if err != nil {
+ return nil, fmt.Errorf("failed to create iwd backend: %w", err)
+ }
+ backend = iwd
+
+ case BackendNetworkd:
+ if detection.HasIwd && !detection.HasNM {
+ wifi, err := NewIWDBackend()
+ if err != nil {
+ return nil, fmt.Errorf("failed to create iwd backend: %w", err)
+ }
+ l3, err := NewSystemdNetworkdBackend()
+ if err != nil {
+ return nil, fmt.Errorf("failed to create networkd backend: %w", err)
+ }
+ hybrid, err := NewHybridIwdNetworkdBackend(wifi, l3)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create hybrid backend: %w", err)
+ }
+ backend = hybrid
+ } else {
+ nd, err := NewSystemdNetworkdBackend()
+ if err != nil {
+ return nil, fmt.Errorf("failed to create networkd backend: %w", err)
+ }
+ backend = nd
+ }
+
+ default:
+ return nil, fmt.Errorf("no supported network backend found: %s", detection.ChosenReason)
+ }
+
+ m := &Manager{
+ backend: backend,
+ state: &NetworkState{
+ NetworkStatus: StatusDisconnected,
+ Preference: PreferenceAuto,
+ WiFiNetworks: []WiFiNetwork{},
+ },
+ stateMutex: sync.RWMutex{},
+
+ stopChan: make(chan struct{}),
+ dirty: make(chan struct{}, 1),
+ }
+
+ broker := NewSubscriptionBroker(m.broadcastCredentialPrompt)
+ if err := backend.SetPromptBroker(broker); err != nil {
+ return nil, fmt.Errorf("failed to set prompt broker: %w", err)
+ }
+
+ if err := backend.Initialize(); err != nil {
+ return nil, fmt.Errorf("failed to initialize backend: %w", err)
+ }
+
+ if err := m.syncStateFromBackend(); err != nil {
+ return nil, fmt.Errorf("failed to sync initial state: %w", err)
+ }
+
+ m.notifierWg.Add(1)
+ go m.notifier()
+
+ if err := backend.StartMonitoring(m.onBackendStateChange); err != nil {
+ m.Close()
+ return nil, fmt.Errorf("failed to start monitoring: %w", err)
+ }
+
+ return m, nil
+}
+
+func (m *Manager) syncStateFromBackend() error {
+ backendState, err := m.backend.GetCurrentState()
+ if err != nil {
+ return err
+ }
+
+ m.stateMutex.Lock()
+ m.state.Backend = backendState.Backend
+ m.state.NetworkStatus = backendState.NetworkStatus
+ m.state.EthernetIP = backendState.EthernetIP
+ m.state.EthernetDevice = backendState.EthernetDevice
+ m.state.EthernetConnected = backendState.EthernetConnected
+ m.state.EthernetConnectionUuid = backendState.EthernetConnectionUuid
+ m.state.EthernetDevices = backendState.EthernetDevices
+ m.state.WiFiIP = backendState.WiFiIP
+ m.state.WiFiDevice = backendState.WiFiDevice
+ m.state.WiFiConnected = backendState.WiFiConnected
+ m.state.WiFiEnabled = backendState.WiFiEnabled
+ m.state.WiFiSSID = backendState.WiFiSSID
+ m.state.WiFiBSSID = backendState.WiFiBSSID
+ m.state.WiFiSignal = backendState.WiFiSignal
+ m.state.WiFiNetworks = backendState.WiFiNetworks
+ m.state.WiFiDevices = backendState.WiFiDevices
+ m.state.WiredConnections = backendState.WiredConnections
+ m.state.VPNProfiles = backendState.VPNProfiles
+ m.state.VPNActive = backendState.VPNActive
+ m.state.IsConnecting = backendState.IsConnecting
+ m.state.ConnectingSSID = backendState.ConnectingSSID
+ m.state.ConnectingDevice = backendState.ConnectingDevice
+ m.state.LastError = backendState.LastError
+ m.stateMutex.Unlock()
+
+ return nil
+}
+
+func (m *Manager) onBackendStateChange() {
+ if err := m.syncStateFromBackend(); err != nil {
+ log.Errorf("failed to sync state from backend: %v", err)
+ }
+ m.notifySubscribers()
+}
+
+func signalChangeSignificant(old, new uint8) bool {
+ if old == 0 || new == 0 {
+ return true
+ }
+ diff := int(new) - int(old)
+ if diff < 0 {
+ diff = -diff
+ }
+ return diff >= 5
+}
+
+func (m *Manager) snapshotState() NetworkState {
+ m.stateMutex.RLock()
+ defer m.stateMutex.RUnlock()
+ s := *m.state
+ s.WiFiNetworks = append([]WiFiNetwork(nil), m.state.WiFiNetworks...)
+ s.WiFiDevices = append([]WiFiDevice(nil), m.state.WiFiDevices...)
+ s.WiredConnections = append([]WiredConnection(nil), m.state.WiredConnections...)
+ s.EthernetDevices = append([]EthernetDevice(nil), m.state.EthernetDevices...)
+ s.VPNProfiles = append([]VPNProfile(nil), m.state.VPNProfiles...)
+ s.VPNActive = append([]VPNActive(nil), m.state.VPNActive...)
+ return s
+}
+
+func stateChangedMeaningfully(old, new *NetworkState) bool {
+ if old.NetworkStatus != new.NetworkStatus {
+ return true
+ }
+ if old.Preference != new.Preference {
+ return true
+ }
+ if old.EthernetConnected != new.EthernetConnected {
+ return true
+ }
+ if old.EthernetIP != new.EthernetIP {
+ return true
+ }
+ if old.WiFiConnected != new.WiFiConnected {
+ return true
+ }
+ if old.WiFiEnabled != new.WiFiEnabled {
+ return true
+ }
+ if old.WiFiSSID != new.WiFiSSID {
+ return true
+ }
+ if old.WiFiBSSID != new.WiFiBSSID {
+ return true
+ }
+ if old.WiFiIP != new.WiFiIP {
+ return true
+ }
+ if !signalChangeSignificant(old.WiFiSignal, new.WiFiSignal) {
+ if old.WiFiSignal != new.WiFiSignal {
+ return false
+ }
+ } else if old.WiFiSignal != new.WiFiSignal {
+ return true
+ }
+ if old.IsConnecting != new.IsConnecting {
+ return true
+ }
+ if old.ConnectingSSID != new.ConnectingSSID {
+ return true
+ }
+ if old.LastError != new.LastError {
+ return true
+ }
+ if len(old.WiFiNetworks) != len(new.WiFiNetworks) {
+ return true
+ }
+ if len(old.WiFiDevices) != len(new.WiFiDevices) {
+ return true
+ }
+ if len(old.WiredConnections) != len(new.WiredConnections) {
+ return true
+ }
+ if len(old.EthernetDevices) != len(new.EthernetDevices) {
+ return true
+ }
+
+ for i := range old.WiFiNetworks {
+ oldNet := &old.WiFiNetworks[i]
+ newNet := &new.WiFiNetworks[i]
+ if oldNet.SSID != newNet.SSID {
+ return true
+ }
+ if oldNet.Connected != newNet.Connected {
+ return true
+ }
+ if oldNet.Saved != newNet.Saved {
+ return true
+ }
+ if oldNet.Autoconnect != newNet.Autoconnect {
+ return true
+ }
+ }
+
+ for i := range old.WiredConnections {
+ oldNet := &old.WiredConnections[i]
+ newNet := &new.WiredConnections[i]
+ if oldNet.ID != newNet.ID {
+ return true
+ }
+ if oldNet.IsActive != newNet.IsActive {
+ return true
+ }
+ }
+
+ for i := range old.EthernetDevices {
+ oldDev := &old.EthernetDevices[i]
+ newDev := &new.EthernetDevices[i]
+ if oldDev.Name != newDev.Name {
+ return true
+ }
+ if oldDev.Connected != newDev.Connected {
+ return true
+ }
+ if oldDev.State != newDev.State {
+ return true
+ }
+ if oldDev.IP != newDev.IP {
+ return true
+ }
+ }
+
+ // Check VPN profiles count
+ if len(old.VPNProfiles) != len(new.VPNProfiles) {
+ return true
+ }
+
+ // Check active VPN connections count or state
+ if len(old.VPNActive) != len(new.VPNActive) {
+ return true
+ }
+
+ // Check if any active VPN changed
+ for i := range old.VPNActive {
+ oldVPN := &old.VPNActive[i]
+ newVPN := &new.VPNActive[i]
+ if oldVPN.UUID != newVPN.UUID {
+ return true
+ }
+ if oldVPN.State != newVPN.State {
+ return true
+ }
+ }
+
+ return false
+}
+
+func (m *Manager) GetState() NetworkState {
+ return m.snapshotState()
+}
+
+func (m *Manager) Subscribe(id string) chan NetworkState {
+ ch := make(chan NetworkState, 64)
+ m.subscribers.Store(id, ch)
+ return ch
+}
+
+func (m *Manager) Unsubscribe(id string) {
+ if val, ok := m.subscribers.LoadAndDelete(id); ok {
+ close(val)
+ }
+}
+
+func (m *Manager) SubscribeCredentials(id string) chan CredentialPrompt {
+ ch := make(chan CredentialPrompt, 16)
+ m.credentialSubscribers.Store(id, ch)
+ return ch
+}
+
+func (m *Manager) UnsubscribeCredentials(id string) {
+ if ch, ok := m.credentialSubscribers.LoadAndDelete(id); ok {
+ close(ch)
+ }
+}
+
+func (m *Manager) broadcastCredentialPrompt(prompt CredentialPrompt) {
+ m.credentialSubscribers.Range(func(key string, ch chan CredentialPrompt) bool {
+ select {
+ case ch <- prompt:
+ default:
+ }
+ return true
+ })
+}
+
+func (m *Manager) notifier() {
+ defer m.notifierWg.Done()
+ const minGap = 100 * time.Millisecond
+ timer := time.NewTimer(minGap)
+ timer.Stop()
+ var pending bool
+ for {
+ select {
+ case <-m.stopChan:
+ timer.Stop()
+ return
+ case <-m.dirty:
+ if pending {
+ continue
+ }
+ pending = true
+ timer.Reset(minGap)
+ case <-timer.C:
+ if !pending {
+ continue
+ }
+
+ currentState := m.snapshotState()
+
+ if m.lastNotifiedState != nil && !stateChangedMeaningfully(m.lastNotifiedState, &currentState) {
+ pending = false
+ continue
+ }
+
+ m.subscribers.Range(func(key string, ch chan NetworkState) bool {
+ select {
+ case ch <- currentState:
+ default:
+ }
+ return true
+ })
+
+ stateCopy := currentState
+ m.lastNotifiedState = &stateCopy
+ pending = false
+ }
+ }
+}
+
+func (m *Manager) notifySubscribers() {
+ select {
+ case m.dirty <- struct{}{}:
+ default:
+ }
+}
+
+func (m *Manager) SetPromptBroker(broker PromptBroker) error {
+ return m.backend.SetPromptBroker(broker)
+}
+
+func (m *Manager) SubmitCredentials(token string, secrets map[string]string, save bool) error {
+ return m.backend.SubmitCredentials(token, secrets, save)
+}
+
+func (m *Manager) CancelCredentials(token string) error {
+ return m.backend.CancelCredentials(token)
+}
+
+func (m *Manager) GetPromptBroker() PromptBroker {
+ return m.backend.GetPromptBroker()
+}
+
+func (m *Manager) Close() {
+ close(m.stopChan)
+ m.notifierWg.Wait()
+
+ if m.backend != nil {
+ m.backend.Close()
+ }
+
+ m.subscribers.Range(func(key string, ch chan NetworkState) bool {
+ close(ch)
+ m.subscribers.Delete(key)
+ return true
+ })
+}
+
+func (m *Manager) ScanWiFi() error {
+ return m.backend.ScanWiFi()
+}
+
+func (m *Manager) GetWiFiNetworks() []WiFiNetwork {
+ m.stateMutex.RLock()
+ defer m.stateMutex.RUnlock()
+ networks := make([]WiFiNetwork, len(m.state.WiFiNetworks))
+ copy(networks, m.state.WiFiNetworks)
+ return networks
+}
+
+func (m *Manager) GetNetworkInfo(ssid string) (*WiFiNetwork, error) {
+ m.stateMutex.RLock()
+ defer m.stateMutex.RUnlock()
+
+ for _, network := range m.state.WiFiNetworks {
+ if network.SSID == ssid {
+ return &network, nil
+ }
+ }
+
+ return nil, fmt.Errorf("network not found: %s", ssid)
+}
+
+func (m *Manager) GetNetworkInfoDetailed(ssid string) (*NetworkInfoResponse, error) {
+ return m.backend.GetWiFiNetworkDetails(ssid)
+}
+
+func (m *Manager) GetNetworkQRCode(ssid string) ([2]string, error) {
+ content, err := m.backend.GetWiFiQRCodeContent(ssid)
+ if err != nil {
+ return [2]string{}, err
+ }
+
+ qrc, err := qrcode.New(content)
+ if err != nil {
+ return [2]string{}, fmt.Errorf("failed to create QR code for `%s`: %w", ssid, err)
+ }
+
+ pathThemed, pathNormal := qrCodePaths(ssid)
+
+ wThemed, err := standard.New(
+ pathThemed,
+ standard.WithBuiltinImageEncoder(standard.PNG_FORMAT),
+ standard.WithBgTransparent(),
+ standard.WithFgColorRGBHex("#ffffff"),
+ )
+ if err != nil {
+ return [2]string{}, fmt.Errorf("failed to create QR code writer: %w", err)
+ }
+ if err := qrc.Save(wThemed); err != nil {
+ return [2]string{}, fmt.Errorf("failed to save QR code for `%s`: %w", ssid, err)
+ }
+
+ wNormal, err := standard.New(pathNormal, standard.WithBuiltinImageEncoder(standard.PNG_FORMAT))
+ if err != nil {
+ return [2]string{}, fmt.Errorf("failed to create QR code writer: %w", err)
+ }
+ if err := qrc.Save(wNormal); err != nil {
+ return [2]string{}, fmt.Errorf("failed to save QR code for `%s`: %w", ssid, err)
+ }
+
+ return [2]string{pathThemed, pathNormal}, nil
+}
+
+func (m *Manager) ToggleWiFi() error {
+ enabled, err := m.backend.GetWiFiEnabled()
+ if err != nil {
+ return fmt.Errorf("failed to get WiFi state: %w", err)
+ }
+
+ err = m.backend.SetWiFiEnabled(!enabled)
+ if err != nil {
+ return fmt.Errorf("failed to toggle WiFi: %w", err)
+ }
+
+ return nil
+}
+
+func (m *Manager) EnableWiFi() error {
+ err := m.backend.SetWiFiEnabled(true)
+ if err != nil {
+ return fmt.Errorf("failed to enable WiFi: %w", err)
+ }
+
+ return nil
+}
+
+func (m *Manager) DisableWiFi() error {
+ err := m.backend.SetWiFiEnabled(false)
+ if err != nil {
+ return fmt.Errorf("failed to disable WiFi: %w", err)
+ }
+
+ return nil
+}
+
+func (m *Manager) ConnectWiFi(req ConnectionRequest) error {
+ return m.backend.ConnectWiFi(req)
+}
+
+func (m *Manager) DisconnectWiFi() error {
+ return m.backend.DisconnectWiFi()
+}
+
+func (m *Manager) ForgetWiFiNetwork(ssid string) error {
+ return m.backend.ForgetWiFiNetwork(ssid)
+}
+
+func (m *Manager) GetWiredConfigs() []WiredConnection {
+ m.stateMutex.RLock()
+ defer m.stateMutex.RUnlock()
+ configs := make([]WiredConnection, len(m.state.WiredConnections))
+ copy(configs, m.state.WiredConnections)
+ return configs
+}
+
+func (m *Manager) GetWiredNetworkInfoDetailed(uuid string) (*WiredNetworkInfoResponse, error) {
+ return m.backend.GetWiredNetworkDetails(uuid)
+}
+
+func (m *Manager) ConnectEthernet() error {
+ return m.backend.ConnectEthernet()
+}
+
+func (m *Manager) DisconnectEthernet() error {
+ return m.backend.DisconnectEthernet()
+}
+
+func (m *Manager) DisconnectEthernetDevice(device string) error {
+ return m.backend.DisconnectEthernetDevice(device)
+}
+
+func (m *Manager) GetEthernetDevices() []EthernetDevice {
+ m.stateMutex.RLock()
+ defer m.stateMutex.RUnlock()
+ devices := make([]EthernetDevice, len(m.state.EthernetDevices))
+ copy(devices, m.state.EthernetDevices)
+ return devices
+}
+
+func (m *Manager) activateConnection(uuid string) error {
+ return m.backend.ActivateWiredConnection(uuid)
+}
+
+func (m *Manager) ListVPNProfiles() ([]VPNProfile, error) {
+ return m.backend.ListVPNProfiles()
+}
+
+func (m *Manager) ListActiveVPN() ([]VPNActive, error) {
+ return m.backend.ListActiveVPN()
+}
+
+func (m *Manager) ConnectVPN(uuidOrName string, singleActive bool) error {
+ return m.backend.ConnectVPN(uuidOrName, singleActive)
+}
+
+func (m *Manager) DisconnectVPN(uuidOrName string) error {
+ return m.backend.DisconnectVPN(uuidOrName)
+}
+
+func (m *Manager) DisconnectAllVPN() error {
+ return m.backend.DisconnectAllVPN()
+}
+
+func (m *Manager) ClearVPNCredentials(uuidOrName string) error {
+ return m.backend.ClearVPNCredentials(uuidOrName)
+}
+
+func (m *Manager) ListVPNPlugins() ([]VPNPlugin, error) {
+ return m.backend.ListVPNPlugins()
+}
+
+func (m *Manager) ImportVPN(filePath string, name string) (*VPNImportResult, error) {
+ return m.backend.ImportVPN(filePath, name)
+}
+
+func (m *Manager) GetVPNConfig(uuidOrName string) (*VPNConfig, error) {
+ return m.backend.GetVPNConfig(uuidOrName)
+}
+
+func (m *Manager) UpdateVPNConfig(uuid string, updates map[string]any) error {
+ return m.backend.UpdateVPNConfig(uuid, updates)
+}
+
+func (m *Manager) DeleteVPN(uuidOrName string) error {
+ return m.backend.DeleteVPN(uuidOrName)
+}
+
+func (m *Manager) SetVPNCredentials(uuid, username, password string, save bool) error {
+ return m.backend.SetVPNCredentials(uuid, username, password, save)
+}
+
+func (m *Manager) SetWiFiAutoconnect(ssid string, autoconnect bool) error {
+ return m.backend.SetWiFiAutoconnect(ssid, autoconnect)
+}
+
+func (m *Manager) GetWiFiDevices() []WiFiDevice {
+ m.stateMutex.RLock()
+ defer m.stateMutex.RUnlock()
+ devices := make([]WiFiDevice, len(m.state.WiFiDevices))
+ copy(devices, m.state.WiFiDevices)
+ return devices
+}
+
+func (m *Manager) ScanWiFiDevice(device string) error {
+ return m.backend.ScanWiFiDevice(device)
+}
+
+func (m *Manager) DisconnectWiFiDevice(device string) error {
+ return m.backend.DisconnectWiFiDevice(device)
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/manager_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/manager_test.go
new file mode 100644
index 0000000..79545c5
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/manager_test.go
@@ -0,0 +1,191 @@
+package network
+
+import (
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestManager_GetState(t *testing.T) {
+ state := &NetworkState{
+ NetworkStatus: StatusWiFi,
+ WiFiSSID: "TestNetwork",
+ WiFiConnected: true,
+ }
+
+ manager := &Manager{
+ state: state,
+ stateMutex: sync.RWMutex{},
+ }
+
+ result := manager.GetState()
+ assert.Equal(t, StatusWiFi, result.NetworkStatus)
+ assert.Equal(t, "TestNetwork", result.WiFiSSID)
+ assert.True(t, result.WiFiConnected)
+}
+
+func TestManager_NotifySubscribers(t *testing.T) {
+ manager := &Manager{
+ state: &NetworkState{
+ NetworkStatus: StatusWiFi,
+ },
+ stateMutex: sync.RWMutex{},
+ stopChan: make(chan struct{}),
+ dirty: make(chan struct{}, 1),
+ }
+ manager.notifierWg.Add(1)
+ go manager.notifier()
+
+ ch := make(chan NetworkState, 10)
+ manager.subscribers.Store("test-client", ch)
+
+ manager.notifySubscribers()
+
+ select {
+ case state := <-ch:
+ assert.Equal(t, StatusWiFi, state.NetworkStatus)
+ case <-time.After(200 * time.Millisecond):
+ t.Fatal("did not receive state update")
+ }
+
+ close(manager.stopChan)
+ manager.notifierWg.Wait()
+}
+
+func TestManager_NotifySubscribers_Debounce(t *testing.T) {
+ manager := &Manager{
+ state: &NetworkState{
+ NetworkStatus: StatusWiFi,
+ },
+ stateMutex: sync.RWMutex{},
+ stopChan: make(chan struct{}),
+ dirty: make(chan struct{}, 1),
+ }
+ manager.notifierWg.Add(1)
+ go manager.notifier()
+
+ ch := make(chan NetworkState, 10)
+ manager.subscribers.Store("test-client", ch)
+
+ manager.notifySubscribers()
+ manager.notifySubscribers()
+ manager.notifySubscribers()
+
+ receivedCount := 0
+ timeout := time.After(200 * time.Millisecond)
+ for {
+ select {
+ case <-ch:
+ receivedCount++
+ case <-timeout:
+ assert.Equal(t, 1, receivedCount, "should receive exactly one debounced update")
+ close(manager.stopChan)
+ manager.notifierWg.Wait()
+ return
+ }
+ }
+}
+
+func TestManager_Close(t *testing.T) {
+ manager := &Manager{
+ state: &NetworkState{},
+ stateMutex: sync.RWMutex{},
+ stopChan: make(chan struct{}),
+ }
+
+ ch1 := make(chan NetworkState, 1)
+ ch2 := make(chan NetworkState, 1)
+ manager.subscribers.Store("client1", ch1)
+ manager.subscribers.Store("client2", ch2)
+
+ manager.Close()
+
+ select {
+ case <-manager.stopChan:
+ case <-time.After(100 * time.Millisecond):
+ t.Fatal("stopChan not closed")
+ }
+
+ _, ok1 := <-ch1
+ _, ok2 := <-ch2
+ assert.False(t, ok1, "ch1 should be closed")
+ assert.False(t, ok2, "ch2 should be closed")
+
+ count := 0
+ manager.subscribers.Range(func(key string, ch chan NetworkState) bool { count++; return true })
+ assert.Equal(t, 0, count)
+}
+
+func TestManager_Subscribe(t *testing.T) {
+ manager := &Manager{
+ state: &NetworkState{},
+ }
+
+ ch := manager.Subscribe("test-client")
+ assert.NotNil(t, ch)
+ assert.Equal(t, 64, cap(ch))
+
+ _, exists := manager.subscribers.Load("test-client")
+ assert.True(t, exists)
+}
+
+func TestManager_Unsubscribe(t *testing.T) {
+ manager := &Manager{
+ state: &NetworkState{},
+ }
+
+ ch := manager.Subscribe("test-client")
+
+ manager.Unsubscribe("test-client")
+
+ _, ok := <-ch
+ assert.False(t, ok)
+
+ _, exists := manager.subscribers.Load("test-client")
+ assert.False(t, exists)
+}
+
+func TestNewManager(t *testing.T) {
+ t.Run("attempts to create manager", func(t *testing.T) {
+ manager, err := NewManager()
+ if err != nil {
+ assert.Nil(t, manager)
+ } else {
+ assert.NotNil(t, manager)
+ assert.NotNil(t, manager.state)
+ assert.NotNil(t, manager.subscribers)
+ assert.NotNil(t, manager.stopChan)
+
+ manager.Close()
+ }
+ })
+}
+
+func TestManager_GetState_ThreadSafe(t *testing.T) {
+ manager := &Manager{
+ state: &NetworkState{
+ NetworkStatus: StatusWiFi,
+ WiFiSSID: "TestNetwork",
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ done := make(chan bool)
+ for i := 0; i < 10; i++ {
+ go func() {
+ state := manager.GetState()
+ assert.Equal(t, StatusWiFi, state.NetworkStatus)
+ done <- true
+ }()
+ }
+
+ for i := 0; i < 10; i++ {
+ select {
+ case <-done:
+ case <-time.After(1 * time.Second):
+ t.Fatal("timeout waiting for goroutines")
+ }
+ }
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/monitor.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/monitor.go
new file mode 100644
index 0000000..1ae2e9d
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/monitor.go
@@ -0,0 +1 @@
+package network
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/priority.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/priority.go
new file mode 100644
index 0000000..8ee7fdf
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/priority.go
@@ -0,0 +1,187 @@
+package network
+
+import (
+ "fmt"
+ "os/exec"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "github.com/godbus/dbus/v5"
+)
+
+const (
+ priorityHigh = int32(100)
+ priorityLow = int32(10)
+ priorityDefault = int32(0)
+
+ metricPreferred = int64(100)
+ metricNonPreferred = int64(300)
+ metricDefault = int64(100)
+)
+
+func (m *Manager) SetConnectionPreference(pref ConnectionPreference) error {
+ switch pref {
+ case PreferenceWiFi, PreferenceEthernet, PreferenceAuto:
+ default:
+ return fmt.Errorf("invalid preference: %s", pref)
+ }
+
+ m.stateMutex.Lock()
+ m.state.Preference = pref
+ m.stateMutex.Unlock()
+
+ if _, ok := m.backend.(*NetworkManagerBackend); !ok {
+ m.notifySubscribers()
+ return nil
+ }
+
+ switch pref {
+ case PreferenceWiFi:
+ return m.prioritizeWiFi()
+ case PreferenceEthernet:
+ return m.prioritizeEthernet()
+ case PreferenceAuto:
+ return m.balancePriorities()
+ }
+
+ return nil
+}
+
+func (m *Manager) prioritizeWiFi() error {
+ if err := m.setConnectionPriority("802-11-wireless", priorityHigh, metricPreferred); err != nil {
+ log.Warnf("Failed to set WiFi priority: %v", err)
+ }
+
+ if err := m.setConnectionPriority("802-3-ethernet", priorityLow, metricNonPreferred); err != nil {
+ log.Warnf("Failed to set Ethernet priority: %v", err)
+ }
+
+ m.reapplyActiveConnections()
+ m.notifySubscribers()
+ return nil
+}
+
+func (m *Manager) prioritizeEthernet() error {
+ if err := m.setConnectionPriority("802-3-ethernet", priorityHigh, metricPreferred); err != nil {
+ log.Warnf("Failed to set Ethernet priority: %v", err)
+ }
+
+ if err := m.setConnectionPriority("802-11-wireless", priorityLow, metricNonPreferred); err != nil {
+ log.Warnf("Failed to set WiFi priority: %v", err)
+ }
+
+ m.reapplyActiveConnections()
+ m.notifySubscribers()
+ return nil
+}
+
+func (m *Manager) balancePriorities() error {
+ if err := m.setConnectionPriority("802-3-ethernet", priorityDefault, metricDefault); err != nil {
+ log.Warnf("Failed to reset Ethernet priority: %v", err)
+ }
+
+ if err := m.setConnectionPriority("802-11-wireless", priorityDefault, metricDefault); err != nil {
+ log.Warnf("Failed to reset WiFi priority: %v", err)
+ }
+
+ m.reapplyActiveConnections()
+ m.notifySubscribers()
+ return nil
+}
+
+func (m *Manager) reapplyActiveConnections() {
+ m.stateMutex.RLock()
+ ethDev := m.state.EthernetDevice
+ wifiDev := m.state.WiFiDevice
+ m.stateMutex.RUnlock()
+
+ if ethDev != "" {
+ exec.Command("nmcli", "dev", "reapply", ethDev).Run()
+ }
+ if wifiDev != "" {
+ exec.Command("nmcli", "dev", "reapply", wifiDev).Run()
+ }
+}
+
+func (m *Manager) setConnectionPriority(connType string, autoconnectPriority int32, routeMetric int64) error {
+ conn, err := dbus.ConnectSystemBus()
+ if err != nil {
+ return fmt.Errorf("failed to connect to system bus: %w", err)
+ }
+ defer conn.Close()
+
+ settingsObj := conn.Object("org.freedesktop.NetworkManager", "/org/freedesktop/NetworkManager/Settings")
+
+ var connPaths []dbus.ObjectPath
+ if err := settingsObj.Call("org.freedesktop.NetworkManager.Settings.ListConnections", 0).Store(&connPaths); err != nil {
+ return fmt.Errorf("failed to list connections: %w", err)
+ }
+
+ for _, connPath := range connPaths {
+ connObj := conn.Object("org.freedesktop.NetworkManager", connPath)
+
+ var settings map[string]map[string]dbus.Variant
+ if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.GetSettings", 0).Store(&settings); err != nil {
+ continue
+ }
+
+ connSection, ok := settings["connection"]
+ if !ok {
+ continue
+ }
+
+ typeVariant, ok := connSection["type"]
+ if !ok {
+ continue
+ }
+
+ cType, ok := typeVariant.Value().(string)
+ if !ok || cType != connType {
+ continue
+ }
+
+ connName := ""
+ if idVariant, ok := connSection["id"]; ok {
+ connName, _ = idVariant.Value().(string)
+ }
+
+ if connName == "" {
+ continue
+ }
+
+ if err := exec.Command("nmcli", "con", "mod", connName,
+ "connection.autoconnect-priority", fmt.Sprintf("%d", autoconnectPriority),
+ "ipv4.route-metric", fmt.Sprintf("%d", routeMetric),
+ "ipv6.route-metric", fmt.Sprintf("%d", routeMetric)).Run(); err != nil {
+ log.Warnf("Failed to set priority for %s: %v", connName, err)
+ continue
+ }
+
+ log.Infof("Updated %v: autoconnect-priority=%d, route-metric=%d", connName, autoconnectPriority, routeMetric)
+ }
+
+ return nil
+}
+
+func (m *Manager) GetConnectionPreference() ConnectionPreference {
+ m.stateMutex.RLock()
+ defer m.stateMutex.RUnlock()
+ return m.state.Preference
+}
+
+func (m *Manager) WasRecentlyFailed(ssid string) bool {
+ nm, ok := m.backend.(*NetworkManagerBackend)
+ if !ok {
+ return false
+ }
+
+ nm.failedMutex.RLock()
+ defer nm.failedMutex.RUnlock()
+
+ if nm.lastFailedSSID != ssid {
+ return false
+ }
+
+ elapsed := time.Now().Unix() - nm.lastFailedTime
+ return elapsed < 10
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/priority_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/priority_test.go
new file mode 100644
index 0000000..c0c65e3
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/priority_test.go
@@ -0,0 +1,50 @@
+package network
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestManager_SetConnectionPreference(t *testing.T) {
+ t.Run("invalid preference", func(t *testing.T) {
+ manager := &Manager{
+ state: &NetworkState{
+ Preference: PreferenceAuto,
+ },
+ }
+
+ err := manager.SetConnectionPreference(ConnectionPreference("invalid"))
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid preference")
+ })
+}
+
+func TestManager_GetConnectionPreference(t *testing.T) {
+ tests := []struct {
+ name string
+ preference ConnectionPreference
+ }{
+ {"auto", PreferenceAuto},
+ {"wifi", PreferenceWiFi},
+ {"ethernet", PreferenceEthernet},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ manager := &Manager{
+ state: &NetworkState{
+ Preference: tt.preference,
+ },
+ }
+
+ result := manager.GetConnectionPreference()
+ assert.Equal(t, tt.preference, result)
+ })
+ }
+}
+
+// Note: Full testing of priority operations would require mocking NetworkManager
+// D-Bus interfaces. The tests above cover the basic logic and error handling.
+// Integration tests would be needed for complete coverage of network connection
+// priority updates and reactivation.
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/subscription_broker.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/subscription_broker.go
new file mode 100644
index 0000000..1b150a2
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/subscription_broker.go
@@ -0,0 +1,125 @@
+package network
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
+)
+
+type SubscriptionBroker struct {
+ pending syncmap.Map[string, chan PromptReply]
+ requests syncmap.Map[string, PromptRequest]
+ pathSettingToToken syncmap.Map[string, string]
+ broadcastPrompt func(CredentialPrompt)
+}
+
+func NewSubscriptionBroker(broadcastPrompt func(CredentialPrompt)) PromptBroker {
+ return &SubscriptionBroker{
+ broadcastPrompt: broadcastPrompt,
+ }
+}
+
+func (b *SubscriptionBroker) Ask(ctx context.Context, req PromptRequest) (string, error) {
+ pathSettingKey := fmt.Sprintf("%s:%s", req.ConnectionPath, req.SettingName)
+
+ if existingToken, alreadyPending := b.pathSettingToToken.Load(pathSettingKey); alreadyPending {
+ log.Infof("[SubscriptionBroker] Duplicate prompt for %s, returning existing token", pathSettingKey)
+ return existingToken, nil
+ }
+
+ token, err := generateToken()
+ if err != nil {
+ return "", err
+ }
+
+ replyChan := make(chan PromptReply, 1)
+ b.pending.Store(token, replyChan)
+ b.requests.Store(token, req)
+ b.pathSettingToToken.Store(pathSettingKey, token)
+
+ if b.broadcastPrompt != nil {
+ prompt := CredentialPrompt{
+ Token: token,
+ Name: req.Name,
+ SSID: req.SSID,
+ ConnType: req.ConnType,
+ VpnService: req.VpnService,
+ Setting: req.SettingName,
+ Fields: req.Fields,
+ FieldsInfo: req.FieldsInfo,
+ Hints: req.Hints,
+ Reason: req.Reason,
+ ConnectionId: req.ConnectionId,
+ ConnectionUuid: req.ConnectionUuid,
+ }
+ b.broadcastPrompt(prompt)
+ }
+
+ return token, nil
+}
+
+func (b *SubscriptionBroker) Wait(ctx context.Context, token string) (PromptReply, error) {
+ replyChan, exists := b.pending.Load(token)
+ if !exists {
+ return PromptReply{}, fmt.Errorf("unknown token: %s", token)
+ }
+
+ select {
+ case <-ctx.Done():
+ b.cleanup(token)
+ return PromptReply{}, errdefs.ErrSecretPromptTimeout
+ case reply := <-replyChan:
+ b.cleanup(token)
+ if reply.Cancel {
+ return reply, errdefs.ErrSecretPromptCancelled
+ }
+ return reply, nil
+ }
+}
+
+func (b *SubscriptionBroker) Resolve(token string, reply PromptReply) error {
+ replyChan, exists := b.pending.Load(token)
+ if !exists {
+ log.Warnf("[SubscriptionBroker] Resolve: unknown or expired token: %s", token)
+ return fmt.Errorf("unknown or expired token: %s", token)
+ }
+
+ select {
+ case replyChan <- reply:
+ return nil
+ default:
+ log.Warnf("[SubscriptionBroker] Resolve: failed to deliver reply for token %s (channel full or closed)", token)
+ return fmt.Errorf("failed to deliver reply for token: %s", token)
+ }
+}
+
+func (b *SubscriptionBroker) cleanup(token string) {
+ if req, exists := b.requests.Load(token); exists {
+ pathSettingKey := fmt.Sprintf("%s:%s", req.ConnectionPath, req.SettingName)
+ b.pathSettingToToken.Delete(pathSettingKey)
+ }
+
+ b.pending.Delete(token)
+ b.requests.Delete(token)
+}
+
+func (b *SubscriptionBroker) Cancel(path string, setting string) error {
+ pathSettingKey := fmt.Sprintf("%s:%s", path, setting)
+
+ token, exists := b.pathSettingToToken.Load(pathSettingKey)
+ if !exists {
+ log.Infof("[SubscriptionBroker] Cancel: no pending prompt for %s", pathSettingKey)
+ return nil
+ }
+
+ log.Infof("[SubscriptionBroker] Cancelling prompt for %s (token=%s)", pathSettingKey, token)
+
+ reply := PromptReply{
+ Cancel: true,
+ }
+
+ return b.Resolve(token, reply)
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/testing.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/testing.go
new file mode 100644
index 0000000..a6c4a00
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/testing.go
@@ -0,0 +1,14 @@
+package network
+
+// NewTestManager creates a Manager for testing with a provided backend
+func NewTestManager(backend Backend, state *NetworkState) *Manager {
+ if state == nil {
+ state = &NetworkState{}
+ }
+ return &Manager{
+ backend: backend,
+ state: state,
+ stopChan: make(chan struct{}),
+ dirty: make(chan struct{}, 1),
+ }
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/types.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/types.go
new file mode 100644
index 0000000..93448cf
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/types.go
@@ -0,0 +1,267 @@
+package network
+
+import (
+ "sync"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
+ "github.com/godbus/dbus/v5"
+)
+
+type NetworkStatus string
+
+const (
+ StatusDisconnected NetworkStatus = "disconnected"
+ StatusEthernet NetworkStatus = "ethernet"
+ StatusWiFi NetworkStatus = "wifi"
+ StatusVPN NetworkStatus = "vpn"
+)
+
+type ConnectionPreference string
+
+const (
+ PreferenceAuto ConnectionPreference = "auto"
+ PreferenceWiFi ConnectionPreference = "wifi"
+ PreferenceEthernet ConnectionPreference = "ethernet"
+)
+
+type WiFiNetwork struct {
+ SSID string `json:"ssid"`
+ BSSID string `json:"bssid"`
+ Signal uint8 `json:"signal"`
+ Secured bool `json:"secured"`
+ Enterprise bool `json:"enterprise"`
+ Connected bool `json:"connected"`
+ Saved bool `json:"saved"`
+ Autoconnect bool `json:"autoconnect"`
+ Hidden bool `json:"hidden"`
+ Frequency uint32 `json:"frequency"`
+ Mode string `json:"mode"`
+ Rate uint32 `json:"rate"`
+ Channel uint32 `json:"channel"`
+ Device string `json:"device,omitempty"`
+}
+
+type WiFiDevice struct {
+ Name string `json:"name"`
+ HwAddress string `json:"hwAddress"`
+ State string `json:"state"`
+ Connected bool `json:"connected"`
+ SSID string `json:"ssid,omitempty"`
+ BSSID string `json:"bssid,omitempty"`
+ Signal uint8 `json:"signal,omitempty"`
+ IP string `json:"ip,omitempty"`
+ Networks []WiFiNetwork `json:"networks"`
+}
+
+type EthernetDevice struct {
+ Name string `json:"name"`
+ HwAddress string `json:"hwAddress"`
+ State string `json:"state"`
+ Connected bool `json:"connected"`
+ IP string `json:"ip,omitempty"`
+ Speed uint32 `json:"speed,omitempty"`
+ Driver string `json:"driver,omitempty"`
+}
+
+type VPNProfile struct {
+ Name string `json:"name"`
+ UUID string `json:"uuid"`
+ Type string `json:"type"`
+ ServiceType string `json:"serviceType"`
+ RemoteHost string `json:"remoteHost,omitempty"`
+ Username string `json:"username,omitempty"`
+ Autoconnect bool `json:"autoconnect"`
+ Data map[string]string `json:"data,omitempty"`
+}
+
+type VPNActive struct {
+ Name string `json:"name"`
+ UUID string `json:"uuid"`
+ Device string `json:"device,omitempty"`
+ State string `json:"state,omitempty"`
+ Type string `json:"type"`
+ Plugin string `json:"serviceType"`
+ IP string `json:"ip,omitempty"`
+ Gateway string `json:"gateway,omitempty"`
+ RemoteHost string `json:"remoteHost,omitempty"`
+ Username string `json:"username,omitempty"`
+ MTU uint32 `json:"mtu,omitempty"`
+ Data map[string]string `json:"data,omitempty"`
+}
+
+type VPNState struct {
+ Profiles []VPNProfile `json:"profiles"`
+ Active []VPNActive `json:"activeConnections"`
+}
+
+type NetworkState struct {
+ Backend string `json:"backend"`
+ NetworkStatus NetworkStatus `json:"networkStatus"`
+ Preference ConnectionPreference `json:"preference"`
+ EthernetIP string `json:"ethernetIP"`
+ EthernetDevice string `json:"ethernetDevice"`
+ EthernetConnected bool `json:"ethernetConnected"`
+ EthernetConnectionUuid string `json:"ethernetConnectionUuid"`
+ EthernetDevices []EthernetDevice `json:"ethernetDevices"`
+ WiFiIP string `json:"wifiIP"`
+ WiFiDevice string `json:"wifiDevice"`
+ WiFiConnected bool `json:"wifiConnected"`
+ WiFiEnabled bool `json:"wifiEnabled"`
+ WiFiSSID string `json:"wifiSSID"`
+ WiFiBSSID string `json:"wifiBSSID"`
+ WiFiSignal uint8 `json:"wifiSignal"`
+ WiFiNetworks []WiFiNetwork `json:"wifiNetworks"`
+ WiFiDevices []WiFiDevice `json:"wifiDevices"`
+ WiredConnections []WiredConnection `json:"wiredConnections"`
+ VPNProfiles []VPNProfile `json:"vpnProfiles"`
+ VPNActive []VPNActive `json:"vpnActive"`
+ IsConnecting bool `json:"isConnecting"`
+ ConnectingSSID string `json:"connectingSSID"`
+ ConnectingDevice string `json:"connectingDevice,omitempty"`
+ LastError string `json:"lastError"`
+}
+
+type ConnectionRequest struct {
+ SSID string `json:"ssid"`
+ Password string `json:"password,omitempty"`
+ Username string `json:"username,omitempty"`
+ AnonymousIdentity string `json:"anonymousIdentity,omitempty"`
+ DomainSuffixMatch string `json:"domainSuffixMatch,omitempty"`
+ Interactive bool `json:"interactive,omitempty"`
+ Hidden bool `json:"hidden,omitempty"`
+ Device string `json:"device,omitempty"`
+ EAPMethod string `json:"eapMethod,omitempty"`
+ Phase2Auth string `json:"phase2Auth,omitempty"`
+ CACertPath string `json:"caCertPath,omitempty"`
+ ClientCertPath string `json:"clientCertPath,omitempty"`
+ PrivateKeyPath string `json:"privateKeyPath,omitempty"`
+ UseSystemCACerts *bool `json:"useSystemCACerts,omitempty"`
+}
+
+type WiredConnection struct {
+ Path dbus.ObjectPath `json:"path"`
+ ID string `json:"id"`
+ UUID string `json:"uuid"`
+ Type string `json:"type"`
+ IsActive bool `json:"isActive"`
+}
+
+type PriorityUpdate struct {
+ Preference ConnectionPreference `json:"preference"`
+}
+
+type Manager struct {
+ backend Backend
+ state *NetworkState
+ stateMutex sync.RWMutex
+ subscribers syncmap.Map[string, chan NetworkState]
+ stopChan chan struct{}
+ dirty chan struct{}
+ notifierWg sync.WaitGroup
+ lastNotifiedState *NetworkState
+ credentialSubscribers syncmap.Map[string, chan CredentialPrompt]
+}
+
+type EventType string
+
+const (
+ EventStateChanged EventType = "state_changed"
+ EventNetworksUpdated EventType = "networks_updated"
+ EventConnecting EventType = "connecting"
+ EventConnected EventType = "connected"
+ EventDisconnected EventType = "disconnected"
+ EventError EventType = "error"
+)
+
+type NetworkEvent struct {
+ Type EventType `json:"type"`
+ Data NetworkState `json:"data"`
+}
+
+type PromptRequest struct {
+ Name string `json:"name"`
+ SSID string `json:"ssid"`
+ ConnType string `json:"connType"`
+ VpnService string `json:"vpnService"`
+ SettingName string `json:"setting"`
+ Fields []string `json:"fields"`
+ FieldsInfo []FieldInfo `json:"fieldsInfo"`
+ Hints []string `json:"hints"`
+ Reason string `json:"reason"`
+ ConnectionId string `json:"connectionId"`
+ ConnectionUuid string `json:"connectionUuid"`
+ ConnectionPath string `json:"connectionPath"`
+}
+
+type PromptReply struct {
+ Secrets map[string]string `json:"secrets"`
+ Save bool `json:"save"`
+ Cancel bool `json:"cancel"`
+}
+
+type FieldInfo struct {
+ Name string `json:"name"`
+ Label string `json:"label"`
+ IsSecret bool `json:"isSecret"`
+}
+
+type CredentialPrompt struct {
+ Token string `json:"token"`
+ Name string `json:"name"`
+ SSID string `json:"ssid"`
+ ConnType string `json:"connType"`
+ VpnService string `json:"vpnService"`
+ Setting string `json:"setting"`
+ Fields []string `json:"fields"`
+ FieldsInfo []FieldInfo `json:"fieldsInfo"`
+ Hints []string `json:"hints"`
+ Reason string `json:"reason"`
+ ConnectionId string `json:"connectionId"`
+ ConnectionUuid string `json:"connectionUuid"`
+}
+
+type NetworkInfoResponse struct {
+ SSID string `json:"ssid"`
+ Bands []WiFiNetwork `json:"bands"`
+}
+
+type WiredNetworkInfoResponse struct {
+ UUID string `json:"uuid"`
+ IFace string `json:"iface"`
+ Driver string `json:"driver"`
+ HwAddr string `json:"hwAddr"`
+ Speed string `json:"speed"`
+ IPv4 WiredIPConfig `json:"IPv4s"`
+ IPv6 WiredIPConfig `json:"IPv6s"`
+}
+
+type WiredIPConfig struct {
+ IPs []string `json:"ips"`
+ Gateway string `json:"gateway"`
+ DNS string `json:"dns"`
+}
+
+type VPNPlugin struct {
+ Name string `json:"name"`
+ ServiceType string `json:"serviceType"`
+ Program string `json:"program,omitempty"`
+ Supports []string `json:"supports,omitempty"`
+ FileExtensions []string `json:"fileExtensions"`
+}
+
+type VPNConfig struct {
+ UUID string `json:"uuid"`
+ Name string `json:"name"`
+ Type string `json:"type"`
+ ServiceType string `json:"serviceType,omitempty"`
+ Autoconnect bool `json:"autoconnect"`
+ Data map[string]string `json:"data,omitempty"`
+}
+
+type VPNImportResult struct {
+ Success bool `json:"success"`
+ UUID string `json:"uuid,omitempty"`
+ Name string `json:"name,omitempty"`
+ ServiceType string `json:"serviceType,omitempty"`
+ Error string `json:"error,omitempty"`
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/types_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/types_test.go
new file mode 100644
index 0000000..1c3e0bf
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/types_test.go
@@ -0,0 +1,178 @@
+package network
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNetworkStatus_Constants(t *testing.T) {
+ assert.Equal(t, NetworkStatus("disconnected"), StatusDisconnected)
+ assert.Equal(t, NetworkStatus("ethernet"), StatusEthernet)
+ assert.Equal(t, NetworkStatus("wifi"), StatusWiFi)
+}
+
+func TestConnectionPreference_Constants(t *testing.T) {
+ assert.Equal(t, ConnectionPreference("auto"), PreferenceAuto)
+ assert.Equal(t, ConnectionPreference("wifi"), PreferenceWiFi)
+ assert.Equal(t, ConnectionPreference("ethernet"), PreferenceEthernet)
+}
+
+func TestEventType_Constants(t *testing.T) {
+ assert.Equal(t, EventType("state_changed"), EventStateChanged)
+ assert.Equal(t, EventType("networks_updated"), EventNetworksUpdated)
+ assert.Equal(t, EventType("connecting"), EventConnecting)
+ assert.Equal(t, EventType("connected"), EventConnected)
+ assert.Equal(t, EventType("disconnected"), EventDisconnected)
+ assert.Equal(t, EventType("error"), EventError)
+}
+
+func TestWiFiNetwork_JSON(t *testing.T) {
+ network := WiFiNetwork{
+ SSID: "TestNetwork",
+ BSSID: "00:11:22:33:44:55",
+ Signal: 85,
+ Secured: true,
+ Enterprise: false,
+ Connected: true,
+ Saved: true,
+ Frequency: 2437,
+ Mode: "infrastructure",
+ Rate: 300,
+ Channel: 6,
+ }
+
+ data, err := json.Marshal(network)
+ require.NoError(t, err)
+
+ var decoded WiFiNetwork
+ err = json.Unmarshal(data, &decoded)
+ require.NoError(t, err)
+
+ assert.Equal(t, network.SSID, decoded.SSID)
+ assert.Equal(t, network.BSSID, decoded.BSSID)
+ assert.Equal(t, network.Signal, decoded.Signal)
+ assert.Equal(t, network.Secured, decoded.Secured)
+ assert.Equal(t, network.Enterprise, decoded.Enterprise)
+ assert.Equal(t, network.Connected, decoded.Connected)
+ assert.Equal(t, network.Saved, decoded.Saved)
+ assert.Equal(t, network.Frequency, decoded.Frequency)
+ assert.Equal(t, network.Mode, decoded.Mode)
+ assert.Equal(t, network.Rate, decoded.Rate)
+ assert.Equal(t, network.Channel, decoded.Channel)
+}
+
+func TestNetworkState_JSON(t *testing.T) {
+ state := NetworkState{
+ NetworkStatus: StatusWiFi,
+ Preference: PreferenceAuto,
+ EthernetIP: "192.168.1.100",
+ EthernetDevice: "eth0",
+ EthernetConnected: false,
+ WiFiIP: "192.168.1.101",
+ WiFiDevice: "wlan0",
+ WiFiConnected: true,
+ WiFiEnabled: true,
+ WiFiSSID: "TestNetwork",
+ WiFiBSSID: "00:11:22:33:44:55",
+ WiFiSignal: 85,
+ WiFiNetworks: []WiFiNetwork{
+ {SSID: "Network1", Signal: 90},
+ {SSID: "Network2", Signal: 60},
+ },
+ IsConnecting: false,
+ ConnectingSSID: "",
+ LastError: "",
+ }
+
+ data, err := json.Marshal(state)
+ require.NoError(t, err)
+
+ var decoded NetworkState
+ err = json.Unmarshal(data, &decoded)
+ require.NoError(t, err)
+
+ assert.Equal(t, state.NetworkStatus, decoded.NetworkStatus)
+ assert.Equal(t, state.Preference, decoded.Preference)
+ assert.Equal(t, state.WiFiIP, decoded.WiFiIP)
+ assert.Equal(t, state.WiFiSSID, decoded.WiFiSSID)
+ assert.Equal(t, len(state.WiFiNetworks), len(decoded.WiFiNetworks))
+}
+
+func TestConnectionRequest_JSON(t *testing.T) {
+ t.Run("with password", func(t *testing.T) {
+ req := ConnectionRequest{
+ SSID: "TestNetwork",
+ Password: "testpass123",
+ }
+
+ data, err := json.Marshal(req)
+ require.NoError(t, err)
+
+ var decoded ConnectionRequest
+ err = json.Unmarshal(data, &decoded)
+ require.NoError(t, err)
+
+ assert.Equal(t, req.SSID, decoded.SSID)
+ assert.Equal(t, req.Password, decoded.Password)
+ assert.Empty(t, decoded.Username)
+ })
+
+ t.Run("with username and password (enterprise)", func(t *testing.T) {
+ req := ConnectionRequest{
+ SSID: "EnterpriseNetwork",
+ Password: "testpass123",
+ Username: "testuser",
+ }
+
+ data, err := json.Marshal(req)
+ require.NoError(t, err)
+
+ var decoded ConnectionRequest
+ err = json.Unmarshal(data, &decoded)
+ require.NoError(t, err)
+
+ assert.Equal(t, req.SSID, decoded.SSID)
+ assert.Equal(t, req.Password, decoded.Password)
+ assert.Equal(t, req.Username, decoded.Username)
+ })
+}
+
+func TestPriorityUpdate_JSON(t *testing.T) {
+ update := PriorityUpdate{
+ Preference: PreferenceWiFi,
+ }
+
+ data, err := json.Marshal(update)
+ require.NoError(t, err)
+
+ var decoded PriorityUpdate
+ err = json.Unmarshal(data, &decoded)
+ require.NoError(t, err)
+
+ assert.Equal(t, update.Preference, decoded.Preference)
+}
+
+func TestNetworkEvent_JSON(t *testing.T) {
+ event := NetworkEvent{
+ Type: EventStateChanged,
+ Data: NetworkState{
+ NetworkStatus: StatusWiFi,
+ WiFiSSID: "TestNetwork",
+ WiFiConnected: true,
+ },
+ }
+
+ data, err := json.Marshal(event)
+ require.NoError(t, err)
+
+ var decoded NetworkEvent
+ err = json.Unmarshal(data, &decoded)
+ require.NoError(t, err)
+
+ assert.Equal(t, event.Type, decoded.Type)
+ assert.Equal(t, event.Data.NetworkStatus, decoded.Data.NetworkStatus)
+ assert.Equal(t, event.Data.WiFiSSID, decoded.Data.WiFiSSID)
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/wifi_qrcode.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/wifi_qrcode.go
new file mode 100644
index 0000000..680b6d1
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/wifi_qrcode.go
@@ -0,0 +1,59 @@
+package network
+
+import (
+ "fmt"
+ "path/filepath"
+ "regexp"
+ "strings"
+)
+
+const qrCodeTmpPrefix = "/tmp/dank-wifi-qrcode-"
+
+func FormatWiFiQRString(securityType, ssid, password string) string {
+ return fmt.Sprintf("WIFI:T:%s;S:%s;P:%s;;", securityType, ssid, password)
+}
+
+func qrCodePaths(ssid string) (themed, normal string) {
+ safe := sanitizeSSIDForPath(ssid)
+ themed = fmt.Sprintf("%s%s-themed.png", qrCodeTmpPrefix, safe)
+ normal = fmt.Sprintf("%s%s-normal.png", qrCodeTmpPrefix, safe)
+ return
+}
+
+func isValidQRCodePath(path string) bool {
+ clean := filepath.Clean(path)
+ return strings.HasPrefix(clean, qrCodeTmpPrefix) && strings.HasSuffix(clean, ".png")
+}
+
+var safePathChar = regexp.MustCompile(`[^a-zA-Z0-9_-]`)
+
+func sanitizeSSIDForPath(ssid string) string {
+ return safePathChar.ReplaceAllString(ssid, "_")
+}
+
+var iwdVerbatimSSID = regexp.MustCompile(`^[a-zA-Z0-9 _-]+$`)
+
+func iwdConfigPath(ssid string) string {
+ switch {
+ case iwdVerbatimSSID.MatchString(ssid):
+ return fmt.Sprintf("/var/lib/iwd/%s.psk", ssid)
+ default:
+ return fmt.Sprintf("/var/lib/iwd/=%x.psk", []byte(ssid))
+ }
+}
+
+func parseIWDPassphrase(data string) (string, error) {
+ inSecurity := false
+ for _, line := range strings.Split(data, "\n") {
+ line = strings.TrimSpace(line)
+ switch {
+ case line == "[Security]":
+ inSecurity = true
+ case strings.HasPrefix(line, "["):
+ inSecurity = false
+ case inSecurity && strings.HasPrefix(line, "Passphrase="):
+ return strings.TrimPrefix(line, "Passphrase="), nil
+ }
+ }
+ return "", fmt.Errorf("no passphrase found in iwd config")
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/wifi_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/wifi_test.go
new file mode 100644
index 0000000..2256b60
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/wifi_test.go
@@ -0,0 +1,148 @@
+package network
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestFrequencyToChannel(t *testing.T) {
+ tests := []struct {
+ name string
+ frequency uint32
+ channel uint32
+ }{
+ {"2.4 GHz channel 1", 2412, 1},
+ {"2.4 GHz channel 6", 2437, 6},
+ {"2.4 GHz channel 11", 2462, 11},
+ {"2.4 GHz channel 14", 2484, 14},
+ {"5 GHz channel 36", 5180, 36},
+ {"5 GHz channel 40", 5200, 40},
+ {"5 GHz channel 165", 5825, 165},
+ {"6 GHz channel 1", 5955, 1},
+ {"6 GHz channel 233", 7115, 233},
+ {"Unknown frequency", 1000, 0},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := frequencyToChannel(tt.frequency)
+ assert.Equal(t, tt.channel, result)
+ })
+ }
+}
+
+func TestSortWiFiNetworks(t *testing.T) {
+ t.Run("connected network comes first", func(t *testing.T) {
+ networks := []WiFiNetwork{
+ {SSID: "Network1", Signal: 90, Connected: false},
+ {SSID: "Network2", Signal: 80, Connected: true},
+ {SSID: "Network3", Signal: 70, Connected: false},
+ }
+
+ sortWiFiNetworks(networks)
+
+ assert.Equal(t, "Network2", networks[0].SSID)
+ assert.True(t, networks[0].Connected)
+ })
+
+ t.Run("sorts by signal strength", func(t *testing.T) {
+ networks := []WiFiNetwork{
+ {SSID: "Weak", Signal: 40, Secured: true},
+ {SSID: "Strong", Signal: 90, Secured: true},
+ {SSID: "Medium", Signal: 60, Secured: true},
+ }
+
+ sortWiFiNetworks(networks)
+
+ assert.Equal(t, "Strong", networks[0].SSID)
+ assert.Equal(t, "Medium", networks[1].SSID)
+ assert.Equal(t, "Weak", networks[2].SSID)
+ })
+
+ t.Run("prioritizes open networks with good signal", func(t *testing.T) {
+ networks := []WiFiNetwork{
+ {SSID: "SecureWeak", Signal: 40, Secured: true},
+ {SSID: "OpenStrong", Signal: 60, Secured: false},
+ {SSID: "SecureStrong", Signal: 90, Secured: true},
+ }
+
+ sortWiFiNetworks(networks)
+
+ assert.Equal(t, "OpenStrong", networks[0].SSID)
+
+ openIdx := -1
+ weakSecureIdx := -1
+ for i, n := range networks {
+ if n.SSID == "OpenStrong" {
+ openIdx = i
+ }
+ if n.SSID == "SecureWeak" {
+ weakSecureIdx = i
+ }
+ }
+ assert.Less(t, openIdx, weakSecureIdx, "OpenStrong should come before SecureWeak")
+ })
+
+ t.Run("prioritizes saved networks after connected", func(t *testing.T) {
+ networks := []WiFiNetwork{
+ {SSID: "UnsavedStrong", Signal: 95, Saved: false},
+ {SSID: "SavedMedium", Signal: 60, Saved: true},
+ {SSID: "SavedWeak", Signal: 50, Saved: true},
+ {SSID: "UnsavedMedium", Signal: 70, Saved: false},
+ }
+
+ sortWiFiNetworks(networks)
+
+ assert.Equal(t, "SavedMedium", networks[0].SSID)
+ assert.Equal(t, "SavedWeak", networks[1].SSID)
+ assert.Equal(t, "UnsavedStrong", networks[2].SSID)
+ assert.Equal(t, "UnsavedMedium", networks[3].SSID)
+ })
+}
+
+func TestManager_GetWiFiNetworks(t *testing.T) {
+ manager := &Manager{
+ state: &NetworkState{
+ WiFiNetworks: []WiFiNetwork{
+ {SSID: "Network1", Signal: 90},
+ {SSID: "Network2", Signal: 80},
+ },
+ },
+ }
+
+ networks := manager.GetWiFiNetworks()
+
+ assert.Len(t, networks, 2)
+ assert.Equal(t, "Network1", networks[0].SSID)
+ assert.Equal(t, "Network2", networks[1].SSID)
+
+ networks[0].SSID = "Modified"
+ assert.Equal(t, "Network1", manager.state.WiFiNetworks[0].SSID)
+}
+
+func TestManager_GetNetworkInfo(t *testing.T) {
+ manager := &Manager{
+ state: &NetworkState{
+ WiFiNetworks: []WiFiNetwork{
+ {SSID: "Network1", Signal: 90, BSSID: "00:11:22:33:44:55"},
+ {SSID: "Network2", Signal: 80, BSSID: "AA:BB:CC:DD:EE:FF"},
+ },
+ },
+ }
+
+ t.Run("finds existing network", func(t *testing.T) {
+ network, err := manager.GetNetworkInfo("Network1")
+ assert.NoError(t, err)
+ assert.NotNil(t, network)
+ assert.Equal(t, "Network1", network.SSID)
+ assert.Equal(t, uint8(90), network.Signal)
+ })
+
+ t.Run("returns error for non-existent network", func(t *testing.T) {
+ network, err := manager.GetNetworkInfo("NonExistent")
+ assert.Error(t, err)
+ assert.Nil(t, network)
+ assert.Contains(t, err.Error(), "network not found")
+ })
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/wired_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/wired_test.go
new file mode 100644
index 0000000..5c346e7
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/wired_test.go
@@ -0,0 +1,51 @@
+package network
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestManager_GetWiredConfigs(t *testing.T) {
+ manager := &Manager{
+ state: &NetworkState{
+ EthernetConnected: true,
+ WiredConnections: []WiredConnection{
+ {ID: "Test", IsActive: true},
+ },
+ },
+ }
+
+ configs := manager.GetWiredConfigs()
+
+ assert.Len(t, configs, 1)
+ assert.Equal(t, "Test", configs[0].ID)
+}
+
+func TestManager_GetEthernetDevices(t *testing.T) {
+ manager := &Manager{
+ state: &NetworkState{
+ EthernetDevices: []EthernetDevice{
+ {Name: "enp0s3", Connected: true, IP: "192.168.1.100"},
+ {Name: "enp0s8", Connected: false},
+ },
+ },
+ }
+
+ devices := manager.GetEthernetDevices()
+
+ assert.Len(t, devices, 2)
+ assert.Equal(t, "enp0s3", devices[0].Name)
+ assert.True(t, devices[0].Connected)
+ assert.Equal(t, "enp0s8", devices[1].Name)
+ assert.False(t, devices[1].Connected)
+}
+
+func TestManager_GetEthernetDevices_Empty(t *testing.T) {
+ manager := &Manager{
+ state: &NetworkState{},
+ }
+
+ devices := manager.GetEthernetDevices()
+ assert.Empty(t, devices)
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/params/params.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/params/params.go
new file mode 100644
index 0000000..1f592c6
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/params/params.go
@@ -0,0 +1,113 @@
+package params
+
+import "fmt"
+
+func Get[T any](params map[string]any, key string) (T, error) {
+ val, ok := params[key].(T)
+ if !ok {
+ var zero T
+ return zero, fmt.Errorf("missing or invalid '%s' parameter", key)
+ }
+ return val, nil
+}
+
+func GetOpt[T any](params map[string]any, key string, def T) T {
+ if val, ok := params[key].(T); ok {
+ return val
+ }
+ return def
+}
+
+func String(params map[string]any, key string) (string, error) {
+ return Get[string](params, key)
+}
+
+func StringNonEmpty(params map[string]any, key string) (string, error) {
+ val, err := Get[string](params, key)
+ if err != nil || val == "" {
+ return "", fmt.Errorf("missing or invalid '%s' parameter", key)
+ }
+ return val, nil
+}
+
+func StringOpt(params map[string]any, key string, def string) string {
+ return GetOpt(params, key, def)
+}
+
+func Int(params map[string]any, key string) (int, error) {
+ val, err := Get[float64](params, key)
+ if err != nil {
+ return 0, err
+ }
+ return int(val), nil
+}
+
+func IntOpt(params map[string]any, key string, def int) int {
+ if val, ok := params[key].(float64); ok {
+ return int(val)
+ }
+ return def
+}
+
+func Float(params map[string]any, key string) (float64, error) {
+ return Get[float64](params, key)
+}
+
+func FloatOpt(params map[string]any, key string, def float64) float64 {
+ return GetOpt(params, key, def)
+}
+
+func Bool(params map[string]any, key string) (bool, error) {
+ return Get[bool](params, key)
+}
+
+func BoolOpt(params map[string]any, key string, def bool) bool {
+ return GetOpt(params, key, def)
+}
+
+func StringMap(params map[string]any, key string) (map[string]string, error) {
+ rawMap, err := Get[map[string]any](params, key)
+ if err != nil {
+ return nil, err
+ }
+ result := make(map[string]string)
+ for k, v := range rawMap {
+ if str, ok := v.(string); ok {
+ result[k] = str
+ }
+ }
+ return result, nil
+}
+
+func StringMapOpt(params map[string]any, key string) map[string]string {
+ rawMap, ok := params[key].(map[string]any)
+ if !ok {
+ return nil
+ }
+ result := make(map[string]string)
+ for k, v := range rawMap {
+ if str, ok := v.(string); ok {
+ result[k] = str
+ }
+ }
+ return result
+}
+
+func Any(params map[string]any, key string) (any, bool) {
+ val, ok := params[key]
+ return val, ok
+}
+
+func AnyMap(params map[string]any, key string) (map[string]any, bool) {
+ val, ok := params[key].(map[string]any)
+ return val, ok
+}
+
+func StringAlt(params map[string]any, keys ...string) (string, bool) {
+ for _, key := range keys {
+ if val, ok := params[key].(string); ok {
+ return val, true
+ }
+ }
+ return "", false
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/params/params_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/params/params_test.go
new file mode 100644
index 0000000..99847ff
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/params/params_test.go
@@ -0,0 +1,154 @@
+package params
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestGet(t *testing.T) {
+ p := map[string]any{"key": "value"}
+ val, err := Get[string](p, "key")
+ assert.NoError(t, err)
+ assert.Equal(t, "value", val)
+
+ _, err = Get[string](p, "missing")
+ assert.Error(t, err)
+
+ _, err = Get[int](p, "key")
+ assert.Error(t, err)
+}
+
+func TestGetOpt(t *testing.T) {
+ p := map[string]any{"key": "value"}
+ assert.Equal(t, "value", GetOpt(p, "key", "default"))
+ assert.Equal(t, "default", GetOpt(p, "missing", "default"))
+}
+
+func TestString(t *testing.T) {
+ p := map[string]any{"s": "hello", "n": 123}
+ val, err := String(p, "s")
+ assert.NoError(t, err)
+ assert.Equal(t, "hello", val)
+
+ _, err = String(p, "n")
+ assert.Error(t, err)
+}
+
+func TestStringNonEmpty(t *testing.T) {
+ p := map[string]any{"s": "hello", "empty": ""}
+ val, err := StringNonEmpty(p, "s")
+ assert.NoError(t, err)
+ assert.Equal(t, "hello", val)
+
+ _, err = StringNonEmpty(p, "empty")
+ assert.Error(t, err)
+
+ _, err = StringNonEmpty(p, "missing")
+ assert.Error(t, err)
+}
+
+func TestStringOpt(t *testing.T) {
+ p := map[string]any{"s": "hello"}
+ assert.Equal(t, "hello", StringOpt(p, "s", "default"))
+ assert.Equal(t, "default", StringOpt(p, "missing", "default"))
+}
+
+func TestInt(t *testing.T) {
+ p := map[string]any{"n": float64(42), "s": "str"}
+ val, err := Int(p, "n")
+ assert.NoError(t, err)
+ assert.Equal(t, 42, val)
+
+ _, err = Int(p, "s")
+ assert.Error(t, err)
+}
+
+func TestIntOpt(t *testing.T) {
+ p := map[string]any{"n": float64(42)}
+ assert.Equal(t, 42, IntOpt(p, "n", 0))
+ assert.Equal(t, 99, IntOpt(p, "missing", 99))
+}
+
+func TestFloat(t *testing.T) {
+ p := map[string]any{"f": 3.14, "s": "str"}
+ val, err := Float(p, "f")
+ assert.NoError(t, err)
+ assert.Equal(t, 3.14, val)
+
+ _, err = Float(p, "s")
+ assert.Error(t, err)
+}
+
+func TestFloatOpt(t *testing.T) {
+ p := map[string]any{"f": 3.14}
+ assert.Equal(t, 3.14, FloatOpt(p, "f", 0))
+ assert.Equal(t, 1.0, FloatOpt(p, "missing", 1.0))
+}
+
+func TestBool(t *testing.T) {
+ p := map[string]any{"b": true, "s": "str"}
+ val, err := Bool(p, "b")
+ assert.NoError(t, err)
+ assert.True(t, val)
+
+ _, err = Bool(p, "s")
+ assert.Error(t, err)
+}
+
+func TestBoolOpt(t *testing.T) {
+ p := map[string]any{"b": true}
+ assert.True(t, BoolOpt(p, "b", false))
+ assert.True(t, BoolOpt(p, "missing", true))
+}
+
+func TestStringMap(t *testing.T) {
+ p := map[string]any{
+ "m": map[string]any{"a": "1", "b": "2", "c": 3},
+ }
+ val, err := StringMap(p, "m")
+ assert.NoError(t, err)
+ assert.Equal(t, map[string]string{"a": "1", "b": "2"}, val)
+
+ _, err = StringMap(p, "missing")
+ assert.Error(t, err)
+}
+
+func TestStringMapOpt(t *testing.T) {
+ p := map[string]any{
+ "m": map[string]any{"a": "1"},
+ }
+ assert.Equal(t, map[string]string{"a": "1"}, StringMapOpt(p, "m"))
+ assert.Nil(t, StringMapOpt(p, "missing"))
+}
+
+func TestAny(t *testing.T) {
+ p := map[string]any{"k": 123}
+ val, ok := Any(p, "k")
+ assert.True(t, ok)
+ assert.Equal(t, 123, val)
+
+ _, ok = Any(p, "missing")
+ assert.False(t, ok)
+}
+
+func TestAnyMap(t *testing.T) {
+ inner := map[string]any{"nested": true}
+ p := map[string]any{"m": inner}
+ val, ok := AnyMap(p, "m")
+ assert.True(t, ok)
+ assert.Equal(t, inner, val)
+
+ _, ok = AnyMap(p, "missing")
+ assert.False(t, ok)
+}
+
+func TestStringAlt(t *testing.T) {
+ p := map[string]any{"b": "found"}
+ val, ok := StringAlt(p, "a", "b", "c")
+ assert.True(t, ok)
+ assert.Equal(t, "found", val)
+
+ _, ok = StringAlt(p, "x", "y")
+ assert.False(t, ok)
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/handlers.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/handlers.go
new file mode 100644
index 0000000..33f6564
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/handlers.go
@@ -0,0 +1,27 @@
+package plugins
+
+import (
+ "fmt"
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+)
+
+func HandleRequest(conn net.Conn, req models.Request) {
+ switch req.Method {
+ case "plugins.list":
+ HandleList(conn, req)
+ case "plugins.listInstalled":
+ HandleListInstalled(conn, req)
+ case "plugins.install":
+ HandleInstall(conn, req)
+ case "plugins.uninstall":
+ HandleUninstall(conn, req)
+ case "plugins.update":
+ HandleUpdate(conn, req)
+ case "plugins.search":
+ HandleSearch(conn, req)
+ default:
+ models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
+ }
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/handlers_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/handlers_test.go
new file mode 100644
index 0000000..70202e3
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/handlers_test.go
@@ -0,0 +1,196 @@
+package plugins
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/net"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+)
+
+func TestHandleList(t *testing.T) {
+ conn := net.NewMockConn(t)
+ conn.EXPECT().Write(mock.Anything).Return(0, nil).Maybe()
+
+ req := models.Request{
+ ID: 123,
+ Method: "plugins.list",
+ Params: map[string]any{},
+ }
+
+ HandleList(conn, req)
+}
+
+func TestHandleListInstalled(t *testing.T) {
+ conn := net.NewMockConn(t)
+ conn.EXPECT().Write(mock.Anything).Return(0, nil).Maybe()
+
+ req := models.Request{
+ ID: 123,
+ Method: "plugins.listInstalled",
+ Params: map[string]any{},
+ }
+
+ HandleListInstalled(conn, req)
+}
+
+func TestHandleInstallMissingName(t *testing.T) {
+ conn := net.NewMockConn(t)
+ var written []byte
+ conn.EXPECT().Write(mock.Anything).RunAndReturn(func(b []byte) (int, error) {
+ written = b
+ return len(b), nil
+ }).Maybe()
+
+ req := models.Request{
+ ID: 123,
+ Method: "plugins.install",
+ Params: map[string]any{},
+ }
+
+ HandleInstall(conn, req)
+
+ var resp models.Response[SuccessResult]
+ err := json.Unmarshal(written, &resp)
+ assert.NoError(t, err)
+ assert.NotEmpty(t, resp.Error)
+ assert.Contains(t, resp.Error, "missing or invalid 'name' parameter")
+}
+
+func TestHandleInstallInvalidName(t *testing.T) {
+ conn := net.NewMockConn(t)
+ var written []byte
+ conn.EXPECT().Write(mock.Anything).RunAndReturn(func(b []byte) (int, error) {
+ written = b
+ return len(b), nil
+ }).Maybe()
+
+ req := models.Request{
+ ID: 123,
+ Method: "plugins.install",
+ Params: map[string]any{
+ "name": 123,
+ },
+ }
+
+ HandleInstall(conn, req)
+
+ var resp models.Response[SuccessResult]
+ err := json.Unmarshal(written, &resp)
+ assert.NoError(t, err)
+ assert.NotEmpty(t, resp.Error)
+}
+
+func TestHandleUninstallMissingName(t *testing.T) {
+ conn := net.NewMockConn(t)
+ var written []byte
+ conn.EXPECT().Write(mock.Anything).RunAndReturn(func(b []byte) (int, error) {
+ written = b
+ return len(b), nil
+ }).Maybe()
+
+ req := models.Request{
+ ID: 123,
+ Method: "plugins.uninstall",
+ Params: map[string]any{},
+ }
+
+ HandleUninstall(conn, req)
+
+ var resp models.Response[SuccessResult]
+ err := json.Unmarshal(written, &resp)
+ assert.NoError(t, err)
+ assert.NotEmpty(t, resp.Error)
+}
+
+func TestHandleUpdateMissingName(t *testing.T) {
+ conn := net.NewMockConn(t)
+ var written []byte
+ conn.EXPECT().Write(mock.Anything).RunAndReturn(func(b []byte) (int, error) {
+ written = b
+ return len(b), nil
+ }).Maybe()
+
+ req := models.Request{
+ ID: 123,
+ Method: "plugins.update",
+ Params: map[string]any{},
+ }
+
+ HandleUpdate(conn, req)
+
+ var resp models.Response[SuccessResult]
+ err := json.Unmarshal(written, &resp)
+ assert.NoError(t, err)
+ assert.NotEmpty(t, resp.Error)
+}
+
+func TestHandleSearchMissingQuery(t *testing.T) {
+ conn := net.NewMockConn(t)
+ var written []byte
+ conn.EXPECT().Write(mock.Anything).RunAndReturn(func(b []byte) (int, error) {
+ written = b
+ return len(b), nil
+ }).Maybe()
+
+ req := models.Request{
+ ID: 123,
+ Method: "plugins.search",
+ Params: map[string]any{},
+ }
+
+ HandleSearch(conn, req)
+
+ var resp models.Response[[]PluginInfo]
+ err := json.Unmarshal(written, &resp)
+ assert.NoError(t, err)
+ assert.NotEmpty(t, resp.Error)
+}
+
+func TestSortPluginInfoByFirstParty(t *testing.T) {
+ plugins := []PluginInfo{
+ {Name: "third-party", Repo: "https://github.com/other/test"},
+ {Name: "first-party", Repo: "https://github.com/AvengeMedia/test"},
+ }
+
+ SortPluginInfoByFirstParty(plugins)
+
+ assert.Equal(t, "first-party", plugins[0].Name)
+ assert.Equal(t, "third-party", plugins[1].Name)
+}
+
+func TestPluginInfoJSON(t *testing.T) {
+ info := PluginInfo{
+ Name: "test",
+ Description: "test description",
+ Installed: true,
+ FirstParty: true,
+ }
+
+ data, err := json.Marshal(info)
+ assert.NoError(t, err)
+
+ var unmarshaled PluginInfo
+ err = json.Unmarshal(data, &unmarshaled)
+ assert.NoError(t, err)
+ assert.Equal(t, info.Name, unmarshaled.Name)
+ assert.Equal(t, info.Installed, unmarshaled.Installed)
+}
+
+func TestSuccessResult(t *testing.T) {
+ result := SuccessResult{
+ Success: true,
+ Message: "test message",
+ }
+
+ data, err := json.Marshal(result)
+ assert.NoError(t, err)
+
+ var unmarshaled SuccessResult
+ err = json.Unmarshal(data, &unmarshaled)
+ assert.NoError(t, err)
+ assert.True(t, unmarshaled.Success)
+ assert.Equal(t, "test message", unmarshaled.Message)
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/install.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/install.go
new file mode 100644
index 0000000..2c13a0e
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/install.go
@@ -0,0 +1,69 @@
+package plugins
+
+import (
+ "fmt"
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/plugins"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+)
+
+func HandleInstall(conn net.Conn, req models.Request) {
+ idOrName, ok := models.Get[string](req, "name")
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'name' parameter")
+ return
+ }
+
+ registry, err := plugins.NewRegistry()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to create registry: %v", err))
+ return
+ }
+
+ pluginList, err := registry.List()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to list plugins: %v", err))
+ return
+ }
+
+ // First, try to find by ID (preferred method)
+ var plugin *plugins.Plugin
+ for _, p := range pluginList {
+ if p.ID == idOrName {
+ plugin = &p
+ break
+ }
+ }
+
+ // Fallback to name for backward compatibility
+ if plugin == nil {
+ for _, p := range pluginList {
+ if p.Name == idOrName {
+ plugin = &p
+ break
+ }
+ }
+ }
+
+ if plugin == nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("plugin not found: %s", idOrName))
+ return
+ }
+
+ manager, err := plugins.NewManager()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to create manager: %v", err))
+ return
+ }
+
+ if err := manager.Install(*plugin); err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to install plugin: %v", err))
+ return
+ }
+
+ models.Respond(conn, req.ID, SuccessResult{
+ Success: true,
+ Message: fmt.Sprintf("plugin installed: %s", plugin.Name),
+ })
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/list.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/list.go
new file mode 100644
index 0000000..e44d034
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/list.go
@@ -0,0 +1,53 @@
+package plugins
+
+import (
+ "fmt"
+ "net"
+ "strings"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/plugins"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+)
+
+func HandleList(conn net.Conn, req models.Request) {
+ registry, err := plugins.NewRegistry()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to create registry: %v", err))
+ return
+ }
+
+ pluginList, err := registry.List()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to list plugins: %v", err))
+ return
+ }
+
+ manager, err := plugins.NewManager()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to create manager: %v", err))
+ return
+ }
+
+ result := make([]PluginInfo, len(pluginList))
+ for i, p := range pluginList {
+ installed, _ := manager.IsInstalled(p)
+ result[i] = PluginInfo{
+ ID: p.ID,
+ Name: p.Name,
+ Category: p.Category,
+ Author: p.Author,
+ Description: p.Description,
+ Repo: p.Repo,
+ Path: p.Path,
+ Capabilities: p.Capabilities,
+ Compositors: p.Compositors,
+ Dependencies: p.Dependencies,
+ Installed: installed,
+ FirstParty: strings.HasPrefix(p.Repo, "https://github.com/AvengeMedia"),
+ Featured: p.Featured,
+ RequiresDMS: p.RequiresDMS,
+ }
+ }
+
+ models.Respond(conn, req.ID, result)
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/list_installed.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/list_installed.go
new file mode 100644
index 0000000..b771b0e
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/list_installed.go
@@ -0,0 +1,77 @@
+package plugins
+
+import (
+ "fmt"
+ "net"
+ "strings"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/plugins"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+)
+
+func HandleListInstalled(conn net.Conn, req models.Request) {
+ manager, err := plugins.NewManager()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to create manager: %v", err))
+ return
+ }
+
+ installedNames, err := manager.ListInstalled()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to list installed plugins: %v", err))
+ return
+ }
+
+ registry, err := plugins.NewRegistry()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to create registry: %v", err))
+ return
+ }
+
+ allPlugins, err := registry.List()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to list plugins: %v", err))
+ return
+ }
+
+ pluginMap := make(map[string]plugins.Plugin)
+ for _, p := range allPlugins {
+ pluginMap[p.ID] = p
+ }
+
+ result := make([]PluginInfo, 0, len(installedNames))
+ for _, id := range installedNames {
+ if plugin, ok := pluginMap[id]; ok {
+ hasUpdate := false
+ if hasUpdates, err := manager.HasUpdates(id, plugin); err == nil {
+ hasUpdate = hasUpdates
+ }
+
+ result = append(result, PluginInfo{
+ ID: plugin.ID,
+ Name: plugin.Name,
+ Category: plugin.Category,
+ Author: plugin.Author,
+ Description: plugin.Description,
+ Repo: plugin.Repo,
+ Path: plugin.Path,
+ Capabilities: plugin.Capabilities,
+ Compositors: plugin.Compositors,
+ Dependencies: plugin.Dependencies,
+ FirstParty: strings.HasPrefix(plugin.Repo, "https://github.com/AvengeMedia"),
+ HasUpdate: hasUpdate,
+ RequiresDMS: plugin.RequiresDMS,
+ })
+ } else {
+ result = append(result, PluginInfo{
+ ID: id,
+ Name: id,
+ Note: "not in registry",
+ })
+ }
+ }
+
+ SortPluginInfoByFirstParty(result)
+
+ models.Respond(conn, req.ID, result)
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/search.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/search.go
new file mode 100644
index 0000000..9dbea6d
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/search.go
@@ -0,0 +1,74 @@
+package plugins
+
+import (
+ "fmt"
+ "net"
+ "strings"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/plugins"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+)
+
+func HandleSearch(conn net.Conn, req models.Request) {
+ query, ok := models.Get[string](req, "query")
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'query' parameter")
+ return
+ }
+
+ registry, err := plugins.NewRegistry()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to create registry: %v", err))
+ return
+ }
+
+ pluginList, err := registry.List()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to list plugins: %v", err))
+ return
+ }
+
+ searchResults := plugins.FuzzySearch(query, pluginList)
+
+ if category := models.GetOr(req, "category", ""); category != "" {
+ searchResults = plugins.FilterByCategory(category, searchResults)
+ }
+
+ if compositor := models.GetOr(req, "compositor", ""); compositor != "" {
+ searchResults = plugins.FilterByCompositor(compositor, searchResults)
+ }
+
+ if capability := models.GetOr(req, "capability", ""); capability != "" {
+ searchResults = plugins.FilterByCapability(capability, searchResults)
+ }
+
+ searchResults = plugins.SortByFirstParty(searchResults)
+
+ manager, err := plugins.NewManager()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to create manager: %v", err))
+ return
+ }
+
+ result := make([]PluginInfo, len(searchResults))
+ for i, p := range searchResults {
+ installed, _ := manager.IsInstalled(p)
+ result[i] = PluginInfo{
+ ID: p.ID,
+ Name: p.Name,
+ Category: p.Category,
+ Author: p.Author,
+ Description: p.Description,
+ Repo: p.Repo,
+ Path: p.Path,
+ Capabilities: p.Capabilities,
+ Compositors: p.Compositors,
+ Dependencies: p.Dependencies,
+ Installed: installed,
+ FirstParty: strings.HasPrefix(p.Repo, "https://github.com/AvengeMedia"),
+ RequiresDMS: p.RequiresDMS,
+ }
+ }
+
+ models.Respond(conn, req.ID, result)
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/types.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/types.go
new file mode 100644
index 0000000..8290f72
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/types.go
@@ -0,0 +1,25 @@
+package plugins
+
+type PluginInfo struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Category string `json:"category,omitempty"`
+ Author string `json:"author,omitempty"`
+ Description string `json:"description,omitempty"`
+ Repo string `json:"repo,omitempty"`
+ Path string `json:"path,omitempty"`
+ Capabilities []string `json:"capabilities,omitempty"`
+ Compositors []string `json:"compositors,omitempty"`
+ Dependencies []string `json:"dependencies,omitempty"`
+ Installed bool `json:"installed,omitempty"`
+ FirstParty bool `json:"firstParty,omitempty"`
+ Featured bool `json:"featured,omitempty"`
+ Note string `json:"note,omitempty"`
+ HasUpdate bool `json:"hasUpdate,omitempty"`
+ RequiresDMS string `json:"requires_dms,omitempty"`
+}
+
+type SuccessResult struct {
+ Success bool `json:"success"`
+ Message string `json:"message"`
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/uninstall.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/uninstall.go
new file mode 100644
index 0000000..7a3d698
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/uninstall.go
@@ -0,0 +1,66 @@
+package plugins
+
+import (
+ "fmt"
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/plugins"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+)
+
+func HandleUninstall(conn net.Conn, req models.Request) {
+ name, ok := models.Get[string](req, "name")
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'name' parameter")
+ return
+ }
+
+ manager, err := plugins.NewManager()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to create manager: %v", err))
+ return
+ }
+
+ // First try to find in registry (by name or ID)
+ registry, err := plugins.NewRegistry()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to create registry: %v", err))
+ return
+ }
+
+ pluginList, _ := registry.List()
+ plugin := plugins.FindByIDOrName(name, pluginList)
+
+ // If found in registry, use that
+ if plugin != nil {
+ installed, err := manager.IsInstalled(*plugin)
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to check if plugin is installed: %v", err))
+ return
+ }
+ if !installed {
+ models.RespondError(conn, req.ID, fmt.Sprintf("plugin not installed: %s", name))
+ return
+ }
+ if err := manager.Uninstall(*plugin); err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to uninstall plugin: %v", err))
+ return
+ }
+ models.Respond(conn, req.ID, SuccessResult{
+ Success: true,
+ Message: fmt.Sprintf("plugin uninstalled: %s", plugin.Name),
+ })
+ return
+ }
+
+ // Not in registry - try to find and uninstall from installed plugins directly
+ if err := manager.UninstallByIDOrName(name); err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("plugin not found: %s", name))
+ return
+ }
+
+ models.Respond(conn, req.ID, SuccessResult{
+ Success: true,
+ Message: fmt.Sprintf("plugin uninstalled: %s", name),
+ })
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/update.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/update.go
new file mode 100644
index 0000000..4ea762a
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/update.go
@@ -0,0 +1,64 @@
+package plugins
+
+import (
+ "fmt"
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/plugins"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+)
+
+func HandleUpdate(conn net.Conn, req models.Request) {
+ name, ok := models.Get[string](req, "name")
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'name' parameter")
+ return
+ }
+
+ manager, err := plugins.NewManager()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to create manager: %v", err))
+ return
+ }
+
+ registry, err := plugins.NewRegistry()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to create registry: %v", err))
+ return
+ }
+
+ pluginList, _ := registry.List()
+ plugin := plugins.FindByIDOrName(name, pluginList)
+
+ if plugin != nil {
+ installed, err := manager.IsInstalled(*plugin)
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to check if plugin is installed: %v", err))
+ return
+ }
+ if !installed {
+ models.RespondError(conn, req.ID, fmt.Sprintf("plugin not installed: %s", name))
+ return
+ }
+ if err := manager.Update(*plugin); err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to update plugin: %v", err))
+ return
+ }
+ models.Respond(conn, req.ID, SuccessResult{
+ Success: true,
+ Message: fmt.Sprintf("plugin updated: %s", plugin.Name),
+ })
+ return
+ }
+
+ // Not in registry - try to update from installed plugins directly
+ if err := manager.UpdateByIDOrName(name); err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("plugin not found: %s", name))
+ return
+ }
+
+ models.Respond(conn, req.ID, SuccessResult{
+ Success: true,
+ Message: fmt.Sprintf("plugin updated: %s", name),
+ })
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/utils.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/utils.go
new file mode 100644
index 0000000..8b89f44
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/utils.go
@@ -0,0 +1,17 @@
+package plugins
+
+import (
+ "sort"
+ "strings"
+)
+
+func SortPluginInfoByFirstParty(pluginInfos []PluginInfo) {
+ sort.SliceStable(pluginInfos, func(i, j int) bool {
+ isFirstPartyI := strings.HasPrefix(pluginInfos[i].Repo, "https://github.com/AvengeMedia")
+ isFirstPartyJ := strings.HasPrefix(pluginInfos[j].Repo, "https://github.com/AvengeMedia")
+ if isFirstPartyI != isFirstPartyJ {
+ return isFirstPartyI
+ }
+ return false
+ })
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/router.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/router.go
new file mode 100644
index 0000000..6b8c10b
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/router.go
@@ -0,0 +1,250 @@
+package server
+
+import (
+ "fmt"
+ "net"
+ "strings"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/apppicker"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/bluez"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/clipboard"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups"
+ serverDbus "github.com/AvengeMedia/DankMaterialShell/core/internal/server/dbus"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/dwl"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/extworkspace"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/freedesktop"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/location"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
+ serverPlugins "github.com/AvengeMedia/DankMaterialShell/core/internal/server/plugins"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
+ serverThemes "github.com/AvengeMedia/DankMaterialShell/core/internal/server/themes"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput"
+)
+
+func RouteRequest(conn net.Conn, req models.Request) {
+ if strings.HasPrefix(req.Method, "network.") {
+ if networkManager == nil {
+ models.RespondError(conn, req.ID, "network manager not initialized")
+ return
+ }
+ network.HandleRequest(conn, req, networkManager)
+ return
+ }
+
+ if strings.HasPrefix(req.Method, "plugins.") {
+ serverPlugins.HandleRequest(conn, req)
+ return
+ }
+
+ if strings.HasPrefix(req.Method, "themes.") {
+ serverThemes.HandleRequest(conn, req)
+ return
+ }
+
+ if strings.HasPrefix(req.Method, "theme.auto.") {
+ if themeModeManager == nil {
+ models.RespondError(conn, req.ID, "theme mode manager not initialized")
+ return
+ }
+ thememode.HandleRequest(conn, req, themeModeManager)
+ return
+ }
+
+ if strings.HasPrefix(req.Method, "loginctl.") {
+ if loginctlManager == nil {
+ models.RespondError(conn, req.ID, "loginctl manager not initialized")
+ return
+ }
+ loginctl.HandleRequest(conn, req, loginctlManager)
+ return
+ }
+
+ if strings.HasPrefix(req.Method, "freedesktop.") {
+ if freedesktopManager == nil {
+ models.RespondError(conn, req.ID, "freedesktop manager not initialized")
+ return
+ }
+ freedesktop.HandleRequest(conn, req, freedesktopManager)
+ return
+ }
+
+ if strings.HasPrefix(req.Method, "wayland.") {
+ if waylandManager == nil {
+ models.RespondError(conn, req.ID, "wayland manager not initialized")
+ return
+ }
+ wayland.HandleRequest(conn, req, waylandManager)
+ return
+ }
+
+ if strings.HasPrefix(req.Method, "bluetooth.") {
+ if bluezManager == nil {
+ models.RespondError(conn, req.ID, "bluetooth manager not initialized")
+ return
+ }
+ bluez.HandleRequest(conn, req, bluezManager)
+ return
+ }
+
+ if strings.HasPrefix(req.Method, "browser.") || strings.HasPrefix(req.Method, "apppicker.") {
+ if appPickerManager == nil {
+ models.RespondError(conn, req.ID, "apppicker manager not initialized")
+ return
+ }
+ apppicker.HandleRequest(conn, req, appPickerManager)
+ return
+ }
+
+ if strings.HasPrefix(req.Method, "cups.") {
+ if cupsManager == nil {
+ models.RespondError(conn, req.ID, "CUPS manager not initialized")
+ return
+ }
+ cups.HandleRequest(conn, req, cupsManager)
+ return
+ }
+
+ if strings.HasPrefix(req.Method, "dwl.") {
+ if dwlManager == nil {
+ models.RespondError(conn, req.ID, "dwl manager not initialized")
+ return
+ }
+ dwl.HandleRequest(conn, req, dwlManager)
+ return
+ }
+
+ if strings.HasPrefix(req.Method, "brightness.") {
+ if brightnessManager == nil {
+ models.RespondError(conn, req.ID, "brightness manager not initialized")
+ return
+ }
+ brightness.HandleRequest(conn, req, brightnessManager)
+ return
+ }
+
+ if strings.HasPrefix(req.Method, "extworkspace.") {
+ if extWorkspaceManager == nil {
+ if extWorkspaceAvailable.Load() {
+ extWorkspaceInitMutex.Lock()
+ if extWorkspaceManager == nil {
+ if err := InitializeExtWorkspaceManager(); err != nil {
+ extWorkspaceInitMutex.Unlock()
+ models.RespondError(conn, req.ID, "extworkspace manager not available")
+ return
+ }
+ }
+ extWorkspaceInitMutex.Unlock()
+ } else {
+ models.RespondError(conn, req.ID, "extworkspace manager not initialized")
+ return
+ }
+ }
+ extworkspace.HandleRequest(conn, req, extWorkspaceManager)
+ return
+ }
+
+ if strings.HasPrefix(req.Method, "wlroutput.") {
+ if wlrOutputManager == nil {
+ models.RespondError(conn, req.ID, "wlroutput manager not initialized")
+ return
+ }
+ wlroutput.HandleRequest(conn, req, wlrOutputManager)
+ return
+ }
+
+ if strings.HasPrefix(req.Method, "evdev.") {
+ if evdevManager == nil {
+ models.RespondError(conn, req.ID, "evdev manager not initialized")
+ return
+ }
+ evdev.HandleRequest(conn, req, evdevManager)
+ return
+ }
+
+ if strings.HasPrefix(req.Method, "dbus.") {
+ if dbusManager == nil {
+ models.RespondError(conn, req.ID, "dbus manager not initialized")
+ return
+ }
+ serverDbus.HandleRequest(conn, req, dbusManager, dbusClientID)
+ return
+ }
+
+ if strings.HasPrefix(req.Method, "clipboard.") {
+ switch req.Method {
+ case "clipboard.getConfig":
+ cfg := clipboard.LoadConfig()
+ models.Respond(conn, req.ID, cfg)
+ return
+ case "clipboard.setConfig":
+ handleClipboardSetConfig(conn, req)
+ return
+ }
+ if clipboardManager == nil {
+ models.RespondError(conn, req.ID, "clipboard manager not initialized")
+ return
+ }
+ clipboard.HandleRequest(conn, req, clipboardManager)
+ return
+ }
+
+ if strings.HasPrefix(req.Method, "location.") {
+ if locationManager == nil {
+ models.RespondError(conn, req.ID, "location manager not initialized")
+ return
+ }
+ location.HandleRequest(conn, req, locationManager)
+ return
+ }
+
+ switch req.Method {
+ case "ping":
+ models.Respond(conn, req.ID, "pong")
+ case "getServerInfo":
+ info := getServerInfo()
+ models.Respond(conn, req.ID, info)
+ case "subscribe":
+ handleSubscribe(conn, req)
+ case "matugen.queue":
+ handleMatugenQueue(conn, req)
+ case "matugen.status":
+ handleMatugenStatus(conn, req)
+ default:
+ models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
+ }
+}
+
+func handleClipboardSetConfig(conn net.Conn, req models.Request) {
+ cfg := clipboard.LoadConfig()
+
+ if v, ok := models.Get[float64](req, "maxHistory"); ok {
+ cfg.MaxHistory = int(v)
+ }
+ if v, ok := models.Get[float64](req, "maxEntrySize"); ok {
+ cfg.MaxEntrySize = int64(v)
+ }
+ if v, ok := models.Get[float64](req, "autoClearDays"); ok {
+ cfg.AutoClearDays = int(v)
+ }
+ if v, ok := models.Get[bool](req, "clearAtStartup"); ok {
+ cfg.ClearAtStartup = v
+ }
+ if v, ok := models.Get[bool](req, "disabled"); ok {
+ cfg.Disabled = v
+ }
+ if v, ok := models.Get[float64](req, "maxPinned"); ok {
+ cfg.MaxPinned = int(v)
+ }
+
+ if err := clipboard.SaveConfig(cfg); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "config updated"})
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/server.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/server.go
new file mode 100644
index 0000000..58acb62
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/server.go
@@ -0,0 +1,1758 @@
+package server
+
+import (
+ "bufio"
+ "encoding/json"
+ "fmt"
+ "net"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "syscall"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/apppicker"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/bluez"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/clipboard"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups"
+ serverDbus "github.com/AvengeMedia/DankMaterialShell/core/internal/server/dbus"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/dwl"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/extworkspace"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/freedesktop"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/location"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/trayrecovery"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput"
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
+)
+
+const APIVersion = 24
+
+var CLIVersion = "dev"
+
+type Capabilities struct {
+ Capabilities []string `json:"capabilities"`
+}
+
+type ServerInfo struct {
+ APIVersion int `json:"apiVersion"`
+ CLIVersion string `json:"cliVersion,omitempty"`
+ Capabilities []string `json:"capabilities"`
+}
+
+type ServiceEvent struct {
+ Service string `json:"service"`
+ Data any `json:"data"`
+}
+
+var networkManager *network.Manager
+var loginctlManager *loginctl.Manager
+var freedesktopManager *freedesktop.Manager
+var waylandManager *wayland.Manager
+var bluezManager *bluez.Manager
+var appPickerManager *apppicker.Manager
+var cupsManager *cups.Manager
+var dwlManager *dwl.Manager
+var extWorkspaceManager *extworkspace.Manager
+var brightnessManager *brightness.Manager
+var wlrOutputManager *wlroutput.Manager
+var evdevManager *evdev.Manager
+var clipboardManager *clipboard.Manager
+var dbusManager *serverDbus.Manager
+var wlContext *wlcontext.SharedContext
+var themeModeManager *thememode.Manager
+var trayRecoveryManager *trayrecovery.Manager
+var locationManager *location.Manager
+var geoClientInstance geolocation.Client
+
+const dbusClientID = "dms-dbus-client"
+
+var capabilitySubscribers syncmap.Map[string, chan ServerInfo]
+var cupsSubscribers syncmap.Map[string, bool]
+var cupsSubscriberCount atomic.Int32
+var extWorkspaceAvailable atomic.Bool
+var extWorkspaceInitMutex sync.Mutex
+
+func getSocketDir() string {
+ if runtime := os.Getenv("XDG_RUNTIME_DIR"); runtime != "" {
+ return runtime
+ }
+
+ if os.Getuid() == 0 {
+ if _, err := os.Stat("/run"); err == nil {
+ return "/run/dankdots"
+ }
+ return "/var/run/dankdots"
+ }
+
+ return os.TempDir()
+}
+
+func GetSocketPath() string {
+ return filepath.Join(getSocketDir(), fmt.Sprintf("danklinux-%d.sock", os.Getpid()))
+}
+
+func FindSocket() (string, error) {
+ dir := getSocketDir()
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return "", err
+ }
+
+ for _, entry := range entries {
+ if strings.HasPrefix(entry.Name(), "danklinux-") && strings.HasSuffix(entry.Name(), ".sock") {
+ return filepath.Join(dir, entry.Name()), nil
+ }
+ }
+ return "", fmt.Errorf("no dms socket found")
+}
+
+func cleanupStaleSockets() {
+ dir := getSocketDir()
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return
+ }
+
+ for _, entry := range entries {
+ if !strings.HasPrefix(entry.Name(), "danklinux-") || !strings.HasSuffix(entry.Name(), ".sock") {
+ continue
+ }
+
+ pidStr := strings.TrimPrefix(entry.Name(), "danklinux-")
+ pidStr = strings.TrimSuffix(pidStr, ".sock")
+ pid, err := strconv.Atoi(pidStr)
+ if err != nil {
+ continue
+ }
+
+ process, err := os.FindProcess(pid)
+ if err != nil {
+ socketPath := filepath.Join(dir, entry.Name())
+ os.Remove(socketPath)
+ log.Debugf("Removed stale socket: %s", socketPath)
+ continue
+ }
+
+ err = process.Signal(syscall.Signal(0))
+ if err != nil {
+ socketPath := filepath.Join(dir, entry.Name())
+ os.Remove(socketPath)
+ log.Debugf("Removed stale socket: %s", socketPath)
+ }
+ }
+}
+
+func InitializeNetworkManager() error {
+ manager, err := network.NewManager()
+ if err != nil {
+ log.Warnf("Failed to initialize network manager: %v", err)
+ return err
+ }
+
+ networkManager = manager
+
+ log.Info("Network manager initialized")
+ return nil
+}
+
+func InitializeLoginctlManager() error {
+ manager, err := loginctl.NewManager()
+ if err != nil {
+ log.Warnf("Failed to initialize loginctl manager: %v", err)
+ return err
+ }
+
+ loginctlManager = manager
+
+ log.Info("Loginctl manager initialized")
+ return nil
+}
+
+func InitializeFreedeskManager() error {
+ manager, err := freedesktop.NewManager()
+ if err != nil {
+ log.Warnf("Failed to initialize freedesktop manager: %v", err)
+ return err
+ }
+
+ freedesktopManager = manager
+
+ log.Info("Freedesktop manager initialized")
+ return nil
+}
+
+func InitializeWaylandManager() error {
+ log.Info("Attempting to initialize Wayland gamma control...")
+
+ if wlContext == nil {
+ ctx, err := wlcontext.New()
+ if err != nil {
+ log.Errorf("Failed to create shared Wayland context: %v", err)
+ return err
+ }
+ wlContext = ctx
+ }
+
+ config := wayland.DefaultConfig()
+ manager, err := wayland.NewManager(wlContext.Display(), config)
+ if err != nil {
+ log.Errorf("Failed to initialize wayland manager: %v", err)
+ return err
+ }
+
+ waylandManager = manager
+
+ log.Info("Wayland gamma control initialized successfully")
+ return nil
+}
+
+func InitializeBluezManager() error {
+ manager, err := bluez.NewManager()
+ if err != nil {
+ log.Warnf("Failed to initialize bluez manager: %v", err)
+ return err
+ }
+
+ bluezManager = manager
+
+ log.Info("Bluez manager initialized")
+ return nil
+}
+
+func InitializeAppPickerManager() error {
+ manager := apppicker.NewManager()
+ appPickerManager = manager
+ log.Info("AppPicker manager initialized")
+ return nil
+}
+
+func InitializeCupsManager() error {
+ manager, err := cups.NewManager()
+ if err != nil {
+ log.Warnf("Failed to initialize cups manager: %v", err)
+ return err
+ }
+
+ cupsManager = manager
+
+ log.Info("CUPS manager initialized")
+ return nil
+}
+
+func InitializeDwlManager() error {
+ log.Info("Attempting to initialize DWL IPC...")
+
+ if wlContext == nil {
+ ctx, err := wlcontext.New()
+ if err != nil {
+ log.Errorf("Failed to create shared Wayland context: %v", err)
+ return err
+ }
+ wlContext = ctx
+ }
+
+ manager, err := dwl.NewManager(wlContext.Display())
+ if err != nil {
+ log.Debug("Failed to initialize dwl manager: %v", err)
+ return err
+ }
+
+ dwlManager = manager
+
+ log.Info("DWL IPC initialized successfully")
+ return nil
+}
+
+func InitializeBrightnessManager() error {
+ manager, err := brightness.NewManager()
+ if err != nil {
+ log.Warnf("Failed to initialize brightness manager: %v", err)
+ return err
+ }
+
+ brightnessManager = manager
+
+ log.Info("Brightness manager initialized")
+ return nil
+}
+
+func InitializeExtWorkspaceManager() error {
+ log.Info("Attempting to initialize ExtWorkspace...")
+
+ if wlContext == nil {
+ ctx, err := wlcontext.New()
+ if err != nil {
+ log.Errorf("Failed to create shared Wayland context: %v", err)
+ return err
+ }
+ wlContext = ctx
+ }
+
+ manager, err := extworkspace.NewManager(wlContext.Display())
+ if err != nil {
+ log.Debug("Failed to initialize extworkspace manager: %v", err)
+ return err
+ }
+
+ extWorkspaceManager = manager
+
+ log.Info("ExtWorkspace initialized successfully")
+ return nil
+}
+
+func InitializeWlrOutputManager() error {
+ log.Info("Attempting to initialize WlrOutput management...")
+
+ if wlContext == nil {
+ ctx, err := wlcontext.New()
+ if err != nil {
+ log.Errorf("Failed to create shared Wayland context: %v", err)
+ return err
+ }
+ wlContext = ctx
+ }
+
+ manager, err := wlroutput.NewManager(wlContext.Display())
+ if err != nil {
+ log.Debug("Failed to initialize wlroutput manager: %v", err)
+ return err
+ }
+
+ wlrOutputManager = manager
+
+ log.Info("WlrOutput management initialized successfully")
+ return nil
+}
+
+func InitializeEvdevManager() error {
+ manager, err := evdev.InitializeManager()
+ if err != nil {
+ log.Warnf("Failed to initialize evdev manager: %v", err)
+ return err
+ }
+
+ evdevManager = manager
+
+ log.Info("Evdev manager initialized")
+ return nil
+}
+
+func InitializeClipboardManager() error {
+ log.Info("Attempting to initialize clipboard manager...")
+
+ if wlContext == nil {
+ ctx, err := wlcontext.New()
+ if err != nil {
+ log.Errorf("Failed to create shared Wayland context: %v", err)
+ return err
+ }
+ wlContext = ctx
+ }
+
+ config := clipboard.LoadConfig()
+ manager, err := clipboard.NewManager(wlContext, config)
+ if err != nil {
+ log.Errorf("Failed to initialize clipboard manager: %v", err)
+ return err
+ }
+
+ clipboardManager = manager
+
+ log.Info("Clipboard manager initialized successfully")
+ return nil
+}
+
+func InitializeDbusManager() error {
+ manager, err := serverDbus.NewManager()
+ if err != nil {
+ log.Warnf("Failed to initialize dbus manager: %v", err)
+ return err
+ }
+
+ dbusManager = manager
+
+ log.Info("DBus manager initialized")
+ return nil
+}
+
+func InitializeThemeModeManager() error {
+ manager := thememode.NewManager()
+ themeModeManager = manager
+
+ log.Info("Theme mode automation manager initialized")
+ return nil
+}
+
+func InitializeTrayRecoveryManager() error {
+ manager, err := trayrecovery.NewManager()
+ if err != nil {
+ return err
+ }
+
+ trayRecoveryManager = manager
+
+ log.Info("TrayRecovery manager initialized")
+ return nil
+}
+
+func InitializeLocationManager(geoClient geolocation.Client) error {
+ manager, err := location.NewManager(geoClient)
+ if err != nil {
+ log.Warnf("Failed to initialize location manager: %v", err)
+ return err
+ }
+
+ locationManager = manager
+
+ log.Info("Location manager initialized")
+ return nil
+}
+
+func handleConnection(conn net.Conn) {
+ defer conn.Close()
+
+ caps := getCapabilities()
+ capsData, _ := json.Marshal(caps)
+ conn.Write(capsData)
+ conn.Write([]byte("\n"))
+ scanner := bufio.NewScanner(conn)
+ for scanner.Scan() {
+ line := scanner.Bytes()
+
+ var req models.Request
+ if err := json.Unmarshal(line, &req); err != nil {
+ log.Warnf("handleConnection: Failed to unmarshal JSON: %v, line: %s", err, string(line))
+ models.RespondError(conn, 0, "invalid json")
+ continue
+ }
+
+ go RouteRequest(conn, req)
+ }
+}
+
+func getCapabilities() Capabilities {
+ caps := []string{"plugins"}
+
+ if networkManager != nil {
+ caps = append(caps, "network")
+ }
+
+ if loginctlManager != nil {
+ caps = append(caps, "loginctl")
+ }
+
+ if freedesktopManager != nil {
+ caps = append(caps, "freedesktop")
+ }
+
+ if waylandManager != nil {
+ caps = append(caps, "gamma")
+ }
+
+ if bluezManager != nil {
+ caps = append(caps, "bluetooth")
+ }
+
+ if appPickerManager != nil {
+ caps = append(caps, "browser")
+ }
+
+ if cupsManager != nil {
+ caps = append(caps, "cups")
+ }
+
+ if dwlManager != nil {
+ caps = append(caps, "dwl")
+ }
+
+ if extWorkspaceAvailable.Load() {
+ caps = append(caps, "extworkspace")
+ }
+
+ if brightnessManager != nil {
+ caps = append(caps, "brightness")
+ }
+
+ if wlrOutputManager != nil {
+ caps = append(caps, "wlroutput")
+ }
+
+ if evdevManager != nil {
+ caps = append(caps, "evdev")
+ }
+
+ if clipboardManager != nil {
+ caps = append(caps, "clipboard")
+ }
+
+ if themeModeManager != nil {
+ caps = append(caps, "theme.auto")
+ }
+
+ if dbusManager != nil {
+ caps = append(caps, "dbus")
+ }
+
+ return Capabilities{Capabilities: caps}
+}
+
+func getServerInfo() ServerInfo {
+ caps := []string{"plugins"}
+
+ if networkManager != nil {
+ caps = append(caps, "network")
+ }
+
+ if loginctlManager != nil {
+ caps = append(caps, "loginctl")
+ }
+
+ if freedesktopManager != nil {
+ caps = append(caps, "freedesktop")
+ }
+
+ if waylandManager != nil {
+ caps = append(caps, "gamma")
+ }
+
+ if bluezManager != nil {
+ caps = append(caps, "bluetooth")
+ }
+
+ if appPickerManager != nil {
+ caps = append(caps, "browser")
+ }
+
+ if cupsManager != nil {
+ caps = append(caps, "cups")
+ }
+
+ if dwlManager != nil {
+ caps = append(caps, "dwl")
+ }
+
+ if extWorkspaceAvailable.Load() {
+ caps = append(caps, "extworkspace")
+ }
+
+ if brightnessManager != nil {
+ caps = append(caps, "brightness")
+ }
+
+ if wlrOutputManager != nil {
+ caps = append(caps, "wlroutput")
+ }
+
+ if evdevManager != nil {
+ caps = append(caps, "evdev")
+ }
+
+ if clipboardManager != nil {
+ caps = append(caps, "clipboard")
+ }
+
+ if themeModeManager != nil {
+ caps = append(caps, "theme.auto")
+ }
+
+ if locationManager != nil {
+ caps = append(caps, "location")
+ }
+
+ if dbusManager != nil {
+ caps = append(caps, "dbus")
+ }
+
+ return ServerInfo{
+ APIVersion: APIVersion,
+ CLIVersion: CLIVersion,
+ Capabilities: caps,
+ }
+}
+
+func notifyCapabilityChange() {
+ info := getServerInfo()
+ capabilitySubscribers.Range(func(key string, ch chan ServerInfo) bool {
+ select {
+ case ch <- info:
+ default:
+ }
+ return true
+ })
+}
+
+func handleSubscribe(conn net.Conn, req models.Request) {
+ clientID := fmt.Sprintf("meta-client-%p", conn)
+
+ var services []string
+ if servicesParam, ok := models.Get[[]any](req, "services"); ok {
+ for _, s := range servicesParam {
+ if str, ok := s.(string); ok {
+ services = append(services, str)
+ }
+ }
+ }
+
+ if len(services) == 0 {
+ services = []string{"all"}
+ }
+
+ subscribeAll := false
+ for _, s := range services {
+ if s == "all" {
+ subscribeAll = true
+ break
+ }
+ }
+
+ var wg sync.WaitGroup
+ eventChan := make(chan ServiceEvent, 256)
+ stopChan := make(chan struct{})
+
+ capChan := make(chan ServerInfo, 64)
+ capabilitySubscribers.Store(clientID+"-capabilities", capChan)
+
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ defer capabilitySubscribers.Delete(clientID + "-capabilities")
+
+ for {
+ select {
+ case info, ok := <-capChan:
+ if !ok {
+ return
+ }
+ select {
+ case eventChan <- ServiceEvent{Service: "server", Data: info}:
+ case <-stopChan:
+ return
+ }
+ case <-stopChan:
+ return
+ }
+ }
+ }()
+
+ shouldSubscribe := func(service string) bool {
+ if subscribeAll {
+ return true
+ }
+ for _, s := range services {
+ if s == service {
+ return true
+ }
+ }
+ return false
+ }
+
+ if shouldSubscribe("network") && networkManager != nil {
+ wg.Add(1)
+ netChan := networkManager.Subscribe(clientID + "-network")
+ go func() {
+ defer wg.Done()
+ defer networkManager.Unsubscribe(clientID + "-network")
+
+ initialState := networkManager.GetState()
+ select {
+ case eventChan <- ServiceEvent{Service: "network", Data: initialState}:
+ case <-stopChan:
+ return
+ }
+
+ for {
+ select {
+ case state, ok := <-netChan:
+ if !ok {
+ return
+ }
+ select {
+ case eventChan <- ServiceEvent{Service: "network", Data: state}:
+ case <-stopChan:
+ return
+ }
+ case <-stopChan:
+ return
+ }
+ }
+ }()
+ }
+
+ if shouldSubscribe("network.credentials") && networkManager != nil {
+ wg.Add(1)
+ credChan := networkManager.SubscribeCredentials(clientID + "-credentials")
+ go func() {
+ defer wg.Done()
+ defer networkManager.UnsubscribeCredentials(clientID + "-credentials")
+
+ for {
+ select {
+ case prompt, ok := <-credChan:
+ if !ok {
+ return
+ }
+ select {
+ case eventChan <- ServiceEvent{Service: "network.credentials", Data: prompt}:
+ case <-stopChan:
+ return
+ }
+ case <-stopChan:
+ return
+ }
+ }
+ }()
+ }
+
+ if shouldSubscribe("loginctl") && loginctlManager != nil {
+ wg.Add(1)
+ loginChan := loginctlManager.Subscribe(clientID + "-loginctl")
+ go func() {
+ defer wg.Done()
+ defer loginctlManager.Unsubscribe(clientID + "-loginctl")
+
+ initialState := loginctlManager.GetState()
+ select {
+ case eventChan <- ServiceEvent{Service: "loginctl", Data: initialState}:
+ case <-stopChan:
+ return
+ }
+
+ for {
+ select {
+ case state, ok := <-loginChan:
+ if !ok {
+ return
+ }
+ select {
+ case eventChan <- ServiceEvent{Service: "loginctl", Data: state}:
+ case <-stopChan:
+ return
+ }
+ case <-stopChan:
+ return
+ }
+ }
+ }()
+ }
+
+ if shouldSubscribe("freedesktop") && freedesktopManager != nil {
+ wg.Add(1)
+ freedesktopChan := freedesktopManager.Subscribe(clientID + "-freedesktop")
+ go func() {
+ defer wg.Done()
+ defer freedesktopManager.Unsubscribe(clientID + "-freedesktop")
+
+ initialState := freedesktopManager.GetState()
+ select {
+ case eventChan <- ServiceEvent{Service: "freedesktop", Data: initialState}:
+ case <-stopChan:
+ return
+ }
+
+ for {
+ select {
+ case state, ok := <-freedesktopChan:
+ if !ok {
+ return
+ }
+ select {
+ case eventChan <- ServiceEvent{Service: "freedesktop", Data: state}:
+ case <-stopChan:
+ return
+ }
+ case <-stopChan:
+ return
+ }
+ }
+ }()
+ }
+
+ if shouldSubscribe("freedesktop.screensaver") && freedesktopManager != nil {
+ wg.Add(1)
+ screensaverChan := freedesktopManager.SubscribeScreensaver(clientID + "-screensaver")
+ go func() {
+ defer wg.Done()
+ defer freedesktopManager.UnsubscribeScreensaver(clientID + "-screensaver")
+
+ initialState := freedesktopManager.GetScreensaverState()
+ select {
+ case eventChan <- ServiceEvent{Service: "freedesktop.screensaver", Data: initialState}:
+ case <-stopChan:
+ return
+ }
+
+ for {
+ select {
+ case state, ok := <-screensaverChan:
+ if !ok {
+ return
+ }
+ select {
+ case eventChan <- ServiceEvent{Service: "freedesktop.screensaver", Data: state}:
+ case <-stopChan:
+ return
+ }
+ case <-stopChan:
+ return
+ }
+ }
+ }()
+ }
+
+ if shouldSubscribe("gamma") && waylandManager != nil {
+ wg.Add(1)
+ waylandChan := waylandManager.Subscribe(clientID + "-gamma")
+ go func() {
+ defer wg.Done()
+ defer waylandManager.Unsubscribe(clientID + "-gamma")
+
+ initialState := waylandManager.GetState()
+ select {
+ case eventChan <- ServiceEvent{Service: "gamma", Data: initialState}:
+ case <-stopChan:
+ return
+ }
+
+ for {
+ select {
+ case state, ok := <-waylandChan:
+ if !ok {
+ return
+ }
+ select {
+ case eventChan <- ServiceEvent{Service: "gamma", Data: state}:
+ case <-stopChan:
+ return
+ }
+ case <-stopChan:
+ return
+ }
+ }
+ }()
+ }
+
+ if shouldSubscribe("theme.auto") && themeModeManager != nil {
+ wg.Add(1)
+ themeAutoChan := themeModeManager.Subscribe(clientID + "-theme-auto")
+ go func() {
+ defer wg.Done()
+ defer themeModeManager.Unsubscribe(clientID + "-theme-auto")
+
+ initialState := themeModeManager.GetState()
+ select {
+ case eventChan <- ServiceEvent{Service: "theme.auto", Data: initialState}:
+ case <-stopChan:
+ return
+ }
+
+ for {
+ select {
+ case state, ok := <-themeAutoChan:
+ if !ok {
+ return
+ }
+ select {
+ case eventChan <- ServiceEvent{Service: "theme.auto", Data: state}:
+ case <-stopChan:
+ return
+ }
+ case <-stopChan:
+ return
+ }
+ }
+ }()
+ }
+
+ if shouldSubscribe("bluetooth") && bluezManager != nil {
+ wg.Add(1)
+ bluezChan := bluezManager.Subscribe(clientID + "-bluetooth")
+ go func() {
+ defer wg.Done()
+ defer bluezManager.Unsubscribe(clientID + "-bluetooth")
+
+ initialState := bluezManager.GetState()
+ select {
+ case eventChan <- ServiceEvent{Service: "bluetooth", Data: initialState}:
+ case <-stopChan:
+ return
+ }
+
+ for {
+ select {
+ case state, ok := <-bluezChan:
+ if !ok {
+ return
+ }
+ select {
+ case eventChan <- ServiceEvent{Service: "bluetooth", Data: state}:
+ case <-stopChan:
+ return
+ }
+ case <-stopChan:
+ return
+ }
+ }
+ }()
+ }
+
+ if shouldSubscribe("bluetooth.pairing") && bluezManager != nil {
+ wg.Add(1)
+ pairingChan := bluezManager.SubscribePairing(clientID + "-pairing")
+ go func() {
+ defer wg.Done()
+ defer bluezManager.UnsubscribePairing(clientID + "-pairing")
+
+ for {
+ select {
+ case prompt, ok := <-pairingChan:
+ if !ok {
+ return
+ }
+ select {
+ case eventChan <- ServiceEvent{Service: "bluetooth.pairing", Data: prompt}:
+ case <-stopChan:
+ return
+ }
+ case <-stopChan:
+ return
+ }
+ }
+ }()
+ }
+
+ if shouldSubscribe("browser") && appPickerManager != nil {
+ wg.Add(1)
+ appPickerChan := appPickerManager.Subscribe(clientID + "-browser")
+ go func() {
+ defer wg.Done()
+ defer appPickerManager.Unsubscribe(clientID + "-browser")
+
+ for {
+ select {
+ case event, ok := <-appPickerChan:
+ if !ok {
+ return
+ }
+ select {
+ case eventChan <- ServiceEvent{Service: "browser.open_requested", Data: event}:
+ case <-stopChan:
+ return
+ }
+ case <-stopChan:
+ return
+ }
+ }
+ }()
+ }
+
+ if shouldSubscribe("cups") {
+ cupsSubscribers.Store(clientID+"-cups", true)
+ count := cupsSubscriberCount.Add(1)
+
+ if count == 1 {
+ if err := InitializeCupsManager(); err != nil {
+ log.Warnf("Failed to initialize CUPS manager for subscription: %v", err)
+ } else {
+ notifyCapabilityChange()
+ }
+ }
+
+ if cupsManager != nil {
+ wg.Add(1)
+ cupsChan := cupsManager.Subscribe(clientID + "-cups")
+ go func() {
+ defer wg.Done()
+ defer func() {
+ cupsManager.Unsubscribe(clientID + "-cups")
+ cupsSubscribers.Delete(clientID + "-cups")
+ count := cupsSubscriberCount.Add(-1)
+
+ if count == 0 {
+ log.Info("Last CUPS subscriber disconnected, shutting down CUPS manager")
+ if cupsManager != nil {
+ cupsManager.Close()
+ cupsManager = nil
+ notifyCapabilityChange()
+ }
+ }
+ }()
+
+ initialState := cupsManager.GetState()
+ select {
+ case eventChan <- ServiceEvent{Service: "cups", Data: initialState}:
+ case <-stopChan:
+ return
+ }
+
+ for {
+ select {
+ case state, ok := <-cupsChan:
+ if !ok {
+ return
+ }
+ select {
+ case eventChan <- ServiceEvent{Service: "cups", Data: state}:
+ case <-stopChan:
+ return
+ }
+ case <-stopChan:
+ return
+ }
+ }
+ }()
+ }
+ }
+
+ if shouldSubscribe("dwl") && dwlManager != nil {
+ wg.Add(1)
+ dwlChan := dwlManager.Subscribe(clientID + "-dwl")
+ go func() {
+ defer wg.Done()
+ defer dwlManager.Unsubscribe(clientID + "-dwl")
+
+ initialState := dwlManager.GetState()
+ select {
+ case eventChan <- ServiceEvent{Service: "dwl", Data: initialState}:
+ case <-stopChan:
+ return
+ }
+
+ for {
+ select {
+ case state, ok := <-dwlChan:
+ if !ok {
+ return
+ }
+ select {
+ case eventChan <- ServiceEvent{Service: "dwl", Data: state}:
+ case <-stopChan:
+ return
+ }
+ case <-stopChan:
+ return
+ }
+ }
+ }()
+ }
+
+ if shouldSubscribe("extworkspace") {
+ if extWorkspaceManager == nil && extWorkspaceAvailable.Load() {
+ extWorkspaceInitMutex.Lock()
+ if extWorkspaceManager == nil {
+ if err := InitializeExtWorkspaceManager(); err != nil {
+ log.Warnf("Failed to initialize ExtWorkspace manager for subscription: %v", err)
+ }
+ }
+ extWorkspaceInitMutex.Unlock()
+ }
+
+ if extWorkspaceManager != nil {
+ wg.Add(1)
+ extWorkspaceChan := extWorkspaceManager.Subscribe(clientID + "-extworkspace")
+ go func() {
+ defer wg.Done()
+ defer extWorkspaceManager.Unsubscribe(clientID + "-extworkspace")
+
+ initialState := extWorkspaceManager.GetState()
+ select {
+ case eventChan <- ServiceEvent{Service: "extworkspace", Data: initialState}:
+ case <-stopChan:
+ return
+ }
+
+ for {
+ select {
+ case state, ok := <-extWorkspaceChan:
+ if !ok {
+ return
+ }
+ select {
+ case eventChan <- ServiceEvent{Service: "extworkspace", Data: state}:
+ case <-stopChan:
+ return
+ }
+ case <-stopChan:
+ return
+ }
+ }
+ }()
+ }
+ }
+
+ if shouldSubscribe("brightness") && brightnessManager != nil {
+ wg.Add(2)
+ brightnessStateChan := brightnessManager.Subscribe(clientID + "-brightness-state")
+ brightnessUpdateChan := brightnessManager.SubscribeUpdates(clientID + "-brightness-updates")
+
+ go func() {
+ defer wg.Done()
+ defer brightnessManager.Unsubscribe(clientID + "-brightness-state")
+
+ initialState := brightnessManager.GetState()
+ select {
+ case eventChan <- ServiceEvent{Service: "brightness", Data: initialState}:
+ case <-stopChan:
+ return
+ }
+
+ for {
+ select {
+ case state, ok := <-brightnessStateChan:
+ if !ok {
+ return
+ }
+ select {
+ case eventChan <- ServiceEvent{Service: "brightness", Data: state}:
+ case <-stopChan:
+ return
+ }
+ case <-stopChan:
+ return
+ }
+ }
+ }()
+
+ go func() {
+ defer wg.Done()
+ defer brightnessManager.UnsubscribeUpdates(clientID + "-brightness-updates")
+
+ for {
+ select {
+ case update, ok := <-brightnessUpdateChan:
+ if !ok {
+ return
+ }
+ select {
+ case eventChan <- ServiceEvent{Service: "brightness.update", Data: update}:
+ case <-stopChan:
+ return
+ }
+ case <-stopChan:
+ return
+ }
+ }
+ }()
+ }
+
+ if shouldSubscribe("wlroutput") && wlrOutputManager != nil {
+ wg.Add(1)
+ wlrOutputChan := wlrOutputManager.Subscribe(clientID + "-wlroutput")
+ go func() {
+ defer wg.Done()
+ defer wlrOutputManager.Unsubscribe(clientID + "-wlroutput")
+
+ initialState := wlrOutputManager.GetState()
+ select {
+ case eventChan <- ServiceEvent{Service: "wlroutput", Data: initialState}:
+ case <-stopChan:
+ return
+ }
+
+ for {
+ select {
+ case state, ok := <-wlrOutputChan:
+ if !ok {
+ return
+ }
+ select {
+ case eventChan <- ServiceEvent{Service: "wlroutput", Data: state}:
+ case <-stopChan:
+ return
+ }
+ case <-stopChan:
+ return
+ }
+ }
+ }()
+ }
+
+ if shouldSubscribe("evdev") && evdevManager != nil {
+ wg.Add(1)
+ evdevChan := evdevManager.Subscribe(clientID + "-evdev")
+ go func() {
+ defer wg.Done()
+ defer evdevManager.Unsubscribe(clientID + "-evdev")
+
+ initialState := evdevManager.GetState()
+ select {
+ case eventChan <- ServiceEvent{Service: "evdev", Data: initialState}:
+ case <-stopChan:
+ return
+ }
+
+ for {
+ select {
+ case state, ok := <-evdevChan:
+ if !ok {
+ return
+ }
+ select {
+ case eventChan <- ServiceEvent{Service: "evdev", Data: state}:
+ case <-stopChan:
+ return
+ }
+ case <-stopChan:
+ return
+ }
+ }
+ }()
+ }
+
+ if shouldSubscribe("clipboard") && clipboardManager != nil {
+ wg.Add(1)
+ clipboardChan := clipboardManager.Subscribe(clientID + "-clipboard")
+ go func() {
+ defer wg.Done()
+ defer clipboardManager.Unsubscribe(clientID + "-clipboard")
+
+ initialState := clipboardManager.GetState()
+ select {
+ case eventChan <- ServiceEvent{Service: "clipboard", Data: initialState}:
+ case <-stopChan:
+ return
+ }
+
+ for {
+ select {
+ case state, ok := <-clipboardChan:
+ if !ok {
+ return
+ }
+ select {
+ case eventChan <- ServiceEvent{Service: "clipboard", Data: state}:
+ case <-stopChan:
+ return
+ }
+ case <-stopChan:
+ return
+ }
+ }
+ }()
+ }
+
+ if shouldSubscribe("dbus") && dbusManager != nil {
+ wg.Add(1)
+ dbusChan := dbusManager.SubscribeSignals(dbusClientID)
+ go func() {
+ defer wg.Done()
+ defer dbusManager.UnsubscribeSignals(dbusClientID)
+
+ for {
+ select {
+ case event, ok := <-dbusChan:
+ if !ok {
+ return
+ }
+ select {
+ case eventChan <- ServiceEvent{Service: "dbus", Data: event}:
+ case <-stopChan:
+ return
+ }
+ case <-stopChan:
+ return
+ }
+ }
+ }()
+ }
+
+ go func() {
+ wg.Wait()
+ close(eventChan)
+ }()
+
+ info := getServerInfo()
+ if err := json.NewEncoder(conn).Encode(models.Response[ServiceEvent]{
+ ID: req.ID,
+ Result: &ServiceEvent{Service: "server", Data: info},
+ }); err != nil {
+ close(stopChan)
+ return
+ }
+
+ for event := range eventChan {
+ if err := json.NewEncoder(conn).Encode(models.Response[ServiceEvent]{
+ ID: req.ID,
+ Result: &event,
+ }); err != nil {
+ close(stopChan)
+ return
+ }
+ }
+}
+
+func cleanupManagers() {
+ if networkManager != nil {
+ networkManager.Close()
+ }
+ if loginctlManager != nil {
+ loginctlManager.Close()
+ }
+ if freedesktopManager != nil {
+ freedesktopManager.Close()
+ }
+ if waylandManager != nil {
+ waylandManager.Close()
+ }
+ if bluezManager != nil {
+ bluezManager.Close()
+ }
+ if appPickerManager != nil {
+ appPickerManager.Close()
+ }
+ if cupsManager != nil {
+ cupsManager.Close()
+ }
+ if dwlManager != nil {
+ dwlManager.Close()
+ }
+ if extWorkspaceManager != nil {
+ extWorkspaceManager.Close()
+ }
+ if brightnessManager != nil {
+ brightnessManager.Close()
+ }
+ if wlrOutputManager != nil {
+ wlrOutputManager.Close()
+ }
+ if evdevManager != nil {
+ evdevManager.Close()
+ }
+ if clipboardManager != nil {
+ clipboardManager.Close()
+ }
+ if dbusManager != nil {
+ dbusManager.Close()
+ }
+ if themeModeManager != nil {
+ themeModeManager.Close()
+ }
+ if trayRecoveryManager != nil {
+ trayRecoveryManager.Close()
+ }
+ if wlContext != nil {
+ wlContext.Close()
+ }
+ if locationManager != nil {
+ locationManager.Close()
+ }
+ if geoClientInstance != nil {
+ geoClientInstance.Close()
+ }
+}
+
+func Start(printDocs bool) error {
+ cleanupStaleSockets()
+
+ socketPath := GetSocketPath()
+ os.Remove(socketPath)
+
+ listener, err := net.Listen("unix", socketPath)
+ if err != nil {
+ return err
+ }
+ defer listener.Close()
+ defer cleanupManagers()
+
+ log.Infof("DMS API Server listening on: %s", socketPath)
+ log.Infof("API Version: %d", APIVersion)
+ log.Info("Protocol: JSON over Unix socket")
+ log.Info("Request format: {\"id\": <any>, \"method\": \"...\", \"params\": {...}}")
+ log.Info("Response format: {\"id\": <any>, \"result\": {...}} or {\"id\": <any>, \"error\": \"...\"}")
+ log.Info("")
+ if printDocs {
+ log.Info("Available methods:")
+ log.Info(" ping - Test connection")
+ log.Info(" getServerInfo - Get server info (API version and capabilities)")
+ log.Info(" subscribe - Subscribe to multiple services (params: services [default: all])")
+ log.Info("Plugins:")
+ log.Info(" plugins.list - List all plugins")
+ log.Info(" plugins.listInstalled - List installed plugins")
+ log.Info(" plugins.install - Install plugin (params: name)")
+ log.Info(" plugins.uninstall - Uninstall plugin (params: name)")
+ log.Info(" plugins.update - Update plugin (params: name)")
+ log.Info(" plugins.search - Search plugins (params: query, category?, compositor?, capability?)")
+ log.Info("Network:")
+ log.Info(" network.getState - Get current network state")
+ log.Info(" network.wifi.scan - Scan for WiFi networks (params: device?)")
+ log.Info(" network.wifi.networks - Get WiFi network list")
+ log.Info(" network.wifi.connect - Connect to WiFi (params: ssid, password?, username?, device?, eapMethod?, phase2Auth?, caCertPath?, clientCertPath?, privateKeyPath?, useSystemCACerts?)")
+ log.Info(" network.wifi.disconnect - Disconnect WiFi (params: device?)")
+ log.Info(" network.wifi.forget - Forget network (params: ssid)")
+ log.Info(" network.wifi.toggle - Toggle WiFi radio")
+ log.Info(" network.wifi.enable - Enable WiFi")
+ log.Info(" network.wifi.disable - Disable WiFi")
+ log.Info(" network.wifi.setAutoconnect - Set network autoconnect (params: ssid, autoconnect)")
+ log.Info(" network.ethernet.connect - Connect Ethernet")
+ log.Info(" network.ethernet.connect.config - Connect Ethernet to a specific configuration")
+ log.Info(" network.ethernet.disconnect - Disconnect Ethernet")
+ log.Info(" network.vpn.profiles - List VPN profiles")
+ log.Info(" network.vpn.active - List active VPN connections")
+ log.Info(" network.vpn.connect - Connect VPN (params: uuidOrName|name|uuid, singleActive?)")
+ log.Info(" network.vpn.disconnect - Disconnect VPN (params: uuidOrName|name|uuid)")
+ log.Info(" network.vpn.disconnectAll - Disconnect all VPNs")
+ log.Info(" network.vpn.clearCredentials - Clear saved VPN credentials (params: uuidOrName|name|uuid)")
+ log.Info(" network.vpn.plugins - List available VPN plugins")
+ log.Info(" network.vpn.import - Import VPN from file (params: file|path, name?)")
+ log.Info(" network.vpn.getConfig - Get VPN configuration (params: uuid|name|uuidOrName)")
+ log.Info(" network.vpn.updateConfig - Update VPN configuration (params: uuid, name?, autoconnect?, data?)")
+ log.Info(" network.vpn.delete - Delete VPN connection (params: uuid|name|uuidOrName)")
+ log.Info(" network.preference.set - Set preference (params: preference [auto|wifi|ethernet])")
+ log.Info(" network.info - Get network info (params: ssid)")
+ log.Info(" network.credentials.submit - Submit credentials for prompt (params: token, secrets, save?)")
+ log.Info(" network.credentials.cancel - Cancel credential prompt (params: token)")
+ log.Info(" network.subscribe - Subscribe to network state changes (streaming)")
+ log.Info("Loginctl:")
+ log.Info(" loginctl.getState - Get current session state")
+ log.Info(" loginctl.lock - Lock session")
+ log.Info(" loginctl.unlock - Unlock session")
+ log.Info(" loginctl.activate - Activate session")
+ log.Info(" loginctl.setIdleHint - Set idle hint (params: idle)")
+ log.Info(" loginctl.setLockBeforeSuspend - Set lock before suspend (params: enabled)")
+ log.Info(" loginctl.setSleepInhibitorEnabled - Enable/disable sleep inhibitor (params: enabled)")
+ log.Info(" loginctl.lockerReady - Signal locker UI is ready (releases sleep inhibitor)")
+ log.Info(" loginctl.terminate - Terminate session")
+ log.Info(" loginctl.subscribe - Subscribe to session state changes (streaming)")
+ log.Info("Freedesktop:")
+ log.Info(" freedesktop.getState - Get accounts & settings state")
+ log.Info(" freedesktop.accounts.setIconFile - Set profile icon (params: path)")
+ log.Info(" freedesktop.accounts.setRealName - Set real name (params: name)")
+ log.Info(" freedesktop.accounts.setEmail - Set email (params: email)")
+ log.Info(" freedesktop.accounts.setLanguage - Set language (params: language)")
+ log.Info(" freedesktop.accounts.setLocation - Set location (params: location)")
+ log.Info(" freedesktop.accounts.getUserIconFile - Get user icon (params: username)")
+ log.Info(" freedesktop.settings.getColorScheme - Get color scheme")
+ log.Info(" freedesktop.settings.setIconTheme - Set icon theme (params: iconTheme)")
+ log.Info("Wayland:")
+ log.Info(" wayland.gamma.getState - Get current gamma control state")
+ log.Info(" wayland.gamma.setTemperature - Set temperature range (params: low, high)")
+ log.Info(" wayland.gamma.setLocation - Set location (params: latitude, longitude)")
+ log.Info(" wayland.gamma.setManualTimes - Set manual times (params: sunrise, sunset)")
+ log.Info(" wayland.gamma.setGamma - Set gamma value (params: gamma)")
+ log.Info(" wayland.gamma.setEnabled - Enable/disable gamma control (params: enabled)")
+ log.Info(" wayland.gamma.subscribe - Subscribe to gamma state changes (streaming)")
+ log.Info("Theme automation:")
+ log.Info(" theme.auto.getState - Get current theme automation state")
+ log.Info(" theme.auto.setEnabled - Enable/disable theme automation (params: enabled)")
+ log.Info(" theme.auto.setMode - Set automation mode (params: mode [time|location])")
+ log.Info(" theme.auto.setSchedule - Set time schedule (params: startHour, startMinute, endHour, endMinute)")
+ log.Info(" theme.auto.setLocation - Set location (params: latitude, longitude)")
+ log.Info(" theme.auto.setUseIPLocation - Use IP location (params: use)")
+ log.Info(" theme.auto.trigger - Trigger immediate re-evaluation")
+ log.Info(" theme.auto.subscribe - Subscribe to theme automation state changes (streaming)")
+ log.Info("Bluetooth:")
+ log.Info(" bluetooth.getState - Get current bluetooth state")
+ log.Info(" bluetooth.startDiscovery - Start device discovery")
+ log.Info(" bluetooth.stopDiscovery - Stop device discovery")
+ log.Info(" bluetooth.setPowered - Set adapter power state (params: powered)")
+ log.Info(" bluetooth.pair - Pair with device (params: device)")
+ log.Info(" bluetooth.connect - Connect to device (params: device)")
+ log.Info(" bluetooth.disconnect - Disconnect from device (params: device)")
+ log.Info(" bluetooth.remove - Remove/unpair device (params: device)")
+ log.Info(" bluetooth.trust - Trust device (params: device)")
+ log.Info(" bluetooth.untrust - Untrust device (params: device)")
+ log.Info(" bluetooth.pairing.submit - Submit pairing response (params: token, secrets?, accept?)")
+ log.Info(" bluetooth.pairing.cancel - Cancel pairing prompt (params: token)")
+ log.Info(" bluetooth.subscribe - Subscribe to bluetooth state changes (streaming)")
+ log.Info("CUPS:")
+ log.Info(" cups.getPrinters - Get printers list")
+ log.Info(" cups.getJobs - Get non-completed jobs list (params: printerName)")
+ log.Info(" cups.pausePrinter - Pause printer (params: printerName)")
+ log.Info(" cups.resumePrinter - Resume printer (params: printerName)")
+ log.Info(" cups.cancelJob - Cancel job (params: printerName, jobID)")
+ log.Info(" cups.purgeJobs - Cancel all jobs (params: printerName)")
+ log.Info("DWL:")
+ log.Info(" dwl.getState - Get current dwl state (tags, windows, layouts, keyboard)")
+ log.Info(" dwl.setTags - Set active tags (params: output, tagmask, toggleTagset)")
+ log.Info(" dwl.setClientTags - Set focused client tags (params: output, andTags, xorTags)")
+ log.Info(" dwl.setLayout - Set layout (params: output, index)")
+ log.Info(" dwl.subscribe - Subscribe to dwl state changes (streaming)")
+ log.Info(" Output state includes:")
+ log.Info(" - tags : Tag states (active, clients, focused)")
+ log.Info(" - layoutSymbol : Current layout name")
+ log.Info(" - title : Focused window title")
+ log.Info(" - appId : Focused window app ID")
+ log.Info(" - kbLayout : Current keyboard layout")
+ log.Info(" - keymode : Current keybind mode")
+ log.Info("ExtWorkspace:")
+ log.Info(" extworkspace.getState - Get current workspace state (groups, workspaces)")
+ log.Info(" extworkspace.activateWorkspace - Activate workspace (params: groupID, workspaceID)")
+ log.Info(" extworkspace.deactivateWorkspace - Deactivate workspace (params: groupID, workspaceID)")
+ log.Info(" extworkspace.removeWorkspace - Remove workspace (params: groupID, workspaceID)")
+ log.Info(" extworkspace.createWorkspace - Create workspace (params: groupID, name)")
+ log.Info(" extworkspace.subscribe - Subscribe to workspace state changes (streaming)")
+ log.Info("Brightness:")
+ log.Info(" brightness.getState - Get current brightness state for all devices")
+ log.Info(" brightness.setBrightness - Set device brightness (params: device, percent)")
+ log.Info(" brightness.increment - Increment device brightness (params: device, step?)")
+ log.Info(" brightness.decrement - Decrement device brightness (params: device, step?)")
+ log.Info(" brightness.rescan - Rescan for brightness devices (e.g., after plugging in monitor)")
+ log.Info(" brightness.subscribe - Subscribe to brightness state changes (streaming)")
+ log.Info(" Subscription events:")
+ log.Info(" - brightness : Full device list (on rescan, DDC discovery, device changes)")
+ log.Info(" - brightness.update: Single device update (on brightness change for efficiency)")
+ log.Info("WlrOutput:")
+ log.Info(" wlroutput.getState - Get current output configuration state")
+ log.Info(" wlroutput.applyConfiguration - Apply output configuration (params: heads)")
+ log.Info(" wlroutput.testConfiguration - Test output configuration without applying (params: heads)")
+ log.Info(" wlroutput.subscribe - Subscribe to output state changes (streaming)")
+ log.Info(" Head configuration params:")
+ log.Info(" - name : Output name (required)")
+ log.Info(" - enabled : Enable/disable output (required)")
+ log.Info(" - modeId : Mode ID from available modes (optional)")
+ log.Info(" - customMode : Custom mode {width, height, refresh} (optional)")
+ log.Info(" - position : Position {x, y} (optional)")
+ log.Info(" - transform : Transform value (optional)")
+ log.Info(" - scale : Scale value (optional)")
+ log.Info(" - adaptiveSync : Adaptive sync state (optional)")
+ log.Info("Evdev:")
+ log.Info(" evdev.getState - Get current evdev state (caps lock)")
+ log.Info(" evdev.subscribe - Subscribe to evdev state changes (streaming)")
+ log.Info("Clipboard:")
+ log.Info(" clipboard.getState - Get clipboard state (enabled, history, current)")
+ log.Info(" clipboard.getHistory - Get clipboard history with previews")
+ log.Info(" clipboard.getEntry - Get full entry by ID (params: id)")
+ log.Info(" clipboard.deleteEntry - Delete entry by ID (params: id)")
+ log.Info(" clipboard.clearHistory - Clear all clipboard history")
+ log.Info(" clipboard.copy - Copy text to clipboard (params: text)")
+ log.Info(" clipboard.paste - Get current clipboard text")
+ log.Info(" clipboard.search - Search history (params: query?, mimeType?, isImage?, limit?, offset?, before?, after?)")
+ log.Info(" clipboard.getConfig - Get clipboard configuration")
+ log.Info(" clipboard.setConfig - Set configuration (params: maxHistory?, maxEntrySize?, autoClearDays?, clearAtStartup?)")
+ log.Info(" clipboard.subscribe - Subscribe to clipboard state changes (streaming)")
+ log.Info("Location:")
+ log.Info(" location.getState - Get current location state")
+ log.Info(" location.subscribe - Subscribe to location changes (streaming)")
+ log.Info("")
+ }
+ log.Info("Initializing managers...")
+ log.Info("")
+
+ go func() {
+ ticker := time.NewTicker(30 * time.Second)
+ defer ticker.Stop()
+
+ if err := InitializeNetworkManager(); err != nil {
+ log.Warnf("Network manager unavailable: %v", err)
+ } else {
+ notifyCapabilityChange()
+ return
+ }
+
+ for range ticker.C {
+ if networkManager != nil {
+ return
+ }
+ if err := InitializeNetworkManager(); err == nil {
+ log.Info("Network manager initialized")
+ notifyCapabilityChange()
+ return
+ }
+ }
+ }()
+
+ loginctlReady := make(chan struct{})
+ freedesktopReady := make(chan struct{})
+
+ go func() {
+ defer close(loginctlReady)
+ if err := InitializeLoginctlManager(); err != nil {
+ log.Warnf("Loginctl manager unavailable: %v", err)
+ } else {
+ notifyCapabilityChange()
+ }
+ }()
+
+ go func() {
+ defer close(freedesktopReady)
+ if err := InitializeFreedeskManager(); err != nil {
+ log.Warnf("Freedesktop manager unavailable: %v", err)
+ } else if freedesktopManager != nil {
+ freedesktopManager.NotifySubscribers()
+ notifyCapabilityChange()
+ }
+ }()
+
+ // Bridge loginctl lock state to the freedesktop/gnome screensaver
+ // ActiveChanged signal so apps like Bitwarden can detect screen lock.
+ go func() {
+ <-loginctlReady
+ <-freedesktopReady
+
+ if loginctlManager == nil || freedesktopManager == nil {
+ return
+ }
+
+ ch := loginctlManager.Subscribe("dms-lock-bridge")
+ defer loginctlManager.Unsubscribe("dms-lock-bridge")
+
+ initial := loginctlManager.GetState()
+ lastLocked := initial.Locked
+ freedesktopManager.SetScreenLockActive(lastLocked)
+
+ for state := range ch {
+ if state.Locked != lastLocked {
+ lastLocked = state.Locked
+ freedesktopManager.SetScreenLockActive(lastLocked)
+ }
+ }
+ }()
+
+ if err := InitializeWaylandManager(); err != nil {
+ log.Warnf("Wayland manager unavailable: %v", err)
+ }
+
+ if err := InitializeThemeModeManager(); err != nil {
+ log.Warnf("Theme mode manager unavailable: %v", err)
+ } else {
+ notifyCapabilityChange()
+ go func() {
+ <-loginctlReady
+ if loginctlManager == nil {
+ return
+ }
+ themeModeManager.WatchLoginctl(loginctlManager)
+ }()
+ }
+
+ go func() {
+ <-loginctlReady
+ if loginctlManager == nil {
+ return
+ }
+ if err := InitializeTrayRecoveryManager(); err != nil {
+ log.Warnf("TrayRecovery manager unavailable: %v", err)
+ } else {
+ trayRecoveryManager.WatchLoginctl(loginctlManager)
+ }
+ }()
+
+ go func() {
+ geoClient := geolocation.NewClient()
+ geoClientInstance = geoClient
+
+ if waylandManager != nil {
+ waylandManager.SetGeoClient(geoClient)
+ }
+ if themeModeManager != nil {
+ themeModeManager.SetGeoClient(geoClient)
+ }
+
+ if err := InitializeLocationManager(geoClient); err != nil {
+ log.Warnf("Location manager unavailable: %v", err)
+ } else {
+ notifyCapabilityChange()
+ }
+ }()
+
+ go func() {
+ if err := InitializeBluezManager(); err != nil {
+ log.Warnf("Bluez manager unavailable: %v", err)
+ } else {
+ notifyCapabilityChange()
+ }
+ }()
+
+ if err := InitializeAppPickerManager(); err != nil {
+ log.Debugf("AppPicker manager unavailable: %v", err)
+ }
+
+ if err := InitializeDwlManager(); err != nil {
+ log.Debugf("DWL manager unavailable: %v", err)
+ }
+
+ if extworkspace.CheckCapability() {
+ extWorkspaceAvailable.Store(true)
+ log.Info("ExtWorkspace capability detected and will be available on subscription")
+ } else {
+ log.Debug("ExtWorkspace capability not available")
+ extWorkspaceAvailable.Store(false)
+ }
+
+ if err := InitializeWlrOutputManager(); err != nil {
+ log.Debugf("WlrOutput manager unavailable: %v", err)
+ }
+
+ fatalErrChan := make(chan error, 1)
+ if wlrOutputManager != nil {
+ go func() {
+ err := <-wlrOutputManager.FatalError()
+ fatalErrChan <- fmt.Errorf("WlrOutput fatal error: %w", err)
+ }()
+ }
+ if wlContext != nil {
+ go func() {
+ err := <-wlContext.FatalError()
+ fatalErrChan <- fmt.Errorf("wayland context fatal error: %w", err)
+ }()
+ }
+
+ go func() {
+ if err := InitializeBrightnessManager(); err != nil {
+ log.Warnf("Brightness manager unavailable: %v", err)
+ } else {
+ notifyCapabilityChange()
+ }
+ }()
+
+ go func() {
+ if err := InitializeEvdevManager(); err != nil {
+ log.Debugf("Evdev manager unavailable: %v", err)
+ } else {
+ notifyCapabilityChange()
+ }
+ }()
+
+ go func() {
+ if err := InitializeClipboardManager(); err != nil {
+ log.Warnf("Clipboard manager unavailable: %v", err)
+ }
+ if wlContext != nil {
+ wlContext.Start()
+ log.Info("Wayland event dispatcher started")
+ }
+ }()
+
+ go func() {
+ if err := InitializeDbusManager(); err != nil {
+ log.Warnf("DBus manager unavailable: %v", err)
+ } else {
+ notifyCapabilityChange()
+ }
+ }()
+
+ log.Info("")
+ log.Infof("Ready! Capabilities: %v", getCapabilities().Capabilities)
+
+ listenerErrChan := make(chan error, 1)
+
+ go func() {
+ for {
+ conn, err := listener.Accept()
+ if err != nil {
+ listenerErrChan <- err
+ return
+ }
+ go handleConnection(conn)
+ }
+ }()
+
+ select {
+ case err := <-listenerErrChan:
+ return err
+ case err := <-fatalErrChan:
+ return err
+ }
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/server_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/server_test.go
new file mode 100644
index 0000000..404f624
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/server_test.go
@@ -0,0 +1,180 @@
+package server
+
+import (
+ "encoding/json"
+ "fmt"
+ "net"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGetSocketDir(t *testing.T) {
+ tests := []struct {
+ name string
+ xdgRuntimeDir string
+ uid int
+ expectedSubstr string
+ }{
+ {
+ name: "uses XDG_RUNTIME_DIR when set",
+ xdgRuntimeDir: "/run/user/1000",
+ expectedSubstr: "/run/user/1000",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if tt.xdgRuntimeDir != "" {
+ t.Setenv("XDG_RUNTIME_DIR", tt.xdgRuntimeDir)
+ }
+
+ result := getSocketDir()
+ assert.Contains(t, result, tt.expectedSubstr)
+ })
+ }
+}
+
+func TestGetSocketPath(t *testing.T) {
+ path := GetSocketPath()
+ assert.Contains(t, path, "danklinux-")
+ assert.Contains(t, path, ".sock")
+ assert.Contains(t, path, fmt.Sprintf("%d", os.Getpid()))
+}
+
+func TestGetCapabilities(t *testing.T) {
+ originalNetworkManager := networkManager
+ defer func() { networkManager = originalNetworkManager }()
+
+ t.Run("capabilities without network manager", func(t *testing.T) {
+ networkManager = nil
+ caps := getCapabilities()
+ assert.Contains(t, caps.Capabilities, "plugins")
+ assert.NotContains(t, caps.Capabilities, "network")
+ })
+
+ t.Run("capabilities with network manager", func(t *testing.T) {
+ networkManager = &network.Manager{}
+ caps := getCapabilities()
+ assert.Contains(t, caps.Capabilities, "plugins")
+ assert.Contains(t, caps.Capabilities, "network")
+ })
+}
+
+type mockConn struct {
+ net.Conn
+ written []byte
+}
+
+func (m *mockConn) Write(b []byte) (n int, err error) {
+ m.written = append(m.written, b...)
+ return len(b), nil
+}
+
+func (m *mockConn) Close() error {
+ return nil
+}
+
+func TestRespondError(t *testing.T) {
+ conn := &mockConn{}
+ models.RespondError(conn, 123, "test error")
+
+ var resp models.Response[any]
+ err := json.Unmarshal(conn.written, &resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Equal(t, "test error", resp.Error)
+ assert.Nil(t, resp.Result)
+}
+
+func TestRespond(t *testing.T) {
+ conn := &mockConn{}
+ result := map[string]string{"foo": "bar"}
+ models.Respond(conn, 123, result)
+
+ var resp models.Response[map[string]string]
+ err := json.Unmarshal(conn.written, &resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Empty(t, resp.Error)
+ require.NotNil(t, resp.Result)
+ assert.Equal(t, "bar", (*resp.Result)["foo"])
+}
+
+func TestRequest_JSON(t *testing.T) {
+ jsonStr := `{"id":123,"method":"test.method","params":{"key":"value"}}`
+ var req models.Request
+ err := json.Unmarshal([]byte(jsonStr), &req)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, req.ID)
+ assert.Equal(t, "test.method", req.Method)
+ assert.Equal(t, "value", req.Params["key"])
+}
+
+func TestResponse_JSON(t *testing.T) {
+ t.Run("success response", func(t *testing.T) {
+ result := "success"
+ resp := models.Response[string]{
+ ID: 123,
+ Result: &result,
+ }
+
+ data, err := json.Marshal(resp)
+ require.NoError(t, err)
+
+ var decoded models.Response[string]
+ err = json.Unmarshal(data, &decoded)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, decoded.ID)
+ assert.Equal(t, "success", *decoded.Result)
+ assert.Empty(t, decoded.Error)
+ })
+
+ t.Run("error response", func(t *testing.T) {
+ resp := models.Response[any]{
+ ID: 123,
+ Error: "test error",
+ }
+
+ data, err := json.Marshal(resp)
+ require.NoError(t, err)
+
+ var decoded models.Response[any]
+ err = json.Unmarshal(data, &decoded)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, decoded.ID)
+ assert.Equal(t, "test error", decoded.Error)
+ assert.Nil(t, decoded.Result)
+ })
+}
+
+func TestCleanupStaleSockets(t *testing.T) {
+ tempDir := t.TempDir()
+ t.Setenv("XDG_RUNTIME_DIR", tempDir)
+
+ staleSocket := filepath.Join(tempDir, "danklinux-4194305.sock")
+ err := os.WriteFile(staleSocket, []byte{}, 0o600)
+ require.NoError(t, err)
+
+ activeSocket := filepath.Join(tempDir, fmt.Sprintf("danklinux-%d.sock", os.Getpid()))
+ err = os.WriteFile(activeSocket, []byte{}, 0o600)
+ require.NoError(t, err)
+
+ cleanupStaleSockets()
+
+ _, err = os.Stat(staleSocket)
+ assert.True(t, os.IsNotExist(err))
+
+ _, err = os.Stat(activeSocket)
+ assert.NoError(t, err)
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/thememode/handlers.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/thememode/handlers.go
new file mode 100644
index 0000000..6891875
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/thememode/handlers.go
@@ -0,0 +1,154 @@
+package thememode
+
+import (
+ "encoding/json"
+ "fmt"
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/params"
+)
+
+func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
+ if manager == nil {
+ models.RespondError(conn, req.ID, "theme mode manager not initialized")
+ return
+ }
+
+ switch req.Method {
+ case "theme.auto.getState":
+ handleGetState(conn, req, manager)
+ case "theme.auto.setEnabled":
+ handleSetEnabled(conn, req, manager)
+ case "theme.auto.setMode":
+ handleSetMode(conn, req, manager)
+ case "theme.auto.setSchedule":
+ handleSetSchedule(conn, req, manager)
+ case "theme.auto.setLocation":
+ handleSetLocation(conn, req, manager)
+ case "theme.auto.setUseIPLocation":
+ handleSetUseIPLocation(conn, req, manager)
+ case "theme.auto.trigger":
+ handleTrigger(conn, req, manager)
+ case "theme.auto.subscribe":
+ handleSubscribe(conn, req, manager)
+ default:
+ models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
+ }
+}
+
+func handleGetState(conn net.Conn, req models.Request, manager *Manager) {
+ models.Respond(conn, req.ID, manager.GetState())
+}
+
+func handleSetEnabled(conn net.Conn, req models.Request, manager *Manager) {
+ enabled, err := params.Bool(req.Params, "enabled")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ manager.SetEnabled(enabled)
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "theme auto enabled set"})
+}
+
+func handleSetMode(conn net.Conn, req models.Request, manager *Manager) {
+ mode, err := params.String(req.Params, "mode")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if mode != "time" && mode != "location" {
+ models.RespondError(conn, req.ID, "invalid mode")
+ return
+ }
+
+ manager.SetMode(mode)
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "theme auto mode set"})
+}
+
+func handleSetSchedule(conn net.Conn, req models.Request, manager *Manager) {
+ startHour, err := params.Int(req.Params, "startHour")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ startMinute, err := params.Int(req.Params, "startMinute")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ endHour, err := params.Int(req.Params, "endHour")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ endMinute, err := params.Int(req.Params, "endMinute")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.ValidateSchedule(startHour, startMinute, endHour, endMinute); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ manager.SetSchedule(startHour, startMinute, endHour, endMinute)
+ models.Respond(conn, req.ID, manager.GetState())
+}
+
+func handleSetLocation(conn net.Conn, req models.Request, manager *Manager) {
+ lat, err := params.Float(req.Params, "latitude")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ lon, err := params.Float(req.Params, "longitude")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ manager.SetLocation(lat, lon)
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "theme auto location set"})
+}
+
+func handleSetUseIPLocation(conn net.Conn, req models.Request, manager *Manager) {
+ use, err := params.Bool(req.Params, "use")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ manager.SetUseIPLocation(use)
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "theme auto IP location set"})
+}
+
+func handleTrigger(conn net.Conn, req models.Request, manager *Manager) {
+ manager.TriggerUpdate()
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "theme auto update triggered"})
+}
+
+func handleSubscribe(conn net.Conn, req models.Request, manager *Manager) {
+ clientID := fmt.Sprintf("client-%p", conn)
+ stateChan := manager.Subscribe(clientID)
+ defer manager.Unsubscribe(clientID)
+
+ initialState := manager.GetState()
+ if err := json.NewEncoder(conn).Encode(models.Response[State]{
+ ID: req.ID,
+ Result: &initialState,
+ }); err != nil {
+ return
+ }
+
+ for state := range stateChan {
+ if err := json.NewEncoder(conn).Encode(models.Response[State]{
+ Result: &state,
+ }); err != nil {
+ return
+ }
+ }
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/thememode/manager.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/thememode/manager.go
new file mode 100644
index 0000000..f7644ad
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/thememode/manager.go
@@ -0,0 +1,462 @@
+package thememode
+
+import (
+ "errors"
+ "sync"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
+)
+
+const (
+ defaultStartHour = 18
+ defaultStartMinute = 0
+ defaultEndHour = 6
+ defaultEndMinute = 0
+ defaultElevationTwilight = -6.0
+ defaultElevationDaylight = 3.0
+)
+
+type Manager struct {
+ config Config
+ configMutex sync.RWMutex
+
+ state *State
+ stateMutex sync.RWMutex
+
+ subscribers syncmap.Map[string, chan State]
+
+ locationMutex sync.RWMutex
+ cachedIPLat *float64
+ cachedIPLon *float64
+
+ geoClient geolocation.Client
+
+ stopChan chan struct{}
+ updateTrigger chan struct{}
+ wg sync.WaitGroup
+}
+
+func NewManager() *Manager {
+ m := &Manager{
+ config: Config{
+ Enabled: false,
+ Mode: "time",
+ StartHour: defaultStartHour,
+ StartMinute: defaultStartMinute,
+ EndHour: defaultEndHour,
+ EndMinute: defaultEndMinute,
+ ElevationTwilight: defaultElevationTwilight,
+ ElevationDaylight: defaultElevationDaylight,
+ },
+ stopChan: make(chan struct{}),
+ updateTrigger: make(chan struct{}, 1),
+ }
+
+ m.updateState(time.Now())
+
+ m.wg.Add(1)
+ go m.schedulerLoop()
+
+ return m
+}
+
+func (m *Manager) GetState() State {
+ m.stateMutex.RLock()
+ defer m.stateMutex.RUnlock()
+ if m.state == nil {
+ return State{Config: m.getConfig()}
+ }
+ stateCopy := *m.state
+ return stateCopy
+}
+
+func (m *Manager) Subscribe(id string) chan State {
+ ch := make(chan State, 64)
+ m.subscribers.Store(id, ch)
+ return ch
+}
+
+func (m *Manager) Unsubscribe(id string) {
+ if val, ok := m.subscribers.LoadAndDelete(id); ok {
+ close(val)
+ }
+}
+
+func (m *Manager) SetEnabled(enabled bool) {
+ m.configMutex.Lock()
+ if m.config.Enabled == enabled {
+ m.configMutex.Unlock()
+ return
+ }
+ m.config.Enabled = enabled
+ m.configMutex.Unlock()
+ m.TriggerUpdate()
+}
+
+func (m *Manager) SetMode(mode string) {
+ m.configMutex.Lock()
+ if m.config.Mode == mode {
+ m.configMutex.Unlock()
+ return
+ }
+ m.config.Mode = mode
+ m.configMutex.Unlock()
+ m.TriggerUpdate()
+}
+
+func (m *Manager) SetSchedule(startHour, startMinute, endHour, endMinute int) {
+ m.configMutex.Lock()
+ changed := m.config.StartHour != startHour ||
+ m.config.StartMinute != startMinute ||
+ m.config.EndHour != endHour ||
+ m.config.EndMinute != endMinute
+ if !changed {
+ m.configMutex.Unlock()
+ return
+ }
+ m.config.StartHour = startHour
+ m.config.StartMinute = startMinute
+ m.config.EndHour = endHour
+ m.config.EndMinute = endMinute
+ m.configMutex.Unlock()
+ m.TriggerUpdate()
+}
+
+func (m *Manager) SetLocation(lat, lon float64) {
+ m.configMutex.Lock()
+ if m.config.Latitude != nil && m.config.Longitude != nil &&
+ *m.config.Latitude == lat && *m.config.Longitude == lon && !m.config.UseIPLocation {
+ m.configMutex.Unlock()
+ return
+ }
+ m.config.Latitude = &lat
+ m.config.Longitude = &lon
+ m.config.UseIPLocation = false
+ m.configMutex.Unlock()
+
+ m.locationMutex.Lock()
+ m.cachedIPLat = nil
+ m.cachedIPLon = nil
+ m.locationMutex.Unlock()
+
+ m.TriggerUpdate()
+}
+
+func (m *Manager) SetUseIPLocation(use bool) {
+ m.configMutex.Lock()
+ if m.config.UseIPLocation == use {
+ m.configMutex.Unlock()
+ return
+ }
+ m.config.UseIPLocation = use
+ if use {
+ m.config.Latitude = nil
+ m.config.Longitude = nil
+ }
+ m.configMutex.Unlock()
+
+ if use {
+ m.locationMutex.Lock()
+ m.cachedIPLat = nil
+ m.cachedIPLon = nil
+ m.locationMutex.Unlock()
+ }
+
+ m.TriggerUpdate()
+}
+
+func (m *Manager) TriggerUpdate() {
+ select {
+ case m.updateTrigger <- struct{}{}:
+ default:
+ }
+}
+
+func (m *Manager) Close() {
+ select {
+ case <-m.stopChan:
+ return
+ default:
+ close(m.stopChan)
+ }
+ m.wg.Wait()
+ m.subscribers.Range(func(key string, ch chan State) bool {
+ close(ch)
+ m.subscribers.Delete(key)
+ return true
+ })
+}
+
+func (m *Manager) WatchLoginctl(lm *loginctl.Manager) {
+ ch := lm.Subscribe("thememode")
+ m.wg.Add(1)
+ go func() {
+ defer m.wg.Done()
+ defer lm.Unsubscribe("thememode")
+ for {
+ select {
+ case <-m.stopChan:
+ return
+ case state, ok := <-ch:
+ if !ok {
+ return
+ }
+ if state.PreparingForSleep {
+ continue
+ }
+ m.TriggerUpdate()
+ }
+ }
+ }()
+}
+
+func (m *Manager) schedulerLoop() {
+ defer m.wg.Done()
+
+ var timer *time.Timer
+ for {
+ config := m.getConfig()
+ now := time.Now()
+ var isLight bool
+ var next time.Time
+ if config.Enabled {
+ isLight, next = m.computeSchedule(now, config)
+ } else {
+ m.stateMutex.RLock()
+ if m.state != nil {
+ isLight = m.state.IsLight
+ }
+ m.stateMutex.RUnlock()
+ next = now.Add(24 * time.Hour)
+ }
+
+ m.updateStateWithValues(config, isLight, next)
+
+ waitDur := time.Until(next)
+ if !config.Enabled {
+ waitDur = 24 * time.Hour
+ }
+ if waitDur < time.Second {
+ waitDur = time.Second
+ }
+
+ if timer != nil {
+ timer.Stop()
+ }
+ timer = time.NewTimer(waitDur)
+
+ select {
+ case <-m.stopChan:
+ timer.Stop()
+ return
+ case <-m.updateTrigger:
+ timer.Stop()
+ continue
+ case <-timer.C:
+ continue
+ }
+ }
+}
+
+func (m *Manager) updateState(now time.Time) {
+ config := m.getConfig()
+ var isLight bool
+ var next time.Time
+ if config.Enabled {
+ isLight, next = m.computeSchedule(now, config)
+ } else {
+ m.stateMutex.RLock()
+ if m.state != nil {
+ isLight = m.state.IsLight
+ }
+ m.stateMutex.RUnlock()
+ next = now.Add(24 * time.Hour)
+ }
+ m.updateStateWithValues(config, isLight, next)
+}
+
+func (m *Manager) updateStateWithValues(config Config, isLight bool, next time.Time) {
+ newState := State{
+ Config: config,
+ IsLight: isLight,
+ NextTransition: next,
+ }
+
+ m.stateMutex.Lock()
+ if m.state != nil && statesEqual(m.state, &newState) {
+ m.stateMutex.Unlock()
+ return
+ }
+ m.state = &newState
+ m.stateMutex.Unlock()
+
+ m.notifySubscribers()
+}
+
+func (m *Manager) notifySubscribers() {
+ state := m.GetState()
+ m.subscribers.Range(func(key string, ch chan State) bool {
+ select {
+ case ch <- state:
+ default:
+ }
+ return true
+ })
+}
+
+func (m *Manager) getConfig() Config {
+ m.configMutex.RLock()
+ defer m.configMutex.RUnlock()
+ return m.config
+}
+
+func (m *Manager) SetGeoClient(client geolocation.Client) {
+ m.geoClient = client
+}
+
+func (m *Manager) getLocation(config Config) (*float64, *float64) {
+ if config.Latitude != nil && config.Longitude != nil {
+ return config.Latitude, config.Longitude
+ }
+ if !config.UseIPLocation {
+ return nil, nil
+ }
+ if m.geoClient == nil {
+ return nil, nil
+ }
+
+ m.locationMutex.RLock()
+ if m.cachedIPLat != nil && m.cachedIPLon != nil {
+ lat, lon := m.cachedIPLat, m.cachedIPLon
+ m.locationMutex.RUnlock()
+ return lat, lon
+ }
+ m.locationMutex.RUnlock()
+
+ location, err := m.geoClient.GetLocation()
+ if err != nil {
+ return nil, nil
+ }
+
+ m.locationMutex.Lock()
+ m.cachedIPLat = &location.Latitude
+ m.cachedIPLon = &location.Longitude
+ m.locationMutex.Unlock()
+
+ return m.cachedIPLat, m.cachedIPLon
+}
+
+func statesEqual(a, b *State) bool {
+ if a == nil || b == nil {
+ return a == b
+ }
+ if a.IsLight != b.IsLight || !a.NextTransition.Equal(b.NextTransition) {
+ return false
+ }
+ return a.Config == b.Config
+}
+
+func (m *Manager) computeSchedule(now time.Time, config Config) (bool, time.Time) {
+ switch config.Mode {
+ case "location":
+ return m.computeLocationSchedule(now, config)
+ default:
+ return computeTimeSchedule(now, config)
+ }
+}
+
+func computeTimeSchedule(now time.Time, config Config) (bool, time.Time) {
+ startMinutes := config.StartHour*60 + config.StartMinute
+ endMinutes := config.EndHour*60 + config.EndMinute
+ currentMinutes := now.Hour()*60 + now.Minute()
+
+ startTime := time.Date(now.Year(), now.Month(), now.Day(), config.StartHour, config.StartMinute, 0, 0, now.Location())
+ endTime := time.Date(now.Year(), now.Month(), now.Day(), config.EndHour, config.EndMinute, 0, 0, now.Location())
+
+ if startMinutes == endMinutes {
+ next := startTime
+ if !next.After(now) {
+ next = next.Add(24 * time.Hour)
+ }
+ return true, next
+ }
+
+ if startMinutes < endMinutes {
+ if currentMinutes < startMinutes {
+ return true, startTime
+ }
+ if currentMinutes >= endMinutes {
+ return true, startTime.Add(24 * time.Hour)
+ }
+ return false, endTime
+ }
+
+ if currentMinutes >= startMinutes {
+ return false, endTime.Add(24 * time.Hour)
+ }
+ if currentMinutes < endMinutes {
+ return false, endTime
+ }
+ return true, startTime
+}
+
+func (m *Manager) computeLocationSchedule(now time.Time, config Config) (bool, time.Time) {
+ lat, lon := m.getLocation(config)
+ if lat == nil || lon == nil {
+ currentIsLight := false
+ m.stateMutex.RLock()
+ if m.state != nil {
+ currentIsLight = m.state.IsLight
+ }
+ m.stateMutex.RUnlock()
+ return currentIsLight, now.Add(10 * time.Minute)
+ }
+
+ times, cond := wayland.CalculateSunTimesWithTwilight(*lat, *lon, now, config.ElevationTwilight, config.ElevationDaylight)
+ switch cond {
+ case wayland.SunMidnightSun:
+ return true, startOfNextDay(now)
+ case wayland.SunPolarNight:
+ return false, startOfNextDay(now)
+ }
+
+ if now.Before(times.Sunrise) {
+ return false, times.Sunrise
+ }
+ if now.Before(times.Sunset) {
+ return true, times.Sunset
+ }
+
+ nextDay := startOfNextDay(now)
+ nextTimes, nextCond := wayland.CalculateSunTimesWithTwilight(*lat, *lon, nextDay, config.ElevationTwilight, config.ElevationDaylight)
+ switch nextCond {
+ case wayland.SunMidnightSun:
+ return true, startOfNextDay(nextDay)
+ case wayland.SunPolarNight:
+ return false, startOfNextDay(nextDay)
+ }
+
+ return false, nextTimes.Sunrise
+}
+
+func startOfNextDay(t time.Time) time.Time {
+ next := t.Add(24 * time.Hour)
+ return time.Date(next.Year(), next.Month(), next.Day(), 0, 0, 0, 0, next.Location())
+}
+
+func validateHourMinute(hour, minute int) bool {
+ return hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59
+}
+
+func (m *Manager) ValidateSchedule(startHour, startMinute, endHour, endMinute int) error {
+ if !validateHourMinute(startHour, startMinute) || !validateHourMinute(endHour, endMinute) {
+ return errInvalidTime
+ }
+ return nil
+}
+
+var errInvalidTime = errors.New("invalid schedule time")
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/thememode/types.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/thememode/types.go
new file mode 100644
index 0000000..72e6468
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/thememode/types.go
@@ -0,0 +1,23 @@
+package thememode
+
+import "time"
+
+type Config struct {
+ Enabled bool `json:"enabled"`
+ Mode string `json:"mode"`
+ StartHour int `json:"startHour"`
+ StartMinute int `json:"startMinute"`
+ EndHour int `json:"endHour"`
+ EndMinute int `json:"endMinute"`
+ Latitude *float64 `json:"latitude,omitempty"`
+ Longitude *float64 `json:"longitude,omitempty"`
+ UseIPLocation bool `json:"useIPLocation"`
+ ElevationTwilight float64 `json:"elevationTwilight"`
+ ElevationDaylight float64 `json:"elevationDaylight"`
+}
+
+type State struct {
+ Config Config `json:"config"`
+ IsLight bool `json:"isLight"`
+ NextTransition time.Time `json:"nextTransition"`
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/themes/handlers.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/themes/handlers.go
new file mode 100644
index 0000000..b3b308a
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/themes/handlers.go
@@ -0,0 +1,27 @@
+package themes
+
+import (
+ "fmt"
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+)
+
+func HandleRequest(conn net.Conn, req models.Request) {
+ switch req.Method {
+ case "themes.list":
+ HandleList(conn, req)
+ case "themes.listInstalled":
+ HandleListInstalled(conn, req)
+ case "themes.install":
+ HandleInstall(conn, req)
+ case "themes.uninstall":
+ HandleUninstall(conn, req)
+ case "themes.update":
+ HandleUpdate(conn, req)
+ case "themes.search":
+ HandleSearch(conn, req)
+ default:
+ models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
+ }
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/themes/install.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/themes/install.go
new file mode 100644
index 0000000..26d22ba
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/themes/install.go
@@ -0,0 +1,52 @@
+package themes
+
+import (
+ "fmt"
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/themes"
+)
+
+func HandleInstall(conn net.Conn, req models.Request) {
+ idOrName, ok := models.Get[string](req, "name")
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'name' parameter")
+ return
+ }
+
+ registry, err := themes.NewRegistry()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to create registry: %v", err))
+ return
+ }
+
+ themeList, err := registry.List()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to list themes: %v", err))
+ return
+ }
+
+ theme := themes.FindByIDOrName(idOrName, themeList)
+ if theme == nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("theme not found: %s", idOrName))
+ return
+ }
+
+ manager, err := themes.NewManager()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to create manager: %v", err))
+ return
+ }
+
+ registryThemeDir := registry.GetThemeDir(theme.SourceDir)
+ if err := manager.Install(*theme, registryThemeDir); err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to install theme: %v", err))
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{
+ Success: true,
+ Message: fmt.Sprintf("theme installed: %s", theme.Name),
+ })
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/themes/list.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/themes/list.go
new file mode 100644
index 0000000..9564be1
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/themes/list.go
@@ -0,0 +1,54 @@
+package themes
+
+import (
+ "fmt"
+ "net"
+ "strings"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/themes"
+)
+
+func HandleList(conn net.Conn, req models.Request) {
+ registry, err := themes.NewRegistry()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to create registry: %v", err))
+ return
+ }
+
+ themeList, err := registry.List()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to list themes: %v", err))
+ return
+ }
+
+ manager, err := themes.NewManager()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to create manager: %v", err))
+ return
+ }
+
+ result := make([]ThemeInfo, len(themeList))
+ for i, t := range themeList {
+ installed, _ := manager.IsInstalled(t)
+ info := ThemeInfo{
+ ID: t.ID,
+ Name: t.Name,
+ Version: t.Version,
+ Author: t.Author,
+ Description: t.Description,
+ PreviewPath: t.PreviewPath,
+ SourceDir: t.SourceDir,
+ Installed: installed,
+ FirstParty: isFirstParty(t.Author),
+ }
+ addVariantsInfo(&info, t.Variants)
+ result[i] = info
+ }
+
+ models.Respond(conn, req.ID, result)
+}
+
+func isFirstParty(author string) bool {
+ return strings.EqualFold(author, "Avenge Media") || strings.EqualFold(author, "AvengeMedia")
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/themes/list_installed.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/themes/list_installed.go
new file mode 100644
index 0000000..49be81b
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/themes/list_installed.go
@@ -0,0 +1,149 @@
+package themes
+
+import (
+ "fmt"
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/themes"
+)
+
+func addVariantsInfo(info *ThemeInfo, variants *themes.ThemeVariants) {
+ if variants == nil {
+ return
+ }
+
+ if variants.Type == "multi" {
+ if len(variants.Flavors) == 0 && len(variants.Accents) == 0 {
+ return
+ }
+ info.HasVariants = true
+ info.Variants = &VariantsInfo{
+ Type: "multi",
+ Flavors: make([]FlavorInfo, len(variants.Flavors)),
+ Accents: make([]AccentInfo, len(variants.Accents)),
+ }
+ if variants.Defaults != nil {
+ info.Variants.Defaults = &MultiDefaults{
+ Dark: variants.Defaults.Dark,
+ Light: variants.Defaults.Light,
+ }
+ }
+ for i, f := range variants.Flavors {
+ mode := ""
+ switch {
+ case f.Dark.Primary != "" && f.Light.Primary != "":
+ mode = "both"
+ case f.Dark.Primary != "":
+ mode = "dark"
+ case f.Light.Primary != "":
+ mode = "light"
+ default:
+ if f.Dark.Surface != "" {
+ mode = "dark"
+ } else if f.Light.Surface != "" {
+ mode = "light"
+ }
+ }
+ info.Variants.Flavors[i] = FlavorInfo{ID: f.ID, Name: f.Name, Mode: mode}
+ }
+ for i, a := range variants.Accents {
+ color := ""
+ if colors, ok := a.FlavorColors["mocha"]; ok && colors.Primary != "" {
+ color = colors.Primary
+ } else if colors, ok := a.FlavorColors["latte"]; ok && colors.Primary != "" {
+ color = colors.Primary
+ } else {
+ for _, c := range a.FlavorColors {
+ if c.Primary != "" {
+ color = c.Primary
+ break
+ }
+ }
+ }
+ info.Variants.Accents[i] = AccentInfo{ID: a.ID, Name: a.Name, Color: color}
+ }
+ return
+ }
+
+ if len(variants.Options) == 0 {
+ return
+ }
+ info.HasVariants = true
+ info.Variants = &VariantsInfo{
+ Default: variants.Default,
+ Options: make([]VariantInfo, len(variants.Options)),
+ }
+ for i, v := range variants.Options {
+ info.Variants.Options[i] = VariantInfo{ID: v.ID, Name: v.Name}
+ }
+}
+
+func HandleListInstalled(conn net.Conn, req models.Request) {
+ manager, err := themes.NewManager()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to create manager: %v", err))
+ return
+ }
+
+ installedIDs, err := manager.ListInstalled()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to list installed themes: %v", err))
+ return
+ }
+
+ themeMap := make(map[string]themes.Theme)
+ if registry, err := themes.NewRegistry(); err == nil {
+ if allThemes, err := registry.List(); err == nil {
+ for _, t := range allThemes {
+ themeMap[t.ID] = t
+ }
+ }
+ }
+
+ result := make([]ThemeInfo, 0, len(installedIDs))
+ for _, id := range installedIDs {
+ if theme, ok := themeMap[id]; ok {
+ hasUpdate := false
+ if hasUpdates, err := manager.HasUpdates(id, theme); err == nil {
+ hasUpdate = hasUpdates
+ }
+
+ info := ThemeInfo{
+ ID: theme.ID,
+ Name: theme.Name,
+ Version: theme.Version,
+ Author: theme.Author,
+ Description: theme.Description,
+ SourceDir: id,
+ FirstParty: isFirstParty(theme.Author),
+ HasUpdate: hasUpdate,
+ }
+ addVariantsInfo(&info, theme.Variants)
+ result = append(result, info)
+ } else {
+ installed, err := manager.GetInstalledTheme(id)
+ if err != nil {
+ result = append(result, ThemeInfo{
+ ID: id,
+ Name: id,
+ SourceDir: id,
+ })
+ continue
+ }
+ info := ThemeInfo{
+ ID: installed.ID,
+ Name: installed.Name,
+ Version: installed.Version,
+ Author: installed.Author,
+ Description: installed.Description,
+ SourceDir: id,
+ FirstParty: isFirstParty(installed.Author),
+ }
+ addVariantsInfo(&info, installed.Variants)
+ result = append(result, info)
+ }
+ }
+
+ models.Respond(conn, req.ID, result)
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/themes/search.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/themes/search.go
new file mode 100644
index 0000000..2823fe0
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/themes/search.go
@@ -0,0 +1,53 @@
+package themes
+
+import (
+ "fmt"
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/themes"
+)
+
+func HandleSearch(conn net.Conn, req models.Request) {
+ query, ok := models.Get[string](req, "query")
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'query' parameter")
+ return
+ }
+
+ registry, err := themes.NewRegistry()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to create registry: %v", err))
+ return
+ }
+
+ themeList, err := registry.List()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to list themes: %v", err))
+ return
+ }
+
+ searchResults := themes.FuzzySearch(query, themeList)
+
+ manager, err := themes.NewManager()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to create manager: %v", err))
+ return
+ }
+
+ result := make([]ThemeInfo, len(searchResults))
+ for i, t := range searchResults {
+ installed, _ := manager.IsInstalled(t)
+ result[i] = ThemeInfo{
+ ID: t.ID,
+ Name: t.Name,
+ Version: t.Version,
+ Author: t.Author,
+ Description: t.Description,
+ Installed: installed,
+ FirstParty: isFirstParty(t.Author),
+ }
+ }
+
+ models.Respond(conn, req.ID, result)
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/themes/types.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/themes/types.go
new file mode 100644
index 0000000..0ad19b4
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/themes/types.go
@@ -0,0 +1,47 @@
+package themes
+
+type VariantInfo struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+}
+
+type FlavorInfo struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Mode string `json:"mode,omitempty"`
+}
+
+type AccentInfo struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Color string `json:"color,omitempty"`
+}
+
+type MultiDefaults struct {
+ Dark map[string]string `json:"dark,omitempty"`
+ Light map[string]string `json:"light,omitempty"`
+}
+
+type VariantsInfo struct {
+ Type string `json:"type,omitempty"`
+ Default string `json:"default,omitempty"`
+ Defaults *MultiDefaults `json:"defaults,omitempty"`
+ Options []VariantInfo `json:"options,omitempty"`
+ Flavors []FlavorInfo `json:"flavors,omitempty"`
+ Accents []AccentInfo `json:"accents,omitempty"`
+}
+
+type ThemeInfo struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Version string `json:"version"`
+ Author string `json:"author,omitempty"`
+ Description string `json:"description,omitempty"`
+ PreviewPath string `json:"previewPath,omitempty"`
+ SourceDir string `json:"sourceDir,omitempty"`
+ Installed bool `json:"installed,omitempty"`
+ FirstParty bool `json:"firstParty,omitempty"`
+ HasUpdate bool `json:"hasUpdate,omitempty"`
+ HasVariants bool `json:"hasVariants,omitempty"`
+ Variants *VariantsInfo `json:"variants,omitempty"`
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/themes/uninstall.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/themes/uninstall.go
new file mode 100644
index 0000000..9b453e4
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/themes/uninstall.go
@@ -0,0 +1,63 @@
+package themes
+
+import (
+ "fmt"
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/themes"
+)
+
+func HandleUninstall(conn net.Conn, req models.Request) {
+ idOrName, ok := models.Get[string](req, "name")
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'name' parameter")
+ return
+ }
+
+ manager, err := themes.NewManager()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to create manager: %v", err))
+ return
+ }
+
+ registry, err := themes.NewRegistry()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to create registry: %v", err))
+ return
+ }
+
+ themeList, _ := registry.List()
+ theme := themes.FindByIDOrName(idOrName, themeList)
+
+ if theme != nil {
+ installed, err := manager.IsInstalled(*theme)
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to check if theme is installed: %v", err))
+ return
+ }
+ if !installed {
+ models.RespondError(conn, req.ID, fmt.Sprintf("theme not installed: %s", idOrName))
+ return
+ }
+ if err := manager.Uninstall(*theme); err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to uninstall theme: %v", err))
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{
+ Success: true,
+ Message: fmt.Sprintf("theme uninstalled: %s", theme.Name),
+ })
+ return
+ }
+
+ if err := manager.UninstallByID(idOrName); err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("theme not found: %s", idOrName))
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{
+ Success: true,
+ Message: fmt.Sprintf("theme uninstalled: %s", idOrName),
+ })
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/themes/update.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/themes/update.go
new file mode 100644
index 0000000..fb9f81f
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/themes/update.go
@@ -0,0 +1,57 @@
+package themes
+
+import (
+ "fmt"
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/themes"
+)
+
+func HandleUpdate(conn net.Conn, req models.Request) {
+ idOrName, ok := models.Get[string](req, "name")
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'name' parameter")
+ return
+ }
+
+ manager, err := themes.NewManager()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to create manager: %v", err))
+ return
+ }
+
+ registry, err := themes.NewRegistry()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to create registry: %v", err))
+ return
+ }
+
+ themeList, _ := registry.List()
+ theme := themes.FindByIDOrName(idOrName, themeList)
+
+ if theme == nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("theme not found in registry: %s", idOrName))
+ return
+ }
+
+ installed, err := manager.IsInstalled(*theme)
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to check if theme is installed: %v", err))
+ return
+ }
+ if !installed {
+ models.RespondError(conn, req.ID, fmt.Sprintf("theme not installed: %s", idOrName))
+ return
+ }
+
+ if err := manager.Update(*theme); err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to update theme: %v", err))
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{
+ Success: true,
+ Message: fmt.Sprintf("theme updated: %s", theme.Name),
+ })
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/trayrecovery/manager.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/trayrecovery/manager.go
new file mode 100644
index 0000000..3610f1b
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/trayrecovery/manager.go
@@ -0,0 +1,93 @@
+package trayrecovery
+
+import (
+ "fmt"
+ "sync"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl"
+ "github.com/godbus/dbus/v5"
+)
+
+const resumeDelay = 3 * time.Second
+
+type Manager struct {
+ conn *dbus.Conn
+ stopChan chan struct{}
+ wg sync.WaitGroup
+}
+
+func NewManager() (*Manager, error) {
+ conn, err := dbus.ConnectSessionBus()
+ if err != nil {
+ return nil, fmt.Errorf("failed to connect to session bus: %w", err)
+ }
+
+ m := &Manager{
+ conn: conn,
+ stopChan: make(chan struct{}),
+ }
+
+ // Run a startup scan after a delay — covers the case where the process
+ // was killed during suspend and restarted by systemd (Type=dbus).
+ // The fresh process never sees the PrepareForSleep true→false transition,
+ // so the loginctl watcher alone is not enough.
+ go m.scheduleRecovery()
+
+ return m, nil
+}
+
+// WatchLoginctl subscribes to loginctl session state changes and triggers
+// tray recovery after resume from suspend (PrepareForSleep false transition).
+// This handles the case where the process survives suspend.
+func (m *Manager) WatchLoginctl(lm *loginctl.Manager) {
+ ch := lm.Subscribe("tray-recovery")
+ m.wg.Add(1)
+ go func() {
+ defer m.wg.Done()
+ defer lm.Unsubscribe("tray-recovery")
+
+ wasSleeping := false
+ for {
+ select {
+ case <-m.stopChan:
+ return
+ case state, ok := <-ch:
+ if !ok {
+ return
+ }
+ if state.PreparingForSleep {
+ wasSleeping = true
+ continue
+ }
+ if wasSleeping {
+ wasSleeping = false
+ go m.scheduleRecovery()
+ }
+ }
+ }
+ }()
+}
+
+func (m *Manager) scheduleRecovery() {
+ select {
+ case <-time.After(resumeDelay):
+ m.recoverTrayItems()
+ case <-m.stopChan:
+ }
+}
+
+func (m *Manager) Close() {
+ select {
+ case <-m.stopChan:
+ return
+ default:
+ close(m.stopChan)
+ }
+ m.wg.Wait()
+ if m.conn != nil {
+ m.conn.Close()
+ }
+ log.Info("TrayRecovery manager closed")
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/trayrecovery/recovery.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/trayrecovery/recovery.go
new file mode 100644
index 0000000..c7b98fb
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/trayrecovery/recovery.go
@@ -0,0 +1,262 @@
+package trayrecovery
+
+import (
+ "context"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "github.com/godbus/dbus/v5"
+)
+
+const (
+ sniWatcherDest = "org.kde.StatusNotifierWatcher"
+ sniWatcherPath = "/StatusNotifierWatcher"
+ sniWatcherIface = "org.kde.StatusNotifierWatcher"
+ sniItemIface = "org.kde.StatusNotifierItem"
+ dbusIface = "org.freedesktop.DBus"
+ propsIface = "org.freedesktop.DBus.Properties"
+ probeTimeout = 300 * time.Millisecond
+ connProbeTimeout = 150 * time.Millisecond
+ batchSize = 30
+)
+
+var excludedPrefixes = []string{
+ "org.freedesktop.",
+ "org.gnome.",
+ "org.kde.StatusNotifier",
+ "com.canonical.AppMenu",
+ "org.mpris.",
+ "org.pipewire.",
+ "org.pulseaudio",
+ "fi.epitaph",
+ "quickshell",
+ "org.kde.quickshell",
+}
+
+func (m *Manager) recoverTrayItems() {
+ registeredItems := m.getRegisteredItems()
+ allNames := m.getDBusNames()
+ if allNames == nil {
+ return
+ }
+
+ registeredConnIDs := m.buildRegisteredConnIDs(registeredItems)
+
+ count := len(registeredItems)
+ log.Infof("TrayRecoveryService: scanning DBus for unregistered SNI items (%d already registered)...", count)
+
+ m.scanWellKnownNames(allNames, registeredItems, registeredConnIDs)
+ m.scanConnectionIDs(allNames, registeredItems, registeredConnIDs)
+}
+
+func (m *Manager) getRegisteredItems() []string {
+ obj := m.conn.Object(sniWatcherDest, sniWatcherPath)
+ variant, err := obj.GetProperty(sniWatcherIface + ".RegisteredStatusNotifierItems")
+ if err != nil {
+ log.Warnf("TrayRecoveryService: failed to get registered items: %v", err)
+ return nil
+ }
+
+ switch v := variant.Value().(type) {
+ case []string:
+ return v
+ case []any:
+ items := make([]string, 0, len(v))
+ for _, elem := range v {
+ if s, ok := elem.(string); ok {
+ items = append(items, s)
+ }
+ }
+ return items
+ }
+ return nil
+}
+
+func (m *Manager) getDBusNames() []string {
+ var names []string
+ err := m.conn.BusObject().Call(dbusIface+".ListNames", 0).Store(&names)
+ if err != nil {
+ log.Warnf("TrayRecoveryService: failed to list bus names: %v", err)
+ return nil
+ }
+ return names
+}
+
+func (m *Manager) getNameOwner(name string) string {
+ var owner string
+ err := m.conn.BusObject().Call(dbusIface+".GetNameOwner", 0, name).Store(&owner)
+ if err != nil {
+ return ""
+ }
+ return owner
+}
+
+// buildRegisteredConnIDs resolves every registered SNI item (well-known name
+// or :1.xxx connection ID) to a canonical connection ID. This prevents
+// duplicates in both directions.
+func (m *Manager) buildRegisteredConnIDs(registeredItems []string) map[string]bool {
+ connIDs := make(map[string]bool, len(registeredItems))
+ for _, item := range registeredItems {
+ name := extractName(item)
+ if strings.HasPrefix(name, ":1.") {
+ connIDs[name] = true
+ } else {
+ owner := m.getNameOwner(name)
+ if owner != "" {
+ connIDs[owner] = true
+ }
+ }
+ }
+ return connIDs
+}
+
+// scanWellKnownNames probes well-known names (e.g. DinoX, nm-applet) for
+// unregistered SNI items and re-registers them.
+func (m *Manager) scanWellKnownNames(allNames []string, registeredItems []string, registeredConnIDs map[string]bool) {
+ registeredRaw := strings.Join(registeredItems, "\n")
+
+ for _, name := range allNames {
+ if strings.HasPrefix(name, ":") {
+ continue
+ }
+
+ if strings.Contains(registeredRaw, name) {
+ continue
+ }
+
+ // Skip if this name's connection ID is already in the registered set
+ // (handles the case where the app registered via connection ID instead)
+ connForName := m.getNameOwner(name)
+ if connForName != "" && registeredConnIDs[connForName] {
+ continue
+ }
+
+ if isExcludedName(name) {
+ continue
+ }
+
+ short := shortName(name)
+ objectPaths := []string{
+ "/StatusNotifierItem",
+ "/org/ayatana/NotificationItem/" + short,
+ }
+
+ for _, objPath := range objectPaths {
+ if m.probeSNI(name, objPath, probeTimeout) {
+ m.registerSNI(name)
+ // Update set so the connection-ID section won't double-register this app
+ if connForName != "" {
+ registeredConnIDs[connForName] = true
+ }
+ break
+ }
+ }
+ }
+}
+
+// scanConnectionIDs probes all :1.xxx connections in parallel for unregistered
+// SNI items (e.g. Vesktop, Electron apps). Most non-SNI connections return an
+// error instantly, so this is fast.
+func (m *Manager) scanConnectionIDs(allNames []string, registeredItems []string, registeredConnIDs map[string]bool) {
+ registeredRaw := strings.Join(registeredItems, "\n")
+ registeredLower := strings.ToLower(registeredRaw)
+
+ var wg sync.WaitGroup
+ sem := make(chan struct{}, batchSize)
+
+ for _, name := range allNames {
+ if !strings.HasPrefix(name, ":1.") {
+ continue
+ }
+ if registeredConnIDs[name] {
+ continue
+ }
+
+ sem <- struct{}{}
+ wg.Add(1)
+ go func(conn string) {
+ defer wg.Done()
+ defer func() { <-sem }()
+
+ sniID := m.getSNIId(conn, connProbeTimeout)
+ if sniID == "" {
+ return
+ }
+
+ // Skip if an item with the same Id is already registered (case-insensitive)
+ if strings.Contains(registeredLower, strings.ToLower(sniID)) {
+ return
+ }
+
+ m.registerSNI(conn)
+ log.Infof("TrayRecovery: re-registered %s (Id: %s)", conn, sniID)
+ }(name)
+ }
+ wg.Wait()
+}
+
+func (m *Manager) probeSNI(dest, path string, timeout time.Duration) bool {
+ ctx, cancel := context.WithTimeout(context.Background(), timeout)
+ defer cancel()
+
+ obj := m.conn.Object(dest, dbus.ObjectPath(path))
+ var props map[string]dbus.Variant
+ err := obj.CallWithContext(ctx, propsIface+".GetAll", 0, sniItemIface).Store(&props)
+ if err != nil {
+ return false
+ }
+
+ _, hasID := props["Id"]
+ return hasID
+}
+
+func (m *Manager) getSNIId(dest string, timeout time.Duration) string {
+ ctx, cancel := context.WithTimeout(context.Background(), timeout)
+ defer cancel()
+
+ obj := m.conn.Object(dest, "/StatusNotifierItem")
+ var variant dbus.Variant
+ err := obj.CallWithContext(ctx, propsIface+".Get", 0, sniItemIface, "Id").Store(&variant)
+ if err != nil {
+ return ""
+ }
+
+ id, _ := variant.Value().(string)
+ return id
+}
+
+func (m *Manager) registerSNI(name string) {
+ obj := m.conn.Object(sniWatcherDest, sniWatcherPath)
+ call := obj.Call(sniWatcherIface+".RegisterStatusNotifierItem", 0, name)
+ if call.Err != nil {
+ log.Warnf("TrayRecovery: failed to register %s: %v", name, call.Err)
+ return
+ }
+ log.Infof("TrayRecovery: re-registered %s", name)
+}
+
+func extractName(item string) string {
+ if idx := strings.IndexByte(item, '/'); idx != -1 {
+ return item[:idx]
+ }
+ return item
+}
+
+func shortName(name string) string {
+ parts := strings.Split(name, ".")
+ if len(parts) > 0 {
+ return parts[len(parts)-1]
+ }
+ return name
+}
+
+func isExcludedName(name string) bool {
+ for _, prefix := range excludedPrefixes {
+ if strings.HasPrefix(name, prefix) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/gamma.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/gamma.go
new file mode 100644
index 0000000..1fff03f
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/gamma.go
@@ -0,0 +1,167 @@
+package wayland
+
+import (
+ "math"
+)
+
+type GammaRamp struct {
+ Red []uint16
+ Green []uint16
+ Blue []uint16
+}
+
+type rgb struct {
+ r, g, b float64
+}
+
+type xyz struct {
+ x, y, z float64
+}
+
+func illuminantD(temp int) (float64, float64, bool) {
+ var x float64
+ switch {
+ case temp >= 2500 && temp <= 7000:
+ t := float64(temp)
+ x = 0.244063 + 0.09911e3/t + 2.9678e6/(t*t) - 4.6070e9/(t*t*t)
+ case temp > 7000 && temp <= 25000:
+ t := float64(temp)
+ x = 0.237040 + 0.24748e3/t + 1.9018e6/(t*t) - 2.0064e9/(t*t*t)
+ default:
+ return 0, 0, false
+ }
+ y := -3*(x*x) + 2.870*x - 0.275
+ return x, y, true
+}
+
+func planckianLocus(temp int) (float64, float64, bool) {
+ var x, y float64
+ switch {
+ case temp >= 1667 && temp <= 4000:
+ t := float64(temp)
+ x = -0.2661239e9/(t*t*t) - 0.2343589e6/(t*t) + 0.8776956e3/t + 0.179910
+ if temp <= 2222 {
+ y = -1.1064814*(x*x*x) - 1.34811020*(x*x) + 2.18555832*x - 0.20219683
+ } else {
+ y = -0.9549476*(x*x*x) - 1.37418593*(x*x) + 2.09137015*x - 0.16748867
+ }
+ case temp > 4000 && temp < 25000:
+ t := float64(temp)
+ x = -3.0258469e9/(t*t*t) + 2.1070379e6/(t*t) + 0.2226347e3/t + 0.240390
+ y = 3.0817580*(x*x*x) - 5.87338670*(x*x) + 3.75112997*x - 0.37001483
+ default:
+ return 0, 0, false
+ }
+ return x, y, true
+}
+
+func srgbGamma(value, gamma float64) float64 {
+ if value <= 0.0031308 {
+ return 12.92 * value
+ }
+ return math.Pow(1.055*value, 1.0/gamma) - 0.055
+}
+
+func clamp01(v float64) float64 {
+ switch {
+ case v > 1.0:
+ return 1.0
+ case v < 0.0:
+ return 0.0
+ default:
+ return v
+ }
+}
+
+func xyzToSRGB(c xyz) rgb {
+ return rgb{
+ r: srgbGamma(clamp01(3.2404542*c.x-1.5371385*c.y-0.4985314*c.z), 2.2),
+ g: srgbGamma(clamp01(-0.9692660*c.x+1.8760108*c.y+0.0415560*c.z), 2.2),
+ b: srgbGamma(clamp01(0.0556434*c.x-0.2040259*c.y+1.0572252*c.z), 2.2),
+ }
+}
+
+func normalizeRGB(c *rgb) {
+ maxw := math.Max(c.r, math.Max(c.g, c.b))
+ if maxw > 0 {
+ c.r /= maxw
+ c.g /= maxw
+ c.b /= maxw
+ }
+}
+
+func calcWhitepoint(temp int) rgb {
+ if temp == 6500 {
+ return rgb{r: 1.0, g: 1.0, b: 1.0}
+ }
+
+ var wp xyz
+
+ switch {
+ case temp >= 25000:
+ x, y, _ := illuminantD(25000)
+ wp.x = x
+ wp.y = y
+ case temp >= 4000:
+ x, y, _ := illuminantD(temp)
+ wp.x = x
+ wp.y = y
+ case temp >= 2500:
+ x1, y1, _ := illuminantD(temp)
+ x2, y2, _ := planckianLocus(temp)
+ factor := float64(4000-temp) / 1500.0
+ sineFactor := (math.Cos(math.Pi*factor) + 1.0) / 2.0
+ wp.x = x1*sineFactor + x2*(1.0-sineFactor)
+ wp.y = y1*sineFactor + y2*(1.0-sineFactor)
+ default:
+ t := temp
+ if t < 1667 {
+ t = 1667
+ }
+ x, y, _ := planckianLocus(t)
+ wp.x = x
+ wp.y = y
+ }
+
+ wp.z = 1.0 - wp.x - wp.y
+
+ wpRGB := xyzToSRGB(wp)
+ normalizeRGB(&wpRGB)
+ return wpRGB
+}
+
+func GenerateGammaRamp(size uint32, temp int, gamma float64) GammaRamp {
+ ramp := GammaRamp{
+ Red: make([]uint16, size),
+ Green: make([]uint16, size),
+ Blue: make([]uint16, size),
+ }
+
+ wp := calcWhitepoint(temp)
+
+ for i := uint32(0); i < size; i++ {
+ val := float64(i) / float64(size-1)
+ ramp.Red[i] = uint16(clamp01(math.Pow(val*wp.r, 1.0/gamma)) * 65535.0)
+ ramp.Green[i] = uint16(clamp01(math.Pow(val*wp.g, 1.0/gamma)) * 65535.0)
+ ramp.Blue[i] = uint16(clamp01(math.Pow(val*wp.b, 1.0/gamma)) * 65535.0)
+ }
+
+ return ramp
+}
+
+func GenerateIdentityRamp(size uint32) GammaRamp {
+ ramp := GammaRamp{
+ Red: make([]uint16, size),
+ Green: make([]uint16, size),
+ Blue: make([]uint16, size),
+ }
+
+ for i := uint32(0); i < size; i++ {
+ val := uint16((float64(i) / float64(size-1)) * 65535.0)
+ ramp.Red[i] = val
+ ramp.Green[i] = val
+ ramp.Blue[i] = val
+ }
+
+ return ramp
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/gamma_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/gamma_test.go
new file mode 100644
index 0000000..0bf8e71
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/gamma_test.go
@@ -0,0 +1,120 @@
+package wayland
+
+import (
+ "testing"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
+)
+
+func TestGenerateGammaRamp(t *testing.T) {
+ tests := []struct {
+ name string
+ size uint32
+ temp int
+ gamma float64
+ }{
+ {"small_warm", 16, 6500, 1.0},
+ {"small_cool", 16, 4000, 1.0},
+ {"large_warm", 256, 6500, 1.0},
+ {"large_cool", 256, 4000, 1.0},
+ {"custom_gamma", 64, 5500, 1.2},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ramp := GenerateGammaRamp(tt.size, tt.temp, tt.gamma)
+
+ if len(ramp.Red) != int(tt.size) {
+ t.Errorf("expected %d red values, got %d", tt.size, len(ramp.Red))
+ }
+ if len(ramp.Green) != int(tt.size) {
+ t.Errorf("expected %d green values, got %d", tt.size, len(ramp.Green))
+ }
+ if len(ramp.Blue) != int(tt.size) {
+ t.Errorf("expected %d blue values, got %d", tt.size, len(ramp.Blue))
+ }
+
+ if ramp.Red[0] != 0 || ramp.Green[0] != 0 || ramp.Blue[0] != 0 {
+ t.Errorf("first values should be 0, got R:%d G:%d B:%d",
+ ramp.Red[0], ramp.Green[0], ramp.Blue[0])
+ }
+
+ lastIdx := tt.size - 1
+ if ramp.Red[lastIdx] == 0 || ramp.Green[lastIdx] == 0 || ramp.Blue[lastIdx] == 0 {
+ t.Errorf("last values should be non-zero, got R:%d G:%d B:%d",
+ ramp.Red[lastIdx], ramp.Green[lastIdx], ramp.Blue[lastIdx])
+ }
+
+ for i := uint32(1); i < tt.size; i++ {
+ if ramp.Red[i] < ramp.Red[i-1] {
+ t.Errorf("red ramp not monotonic at index %d", i)
+ }
+ }
+ })
+ }
+}
+
+func TestCalcWhitepoint(t *testing.T) {
+ tests := []struct {
+ name string
+ temp int
+ }{
+ {"very_warm", 6500},
+ {"neutral", 5500},
+ {"cool", 4000},
+ {"very_cool", 3000},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ wp := calcWhitepoint(tt.temp)
+
+ if wp.r < 0 || wp.r > 1 {
+ t.Errorf("red out of range: %f", wp.r)
+ }
+ if wp.g < 0 || wp.g > 1 {
+ t.Errorf("green out of range: %f", wp.g)
+ }
+ if wp.b < 0 || wp.b > 1 {
+ t.Errorf("blue out of range: %f", wp.b)
+ }
+ })
+ }
+}
+
+func TestWhitepointProgression(t *testing.T) {
+ temps := []int{3000, 4000, 5000, 6000, 6500}
+
+ var prevBlue float64
+ for i, temp := range temps {
+ wp := calcWhitepoint(temp)
+ if i > 0 && wp.b < prevBlue {
+ t.Errorf("blue should increase with temperature, %d->%d: %f->%f",
+ temps[i-1], temp, prevBlue, wp.b)
+ }
+ prevBlue = wp.b
+ }
+}
+
+func TestClamp(t *testing.T) {
+ tests := []struct {
+ val float64
+ min float64
+ max float64
+ expected float64
+ }{
+ {5, 0, 10, 5},
+ {-5, 0, 10, 0},
+ {15, 0, 10, 10},
+ {0, 0, 10, 0},
+ {10, 0, 10, 10},
+ }
+
+ for _, tt := range tests {
+ result := utils.Clamp(tt.val, tt.min, tt.max)
+ if result != tt.expected {
+ t.Errorf("clamp(%f, %f, %f) = %f, want %f",
+ tt.val, tt.min, tt.max, result, tt.expected)
+ }
+ }
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/geolocation.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/geolocation.go
new file mode 100644
index 0000000..ba99b5f
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/geolocation.go
@@ -0,0 +1,50 @@
+package wayland
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+)
+
+type ipAPIResponse struct {
+ Lat float64 `json:"lat"`
+ Lon float64 `json:"lon"`
+ City string `json:"city"`
+}
+
+func FetchIPLocation() (*float64, *float64, error) {
+ client := &http.Client{
+ Timeout: 10 * time.Second,
+ }
+
+ resp, err := client.Get("http://ip-api.com/json/")
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to fetch IP location: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, nil, fmt.Errorf("ip-api.com returned status %d", resp.StatusCode)
+ }
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to read response: %w", err)
+ }
+
+ var data ipAPIResponse
+ if err := json.Unmarshal(body, &data); err != nil {
+ return nil, nil, fmt.Errorf("failed to parse response: %w", err)
+ }
+
+ if data.Lat == 0 && data.Lon == 0 {
+ return nil, nil, fmt.Errorf("missing location data in response")
+ }
+
+ log.Infof("Fetched IP-based location: %s (%.4f, %.4f)", data.City, data.Lat, data.Lon)
+ return &data.Lat, &data.Lon, nil
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/handlers.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/handlers.go
new file mode 100644
index 0000000..76b08b5
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/handlers.go
@@ -0,0 +1,182 @@
+package wayland
+
+import (
+ "encoding/json"
+ "fmt"
+ "net"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/params"
+)
+
+func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
+ if manager == nil {
+ models.RespondError(conn, req.ID, "wayland manager not initialized")
+ return
+ }
+
+ switch req.Method {
+ case "wayland.gamma.getState":
+ handleGetState(conn, req, manager)
+ case "wayland.gamma.setTemperature":
+ handleSetTemperature(conn, req, manager)
+ case "wayland.gamma.setLocation":
+ handleSetLocation(conn, req, manager)
+ case "wayland.gamma.setManualTimes":
+ handleSetManualTimes(conn, req, manager)
+ case "wayland.gamma.setUseIPLocation":
+ handleSetUseIPLocation(conn, req, manager)
+ case "wayland.gamma.setGamma":
+ handleSetGamma(conn, req, manager)
+ case "wayland.gamma.setEnabled":
+ handleSetEnabled(conn, req, manager)
+ case "wayland.gamma.subscribe":
+ handleSubscribe(conn, req, manager)
+ default:
+ models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
+ }
+}
+
+func handleGetState(conn net.Conn, req models.Request, manager *Manager) {
+ models.Respond(conn, req.ID, manager.GetState())
+}
+
+func handleSetTemperature(conn net.Conn, req models.Request, manager *Manager) {
+ var lowTemp, highTemp int
+
+ if temp, ok := models.Get[float64](req, "temp"); ok {
+ lowTemp = int(temp)
+ highTemp = int(temp)
+ } else {
+ low, err := params.Float(req.Params, "low")
+ if err != nil {
+ models.RespondError(conn, req.ID, "missing temperature parameters (provide 'temp' or both 'low' and 'high')")
+ return
+ }
+ high, err := params.Float(req.Params, "high")
+ if err != nil {
+ models.RespondError(conn, req.ID, "missing temperature parameters (provide 'temp' or both 'low' and 'high')")
+ return
+ }
+ lowTemp = int(low)
+ highTemp = int(high)
+ }
+
+ if err := manager.SetTemperature(lowTemp, highTemp); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "temperature set"})
+}
+
+func handleSetLocation(conn net.Conn, req models.Request, manager *Manager) {
+ lat, err := params.Float(req.Params, "latitude")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ lon, err := params.Float(req.Params, "longitude")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.SetLocation(lat, lon); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "location set"})
+}
+
+func handleSetManualTimes(conn net.Conn, req models.Request, manager *Manager) {
+ sunriseStr, sunriseOK := models.Get[string](req, "sunrise")
+ sunsetStr, sunsetOK := models.Get[string](req, "sunset")
+
+ if !sunriseOK || !sunsetOK || sunriseStr == "" || sunsetStr == "" {
+ manager.ClearManualTimes()
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "manual times cleared"})
+ return
+ }
+
+ sunrise, err := time.Parse("15:04", sunriseStr)
+ if err != nil {
+ models.RespondError(conn, req.ID, "invalid sunrise format (use HH:MM)")
+ return
+ }
+
+ sunset, err := time.Parse("15:04", sunsetStr)
+ if err != nil {
+ models.RespondError(conn, req.ID, "invalid sunset format (use HH:MM)")
+ return
+ }
+
+ if err := manager.SetManualTimes(sunrise, sunset); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "manual times set"})
+}
+
+func handleSetUseIPLocation(conn net.Conn, req models.Request, manager *Manager) {
+ use, err := params.Bool(req.Params, "use")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ manager.SetUseIPLocation(use)
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "IP location preference set"})
+}
+
+func handleSetGamma(conn net.Conn, req models.Request, manager *Manager) {
+ gamma, err := params.Float(req.Params, "gamma")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.SetGamma(gamma); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "gamma set"})
+}
+
+func handleSetEnabled(conn net.Conn, req models.Request, manager *Manager) {
+ enabled, err := params.Bool(req.Params, "enabled")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ manager.SetEnabled(enabled)
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "enabled state set"})
+}
+
+func handleSubscribe(conn net.Conn, req models.Request, manager *Manager) {
+ clientID := fmt.Sprintf("client-%p", conn)
+ stateChan := manager.Subscribe(clientID)
+ defer manager.Unsubscribe(clientID)
+
+ initialState := manager.GetState()
+ if err := json.NewEncoder(conn).Encode(models.Response[State]{
+ ID: req.ID,
+ Result: &initialState,
+ }); err != nil {
+ return
+ }
+
+ for state := range stateChan {
+ if err := json.NewEncoder(conn).Encode(models.Response[State]{
+ Result: &state,
+ }); err != nil {
+ return
+ }
+ }
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/manager.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/manager.go
new file mode 100644
index 0000000..3540e3d
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/manager.go
@@ -0,0 +1,1256 @@
+package wayland
+
+import (
+ "bytes"
+ "encoding/binary"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "slices"
+ "syscall"
+ "time"
+
+ wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
+ "github.com/godbus/dbus/v5"
+ "golang.org/x/sys/unix"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_gamma_control"
+)
+
+const animKelvinStep = 25
+
+func NewManager(display wlclient.WaylandDisplay, config Config) (*Manager, error) {
+ if err := config.Validate(); err != nil {
+ return nil, err
+ }
+
+ if config.ElevationTwilight == 0 {
+ config.ElevationTwilight = -6.0
+ }
+ if config.ElevationDaylight == 0 {
+ config.ElevationDaylight = 3.0
+ }
+
+ m := &Manager{
+ config: config,
+ display: display,
+ ctx: display.Context(),
+ cmdq: make(chan cmd, 128),
+ stopChan: make(chan struct{}),
+ updateTrigger: make(chan struct{}, 1),
+ dirty: make(chan struct{}, 1),
+ dbusSignal: make(chan *dbus.Signal, 16),
+ }
+
+ if err := m.setupRegistry(); err != nil {
+ return nil, err
+ }
+
+ if err := m.setupDBusMonitor(); err != nil {
+ log.Warnf("Failed to setup D-Bus monitoring: %v", err)
+ }
+
+ m.alive = true
+ m.recalcSchedule(time.Now())
+ m.updateStateFromSchedule()
+
+ m.notifierWg.Add(1)
+ go m.notifier()
+
+ m.wg.Add(1)
+ go m.schedulerLoop()
+
+ if m.dbusConn != nil {
+ m.wg.Add(1)
+ go m.dbusMonitor()
+ }
+
+ m.wg.Add(1)
+ go m.waylandActor()
+
+ if config.Enabled {
+ m.post(func() {
+ log.Info("Gamma control enabled at startup")
+ gammaMgr := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1)
+ m.availOutputsMu.RLock()
+ outs := slices.Clone(m.availableOutputs)
+ m.availOutputsMu.RUnlock()
+ if err := m.setupOutputControls(outs, gammaMgr); err != nil {
+ log.Errorf("Failed to initialize gamma controls: %v", err)
+ return
+ }
+ m.controlsInitialized = true
+ })
+ }
+
+ return m, nil
+}
+
+func (m *Manager) post(fn func()) {
+ select {
+ case m.cmdq <- cmd{fn: fn}:
+ default:
+ log.Warn("Actor command queue full")
+ }
+}
+
+func (m *Manager) waylandActor() {
+ defer m.wg.Done()
+ for {
+ select {
+ case <-m.stopChan:
+ return
+ case c := <-m.cmdq:
+ c.fn()
+ }
+ }
+}
+
+func (m *Manager) anyOutputReady() bool {
+ anyReady := false
+ m.outputs.Range(func(_ uint32, out *outputState) bool {
+ if out.rampSize > 0 && !out.failed {
+ anyReady = true
+ return false // stop iteration
+ }
+ return true
+ })
+ return anyReady
+}
+
+func (m *Manager) setupDBusMonitor() error {
+ conn, err := dbus.ConnectSystemBus()
+ if err != nil {
+ return fmt.Errorf("system bus: %w", err)
+ }
+
+ matchRule := "type='signal',interface='org.freedesktop.login1.Manager',member='PrepareForSleep',path='/org/freedesktop/login1'"
+ if err := conn.BusObject().Call("org.freedesktop.DBus.AddMatch", 0, matchRule).Err; err != nil {
+ conn.Close()
+ return fmt.Errorf("add match: %w", err)
+ }
+
+ conn.Signal(m.dbusSignal)
+ m.dbusConn = conn
+ return nil
+}
+
+func (m *Manager) setupRegistry() error {
+ registry, err := m.display.GetRegistry()
+ if err != nil {
+ return fmt.Errorf("get registry: %w", err)
+ }
+ m.registry = registry
+
+ outputs := make([]*wlclient.Output, 0)
+ outputNames := make(map[uint32]string)
+ var gammaMgr *wlr_gamma_control.ZwlrGammaControlManagerV1
+
+ registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
+ switch e.Interface {
+ case wlr_gamma_control.ZwlrGammaControlManagerV1InterfaceName:
+ manager := wlr_gamma_control.NewZwlrGammaControlManagerV1(m.ctx)
+ version := e.Version
+ if version > 1 {
+ version = 1
+ }
+ if err := registry.Bind(e.Name, e.Interface, version, manager); err == nil {
+ gammaMgr = manager
+ }
+ case "wl_output":
+ output := wlclient.NewOutput(m.ctx)
+ version := e.Version
+ if version > 4 {
+ version = 4
+ }
+ if err := registry.Bind(e.Name, e.Interface, version, output); err != nil {
+ return
+ }
+ outputID := output.ID()
+ output.SetNameHandler(func(ev wlclient.OutputNameEvent) {
+ outputNames[outputID] = ev.Name
+ })
+ if gammaMgr != nil {
+ outputs = append(outputs, output)
+ m.addAvailableOutput(output)
+ }
+ m.outputRegNames.Store(outputID, e.Name)
+
+ m.configMutex.RLock()
+ enabled := m.config.Enabled
+ m.configMutex.RUnlock()
+
+ if enabled && m.controlsInitialized {
+ m.post(func() {
+ if err := m.addOutputControl(output); err != nil {
+ log.Warnf("Failed to add output control: %v", err)
+ }
+ })
+ }
+ }
+ })
+
+ registry.SetGlobalRemoveHandler(func(e wlclient.RegistryGlobalRemoveEvent) {
+ m.post(func() {
+ var foundID uint32
+ var foundOut *outputState
+ m.outputs.Range(func(id uint32, out *outputState) bool {
+ if out.registryName == e.Name {
+ foundID = id
+ foundOut = out
+ return false
+ }
+ return true
+ })
+ if foundOut == nil {
+ return
+ }
+ if foundOut.gammaControl != nil {
+ foundOut.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1).Destroy()
+ foundOut.gammaControl = nil
+ }
+ m.removeAvailableOutput(foundOut.output)
+ if foundOut.output != nil && !foundOut.output.IsZombie() {
+ _ = foundOut.output.Release()
+ }
+ m.outputs.Delete(foundID)
+
+ hasOutputs := false
+ m.outputs.Range(func(_ uint32, _ *outputState) bool {
+ hasOutputs = true
+ return false
+ })
+ if !hasOutputs {
+ m.controlsInitialized = false
+ }
+ })
+ })
+
+ if err := m.display.Roundtrip(); err != nil {
+ return fmt.Errorf("roundtrip 1: %w", err)
+ }
+ if err := m.display.Roundtrip(); err != nil {
+ return fmt.Errorf("roundtrip 2: %w", err)
+ }
+
+ if gammaMgr == nil {
+ return errdefs.ErrNoGammaControl
+ }
+ if len(outputs) == 0 {
+ return fmt.Errorf("no outputs")
+ }
+
+ physicalOutputs := make([]*wlclient.Output, 0, len(outputs))
+ for _, output := range outputs {
+ name := outputNames[output.ID()]
+ if len(name) >= 9 && name[:9] == "HEADLESS-" {
+ continue
+ }
+ physicalOutputs = append(physicalOutputs, output)
+ }
+
+ m.gammaControl = gammaMgr
+ m.availableOutputs = physicalOutputs
+ return nil
+}
+
+func (m *Manager) setupOutputControls(outputs []*wlclient.Output, manager *wlr_gamma_control.ZwlrGammaControlManagerV1) error {
+ for _, output := range outputs {
+ control, err := manager.GetGammaControl(output)
+ if err != nil {
+ continue
+ }
+ outputID := output.ID()
+ registryName, _ := m.outputRegNames.Load(outputID)
+ outState := &outputState{
+ id: outputID,
+ registryName: registryName,
+ output: output,
+ gammaControl: control,
+ }
+ m.setupControlHandlers(outState, control)
+ m.outputs.Store(outputID, outState)
+ }
+ return nil
+}
+
+func (m *Manager) setupControlHandlers(state *outputState, control *wlr_gamma_control.ZwlrGammaControlV1) {
+ outputID := state.id
+
+ control.SetGammaSizeHandler(func(e wlr_gamma_control.ZwlrGammaControlV1GammaSizeEvent) {
+ size := e.Size
+ m.post(func() {
+ if out, ok := m.outputs.Load(outputID); ok {
+ out.rampSize = size
+ out.failed = false
+ out.retryCount = 0
+ }
+ m.lastAppliedTemp = 0
+ m.applyCurrentTemp("gamma_size")
+ })
+ })
+
+ control.SetFailedHandler(func(_ wlr_gamma_control.ZwlrGammaControlV1FailedEvent) {
+ m.post(func() {
+ out, ok := m.outputs.Load(outputID)
+ if !ok {
+ return
+ }
+ if ctrl, ok := out.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1); ok && ctrl != nil && !ctrl.IsZombie() {
+ ctrl.Destroy()
+ }
+ out.gammaControl = nil
+ out.failed = true
+ out.rampSize = 0
+ out.retryCount++
+ out.lastFailTime = time.Now()
+
+ if !m.outputStillValid(out) {
+ return
+ }
+
+ backoff := time.Duration(300<<uint(min(out.retryCount-1, 4))) * time.Millisecond
+ time.AfterFunc(backoff, func() {
+ m.post(func() {
+ if !m.outputStillValid(out) {
+ return
+ }
+ if _, stillTracked := m.outputs.Load(outputID); !stillTracked {
+ return
+ }
+ m.recreateOutputControl(out)
+ })
+ })
+ })
+ })
+}
+
+func (m *Manager) addAvailableOutput(o *wlclient.Output) {
+ if o == nil {
+ return
+ }
+ m.availOutputsMu.Lock()
+ defer m.availOutputsMu.Unlock()
+ if slices.Contains(m.availableOutputs, o) {
+ return
+ }
+ m.availableOutputs = append(m.availableOutputs, o)
+}
+
+func (m *Manager) removeAvailableOutput(o *wlclient.Output) {
+ if o == nil {
+ return
+ }
+ m.availOutputsMu.Lock()
+ defer m.availOutputsMu.Unlock()
+ m.availableOutputs = slices.DeleteFunc(m.availableOutputs, func(existing *wlclient.Output) bool {
+ return existing == o
+ })
+}
+
+func (m *Manager) outputStillValid(out *outputState) bool {
+ switch {
+ case out == nil:
+ return false
+ case out.output == nil:
+ return false
+ case out.output.IsZombie():
+ return false
+ }
+ m.availOutputsMu.RLock()
+ defer m.availOutputsMu.RUnlock()
+ return slices.Contains(m.availableOutputs, out.output)
+}
+
+func isConnectionDeadErr(err error) bool {
+ switch {
+ case err == nil:
+ return false
+ case errors.Is(err, syscall.EPIPE):
+ return true
+ case errors.Is(err, syscall.ECONNRESET):
+ return true
+ case errors.Is(err, syscall.EBADF):
+ return true
+ case errors.Is(err, io.EOF):
+ return true
+ }
+ return false
+}
+
+func (m *Manager) addOutputControl(output *wlclient.Output) error {
+ switch {
+ case m.connectionDead.Load():
+ return nil
+ case output == nil || output.IsZombie():
+ return nil
+ }
+
+ outputID := output.ID()
+ gammaMgr := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1)
+
+ control, err := gammaMgr.GetGammaControl(output)
+ if err != nil {
+ if isConnectionDeadErr(err) {
+ m.markConnectionDead(err)
+ }
+ return err
+ }
+
+ registryName, _ := m.outputRegNames.Load(outputID)
+ outState := &outputState{
+ id: outputID,
+ registryName: registryName,
+ output: output,
+ gammaControl: control,
+ }
+ m.setupControlHandlers(outState, control)
+ m.outputs.Store(outputID, outState)
+ return nil
+}
+
+func (m *Manager) recreateOutputControl(out *outputState) error {
+ m.configMutex.RLock()
+ enabled := m.config.Enabled
+ m.configMutex.RUnlock()
+
+ switch {
+ case m.connectionDead.Load():
+ return nil
+ case !enabled || !m.controlsInitialized:
+ return nil
+ case out.isVirtual:
+ return nil
+ case out.retryCount >= 10:
+ return nil
+ case !m.outputStillValid(out):
+ return nil
+ }
+ if _, ok := m.outputs.Load(out.id); !ok {
+ return nil
+ }
+
+ gammaMgr, ok := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1)
+ if !ok {
+ return fmt.Errorf("no gamma manager")
+ }
+
+ if existing, ok := out.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1); ok && existing != nil && !existing.IsZombie() {
+ existing.Destroy()
+ out.gammaControl = nil
+ }
+
+ control, err := gammaMgr.GetGammaControl(out.output)
+ if err != nil {
+ if isConnectionDeadErr(err) {
+ m.markConnectionDead(err)
+ }
+ return err
+ }
+
+ m.setupControlHandlers(out, control)
+ out.gammaControl = control
+ out.failed = false
+ return nil
+}
+
+func (m *Manager) markConnectionDead(err error) {
+ if m.connectionDead.Swap(true) {
+ return
+ }
+ log.Errorf("gamma: wayland connection appears dead (%v); pausing gamma operations", err)
+}
+
+func (m *Manager) recalcSchedule(now time.Time) {
+ m.configMutex.RLock()
+ config := m.config
+ m.configMutex.RUnlock()
+
+ m.scheduleMutex.Lock()
+ defer m.scheduleMutex.Unlock()
+
+ dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
+ alreadyValid := !m.schedule.times.Sunrise.IsZero()
+ if m.schedule.calcDay.Equal(dayStart) && alreadyValid {
+ return
+ }
+
+ var times SunTimes
+ var cond SunCondition
+
+ if config.ManualSunrise != nil && config.ManualSunset != nil {
+ dur := time.Hour
+ if config.ManualDuration != nil {
+ dur = *config.ManualDuration
+ }
+ sunrise := time.Date(now.Year(), now.Month(), now.Day(),
+ config.ManualSunrise.Hour(), config.ManualSunrise.Minute(), config.ManualSunrise.Second(), 0, now.Location())
+ sunset := time.Date(now.Year(), now.Month(), now.Day(),
+ config.ManualSunset.Hour(), config.ManualSunset.Minute(), config.ManualSunset.Second(), 0, now.Location())
+ times = SunTimes{
+ Dawn: sunrise.Add(-dur),
+ Sunrise: sunrise,
+ Sunset: sunset,
+ Night: sunset.Add(dur),
+ }
+ cond = SunNormal
+ } else {
+ lat, lon := m.getLocation()
+ if lat == nil || lon == nil {
+ m.gammaState = StateStatic
+ return
+ }
+ times, cond = CalculateSunTimesWithTwilight(*lat, *lon, now, config.ElevationTwilight, config.ElevationDaylight)
+ }
+
+ m.schedule.calcDay = dayStart
+ m.schedule.times = times
+ m.schedule.condition = cond
+
+ switch cond {
+ case SunNormal:
+ m.gammaState = StateNormal
+ tempDiff := config.HighTemp - config.LowTemp
+ if tempDiff > 0 {
+ dawnDur := times.Sunrise.Sub(times.Dawn)
+ nightDur := times.Night.Sub(times.Sunset)
+ m.schedule.dawnStepTime = time.Duration(max(1, int(dawnDur.Seconds())*animKelvinStep/tempDiff)) * time.Second
+ m.schedule.nightStepTime = time.Duration(max(1, int(nightDur.Seconds())*animKelvinStep/tempDiff)) * time.Second
+ }
+ case SunMidnightSun:
+ m.gammaState = StateStatic
+ case SunPolarNight:
+ m.gammaState = StateStatic
+ }
+}
+
+func (m *Manager) SetGeoClient(client geolocation.Client) {
+ m.geoClient = client
+}
+
+func (m *Manager) getLocation() (*float64, *float64) {
+ m.configMutex.RLock()
+ config := m.config
+ m.configMutex.RUnlock()
+
+ if config.Latitude != nil && config.Longitude != nil {
+ return config.Latitude, config.Longitude
+ }
+ if !config.UseIPLocation {
+ return nil, nil
+ }
+ if m.geoClient == nil {
+ return nil, nil
+ }
+
+ m.locationMutex.RLock()
+ if m.cachedIPLat != nil && m.cachedIPLon != nil {
+ lat, lon := m.cachedIPLat, m.cachedIPLon
+ m.locationMutex.RUnlock()
+ return lat, lon
+ }
+ m.locationMutex.RUnlock()
+
+ location, err := m.geoClient.GetLocation()
+ if err != nil {
+ return nil, nil
+ }
+
+ m.locationMutex.Lock()
+ m.cachedIPLat = &location.Latitude
+ m.cachedIPLon = &location.Longitude
+ m.locationMutex.Unlock()
+ return m.cachedIPLat, m.cachedIPLon
+}
+
+func (m *Manager) hasValidSchedule() bool {
+ m.scheduleMutex.RLock()
+ defer m.scheduleMutex.RUnlock()
+ return !m.schedule.times.Sunrise.IsZero()
+}
+
+func (m *Manager) getSunPosition(now time.Time) float64 {
+ m.scheduleMutex.RLock()
+ sched := m.schedule
+ state := m.gammaState
+ m.scheduleMutex.RUnlock()
+
+ if sched.times.Sunrise.IsZero() {
+ return 1.0
+ }
+
+ switch state {
+ case StateStatic:
+ if sched.condition == SunMidnightSun {
+ return 1.0
+ }
+ return 0.0
+ case StateNormal:
+ return m.getSunPositionNormal(now, sched.times)
+ }
+ return 1.0
+}
+
+func (m *Manager) getSunPositionNormal(now time.Time, times SunTimes) float64 {
+ if now.Before(times.Dawn) {
+ return 0.0
+ }
+ if now.Before(times.Sunrise) {
+ return interpolate(now, times.Dawn, times.Sunrise)
+ }
+ if now.Before(times.Sunset) {
+ return 1.0
+ }
+ if now.Before(times.Night) {
+ return interpolate(now, times.Night, times.Sunset)
+ }
+ return 0.0
+}
+
+func interpolate(now time.Time, start, stop time.Time) float64 {
+ if start.Equal(stop) {
+ return 1.0
+ }
+ pos := float64(now.Sub(start)) / float64(stop.Sub(start))
+ switch {
+ case pos > 1.0:
+ return 1.0
+ case pos < 0.0:
+ return 0.0
+ default:
+ return pos
+ }
+}
+
+func (m *Manager) getTempFromPosition(pos float64) int {
+ m.configMutex.RLock()
+ low, high := m.config.LowTemp, m.config.HighTemp
+ m.configMutex.RUnlock()
+ return low + int(float64(high-low)*pos)
+}
+
+func (m *Manager) getNextDeadline(now time.Time) time.Time {
+ m.scheduleMutex.RLock()
+ sched := m.schedule
+ state := m.gammaState
+ m.scheduleMutex.RUnlock()
+
+ switch state {
+ case StateStatic:
+ return m.tomorrow(now)
+ case StateNormal:
+ return m.getDeadlineNormal(now, sched)
+ default:
+ return m.tomorrow(now)
+ }
+}
+
+func (m *Manager) getDeadlineNormal(now time.Time, sched sunSchedule) time.Time {
+ times := sched.times
+ switch {
+ case now.Before(times.Dawn):
+ return times.Dawn
+ case now.Before(times.Sunrise):
+ return now.Add(sched.dawnStepTime)
+ case now.Before(times.Sunset):
+ return times.Sunset
+ case now.Before(times.Night):
+ return now.Add(sched.nightStepTime)
+ default:
+ return m.tomorrowDawn(now)
+ }
+}
+
+func (m *Manager) tomorrowDawn(now time.Time) time.Time {
+ tomorrow := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location())
+
+ m.configMutex.RLock()
+ config := m.config
+ m.configMutex.RUnlock()
+
+ if config.ManualSunrise != nil {
+ dur := time.Hour
+ if config.ManualDuration != nil {
+ dur = *config.ManualDuration
+ }
+ return time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(),
+ config.ManualSunrise.Hour(), config.ManualSunrise.Minute(), config.ManualSunrise.Second(), 0, tomorrow.Location()).Add(-dur)
+ }
+
+ lat, lon := m.getLocation()
+ if lat == nil || lon == nil {
+ return tomorrow
+ }
+
+ times, cond := CalculateSunTimesWithTwilight(*lat, *lon, tomorrow, config.ElevationTwilight, config.ElevationDaylight)
+ if cond != SunNormal {
+ return tomorrow
+ }
+ return times.Dawn
+}
+
+func (m *Manager) tomorrow(now time.Time) time.Time {
+ return time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location())
+}
+
+func (m *Manager) schedulerLoop() {
+ defer m.wg.Done()
+
+ m.configMutex.RLock()
+ enabled := m.config.Enabled
+ m.configMutex.RUnlock()
+
+ if enabled {
+ m.post(func() { m.applyCurrentTemp("startup") })
+ }
+
+ var timer *time.Timer
+ for {
+ m.configMutex.RLock()
+ enabled := m.config.Enabled
+ m.configMutex.RUnlock()
+
+ now := time.Now()
+ m.recalcSchedule(now)
+
+ var waitDur time.Duration
+ if enabled {
+ deadline := m.getNextDeadline(now)
+ waitDur = time.Until(deadline)
+ if waitDur < time.Second {
+ waitDur = time.Second
+ }
+ } else {
+ waitDur = 24 * time.Hour
+ }
+
+ if timer != nil {
+ timer.Stop()
+ }
+ timer = time.NewTimer(waitDur)
+
+ select {
+ case <-m.stopChan:
+ timer.Stop()
+ return
+ case <-m.updateTrigger:
+ timer.Stop()
+ m.scheduleMutex.Lock()
+ m.schedule.calcDay = time.Time{}
+ m.scheduleMutex.Unlock()
+ m.recalcSchedule(time.Now())
+ m.updateStateFromSchedule()
+ m.configMutex.RLock()
+ enabled := m.config.Enabled
+ m.configMutex.RUnlock()
+ if enabled {
+ m.post(func() { m.applyCurrentTemp("updateTrigger") })
+ }
+ case <-timer.C:
+ m.configMutex.RLock()
+ enabled := m.config.Enabled
+ m.configMutex.RUnlock()
+ if enabled {
+ m.post(func() { m.applyCurrentTemp("timer") })
+ }
+ }
+ }
+}
+
+func (m *Manager) applyCurrentTemp(_ string) {
+ if !m.controlsInitialized || !m.anyOutputReady() {
+ return
+ }
+
+ // Ensure schedule is up-to-date (handles display wake after overnight sleep)
+ m.recalcSchedule(time.Now())
+
+ m.configMutex.RLock()
+ low, high := m.config.LowTemp, m.config.HighTemp
+ m.configMutex.RUnlock()
+
+ if low == high {
+ m.applyGamma(low)
+ m.updateStateFromSchedule()
+ return
+ }
+
+ if !m.hasValidSchedule() {
+ m.updateStateFromSchedule()
+ return
+ }
+
+ now := time.Now()
+ pos := m.getSunPosition(now)
+ temp := m.getTempFromPosition(pos)
+
+ m.applyGamma(temp)
+ m.updateStateFromSchedule()
+}
+
+func (m *Manager) applyGamma(temp int) {
+ m.configMutex.RLock()
+ gamma := m.config.Gamma
+ m.configMutex.RUnlock()
+
+ switch {
+ case m.connectionDead.Load():
+ return
+ case !m.controlsInitialized:
+ return
+ case m.lastAppliedTemp == temp && m.lastAppliedGamma == gamma:
+ return
+ }
+
+ var outs []*outputState
+ m.outputs.Range(func(_ uint32, out *outputState) bool {
+ outs = append(outs, out)
+ return true
+ })
+ if len(outs) == 0 {
+ return
+ }
+
+ type job struct {
+ out *outputState
+ data []byte
+ }
+ var jobs []job
+
+ for _, out := range outs {
+ switch {
+ case out.failed:
+ continue
+ case out.rampSize == 0:
+ continue
+ case out.gammaControl == nil:
+ continue
+ case !m.outputStillValid(out):
+ continue
+ }
+ ramp := GenerateGammaRamp(out.rampSize, temp, gamma)
+ buf := bytes.NewBuffer(make([]byte, 0, int(out.rampSize)*6))
+ for _, v := range ramp.Red {
+ binary.Write(buf, binary.LittleEndian, v)
+ }
+ for _, v := range ramp.Green {
+ binary.Write(buf, binary.LittleEndian, v)
+ }
+ for _, v := range ramp.Blue {
+ binary.Write(buf, binary.LittleEndian, v)
+ }
+ jobs = append(jobs, job{out: out, data: buf.Bytes()})
+ }
+
+ for _, j := range jobs {
+ err := m.setGammaBytes(j.out, j.data)
+ if err == nil {
+ continue
+ }
+ log.Warnf("gamma: failed to set output %d: %v", j.out.id, err)
+ j.out.failed = true
+ j.out.rampSize = 0
+ if isConnectionDeadErr(err) {
+ m.markConnectionDead(err)
+ return
+ }
+ }
+
+ m.lastAppliedTemp = temp
+ m.lastAppliedGamma = gamma
+}
+
+func (m *Manager) setGammaBytes(out *outputState, data []byte) error {
+ if out.gammaControl == nil {
+ return fmt.Errorf("no gamma control")
+ }
+ ctrl, ok := out.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1)
+ if !ok || ctrl == nil || ctrl.IsZombie() {
+ return fmt.Errorf("gamma control invalid")
+ }
+
+ fd, err := MemfdCreate("gamma-ramp", 0)
+ if err != nil {
+ return err
+ }
+ defer syscall.Close(fd)
+
+ if err := syscall.Ftruncate(fd, int64(len(data))); err != nil {
+ return err
+ }
+
+ dupFd, err := syscall.Dup(fd)
+ if err != nil {
+ return err
+ }
+ f := os.NewFile(uintptr(dupFd), "gamma")
+ defer f.Close()
+
+ if _, err := f.Write(data); err != nil {
+ return err
+ }
+ syscall.Seek(fd, 0, 0)
+
+ return ctrl.SetGamma(fd)
+}
+
+func (m *Manager) updateStateFromSchedule() {
+ now := time.Now()
+
+ m.configMutex.RLock()
+ config := m.config
+ m.configMutex.RUnlock()
+
+ m.scheduleMutex.RLock()
+ times := m.schedule.times
+ m.scheduleMutex.RUnlock()
+
+ var pos float64
+ var temp int
+ var isDay bool
+ var deadline time.Time
+
+ if times.Sunrise.IsZero() {
+ pos = 1.0
+ temp = config.HighTemp
+ isDay = true
+ deadline = m.tomorrow(now)
+ } else {
+ pos = m.getSunPosition(now)
+ temp = m.getTempFromPosition(pos)
+ deadline = m.getNextDeadline(now)
+ isDay = now.After(times.Sunrise) && now.Before(times.Sunset)
+ }
+
+ newState := State{
+ Config: config,
+ CurrentTemp: temp,
+ NextTransition: deadline,
+ SunriseTime: times.Sunrise,
+ SunsetTime: times.Sunset,
+ DawnTime: times.Dawn,
+ NightTime: times.Night,
+ IsDay: isDay,
+ SunPosition: pos,
+ }
+
+ m.stateMutex.Lock()
+ m.state = &newState
+ m.stateMutex.Unlock()
+
+ m.notifySubscribers()
+}
+
+func (m *Manager) notifier() {
+ defer m.notifierWg.Done()
+ const minGap = 100 * time.Millisecond
+ timer := time.NewTimer(minGap)
+ timer.Stop()
+ var pending bool
+
+ for {
+ select {
+ case <-m.stopChan:
+ timer.Stop()
+ return
+ case <-m.dirty:
+ if pending {
+ continue
+ }
+ pending = true
+ timer.Reset(minGap)
+ case <-timer.C:
+ if !pending {
+ continue
+ }
+ currentState := m.GetState()
+ if m.lastNotified != nil && !stateChanged(m.lastNotified, &currentState) {
+ pending = false
+ continue
+ }
+ m.subscribers.Range(func(_ string, ch chan State) bool {
+ select {
+ case ch <- currentState:
+ default:
+ }
+ return true
+ })
+ stateCopy := currentState
+ m.lastNotified = &stateCopy
+ pending = false
+ }
+ }
+}
+
+func (m *Manager) dbusMonitor() {
+ defer m.wg.Done()
+ for {
+ select {
+ case <-m.stopChan:
+ return
+ case sig := <-m.dbusSignal:
+ if sig == nil {
+ continue
+ }
+ m.handleDBusSignal(sig)
+ }
+ }
+}
+
+func (m *Manager) handleDBusSignal(sig *dbus.Signal) {
+ switch {
+ case sig.Name != "org.freedesktop.login1.Manager.PrepareForSleep":
+ return
+ case len(sig.Body) == 0:
+ return
+ }
+ preparing, ok := sig.Body[0].(bool)
+ if !ok || preparing {
+ return
+ }
+ m.configMutex.RLock()
+ enabled := m.config.Enabled
+ m.configMutex.RUnlock()
+ if !enabled {
+ return
+ }
+ time.AfterFunc(500*time.Millisecond, func() {
+ m.post(m.handleResume)
+ })
+}
+
+func (m *Manager) handleResume() {
+ m.configMutex.RLock()
+ stillEnabled := m.config.Enabled
+ m.configMutex.RUnlock()
+
+ switch {
+ case !stillEnabled:
+ return
+ case !m.controlsInitialized:
+ return
+ case m.connectionDead.Load():
+ return
+ }
+
+ // Compositors (Niri, Hyprland, wlroots-based) re-apply the cached gamma
+ // ramp to DRM on resume; gamma_control objects stay valid. We just need
+ // to force a resend so the schedule catches up with the current time of
+ // day — the original #1235 regression was caused by lastAppliedTemp
+ // matching and the send being skipped.
+ m.recalcSchedule(time.Now())
+ m.lastAppliedTemp = 0
+ m.applyCurrentTemp("resume")
+}
+
+func (m *Manager) triggerUpdate() {
+ select {
+ case m.updateTrigger <- struct{}{}:
+ default:
+ }
+}
+
+func (m *Manager) SetConfig(config Config) error {
+ if err := config.Validate(); err != nil {
+ return err
+ }
+ m.configMutex.Lock()
+ m.config = config
+ m.configMutex.Unlock()
+ m.triggerUpdate()
+ return nil
+}
+
+func (m *Manager) SetTemperature(low, high int) error {
+ m.configMutex.Lock()
+ if m.config.LowTemp == low && m.config.HighTemp == high {
+ m.configMutex.Unlock()
+ return nil
+ }
+ m.config.LowTemp = low
+ m.config.HighTemp = high
+ err := m.config.Validate()
+ m.configMutex.Unlock()
+ if err != nil {
+ return err
+ }
+ m.triggerUpdate()
+ return nil
+}
+
+func (m *Manager) SetLocation(lat, lon float64) error {
+ m.configMutex.Lock()
+ if m.config.Latitude != nil && m.config.Longitude != nil &&
+ *m.config.Latitude == lat && *m.config.Longitude == lon && !m.config.UseIPLocation {
+ m.configMutex.Unlock()
+ return nil
+ }
+ m.config.Latitude = &lat
+ m.config.Longitude = &lon
+ m.config.UseIPLocation = false
+ err := m.config.Validate()
+ m.configMutex.Unlock()
+ if err != nil {
+ return err
+ }
+ m.triggerUpdate()
+ return nil
+}
+
+func (m *Manager) SetUseIPLocation(use bool) {
+ m.configMutex.Lock()
+ if m.config.UseIPLocation == use {
+ m.configMutex.Unlock()
+ return
+ }
+ m.config.UseIPLocation = use
+ if use {
+ m.config.Latitude = nil
+ m.config.Longitude = nil
+ }
+ m.configMutex.Unlock()
+
+ if use {
+ m.locationMutex.Lock()
+ m.cachedIPLat = nil
+ m.cachedIPLon = nil
+ m.locationMutex.Unlock()
+ }
+ m.triggerUpdate()
+}
+
+func (m *Manager) SetManualTimes(sunrise, sunset time.Time) error {
+ m.configMutex.Lock()
+ if m.config.ManualSunrise != nil && m.config.ManualSunset != nil &&
+ m.config.ManualSunrise.Hour() == sunrise.Hour() && m.config.ManualSunrise.Minute() == sunrise.Minute() &&
+ m.config.ManualSunset.Hour() == sunset.Hour() && m.config.ManualSunset.Minute() == sunset.Minute() {
+ m.configMutex.Unlock()
+ return nil
+ }
+ m.config.ManualSunrise = &sunrise
+ m.config.ManualSunset = &sunset
+ err := m.config.Validate()
+ m.configMutex.Unlock()
+ if err != nil {
+ return err
+ }
+ m.triggerUpdate()
+ return nil
+}
+
+func (m *Manager) ClearManualTimes() {
+ m.configMutex.Lock()
+ if m.config.ManualSunrise == nil && m.config.ManualSunset == nil {
+ m.configMutex.Unlock()
+ return
+ }
+ m.config.ManualSunrise = nil
+ m.config.ManualSunset = nil
+ m.configMutex.Unlock()
+ m.triggerUpdate()
+}
+
+func (m *Manager) SetGamma(gamma float64) error {
+ m.configMutex.Lock()
+ if m.config.Gamma == gamma {
+ m.configMutex.Unlock()
+ return nil
+ }
+ m.config.Gamma = gamma
+ err := m.config.Validate()
+ m.configMutex.Unlock()
+ if err != nil {
+ return err
+ }
+ m.triggerUpdate()
+ return nil
+}
+
+func (m *Manager) SetEnabled(enabled bool) {
+ m.configMutex.Lock()
+ wasEnabled := m.config.Enabled
+ if wasEnabled == enabled {
+ m.configMutex.Unlock()
+ return
+ }
+ m.config.Enabled = enabled
+ highTemp := m.config.HighTemp
+ m.configMutex.Unlock()
+
+ switch {
+ case enabled && !m.controlsInitialized:
+ m.post(func() {
+ gammaMgr := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1)
+ m.availOutputsMu.RLock()
+ outs := slices.Clone(m.availableOutputs)
+ m.availOutputsMu.RUnlock()
+ if err := m.setupOutputControls(outs, gammaMgr); err != nil {
+ log.Errorf("gamma: failed to create controls: %v", err)
+ return
+ }
+ m.controlsInitialized = true
+ m.triggerUpdate()
+ })
+ case enabled && !wasEnabled:
+ m.triggerUpdate()
+ case !enabled && m.controlsInitialized:
+ m.post(func() {
+ m.outputs.Range(func(id uint32, out *outputState) bool {
+ if out.gammaControl != nil {
+ out.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1).Destroy()
+ }
+ return true
+ })
+ m.outputs.Range(func(key uint32, _ *outputState) bool {
+ m.outputs.Delete(key)
+ return true
+ })
+ m.controlsInitialized = false
+ })
+ _ = highTemp
+ }
+}
+
+func (m *Manager) Close() {
+ close(m.stopChan)
+ m.wg.Wait()
+ m.notifierWg.Wait()
+
+ m.subscribers.Range(func(key string, ch chan State) bool {
+ close(ch)
+ m.subscribers.Delete(key)
+ return true
+ })
+
+ m.outputs.Range(func(_ uint32, out *outputState) bool {
+ if ctrl, ok := out.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1); ok {
+ ctrl.Destroy()
+ }
+ return true
+ })
+ m.outputs.Range(func(key uint32, _ *outputState) bool {
+ m.outputs.Delete(key)
+ return true
+ })
+
+ if manager, ok := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1); ok {
+ manager.Destroy()
+ }
+
+ if m.dbusConn != nil {
+ m.dbusConn.RemoveSignal(m.dbusSignal)
+ m.dbusConn.Close()
+ }
+}
+
+func MemfdCreate(name string, flags int) (int, error) {
+ fd, err := unix.MemfdCreate(name, flags)
+ if err != nil {
+ return -1, err
+ }
+ return fd, nil
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/manager_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/manager_test.go
new file mode 100644
index 0000000..8c00129
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/manager_test.go
@@ -0,0 +1,414 @@
+package wayland
+
+import (
+ "errors"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+
+ mocks_wlclient "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/wlclient"
+)
+
+func TestManager_ActorSerializesOutputStateAccess(t *testing.T) {
+ m := &Manager{
+ cmdq: make(chan cmd, 8192),
+ stopChan: make(chan struct{}),
+ }
+
+ m.wg.Add(1)
+ go m.waylandActor()
+
+ state := &outputState{
+ id: 1,
+ registryName: 100,
+ rampSize: 256,
+ }
+ m.outputs.Store(state.id, state)
+
+ var wg sync.WaitGroup
+ const goroutines = 50
+ const iterations = 100
+
+ for i := 0; i < goroutines; i++ {
+ wg.Add(1)
+ go func(id int) {
+ defer wg.Done()
+ for j := 0; j < iterations; j++ {
+ m.post(func() {
+ if out, ok := m.outputs.Load(state.id); ok {
+ out.rampSize = uint32(j)
+ out.failed = j%2 == 0
+ out.retryCount = j
+ out.lastFailTime = time.Now()
+ }
+ })
+ }
+ }(i)
+ }
+
+ wg.Wait()
+
+ done := make(chan struct{})
+ m.post(func() { close(done) })
+ <-done
+
+ close(m.stopChan)
+ m.wg.Wait()
+}
+
+func TestManager_ConcurrentSubscriberAccess(t *testing.T) {
+ m := &Manager{
+ stopChan: make(chan struct{}),
+ dirty: make(chan struct{}, 1),
+ updateTrigger: make(chan struct{}, 1),
+ }
+
+ var wg sync.WaitGroup
+ const goroutines = 20
+
+ for i := 0; i < goroutines; i++ {
+ wg.Add(1)
+ go func(id int) {
+ defer wg.Done()
+ subID := string(rune('a' + id))
+ ch := m.Subscribe(subID)
+ assert.NotNil(t, ch)
+ time.Sleep(time.Millisecond)
+ m.Unsubscribe(subID)
+ }(i)
+ }
+
+ wg.Wait()
+}
+
+func TestManager_ConcurrentGetState(t *testing.T) {
+ m := &Manager{
+ state: &State{
+ CurrentTemp: 5000,
+ IsDay: true,
+ },
+ }
+
+ var wg sync.WaitGroup
+ const goroutines = 50
+ const iterations = 100
+
+ for i := 0; i < goroutines/2; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for j := 0; j < iterations; j++ {
+ s := m.GetState()
+ assert.GreaterOrEqual(t, s.CurrentTemp, 0)
+ }
+ }()
+ }
+
+ for i := 0; i < goroutines/2; i++ {
+ wg.Add(1)
+ go func(i int) {
+ defer wg.Done()
+ for j := 0; j < iterations; j++ {
+ m.stateMutex.Lock()
+ m.state = &State{
+ CurrentTemp: 4000 + i*100,
+ IsDay: j%2 == 0,
+ }
+ m.stateMutex.Unlock()
+ }
+ }(i)
+ }
+
+ wg.Wait()
+}
+
+func TestManager_ConcurrentConfigAccess(t *testing.T) {
+ m := &Manager{
+ config: DefaultConfig(),
+ }
+
+ var wg sync.WaitGroup
+ const goroutines = 30
+ const iterations = 100
+
+ for i := 0; i < goroutines/2; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for j := 0; j < iterations; j++ {
+ m.configMutex.RLock()
+ _ = m.config.LowTemp
+ _ = m.config.HighTemp
+ _ = m.config.Enabled
+ m.configMutex.RUnlock()
+ }
+ }()
+ }
+
+ for i := 0; i < goroutines/2; i++ {
+ wg.Add(1)
+ go func(i int) {
+ defer wg.Done()
+ for j := 0; j < iterations; j++ {
+ m.configMutex.Lock()
+ m.config.LowTemp = 3000 + j
+ m.config.HighTemp = 7000 - j
+ m.config.Enabled = j%2 == 0
+ m.configMutex.Unlock()
+ }
+ }(i)
+ }
+
+ wg.Wait()
+}
+
+func TestManager_SyncmapOutputsConcurrentAccess(t *testing.T) {
+ m := &Manager{}
+
+ var wg sync.WaitGroup
+ const goroutines = 30
+ const iterations = 50
+
+ for i := 0; i < goroutines; i++ {
+ wg.Add(1)
+ go func(id int) {
+ defer wg.Done()
+ key := uint32(id)
+
+ for j := 0; j < iterations; j++ {
+ state := &outputState{
+ id: key,
+ rampSize: uint32(j),
+ failed: j%2 == 0,
+ }
+ m.outputs.Store(key, state)
+
+ if loaded, ok := m.outputs.Load(key); ok {
+ assert.Equal(t, key, loaded.id)
+ }
+
+ m.outputs.Range(func(k uint32, v *outputState) bool {
+ _ = v.rampSize
+ _ = v.failed
+ return true
+ })
+ }
+
+ m.outputs.Delete(key)
+ }(i)
+ }
+
+ wg.Wait()
+}
+
+func TestManager_LocationCacheConcurrentAccess(t *testing.T) {
+ m := &Manager{}
+
+ var wg sync.WaitGroup
+ const goroutines = 20
+ const iterations = 100
+
+ for i := 0; i < goroutines/2; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for j := 0; j < iterations; j++ {
+ m.locationMutex.RLock()
+ _ = m.cachedIPLat
+ _ = m.cachedIPLon
+ m.locationMutex.RUnlock()
+ }
+ }()
+ }
+
+ for i := 0; i < goroutines/2; i++ {
+ wg.Add(1)
+ go func(i int) {
+ defer wg.Done()
+ for j := 0; j < iterations; j++ {
+ lat := float64(40 + i)
+ lon := float64(-74 + j)
+ m.locationMutex.Lock()
+ m.cachedIPLat = &lat
+ m.cachedIPLon = &lon
+ m.locationMutex.Unlock()
+ }
+ }(i)
+ }
+
+ wg.Wait()
+}
+
+func TestManager_ScheduleConcurrentAccess(t *testing.T) {
+ now := time.Now()
+ m := &Manager{
+ schedule: sunSchedule{
+ times: SunTimes{
+ Dawn: now,
+ Sunrise: now.Add(time.Hour),
+ Sunset: now.Add(12 * time.Hour),
+ Night: now.Add(13 * time.Hour),
+ },
+ },
+ }
+
+ var wg sync.WaitGroup
+ const goroutines = 20
+ const iterations = 100
+
+ for i := 0; i < goroutines/2; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for j := 0; j < iterations; j++ {
+ m.scheduleMutex.RLock()
+ _ = m.schedule.times.Dawn
+ _ = m.schedule.times.Sunrise
+ _ = m.schedule.times.Sunset
+ _ = m.schedule.condition
+ m.scheduleMutex.RUnlock()
+ }
+ }()
+ }
+
+ for i := 0; i < goroutines/2; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for j := 0; j < iterations; j++ {
+ m.scheduleMutex.Lock()
+ m.schedule.times.Dawn = time.Now()
+ m.schedule.times.Sunrise = time.Now().Add(time.Hour)
+ m.schedule.condition = SunNormal
+ m.scheduleMutex.Unlock()
+ }
+ }()
+ }
+
+ wg.Wait()
+}
+
+func TestInterpolate_EdgeCases(t *testing.T) {
+ now := time.Now()
+
+ tests := []struct {
+ name string
+ now time.Time
+ start time.Time
+ stop time.Time
+ expected float64
+ }{
+ {
+ name: "same start and stop",
+ now: now,
+ start: now,
+ stop: now,
+ expected: 1.0,
+ },
+ {
+ name: "now before start",
+ now: now,
+ start: now.Add(time.Hour),
+ stop: now.Add(2 * time.Hour),
+ expected: 0.0,
+ },
+ {
+ name: "now after stop",
+ now: now.Add(3 * time.Hour),
+ start: now,
+ stop: now.Add(time.Hour),
+ expected: 1.0,
+ },
+ {
+ name: "now at midpoint",
+ now: now.Add(30 * time.Minute),
+ start: now,
+ stop: now.Add(time.Hour),
+ expected: 0.5,
+ },
+ {
+ name: "now equals start",
+ now: now,
+ start: now,
+ stop: now.Add(time.Hour),
+ expected: 0.0,
+ },
+ {
+ name: "now equals stop",
+ now: now.Add(time.Hour),
+ start: now,
+ stop: now.Add(time.Hour),
+ expected: 1.0,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := interpolate(tt.now, tt.start, tt.stop)
+ assert.InDelta(t, tt.expected, result, 0.01)
+ })
+ }
+}
+
+func TestGenerateGammaRamp_ZeroSize(t *testing.T) {
+ ramp := GenerateGammaRamp(0, 5000, 1.0)
+ assert.Empty(t, ramp.Red)
+ assert.Empty(t, ramp.Green)
+ assert.Empty(t, ramp.Blue)
+}
+
+func TestGenerateGammaRamp_ValidSizes(t *testing.T) {
+ sizes := []uint32{1, 256, 1024}
+ temps := []int{1000, 4000, 6500, 10000}
+ gammas := []float64{0.5, 1.0, 2.0}
+
+ for _, size := range sizes {
+ for _, temp := range temps {
+ for _, gamma := range gammas {
+ ramp := GenerateGammaRamp(size, temp, gamma)
+ assert.Len(t, ramp.Red, int(size))
+ assert.Len(t, ramp.Green, int(size))
+ assert.Len(t, ramp.Blue, int(size))
+ }
+ }
+ }
+}
+
+func TestNotifySubscribers_NonBlocking(t *testing.T) {
+ m := &Manager{
+ dirty: make(chan struct{}, 1),
+ }
+
+ for i := 0; i < 10; i++ {
+ m.notifySubscribers()
+ }
+
+ assert.Len(t, m.dirty, 1)
+}
+
+func TestNewManager_GetRegistryError(t *testing.T) {
+ mockDisplay := mocks_wlclient.NewMockWaylandDisplay(t)
+
+ mockDisplay.EXPECT().Context().Return(nil)
+ mockDisplay.EXPECT().GetRegistry().Return(nil, errors.New("failed to get registry"))
+
+ config := DefaultConfig()
+ _, err := NewManager(mockDisplay, config)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "get registry")
+}
+
+func TestNewManager_InvalidConfig(t *testing.T) {
+ mockDisplay := mocks_wlclient.NewMockWaylandDisplay(t)
+
+ config := Config{
+ LowTemp: 500,
+ HighTemp: 6500,
+ Gamma: 1.0,
+ }
+
+ _, err := NewManager(mockDisplay, config)
+ assert.Error(t, err)
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/suncalc.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/suncalc.go
new file mode 100644
index 0000000..c978a8e
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/suncalc.go
@@ -0,0 +1,122 @@
+package wayland
+
+import (
+ "math"
+ "time"
+)
+
+const (
+ degToRad = math.Pi / 180.0
+ radToDeg = 180.0 / math.Pi
+)
+
+type SunCondition int
+
+const (
+ SunNormal SunCondition = iota
+ SunMidnightSun
+ SunPolarNight
+)
+
+type SunTimes struct {
+ Dawn time.Time
+ Sunrise time.Time
+ Sunset time.Time
+ Night time.Time
+}
+
+func daysInYear(year int) int {
+ if (year%4 == 0 && year%100 != 0) || year%400 == 0 {
+ return 366
+ }
+ return 365
+}
+
+func dateOrbitAngle(t time.Time) float64 {
+ return 2 * math.Pi / float64(daysInYear(t.Year())) * float64(t.YearDay()-1)
+}
+
+func equationOfTime(orbitAngle float64) float64 {
+ return 4 * (0.000075 +
+ 0.001868*math.Cos(orbitAngle) -
+ 0.032077*math.Sin(orbitAngle) -
+ 0.014615*math.Cos(2*orbitAngle) -
+ 0.040849*math.Sin(2*orbitAngle))
+}
+
+func sunDeclination(orbitAngle float64) float64 {
+ return 0.006918 -
+ 0.399912*math.Cos(orbitAngle) +
+ 0.070257*math.Sin(orbitAngle) -
+ 0.006758*math.Cos(2*orbitAngle) +
+ 0.000907*math.Sin(2*orbitAngle) -
+ 0.002697*math.Cos(3*orbitAngle) +
+ 0.00148*math.Sin(3*orbitAngle)
+}
+
+func sunHourAngle(latRad, declination, targetSunRad float64) float64 {
+ return math.Acos(math.Cos(targetSunRad)/
+ math.Cos(latRad)*math.Cos(declination) -
+ math.Tan(latRad)*math.Tan(declination))
+}
+
+func hourAngleToSeconds(hourAngle, eqtime float64) float64 {
+ return radToDeg * (4.0*math.Pi - 4*hourAngle - eqtime) * 60
+}
+
+func sunCondition(latRad, declination float64) SunCondition {
+ signLat := latRad >= 0
+ signDecl := declination >= 0
+ if signLat == signDecl {
+ return SunMidnightSun
+ }
+ return SunPolarNight
+}
+
+func CalculateSunTimesWithTwilight(lat, lon float64, date time.Time, elevTwilight, elevDaylight float64) (SunTimes, SunCondition) {
+ latRad := lat * degToRad
+ elevTwilightRad := (90.833 - elevTwilight) * degToRad
+ elevDaylightRad := (90.833 - elevDaylight) * degToRad
+
+ utc := date.UTC()
+ orbitAngle := dateOrbitAngle(utc)
+ decl := sunDeclination(orbitAngle)
+ eqtime := equationOfTime(orbitAngle)
+
+ haTwilight := sunHourAngle(latRad, decl, elevTwilightRad)
+ haDaylight := sunHourAngle(latRad, decl, elevDaylightRad)
+
+ if math.IsNaN(haTwilight) || math.IsNaN(haDaylight) {
+ cond := sunCondition(latRad, decl)
+ return SunTimes{}, cond
+ }
+
+ dayStart := time.Date(utc.Year(), utc.Month(), utc.Day(), 0, 0, 0, 0, time.UTC)
+ lonOffset := time.Duration(-lon*4) * time.Minute
+
+ dawnSecs := hourAngleToSeconds(math.Abs(haTwilight), eqtime)
+ sunriseSecs := hourAngleToSeconds(math.Abs(haDaylight), eqtime)
+ sunsetSecs := hourAngleToSeconds(-math.Abs(haDaylight), eqtime)
+ nightSecs := hourAngleToSeconds(-math.Abs(haTwilight), eqtime)
+
+ return SunTimes{
+ Dawn: dayStart.Add(time.Duration(dawnSecs)*time.Second + lonOffset).In(date.Location()),
+ Sunrise: dayStart.Add(time.Duration(sunriseSecs)*time.Second + lonOffset).In(date.Location()),
+ Sunset: dayStart.Add(time.Duration(sunsetSecs)*time.Second + lonOffset).In(date.Location()),
+ Night: dayStart.Add(time.Duration(nightSecs)*time.Second + lonOffset).In(date.Location()),
+ }, SunNormal
+}
+
+func CalculateSunTimes(lat, lon float64, date time.Time) SunTimes {
+ times, cond := CalculateSunTimesWithTwilight(lat, lon, date, -6.0, 3.0)
+ switch cond {
+ case SunMidnightSun:
+ dayStart := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
+ dayEnd := dayStart.Add(24*time.Hour - time.Second)
+ return SunTimes{Dawn: dayStart, Sunrise: dayStart, Sunset: dayEnd, Night: dayEnd}
+ case SunPolarNight:
+ dayStart := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
+ return SunTimes{Dawn: dayStart, Sunrise: dayStart, Sunset: dayStart, Night: dayStart}
+ }
+ return times
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/suncalc_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/suncalc_test.go
new file mode 100644
index 0000000..b2815d9
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/suncalc_test.go
@@ -0,0 +1,387 @@
+package wayland
+
+import (
+ "math"
+ "testing"
+ "time"
+)
+
+func calculateTemperature(config Config, now time.Time) int {
+ if !config.Enabled {
+ return config.HighTemp
+ }
+
+ var sunrise, sunset time.Time
+
+ if config.ManualSunrise != nil && config.ManualSunset != nil {
+ year, month, day := now.Date()
+ loc := now.Location()
+
+ sunrise = time.Date(year, month, day,
+ config.ManualSunrise.Hour(),
+ config.ManualSunrise.Minute(),
+ config.ManualSunrise.Second(), 0, loc)
+ sunset = time.Date(year, month, day,
+ config.ManualSunset.Hour(),
+ config.ManualSunset.Minute(),
+ config.ManualSunset.Second(), 0, loc)
+
+ if sunset.Before(sunrise) {
+ sunset = sunset.Add(24 * time.Hour)
+ }
+ } else if config.UseIPLocation {
+ lat, lon, err := FetchIPLocation()
+ if err != nil {
+ return config.HighTemp
+ }
+ times := CalculateSunTimes(*lat, *lon, now)
+ sunrise = times.Sunrise
+ sunset = times.Sunset
+ } else if config.Latitude != nil && config.Longitude != nil {
+ times := CalculateSunTimes(*config.Latitude, *config.Longitude, now)
+ sunrise = times.Sunrise
+ sunset = times.Sunset
+ } else {
+ return config.HighTemp
+ }
+
+ if now.Before(sunrise) || now.After(sunset) {
+ return config.LowTemp
+ }
+ return config.HighTemp
+}
+
+func calculateNextTransition(config Config, now time.Time) time.Time {
+ if !config.Enabled {
+ return now.Add(24 * time.Hour)
+ }
+
+ var sunrise, sunset time.Time
+
+ if config.ManualSunrise != nil && config.ManualSunset != nil {
+ year, month, day := now.Date()
+ loc := now.Location()
+
+ sunrise = time.Date(year, month, day,
+ config.ManualSunrise.Hour(),
+ config.ManualSunrise.Minute(),
+ config.ManualSunrise.Second(), 0, loc)
+ sunset = time.Date(year, month, day,
+ config.ManualSunset.Hour(),
+ config.ManualSunset.Minute(),
+ config.ManualSunset.Second(), 0, loc)
+
+ if sunset.Before(sunrise) {
+ sunset = sunset.Add(24 * time.Hour)
+ }
+ } else if config.UseIPLocation {
+ lat, lon, err := FetchIPLocation()
+ if err != nil {
+ return now.Add(24 * time.Hour)
+ }
+ times := CalculateSunTimes(*lat, *lon, now)
+ sunrise = times.Sunrise
+ sunset = times.Sunset
+ } else if config.Latitude != nil && config.Longitude != nil {
+ times := CalculateSunTimes(*config.Latitude, *config.Longitude, now)
+ sunrise = times.Sunrise
+ sunset = times.Sunset
+ } else {
+ return now.Add(24 * time.Hour)
+ }
+
+ if now.Before(sunrise) {
+ return sunrise
+ }
+ if now.Before(sunset) {
+ return sunset
+ }
+
+ if config.ManualSunrise != nil && config.ManualSunset != nil {
+ year, month, day := now.Add(24 * time.Hour).Date()
+ loc := now.Location()
+ nextSunrise := time.Date(year, month, day,
+ config.ManualSunrise.Hour(),
+ config.ManualSunrise.Minute(),
+ config.ManualSunrise.Second(), 0, loc)
+ return nextSunrise
+ }
+
+ if config.UseIPLocation {
+ lat, lon, err := FetchIPLocation()
+ if err != nil {
+ return now.Add(24 * time.Hour)
+ }
+ nextDayTimes := CalculateSunTimes(*lat, *lon, now.Add(24*time.Hour))
+ return nextDayTimes.Sunrise
+ }
+
+ if config.Latitude != nil && config.Longitude != nil {
+ nextDayTimes := CalculateSunTimes(*config.Latitude, *config.Longitude, now.Add(24*time.Hour))
+ return nextDayTimes.Sunrise
+ }
+
+ return now.Add(24 * time.Hour)
+}
+
+func TestCalculateSunTimes(t *testing.T) {
+ tests := []struct {
+ name string
+ lat float64
+ lon float64
+ date time.Time
+ checkFunc func(*testing.T, SunTimes)
+ }{
+ {
+ name: "new_york_summer",
+ lat: 40.7128,
+ lon: -74.0060,
+ date: time.Date(2024, 6, 21, 12, 0, 0, 0, time.Local),
+ checkFunc: func(t *testing.T, times SunTimes) {
+ if times.Sunrise.Hour() < 4 || times.Sunrise.Hour() > 6 {
+ t.Logf("sunrise: %v", times.Sunrise)
+ }
+ if times.Sunset.Hour() < 19 || times.Sunset.Hour() > 21 {
+ t.Logf("sunset: %v", times.Sunset)
+ }
+ if !times.Sunset.After(times.Sunrise) {
+ t.Error("sunset should be after sunrise")
+ }
+ },
+ },
+ {
+ name: "london_winter",
+ lat: 51.5074,
+ lon: -0.1278,
+ date: time.Date(2024, 12, 21, 12, 0, 0, 0, time.UTC),
+ checkFunc: func(t *testing.T, times SunTimes) {
+ if times.Sunrise.Hour() < 7 || times.Sunrise.Hour() > 9 {
+ t.Errorf("unexpected sunrise hour: %d", times.Sunrise.Hour())
+ }
+ if times.Sunset.Hour() < 15 || times.Sunset.Hour() > 17 {
+ t.Errorf("unexpected sunset hour: %d", times.Sunset.Hour())
+ }
+ },
+ },
+ {
+ name: "equator_equinox",
+ lat: 0.0,
+ lon: 0.0,
+ date: time.Date(2024, 3, 20, 12, 0, 0, 0, time.UTC),
+ checkFunc: func(t *testing.T, times SunTimes) {
+ if times.Sunrise.Hour() < 5 || times.Sunrise.Hour() > 7 {
+ t.Errorf("unexpected sunrise hour: %d", times.Sunrise.Hour())
+ }
+ if times.Sunset.Hour() < 17 || times.Sunset.Hour() > 19 {
+ t.Errorf("unexpected sunset hour: %d", times.Sunset.Hour())
+ }
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ times := CalculateSunTimes(tt.lat, tt.lon, tt.date)
+ tt.checkFunc(t, times)
+ })
+ }
+}
+
+func TestCalculateTemperature(t *testing.T) {
+ lat := 40.7128
+ lon := -74.0060
+ date := time.Date(2024, 6, 21, 0, 0, 0, 0, time.Local)
+
+ config := Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ Latitude: &lat,
+ Longitude: &lon,
+ Enabled: true,
+ }
+
+ times := CalculateSunTimes(lat, lon, date)
+
+ tests := []struct {
+ name string
+ timeFunc func() time.Time
+ wantTemp int
+ }{
+ {
+ name: "midnight",
+ timeFunc: func() time.Time { return times.Sunrise.Add(-4 * time.Hour) },
+ wantTemp: 4000,
+ },
+ {
+ name: "sunrise",
+ timeFunc: func() time.Time { return times.Sunrise },
+ wantTemp: 6500,
+ },
+ {
+ name: "noon",
+ timeFunc: func() time.Time { return times.Sunrise.Add(6 * time.Hour) },
+ wantTemp: 6500,
+ },
+ {
+ name: "after_sunset_transition",
+ timeFunc: func() time.Time { return times.Sunset.Add(2 * time.Hour) },
+ wantTemp: 4000,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ temp := calculateTemperature(config, tt.timeFunc())
+
+ if math.Abs(float64(temp-tt.wantTemp)) > 500 {
+ t.Errorf("temperature = %d, want approximately %d", temp, tt.wantTemp)
+ }
+ })
+ }
+}
+
+func TestCalculateTemperatureManualTimes(t *testing.T) {
+ sunrise := time.Date(0, 1, 1, 6, 30, 0, 0, time.Local)
+ sunset := time.Date(0, 1, 1, 18, 30, 0, 0, time.Local)
+
+ config := Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ ManualSunrise: &sunrise,
+ ManualSunset: &sunset,
+ Enabled: true,
+ }
+
+ tests := []struct {
+ name string
+ time time.Time
+ want int
+ }{
+ {"before_sunrise", time.Date(2024, 1, 1, 3, 0, 0, 0, time.Local), 4000},
+ {"at_sunrise", time.Date(2024, 1, 1, 6, 30, 0, 0, time.Local), 6500},
+ {"midday", time.Date(2024, 1, 1, 12, 0, 0, 0, time.Local), 6500},
+ {"at_sunset", time.Date(2024, 1, 1, 18, 30, 0, 0, time.Local), 6500},
+ {"after_sunset", time.Date(2024, 1, 1, 22, 0, 0, 0, time.Local), 4000},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ temp := calculateTemperature(config, tt.time)
+ if math.Abs(float64(temp-tt.want)) > 500 {
+ t.Errorf("temperature = %d, want approximately %d", temp, tt.want)
+ }
+ })
+ }
+}
+
+func TestCalculateTemperatureDisabled(t *testing.T) {
+ lat := 40.7128
+ lon := -74.0060
+
+ config := Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ Latitude: &lat,
+ Longitude: &lon,
+ Enabled: false,
+ }
+
+ temp := calculateTemperature(config, time.Now())
+ if temp != 6500 {
+ t.Errorf("disabled should return high temp, got %d", temp)
+ }
+}
+
+func TestCalculateNextTransition(t *testing.T) {
+ lat := 40.7128
+ lon := -74.0060
+ date := time.Date(2024, 6, 21, 0, 0, 0, 0, time.Local)
+
+ config := Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ Latitude: &lat,
+ Longitude: &lon,
+ Enabled: true,
+ }
+
+ times := CalculateSunTimes(lat, lon, date)
+
+ tests := []struct {
+ name string
+ now time.Time
+ checkFunc func(*testing.T, time.Time)
+ }{
+ {
+ name: "before_sunrise",
+ now: times.Sunrise.Add(-2 * time.Hour),
+ checkFunc: func(t *testing.T, next time.Time) {
+ if !next.Equal(times.Sunrise) && !next.After(times.Sunrise.Add(-1*time.Minute)) {
+ t.Error("next transition should be at or near sunrise")
+ }
+ },
+ },
+ {
+ name: "after_sunrise",
+ now: times.Sunrise.Add(2 * time.Hour),
+ checkFunc: func(t *testing.T, next time.Time) {
+ if !next.After(times.Sunrise) {
+ t.Error("next transition should be after sunrise")
+ }
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ next := calculateNextTransition(config, tt.now)
+ tt.checkFunc(t, next)
+ })
+ }
+}
+
+func TestSunTimesWithTwilight(t *testing.T) {
+ lat := 40.7128
+ lon := -74.0060
+ date := time.Date(2024, 6, 21, 12, 0, 0, 0, time.Local)
+
+ times, cond := CalculateSunTimesWithTwilight(lat, lon, date, -6.0, 3.0)
+
+ if cond != SunNormal {
+ t.Errorf("expected SunNormal, got %v", cond)
+ }
+ if !times.Dawn.Before(times.Sunrise) {
+ t.Error("dawn should be before sunrise")
+ }
+ if !times.Sunrise.Before(times.Sunset) {
+ t.Error("sunrise should be before sunset")
+ }
+ if !times.Sunset.Before(times.Night) {
+ t.Error("sunset should be before night")
+ }
+}
+
+func TestSunConditions(t *testing.T) {
+ tests := []struct {
+ name string
+ lat float64
+ date time.Time
+ expected SunCondition
+ }{
+ {
+ name: "normal_conditions",
+ lat: 40.0,
+ date: time.Date(2024, 6, 21, 12, 0, 0, 0, time.UTC),
+ expected: SunNormal,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ _, cond := CalculateSunTimesWithTwilight(tt.lat, 0, tt.date, -6.0, 3.0)
+ if cond != tt.expected {
+ t.Errorf("expected condition %v, got %v", tt.expected, cond)
+ }
+ })
+ }
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/types.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/types.go
new file mode 100644
index 0000000..31bac15
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/types.go
@@ -0,0 +1,216 @@
+package wayland
+
+import (
+ "math"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation"
+ wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
+ "github.com/godbus/dbus/v5"
+)
+
+type GammaState int
+
+const (
+ StateNormal GammaState = iota
+ StateTransition
+ StateStatic
+)
+
+type Config struct {
+ Outputs []string
+ LowTemp int
+ HighTemp int
+ Latitude *float64
+ Longitude *float64
+ UseIPLocation bool
+ ManualSunrise *time.Time
+ ManualSunset *time.Time
+ ManualDuration *time.Duration
+ Gamma float64
+ Enabled bool
+ ElevationTwilight float64
+ ElevationDaylight float64
+}
+
+type State struct {
+ Config Config `json:"config"`
+ CurrentTemp int `json:"currentTemp"`
+ NextTransition time.Time `json:"nextTransition"`
+ SunriseTime time.Time `json:"sunriseTime"`
+ SunsetTime time.Time `json:"sunsetTime"`
+ DawnTime time.Time `json:"dawnTime"`
+ NightTime time.Time `json:"nightTime"`
+ IsDay bool `json:"isDay"`
+ SunPosition float64 `json:"sunPosition"`
+}
+
+type cmd struct {
+ fn func()
+}
+
+type sunSchedule struct {
+ times SunTimes
+ condition SunCondition
+ dawnStepTime time.Duration
+ nightStepTime time.Duration
+ calcDay time.Time
+}
+
+type Manager struct {
+ config Config
+ configMutex sync.RWMutex
+ state *State
+ stateMutex sync.RWMutex
+
+ display wlclient.WaylandDisplay
+ ctx *wlclient.Context
+ registry *wlclient.Registry
+ gammaControl any
+ availableOutputs []*wlclient.Output
+ availOutputsMu sync.RWMutex
+ outputRegNames syncmap.Map[uint32, uint32]
+ outputs syncmap.Map[uint32, *outputState]
+ controlsInitialized bool
+ connectionDead atomic.Bool
+
+ cmdq chan cmd
+ alive bool
+
+ stopChan chan struct{}
+ updateTrigger chan struct{}
+ wg sync.WaitGroup
+
+ schedule sunSchedule
+ scheduleMutex sync.RWMutex
+ gammaState GammaState
+
+ cachedIPLat *float64
+ cachedIPLon *float64
+ locationMutex sync.RWMutex
+
+ subscribers syncmap.Map[string, chan State]
+ dirty chan struct{}
+ notifierWg sync.WaitGroup
+ lastNotified *State
+
+ dbusConn *dbus.Conn
+ dbusSignal chan *dbus.Signal
+
+ geoClient geolocation.Client
+
+ lastAppliedTemp int
+ lastAppliedGamma float64
+}
+
+type outputState struct {
+ id uint32
+ registryName uint32
+ output *wlclient.Output
+ gammaControl any
+ rampSize uint32
+ failed bool
+ isVirtual bool
+ retryCount int
+ lastFailTime time.Time
+}
+
+func DefaultConfig() Config {
+ return Config{
+ Outputs: []string{},
+ LowTemp: 4000,
+ HighTemp: 6500,
+ Gamma: 1.0,
+ Enabled: false,
+ ElevationTwilight: -6.0,
+ ElevationDaylight: 3.0,
+ }
+}
+
+func (c *Config) Validate() error {
+ if c.LowTemp < 1000 || c.LowTemp > 10000 {
+ return errdefs.ErrInvalidTemperature
+ }
+ if c.HighTemp < 1000 || c.HighTemp > 10000 {
+ return errdefs.ErrInvalidTemperature
+ }
+ if c.LowTemp > c.HighTemp {
+ return errdefs.ErrInvalidTemperature
+ }
+ if c.Gamma <= 0 || c.Gamma > 10 {
+ return errdefs.ErrInvalidGamma
+ }
+ if c.Latitude != nil && (math.Abs(*c.Latitude) > 90) {
+ return errdefs.ErrInvalidLocation
+ }
+ if c.Longitude != nil && (math.Abs(*c.Longitude) > 180) {
+ return errdefs.ErrInvalidLocation
+ }
+ if (c.Latitude != nil) != (c.Longitude != nil) {
+ return errdefs.ErrInvalidLocation
+ }
+ if (c.ManualSunrise != nil) != (c.ManualSunset != nil) {
+ return errdefs.ErrInvalidManualTimes
+ }
+ return nil
+}
+
+func (m *Manager) GetState() State {
+ m.stateMutex.RLock()
+ defer m.stateMutex.RUnlock()
+ if m.state == nil {
+ return State{}
+ }
+ return *m.state
+}
+
+func (m *Manager) Subscribe(id string) chan State {
+ ch := make(chan State, 64)
+ m.subscribers.Store(id, ch)
+ return ch
+}
+
+func (m *Manager) Unsubscribe(id string) {
+ if val, ok := m.subscribers.LoadAndDelete(id); ok {
+ close(val)
+ }
+}
+
+func (m *Manager) notifySubscribers() {
+ select {
+ case m.dirty <- struct{}{}:
+ default:
+ }
+}
+
+func stateChanged(old, new *State) bool {
+ if old == nil || new == nil {
+ return true
+ }
+ if old.CurrentTemp != new.CurrentTemp {
+ return true
+ }
+ if old.IsDay != new.IsDay {
+ return true
+ }
+ if !old.NextTransition.Equal(new.NextTransition) {
+ return true
+ }
+ if !old.SunriseTime.Equal(new.SunriseTime) {
+ return true
+ }
+ if !old.SunsetTime.Equal(new.SunsetTime) {
+ return true
+ }
+ if old.Config.Enabled != new.Config.Enabled {
+ return true
+ }
+ if old.SunPosition != new.SunPosition {
+ return true
+ }
+ return false
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/types_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/types_test.go
new file mode 100644
index 0000000..f5bbb42
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/types_test.go
@@ -0,0 +1,330 @@
+package wayland
+
+import (
+ "testing"
+ "time"
+)
+
+func TestConfigValidate(t *testing.T) {
+ tests := []struct {
+ name string
+ config Config
+ wantErr bool
+ }{
+ {
+ name: "valid_default",
+ config: DefaultConfig(),
+ wantErr: false,
+ },
+ {
+ name: "valid_with_location",
+ config: Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ Latitude: floatPtr(40.7128),
+ Longitude: floatPtr(-74.0060),
+ Gamma: 1.0,
+ Enabled: true,
+ },
+ wantErr: false,
+ },
+ {
+ name: "valid_manual_times",
+ config: Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ ManualSunrise: timePtr(time.Date(0, 1, 1, 6, 30, 0, 0, time.Local)),
+ ManualSunset: timePtr(time.Date(0, 1, 1, 18, 30, 0, 0, time.Local)),
+ Gamma: 1.0,
+ Enabled: true,
+ },
+ wantErr: false,
+ },
+ {
+ name: "invalid_low_temp_too_low",
+ config: Config{
+ LowTemp: 500,
+ HighTemp: 6500,
+ Gamma: 1.0,
+ },
+ wantErr: true,
+ },
+ {
+ name: "invalid_low_temp_too_high",
+ config: Config{
+ LowTemp: 15000,
+ HighTemp: 20000,
+ Gamma: 1.0,
+ },
+ wantErr: true,
+ },
+ {
+ name: "invalid_high_temp_too_low",
+ config: Config{
+ LowTemp: 4000,
+ HighTemp: 500,
+ Gamma: 1.0,
+ },
+ wantErr: true,
+ },
+ {
+ name: "valid_temps_equal",
+ config: Config{
+ LowTemp: 5000,
+ HighTemp: 5000,
+ Gamma: 1.0,
+ },
+ wantErr: false,
+ },
+ {
+ name: "invalid_temps_reversed",
+ config: Config{
+ LowTemp: 6500,
+ HighTemp: 4000,
+ Gamma: 1.0,
+ },
+ wantErr: true,
+ },
+ {
+ name: "invalid_gamma_zero",
+ config: Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ Gamma: 0,
+ },
+ wantErr: true,
+ },
+ {
+ name: "invalid_gamma_negative",
+ config: Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ Gamma: -1.0,
+ },
+ wantErr: true,
+ },
+ {
+ name: "invalid_gamma_too_high",
+ config: Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ Gamma: 15.0,
+ },
+ wantErr: true,
+ },
+ {
+ name: "invalid_latitude_too_high",
+ config: Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ Latitude: floatPtr(100),
+ Longitude: floatPtr(0),
+ Gamma: 1.0,
+ },
+ wantErr: true,
+ },
+ {
+ name: "invalid_latitude_too_low",
+ config: Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ Latitude: floatPtr(-100),
+ Longitude: floatPtr(0),
+ Gamma: 1.0,
+ },
+ wantErr: true,
+ },
+ {
+ name: "invalid_longitude_too_high",
+ config: Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ Latitude: floatPtr(40),
+ Longitude: floatPtr(200),
+ Gamma: 1.0,
+ },
+ wantErr: true,
+ },
+ {
+ name: "invalid_longitude_too_low",
+ config: Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ Latitude: floatPtr(40),
+ Longitude: floatPtr(-200),
+ Gamma: 1.0,
+ },
+ wantErr: true,
+ },
+ {
+ name: "invalid_latitude_without_longitude",
+ config: Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ Latitude: floatPtr(40),
+ Gamma: 1.0,
+ },
+ wantErr: true,
+ },
+ {
+ name: "invalid_longitude_without_latitude",
+ config: Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ Longitude: floatPtr(-74),
+ Gamma: 1.0,
+ },
+ wantErr: true,
+ },
+ {
+ name: "invalid_sunrise_without_sunset",
+ config: Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ ManualSunrise: timePtr(time.Date(0, 1, 1, 6, 30, 0, 0, time.Local)),
+ Gamma: 1.0,
+ },
+ wantErr: true,
+ },
+ {
+ name: "invalid_sunset_without_sunrise",
+ config: Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ ManualSunset: timePtr(time.Date(0, 1, 1, 18, 30, 0, 0, time.Local)),
+ Gamma: 1.0,
+ },
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := tt.config.Validate()
+ if (err != nil) != tt.wantErr {
+ t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
+
+func TestDefaultConfig(t *testing.T) {
+ config := DefaultConfig()
+
+ if config.LowTemp != 4000 {
+ t.Errorf("default low temp = %d, want 4000", config.LowTemp)
+ }
+ if config.HighTemp != 6500 {
+ t.Errorf("default high temp = %d, want 6500", config.HighTemp)
+ }
+ if config.Gamma != 1.0 {
+ t.Errorf("default gamma = %f, want 1.0", config.Gamma)
+ }
+ if config.Enabled {
+ t.Error("default should be disabled")
+ }
+ if config.Latitude != nil {
+ t.Error("default should not have latitude")
+ }
+ if config.Longitude != nil {
+ t.Error("default should not have longitude")
+ }
+}
+
+func TestStateChanged(t *testing.T) {
+ baseState := &State{
+ CurrentTemp: 5000,
+ NextTransition: time.Now(),
+ SunriseTime: time.Now().Add(6 * time.Hour),
+ SunsetTime: time.Now().Add(18 * time.Hour),
+ IsDay: true,
+ Config: DefaultConfig(),
+ }
+
+ tests := []struct {
+ name string
+ old *State
+ new *State
+ wantChanged bool
+ }{
+ {
+ name: "nil_old",
+ old: nil,
+ new: baseState,
+ wantChanged: true,
+ },
+ {
+ name: "nil_new",
+ old: baseState,
+ new: nil,
+ wantChanged: true,
+ },
+ {
+ name: "same_state",
+ old: baseState,
+ new: baseState,
+ wantChanged: false,
+ },
+ {
+ name: "temp_changed",
+ old: baseState,
+ new: &State{
+ CurrentTemp: 6000,
+ NextTransition: baseState.NextTransition,
+ SunriseTime: baseState.SunriseTime,
+ SunsetTime: baseState.SunsetTime,
+ IsDay: baseState.IsDay,
+ Config: baseState.Config,
+ },
+ wantChanged: true,
+ },
+ {
+ name: "is_day_changed",
+ old: baseState,
+ new: &State{
+ CurrentTemp: baseState.CurrentTemp,
+ NextTransition: baseState.NextTransition,
+ SunriseTime: baseState.SunriseTime,
+ SunsetTime: baseState.SunsetTime,
+ IsDay: false,
+ Config: baseState.Config,
+ },
+ wantChanged: true,
+ },
+ {
+ name: "enabled_changed",
+ old: baseState,
+ new: &State{
+ CurrentTemp: baseState.CurrentTemp,
+ NextTransition: baseState.NextTransition,
+ SunriseTime: baseState.SunriseTime,
+ SunsetTime: baseState.SunsetTime,
+ IsDay: baseState.IsDay,
+ Config: Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ Gamma: 1.0,
+ Enabled: true,
+ },
+ },
+ wantChanged: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ changed := stateChanged(tt.old, tt.new)
+ if changed != tt.wantChanged {
+ t.Errorf("stateChanged() = %v, want %v", changed, tt.wantChanged)
+ }
+ })
+ }
+}
+
+func floatPtr(f float64) *float64 {
+ return &f
+}
+
+func timePtr(t time.Time) *time.Time {
+ return &t
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wlcontext/context.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wlcontext/context.go
new file mode 100644
index 0000000..9bbb30a
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wlcontext/context.go
@@ -0,0 +1,200 @@
+package wlcontext
+
+import (
+ "fmt"
+ "golang.org/x/sys/unix"
+ "os"
+ "sync"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
+)
+
+type WaylandContext interface {
+ Display() *wlclient.Display
+ Post(fn func())
+ FatalError() <-chan error
+ Start()
+ Close()
+}
+
+var _ WaylandContext = (*SharedContext)(nil)
+
+type SharedContext struct {
+ display *wlclient.Display
+ stopChan chan struct{}
+ fatalError chan error
+ cmdQueue chan func()
+ wakeR int
+ wakeW int
+ wg sync.WaitGroup
+ mu sync.Mutex
+ started bool
+}
+
+func New() (*SharedContext, error) {
+ display, err := wlclient.Connect("")
+ if err != nil {
+ return nil, fmt.Errorf("%w: %v", errdefs.ErrNoWaylandDisplay, err)
+ }
+
+ fds := make([]int, 2)
+ if err := unix.Pipe(fds); err != nil {
+ display.Context().Close()
+ return nil, fmt.Errorf("failed to create wake pipe: %w", err)
+ }
+ if err := unix.SetNonblock(fds[0], true); err != nil {
+ unix.Close(fds[0])
+ unix.Close(fds[1])
+ display.Context().Close()
+ return nil, fmt.Errorf("failed to set wake pipe nonblock: %w", err)
+ }
+ if err := unix.SetNonblock(fds[1], true); err != nil {
+ unix.Close(fds[0])
+ unix.Close(fds[1])
+ display.Context().Close()
+ return nil, fmt.Errorf("failed to set wake pipe nonblock: %w", err)
+ }
+
+ sc := &SharedContext{
+ display: display,
+ stopChan: make(chan struct{}),
+ fatalError: make(chan error, 1),
+ cmdQueue: make(chan func(), 256),
+ wakeR: fds[0],
+ wakeW: fds[1],
+ started: false,
+ }
+
+ return sc, nil
+}
+
+func (sc *SharedContext) Start() {
+ sc.mu.Lock()
+ defer sc.mu.Unlock()
+
+ if sc.started {
+ return
+ }
+
+ sc.started = true
+ sc.wg.Add(1)
+ go sc.eventDispatcher()
+}
+
+func (sc *SharedContext) Display() *wlclient.Display {
+ return sc.display
+}
+
+func (sc *SharedContext) Post(fn func()) {
+ select {
+ case sc.cmdQueue <- fn:
+ if _, err := unix.Write(sc.wakeW, []byte{1}); err != nil && err != unix.EAGAIN {
+ log.Errorf("wake pipe write error: %v", err)
+ }
+ default:
+ }
+}
+
+func (sc *SharedContext) FatalError() <-chan error {
+ return sc.fatalError
+}
+
+func (sc *SharedContext) eventDispatcher() {
+ defer sc.wg.Done()
+ defer func() {
+ if r := recover(); r != nil {
+ err := fmt.Errorf("FATAL: Wayland event dispatcher panic: %v", r)
+ log.Error(err)
+ select {
+ case sc.fatalError <- err:
+ default:
+ }
+ }
+ }()
+
+ ctx := sc.display.Context()
+ wlFd := ctx.Fd()
+
+ pollFds := []unix.PollFd{
+ {Fd: int32(wlFd), Events: unix.POLLIN},
+ {Fd: int32(sc.wakeR), Events: unix.POLLIN},
+ }
+
+ consecutiveErrors := 0
+ const maxConsecutiveErrors = 20
+
+ for {
+ sc.drainCmdQueue()
+
+ select {
+ case <-sc.stopChan:
+ return
+ default:
+ }
+
+ _, err := unix.Poll(pollFds, -1)
+ switch {
+ case err == unix.EINTR:
+ continue
+ case err != nil:
+ log.Errorf("Poll error: %v", err)
+ return
+ }
+
+ if pollFds[1].Revents&unix.POLLIN != 0 {
+ var buf [64]byte
+ if _, err := unix.Read(sc.wakeR, buf[:]); err != nil && err != unix.EAGAIN {
+ log.Errorf("wake pipe read error: %v", err)
+ }
+ }
+
+ if pollFds[0].Revents&unix.POLLIN == 0 {
+ continue
+ }
+
+ if err := ctx.Dispatch(); err != nil && !os.IsTimeout(err) {
+ consecutiveErrors++
+ log.Warnf("Wayland connection error (%d/%d): %v", consecutiveErrors, maxConsecutiveErrors, err)
+
+ if consecutiveErrors >= maxConsecutiveErrors {
+ log.Errorf("Fatal: Wayland connection unrecoverable after %d attempts. Exiting dispatcher.", maxConsecutiveErrors)
+ return
+ }
+
+ time.Sleep(100 * time.Millisecond * time.Duration(consecutiveErrors))
+ continue
+ }
+
+ consecutiveErrors = 0
+ }
+}
+
+func (sc *SharedContext) drainCmdQueue() {
+ for {
+ select {
+ case fn := <-sc.cmdQueue:
+ fn()
+ default:
+ return
+ }
+ }
+}
+
+func (sc *SharedContext) Close() {
+ close(sc.stopChan)
+ if _, err := unix.Write(sc.wakeW, []byte{1}); err != nil && err != unix.EAGAIN {
+ log.Errorf("wake pipe write error on close: %v", err)
+ }
+ sc.wg.Wait()
+
+ unix.Close(sc.wakeR)
+ unix.Close(sc.wakeW)
+
+ if sc.display == nil {
+ return
+ }
+ sc.display.Context().Close()
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wlcontext/context_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wlcontext/context_test.go
new file mode 100644
index 0000000..11ecfbf
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wlcontext/context_test.go
@@ -0,0 +1,128 @@
+package wlcontext
+
+import (
+ "sync"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "golang.org/x/sys/unix"
+)
+
+func newTestSharedContext(t *testing.T, queueSize int) *SharedContext {
+ t.Helper()
+ fds := make([]int, 2)
+ if err := unix.Pipe(fds); err != nil {
+ t.Fatalf("failed to create test pipe: %v", err)
+ }
+ t.Cleanup(func() {
+ unix.Close(fds[0])
+ unix.Close(fds[1])
+ })
+ return &SharedContext{
+ cmdQueue: make(chan func(), queueSize),
+ stopChan: make(chan struct{}),
+ wakeR: fds[0],
+ wakeW: fds[1],
+ }
+}
+
+func TestSharedContext_ConcurrentPostNonBlocking(t *testing.T) {
+ sc := newTestSharedContext(t, 256)
+
+ var wg sync.WaitGroup
+ const goroutines = 100
+ const iterations = 50
+
+ for i := 0; i < goroutines; i++ {
+ wg.Add(1)
+ go func(id int) {
+ defer wg.Done()
+ for j := 0; j < iterations; j++ {
+ sc.Post(func() {
+ _ = id + j
+ })
+ }
+ }(i)
+ }
+
+ wg.Wait()
+}
+
+func TestSharedContext_PostQueueFull(t *testing.T) {
+ sc := newTestSharedContext(t, 2)
+
+ sc.Post(func() {})
+ sc.Post(func() {})
+ sc.Post(func() {})
+ sc.Post(func() {})
+
+ assert.Len(t, sc.cmdQueue, 2)
+}
+
+func TestSharedContext_StartMultipleTimes(t *testing.T) {
+ sc := newTestSharedContext(t, 256)
+ sc.started = true
+
+ var wg sync.WaitGroup
+ const goroutines = 10
+
+ for i := 0; i < goroutines; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ sc.Start()
+ }()
+ }
+
+ wg.Wait()
+
+ assert.True(t, sc.started)
+}
+
+func TestSharedContext_DrainCmdQueue(t *testing.T) {
+ sc := newTestSharedContext(t, 256)
+
+ counter := 0
+ for i := 0; i < 10; i++ {
+ sc.cmdQueue <- func() {
+ counter++
+ }
+ }
+
+ sc.drainCmdQueue()
+
+ assert.Equal(t, 10, counter)
+ assert.Len(t, sc.cmdQueue, 0)
+}
+
+func TestSharedContext_DrainCmdQueueEmpty(t *testing.T) {
+ sc := newTestSharedContext(t, 256)
+
+ sc.drainCmdQueue()
+
+ assert.Len(t, sc.cmdQueue, 0)
+}
+
+func TestSharedContext_ConcurrentDrainAndPost(t *testing.T) {
+ sc := newTestSharedContext(t, 256)
+
+ var wg sync.WaitGroup
+
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for i := 0; i < 100; i++ {
+ sc.Post(func() {})
+ }
+ }()
+
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for i := 0; i < 50; i++ {
+ sc.drainCmdQueue()
+ }
+ }()
+
+ wg.Wait()
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wlroutput/handlers.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wlroutput/handlers.go
new file mode 100644
index 0000000..c885e13
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wlroutput/handlers.go
@@ -0,0 +1,282 @@
+package wlroutput
+
+import (
+ "encoding/json"
+ "fmt"
+ "net"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_output_management"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+)
+
+type HeadConfig struct {
+ Name string `json:"name"`
+ Enabled bool `json:"enabled"`
+ ModeID *uint32 `json:"modeId,omitempty"`
+ CustomMode *struct {
+ Width int32 `json:"width"`
+ Height int32 `json:"height"`
+ Refresh int32 `json:"refresh"`
+ } `json:"customMode,omitempty"`
+ Position *struct{ X, Y int32 } `json:"position,omitempty"`
+ Transform *int32 `json:"transform,omitempty"`
+ Scale *float64 `json:"scale,omitempty"`
+ AdaptiveSync *uint32 `json:"adaptiveSync,omitempty"`
+}
+
+type ConfigurationRequest struct {
+ Heads []HeadConfig `json:"heads"`
+ Test bool `json:"test"`
+}
+
+func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
+ if manager == nil {
+ models.RespondError(conn, req.ID, "wlroutput manager not initialized")
+ return
+ }
+
+ switch req.Method {
+ case "wlroutput.getState":
+ handleGetState(conn, req, manager)
+ case "wlroutput.applyConfiguration":
+ handleApplyConfiguration(conn, req, manager, false)
+ case "wlroutput.testConfiguration":
+ handleApplyConfiguration(conn, req, manager, true)
+ case "wlroutput.subscribe":
+ handleSubscribe(conn, req, manager)
+ default:
+ models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
+ }
+}
+
+func handleGetState(conn net.Conn, req models.Request, manager *Manager) {
+ models.Respond(conn, req.ID, manager.GetState())
+}
+
+func handleApplyConfiguration(conn net.Conn, req models.Request, manager *Manager, test bool) {
+ headsParam, ok := models.Get[any](req, "heads")
+ if !ok {
+ models.RespondError(conn, req.ID, "missing 'heads' parameter")
+ return
+ }
+
+ headsJSON, err := json.Marshal(headsParam)
+ if err != nil {
+ models.RespondError(conn, req.ID, "invalid 'heads' parameter format")
+ return
+ }
+
+ var heads []HeadConfig
+ if err := json.Unmarshal(headsJSON, &heads); err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("invalid heads configuration: %v", err))
+ return
+ }
+
+ if err := manager.ApplyConfiguration(heads, test); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ msg := "configuration applied"
+ if test {
+ msg = "configuration test succeeded"
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: msg})
+}
+
+func handleSubscribe(conn net.Conn, req models.Request, manager *Manager) {
+ clientID := fmt.Sprintf("client-%p", conn)
+ stateChan := manager.Subscribe(clientID)
+ defer manager.Unsubscribe(clientID)
+
+ initialState := manager.GetState()
+ if err := json.NewEncoder(conn).Encode(models.Response[State]{
+ ID: req.ID,
+ Result: &initialState,
+ }); err != nil {
+ return
+ }
+
+ for state := range stateChan {
+ if err := json.NewEncoder(conn).Encode(models.Response[State]{
+ Result: &state,
+ }); err != nil {
+ return
+ }
+ }
+}
+
+func (m *Manager) ApplyConfiguration(heads []HeadConfig, test bool) error {
+ if m.manager == nil {
+ return fmt.Errorf("output manager not initialized")
+ }
+
+ resultChan := make(chan error, 1)
+
+ m.post(func() {
+ m.wlMutex.Lock()
+ defer m.wlMutex.Unlock()
+
+ config, err := m.manager.CreateConfiguration(m.serial)
+ if err != nil {
+ resultChan <- fmt.Errorf("failed to create configuration: %w", err)
+ return
+ }
+
+ responded := false
+
+ config.SetSucceededHandler(func(e wlr_output_management.ZwlrOutputConfigurationV1SucceededEvent) {
+ if responded {
+ return
+ }
+ responded = true
+ log.Info("WlrOutput: configuration succeeded")
+ config.Destroy()
+ resultChan <- nil
+ })
+
+ config.SetFailedHandler(func(e wlr_output_management.ZwlrOutputConfigurationV1FailedEvent) {
+ if responded {
+ return
+ }
+ responded = true
+ log.Warn("WlrOutput: configuration failed")
+ config.Destroy()
+ resultChan <- fmt.Errorf("compositor rejected configuration")
+ })
+
+ config.SetCancelledHandler(func(e wlr_output_management.ZwlrOutputConfigurationV1CancelledEvent) {
+ if responded {
+ return
+ }
+ responded = true
+ log.Warn("WlrOutput: configuration cancelled")
+ config.Destroy()
+ resultChan <- fmt.Errorf("configuration cancelled (outdated serial)")
+ })
+
+ time.AfterFunc(time.Second, func() {
+ m.post(func() {
+ if responded {
+ return
+ }
+ responded = true
+ config.Destroy()
+ resultChan <- fmt.Errorf("timeout waiting for configuration response")
+ })
+ })
+
+ headsByName := make(map[string]*headState)
+ m.heads.Range(func(key uint32, head *headState) bool {
+ if !head.finished {
+ headsByName[head.name] = head
+ }
+ return true
+ })
+
+ for _, headCfg := range heads {
+ head, exists := headsByName[headCfg.Name]
+ if !exists {
+ config.Destroy()
+ resultChan <- fmt.Errorf("head not found: %s", headCfg.Name)
+ return
+ }
+
+ if !headCfg.Enabled {
+ if err := config.DisableHead(head.handle); err != nil {
+ config.Destroy()
+ resultChan <- fmt.Errorf("failed to disable head %s: %w", headCfg.Name, err)
+ return
+ }
+ continue
+ }
+
+ headConfig, err := config.EnableHead(head.handle)
+ if err != nil {
+ config.Destroy()
+ resultChan <- fmt.Errorf("failed to enable head %s: %w", headCfg.Name, err)
+ return
+ }
+
+ if headCfg.ModeID != nil {
+ mode, exists := m.modes.Load(*headCfg.ModeID)
+
+ if !exists {
+ config.Destroy()
+ resultChan <- fmt.Errorf("mode not found: %d", *headCfg.ModeID)
+ return
+ }
+
+ if err := headConfig.SetMode(mode.handle); err != nil {
+ config.Destroy()
+ resultChan <- fmt.Errorf("failed to set mode for %s: %w", headCfg.Name, err)
+ return
+ }
+ } else if headCfg.CustomMode != nil {
+ if err := headConfig.SetCustomMode(
+ headCfg.CustomMode.Width,
+ headCfg.CustomMode.Height,
+ headCfg.CustomMode.Refresh,
+ ); err != nil {
+ config.Destroy()
+ resultChan <- fmt.Errorf("failed to set custom mode for %s: %w", headCfg.Name, err)
+ return
+ }
+ }
+
+ if headCfg.Position != nil {
+ if err := headConfig.SetPosition(headCfg.Position.X, headCfg.Position.Y); err != nil {
+ config.Destroy()
+ resultChan <- fmt.Errorf("failed to set position for %s: %w", headCfg.Name, err)
+ return
+ }
+ }
+
+ if headCfg.Transform != nil {
+ if err := headConfig.SetTransform(*headCfg.Transform); err != nil {
+ config.Destroy()
+ resultChan <- fmt.Errorf("failed to set transform for %s: %w", headCfg.Name, err)
+ return
+ }
+ }
+
+ if headCfg.Scale != nil {
+ if err := headConfig.SetScale(*headCfg.Scale); err != nil {
+ config.Destroy()
+ resultChan <- fmt.Errorf("failed to set scale for %s: %w", headCfg.Name, err)
+ return
+ }
+ }
+
+ if headCfg.AdaptiveSync != nil {
+ if err := headConfig.SetAdaptiveSync(*headCfg.AdaptiveSync); err != nil {
+ config.Destroy()
+ resultChan <- fmt.Errorf("failed to set adaptive sync for %s: %w", headCfg.Name, err)
+ return
+ }
+ }
+ }
+
+ var applyErr error
+ if test {
+ applyErr = config.Test()
+ } else {
+ applyErr = config.Apply()
+ }
+
+ if applyErr != nil {
+ responded = true
+ config.Destroy()
+ action := "apply"
+ if test {
+ action = "test"
+ }
+ resultChan <- fmt.Errorf("failed to %s configuration: %w", action, applyErr)
+ return
+ }
+ })
+
+ return <-resultChan
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wlroutput/manager.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wlroutput/manager.go
new file mode 100644
index 0000000..cbd89a3
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wlroutput/manager.go
@@ -0,0 +1,492 @@
+package wlroutput
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_output_management"
+ wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
+)
+
+func NewManager(display wlclient.WaylandDisplay) (*Manager, error) {
+ m := &Manager{
+ display: display,
+ ctx: display.Context(),
+ cmdq: make(chan cmd, 512),
+ stopChan: make(chan struct{}),
+ dirty: make(chan struct{}, 1),
+ fatalError: make(chan error, 1),
+ }
+
+ m.wg.Add(1)
+ go m.waylandActor()
+
+ if err := m.setupRegistry(); err != nil {
+ close(m.stopChan)
+ m.wg.Wait()
+ return nil, err
+ }
+
+ m.updateState()
+
+ m.notifierWg.Add(1)
+ go m.notifier()
+
+ return m, nil
+}
+
+func (m *Manager) post(fn func()) {
+ select {
+ case m.cmdq <- cmd{fn: fn}:
+ default:
+ log.Warn("WlrOutput actor command queue full, dropping command")
+ }
+}
+
+func (m *Manager) waylandActor() {
+ defer m.wg.Done()
+ defer func() {
+ if r := recover(); r != nil {
+ err := fmt.Errorf("waylandActor panic: %v", r)
+ log.Errorf("WlrOutput: %v", err)
+
+ select {
+ case m.fatalError <- err:
+ default:
+ }
+
+ select {
+ case <-m.stopChan:
+ default:
+ close(m.stopChan)
+ }
+ }
+ }()
+
+ for {
+ select {
+ case <-m.stopChan:
+ return
+ case c := <-m.cmdq:
+ func() {
+ defer func() {
+ if r := recover(); r != nil {
+ log.Errorf("WlrOutput: command execution panic: %v", r)
+ }
+ }()
+ c.fn()
+ }()
+ }
+ }
+}
+
+func (m *Manager) setupRegistry() error {
+ log.Info("WlrOutput: starting registry setup")
+
+ registry, err := m.display.GetRegistry()
+ if err != nil {
+ return fmt.Errorf("failed to get registry: %w", err)
+ }
+ m.registry = registry
+
+ registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
+ if e.Interface == wlr_output_management.ZwlrOutputManagerV1InterfaceName {
+ log.Infof("WlrOutput: found %s", wlr_output_management.ZwlrOutputManagerV1InterfaceName)
+ manager := wlr_output_management.NewZwlrOutputManagerV1(m.ctx)
+ version := e.Version
+ if version > 4 {
+ version = 4
+ }
+
+ manager.SetHeadHandler(func(e wlr_output_management.ZwlrOutputManagerV1HeadEvent) {
+ m.handleHead(e)
+ })
+
+ manager.SetDoneHandler(func(e wlr_output_management.ZwlrOutputManagerV1DoneEvent) {
+ log.Debugf("WlrOutput: done event received (serial=%d)", e.Serial)
+ m.serial = e.Serial
+ m.post(func() {
+ m.updateState()
+ })
+ })
+
+ manager.SetFinishedHandler(func(e wlr_output_management.ZwlrOutputManagerV1FinishedEvent) {
+ log.Info("WlrOutput: finished event received")
+ })
+
+ if err := registry.Bind(e.Name, e.Interface, version, manager); err == nil {
+ m.manager = manager
+ log.Info("WlrOutput: manager bound successfully")
+ } else {
+ log.Errorf("WlrOutput: failed to bind manager: %v", err)
+ }
+ }
+ })
+
+ log.Info("WlrOutput: registry setup complete (events will be processed async)")
+ return nil
+}
+
+func (m *Manager) handleHead(e wlr_output_management.ZwlrOutputManagerV1HeadEvent) {
+ handle := e.Head
+ headID := handle.ID()
+
+ log.Debugf("WlrOutput: New head (id=%d)", headID)
+
+ head := &headState{
+ id: headID,
+ handle: handle,
+ modeIDs: make([]uint32, 0),
+ }
+
+ m.heads.Store(headID, head)
+
+ handle.SetNameHandler(func(e wlr_output_management.ZwlrOutputHeadV1NameEvent) {
+ log.Debugf("WlrOutput: Head %d name: %s", headID, e.Name)
+ head.name = e.Name
+ head.ready = true
+ m.post(func() {
+ m.updateState()
+ })
+ })
+
+ handle.SetDescriptionHandler(func(e wlr_output_management.ZwlrOutputHeadV1DescriptionEvent) {
+ log.Debugf("WlrOutput: Head %d description: %s", headID, e.Description)
+ head.description = e.Description
+ m.post(func() {
+ m.updateState()
+ })
+ })
+
+ handle.SetPhysicalSizeHandler(func(e wlr_output_management.ZwlrOutputHeadV1PhysicalSizeEvent) {
+ log.Debugf("WlrOutput: Head %d physical size: %dx%d", headID, e.Width, e.Height)
+ head.physicalWidth = e.Width
+ head.physicalHeight = e.Height
+ m.post(func() {
+ m.updateState()
+ })
+ })
+
+ handle.SetModeHandler(func(e wlr_output_management.ZwlrOutputHeadV1ModeEvent) {
+ m.handleMode(headID, e)
+ })
+
+ handle.SetEnabledHandler(func(e wlr_output_management.ZwlrOutputHeadV1EnabledEvent) {
+ log.Debugf("WlrOutput: Head %d enabled: %d", headID, e.Enabled)
+ head.enabled = e.Enabled != 0
+ m.post(func() {
+ m.updateState()
+ })
+ })
+
+ handle.SetCurrentModeHandler(func(e wlr_output_management.ZwlrOutputHeadV1CurrentModeEvent) {
+ modeID := e.Mode.ID()
+ log.Debugf("WlrOutput: Head %d current mode: %d", headID, modeID)
+ head.currentModeID = modeID
+ m.post(func() {
+ m.updateState()
+ })
+ })
+
+ handle.SetPositionHandler(func(e wlr_output_management.ZwlrOutputHeadV1PositionEvent) {
+ log.Debugf("WlrOutput: Head %d position: %d,%d", headID, e.X, e.Y)
+ head.x = e.X
+ head.y = e.Y
+ m.post(func() {
+ m.updateState()
+ })
+ })
+
+ handle.SetTransformHandler(func(e wlr_output_management.ZwlrOutputHeadV1TransformEvent) {
+ log.Debugf("WlrOutput: Head %d transform: %d", headID, e.Transform)
+ head.transform = e.Transform
+ m.post(func() {
+ m.updateState()
+ })
+ })
+
+ handle.SetScaleHandler(func(e wlr_output_management.ZwlrOutputHeadV1ScaleEvent) {
+ log.Debugf("WlrOutput: Head %d scale: %f", headID, e.Scale)
+ head.scale = e.Scale
+ m.post(func() {
+ m.updateState()
+ })
+ })
+
+ handle.SetMakeHandler(func(e wlr_output_management.ZwlrOutputHeadV1MakeEvent) {
+ log.Debugf("WlrOutput: Head %d make: %s", headID, e.Make)
+ head.make = e.Make
+ m.post(func() {
+ m.updateState()
+ })
+ })
+
+ handle.SetModelHandler(func(e wlr_output_management.ZwlrOutputHeadV1ModelEvent) {
+ log.Debugf("WlrOutput: Head %d model: %s", headID, e.Model)
+ head.model = e.Model
+ m.post(func() {
+ m.updateState()
+ })
+ })
+
+ handle.SetSerialNumberHandler(func(e wlr_output_management.ZwlrOutputHeadV1SerialNumberEvent) {
+ log.Debugf("WlrOutput: Head %d serial: %s", headID, e.SerialNumber)
+ head.serialNumber = e.SerialNumber
+ m.post(func() {
+ m.updateState()
+ })
+ })
+
+ handle.SetAdaptiveSyncHandler(func(e wlr_output_management.ZwlrOutputHeadV1AdaptiveSyncEvent) {
+ log.Debugf("WlrOutput: Head %d adaptive sync: %d", headID, e.State)
+ head.adaptiveSync = e.State
+ head.adaptiveSyncSupported = true
+ m.post(func() {
+ m.updateState()
+ })
+ })
+
+ handle.SetFinishedHandler(func(e wlr_output_management.ZwlrOutputHeadV1FinishedEvent) {
+ log.Debugf("WlrOutput: Head %d finished", headID)
+ head.finished = true
+
+ m.heads.Delete(headID)
+
+ m.wlMutex.Lock()
+ handle.Release()
+ m.wlMutex.Unlock()
+
+ m.post(func() {
+ m.updateState()
+ })
+ })
+}
+
+func (m *Manager) handleMode(headID uint32, e wlr_output_management.ZwlrOutputHeadV1ModeEvent) {
+ handle := e.Mode
+ modeID := handle.ID()
+
+ log.Debugf("WlrOutput: Head %d new mode (id=%d)", headID, modeID)
+
+ mode := &modeState{
+ id: modeID,
+ handle: handle,
+ }
+
+ m.modes.Store(modeID, mode)
+
+ if head, ok := m.heads.Load(headID); ok {
+ head.modeIDs = append(head.modeIDs, modeID)
+ m.heads.Store(headID, head)
+ }
+
+ handle.SetSizeHandler(func(e wlr_output_management.ZwlrOutputModeV1SizeEvent) {
+ log.Debugf("WlrOutput: Mode %d size: %dx%d", modeID, e.Width, e.Height)
+ mode.width = e.Width
+ mode.height = e.Height
+ m.post(func() {
+ m.updateState()
+ })
+ })
+
+ handle.SetRefreshHandler(func(e wlr_output_management.ZwlrOutputModeV1RefreshEvent) {
+ log.Debugf("WlrOutput: Mode %d refresh: %d", modeID, e.Refresh)
+ mode.refresh = e.Refresh
+ m.post(func() {
+ m.updateState()
+ })
+ })
+
+ handle.SetPreferredHandler(func(e wlr_output_management.ZwlrOutputModeV1PreferredEvent) {
+ log.Debugf("WlrOutput: Mode %d preferred", modeID)
+ mode.preferred = true
+ m.post(func() {
+ m.updateState()
+ })
+ })
+
+ handle.SetFinishedHandler(func(e wlr_output_management.ZwlrOutputModeV1FinishedEvent) {
+ log.Debugf("WlrOutput: Mode %d finished", modeID)
+ mode.finished = true
+
+ m.modes.Delete(modeID)
+
+ m.wlMutex.Lock()
+ handle.Release()
+ m.wlMutex.Unlock()
+
+ m.post(func() {
+ m.updateState()
+ })
+ })
+}
+
+func (m *Manager) updateState() {
+ outputs := make([]Output, 0)
+
+ m.heads.Range(func(key uint32, head *headState) bool {
+ if head.finished {
+ return true
+ }
+
+ if !head.ready {
+ return true
+ }
+
+ modes := make([]OutputMode, 0)
+ var currentMode *OutputMode
+
+ for _, modeID := range head.modeIDs {
+ mode, exists := m.modes.Load(modeID)
+ if !exists {
+ continue
+ }
+ if mode.finished {
+ continue
+ }
+
+ outMode := OutputMode{
+ Width: mode.width,
+ Height: mode.height,
+ Refresh: mode.refresh,
+ Preferred: mode.preferred,
+ ID: modeID,
+ }
+ modes = append(modes, outMode)
+
+ if modeID == head.currentModeID {
+ currentMode = &outMode
+ }
+ }
+
+ output := Output{
+ Name: head.name,
+ Description: head.description,
+ Make: head.make,
+ Model: head.model,
+ SerialNumber: head.serialNumber,
+ PhysicalWidth: head.physicalWidth,
+ PhysicalHeight: head.physicalHeight,
+ Enabled: head.enabled,
+ X: head.x,
+ Y: head.y,
+ Transform: head.transform,
+ Scale: head.scale,
+ CurrentMode: currentMode,
+ Modes: modes,
+ AdaptiveSync: head.adaptiveSync,
+ AdaptiveSyncSupported: head.adaptiveSyncSupported,
+ ID: head.id,
+ }
+ outputs = append(outputs, output)
+ return true
+ })
+
+ newState := State{
+ Outputs: outputs,
+ Serial: m.serial,
+ }
+
+ m.stateMutex.Lock()
+ m.state = &newState
+ m.stateMutex.Unlock()
+
+ m.notifySubscribers()
+}
+
+func (m *Manager) notifier() {
+ defer m.notifierWg.Done()
+ defer func() {
+ if r := recover(); r != nil {
+ err := fmt.Errorf("notifier panic: %v", r)
+ log.Errorf("WlrOutput: %v", err)
+
+ select {
+ case m.fatalError <- err:
+ default:
+ }
+
+ select {
+ case <-m.stopChan:
+ default:
+ close(m.stopChan)
+ }
+ }
+ }()
+
+ const minGap = 100 * time.Millisecond
+ timer := time.NewTimer(minGap)
+ timer.Stop()
+ var pending bool
+
+ for {
+ select {
+ case <-m.stopChan:
+ timer.Stop()
+ return
+ case <-m.dirty:
+ if pending {
+ continue
+ }
+ pending = true
+ timer.Reset(minGap)
+ case <-timer.C:
+ if !pending {
+ continue
+ }
+
+ currentState := m.GetState()
+
+ if m.lastNotified != nil && !stateChanged(m.lastNotified, &currentState) {
+ pending = false
+ continue
+ }
+
+ m.subscribers.Range(func(key string, ch chan State) bool {
+ select {
+ case ch <- currentState:
+ default:
+ log.Warn("WlrOutput: subscriber channel full, dropping update")
+ }
+ return true
+ })
+
+ stateCopy := currentState
+ m.lastNotified = &stateCopy
+ pending = false
+ }
+ }
+}
+
+func (m *Manager) Close() {
+ close(m.stopChan)
+ m.wg.Wait()
+ m.notifierWg.Wait()
+
+ m.subscribers.Range(func(key string, ch chan State) bool {
+ close(ch)
+ m.subscribers.Delete(key)
+ return true
+ })
+
+ m.modes.Range(func(key uint32, mode *modeState) bool {
+ if mode.handle != nil {
+ mode.handle.Release()
+ }
+ m.modes.Delete(key)
+ return true
+ })
+
+ m.heads.Range(func(key uint32, head *headState) bool {
+ if head.handle != nil {
+ head.handle.Release()
+ }
+ m.heads.Delete(key)
+ return true
+ })
+
+ if m.manager != nil {
+ m.manager.Stop()
+ }
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wlroutput/manager_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wlroutput/manager_test.go
new file mode 100644
index 0000000..e90c70c
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wlroutput/manager_test.go
@@ -0,0 +1,414 @@
+package wlroutput
+
+import (
+ "errors"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+
+ mocks_wlclient "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/wlclient"
+)
+
+func TestStateChanged_BothNil(t *testing.T) {
+ assert.True(t, stateChanged(nil, nil))
+}
+
+func TestStateChanged_OneNil(t *testing.T) {
+ s := &State{Serial: 1}
+ assert.True(t, stateChanged(s, nil))
+ assert.True(t, stateChanged(nil, s))
+}
+
+func TestStateChanged_SerialDiffers(t *testing.T) {
+ a := &State{Serial: 1, Outputs: []Output{}}
+ b := &State{Serial: 2, Outputs: []Output{}}
+ assert.True(t, stateChanged(a, b))
+}
+
+func TestStateChanged_OutputCountDiffers(t *testing.T) {
+ a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1"}}}
+ b := &State{Serial: 1, Outputs: []Output{}}
+ assert.True(t, stateChanged(a, b))
+}
+
+func TestStateChanged_OutputNameDiffers(t *testing.T) {
+ a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", Enabled: true}}}
+ b := &State{Serial: 1, Outputs: []Output{{Name: "HDMI-A-1", Enabled: true}}}
+ assert.True(t, stateChanged(a, b))
+}
+
+func TestStateChanged_OutputEnabledDiffers(t *testing.T) {
+ a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", Enabled: true}}}
+ b := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", Enabled: false}}}
+ assert.True(t, stateChanged(a, b))
+}
+
+func TestStateChanged_OutputPositionDiffers(t *testing.T) {
+ a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", X: 0, Y: 0}}}
+ b := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", X: 1920, Y: 0}}}
+ assert.True(t, stateChanged(a, b))
+}
+
+func TestStateChanged_OutputTransformDiffers(t *testing.T) {
+ a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", Transform: 0}}}
+ b := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", Transform: 1}}}
+ assert.True(t, stateChanged(a, b))
+}
+
+func TestStateChanged_OutputScaleDiffers(t *testing.T) {
+ a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", Scale: 1.0}}}
+ b := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", Scale: 2.0}}}
+ assert.True(t, stateChanged(a, b))
+}
+
+func TestStateChanged_OutputAdaptiveSyncDiffers(t *testing.T) {
+ a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", AdaptiveSync: 0}}}
+ b := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", AdaptiveSync: 1}}}
+ assert.True(t, stateChanged(a, b))
+}
+
+func TestStateChanged_CurrentModeNilVsNonNil(t *testing.T) {
+ a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", CurrentMode: nil}}}
+ b := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", CurrentMode: &OutputMode{Width: 1920}}}}
+ assert.True(t, stateChanged(a, b))
+}
+
+func TestStateChanged_CurrentModeDiffers(t *testing.T) {
+ a := &State{Serial: 1, Outputs: []Output{{
+ Name: "eDP-1",
+ CurrentMode: &OutputMode{Width: 1920, Height: 1080, Refresh: 60000},
+ }}}
+ b := &State{Serial: 1, Outputs: []Output{{
+ Name: "eDP-1",
+ CurrentMode: &OutputMode{Width: 2560, Height: 1440, Refresh: 60000},
+ }}}
+ assert.True(t, stateChanged(a, b))
+
+ b.Outputs[0].CurrentMode.Width = 1920
+ b.Outputs[0].CurrentMode.Height = 1080
+ b.Outputs[0].CurrentMode.Refresh = 144000
+ assert.True(t, stateChanged(a, b))
+}
+
+func TestStateChanged_ModesLengthDiffers(t *testing.T) {
+ a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", Modes: []OutputMode{{Width: 1920}}}}}
+ b := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", Modes: []OutputMode{{Width: 1920}, {Width: 1280}}}}}
+ assert.True(t, stateChanged(a, b))
+}
+
+func TestStateChanged_Equal(t *testing.T) {
+ mode := OutputMode{Width: 1920, Height: 1080, Refresh: 60000, Preferred: true}
+ a := &State{
+ Serial: 5,
+ Outputs: []Output{{
+ Name: "eDP-1",
+ Description: "Built-in display",
+ Make: "BOE",
+ Model: "0x0ABC",
+ SerialNumber: "12345",
+ PhysicalWidth: 309,
+ PhysicalHeight: 174,
+ Enabled: true,
+ X: 0,
+ Y: 0,
+ Transform: 0,
+ Scale: 1.0,
+ CurrentMode: &mode,
+ Modes: []OutputMode{mode},
+ AdaptiveSync: 0,
+ }},
+ }
+ b := &State{
+ Serial: 5,
+ Outputs: []Output{{
+ Name: "eDP-1",
+ Description: "Built-in display",
+ Make: "BOE",
+ Model: "0x0ABC",
+ SerialNumber: "12345",
+ PhysicalWidth: 309,
+ PhysicalHeight: 174,
+ Enabled: true,
+ X: 0,
+ Y: 0,
+ Transform: 0,
+ Scale: 1.0,
+ CurrentMode: &mode,
+ Modes: []OutputMode{mode},
+ AdaptiveSync: 0,
+ }},
+ }
+ assert.False(t, stateChanged(a, b))
+}
+
+func TestManager_ConcurrentGetState(t *testing.T) {
+ m := &Manager{
+ state: &State{
+ Serial: 1,
+ Outputs: []Output{{Name: "eDP-1", Enabled: true}},
+ },
+ }
+
+ var wg sync.WaitGroup
+ const goroutines = 50
+ const iterations = 100
+
+ for i := 0; i < goroutines/2; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for j := 0; j < iterations; j++ {
+ s := m.GetState()
+ _ = s.Serial
+ _ = s.Outputs
+ }
+ }()
+ }
+
+ for i := 0; i < goroutines/2; i++ {
+ wg.Add(1)
+ go func(i int) {
+ defer wg.Done()
+ for j := 0; j < iterations; j++ {
+ m.stateMutex.Lock()
+ m.state = &State{
+ Serial: uint32(j),
+ Outputs: []Output{{Name: "eDP-1", Scale: float64(j % 3)}},
+ }
+ m.stateMutex.Unlock()
+ }
+ }(i)
+ }
+
+ wg.Wait()
+}
+
+func TestManager_ConcurrentSubscriberAccess(t *testing.T) {
+ m := &Manager{
+ stopChan: make(chan struct{}),
+ dirty: make(chan struct{}, 1),
+ }
+
+ var wg sync.WaitGroup
+ const goroutines = 20
+
+ for i := 0; i < goroutines; i++ {
+ wg.Add(1)
+ go func(id int) {
+ defer wg.Done()
+ subID := string(rune('a' + id))
+ ch := m.Subscribe(subID)
+ assert.NotNil(t, ch)
+ time.Sleep(time.Millisecond)
+ m.Unsubscribe(subID)
+ }(i)
+ }
+
+ wg.Wait()
+}
+
+func TestManager_SyncmapHeadsConcurrentAccess(t *testing.T) {
+ m := &Manager{}
+
+ var wg sync.WaitGroup
+ const goroutines = 30
+ const iterations = 50
+
+ for i := 0; i < goroutines; i++ {
+ wg.Add(1)
+ go func(id int) {
+ defer wg.Done()
+ key := uint32(id)
+
+ for j := 0; j < iterations; j++ {
+ state := &headState{
+ id: key,
+ name: "test-head",
+ enabled: j%2 == 0,
+ scale: float64(j % 3),
+ modeIDs: []uint32{uint32(j)},
+ }
+ m.heads.Store(key, state)
+
+ if loaded, ok := m.heads.Load(key); ok {
+ assert.Equal(t, key, loaded.id)
+ }
+
+ m.heads.Range(func(k uint32, v *headState) bool {
+ _ = v.name
+ _ = v.enabled
+ return true
+ })
+ }
+
+ m.heads.Delete(key)
+ }(i)
+ }
+
+ wg.Wait()
+}
+
+func TestManager_SyncmapModesConcurrentAccess(t *testing.T) {
+ m := &Manager{}
+
+ var wg sync.WaitGroup
+ const goroutines = 30
+ const iterations = 50
+
+ for i := 0; i < goroutines; i++ {
+ wg.Add(1)
+ go func(id int) {
+ defer wg.Done()
+ key := uint32(id)
+
+ for j := 0; j < iterations; j++ {
+ state := &modeState{
+ id: key,
+ width: int32(1920 + j),
+ height: int32(1080 + j),
+ refresh: 60000,
+ preferred: j == 0,
+ }
+ m.modes.Store(key, state)
+
+ if loaded, ok := m.modes.Load(key); ok {
+ assert.Equal(t, key, loaded.id)
+ }
+
+ m.modes.Range(func(k uint32, v *modeState) bool {
+ _ = v.width
+ _ = v.height
+ return true
+ })
+ }
+
+ m.modes.Delete(key)
+ }(i)
+ }
+
+ wg.Wait()
+}
+
+func TestManager_NotifySubscribersNonBlocking(t *testing.T) {
+ m := &Manager{
+ dirty: make(chan struct{}, 1),
+ }
+
+ for i := 0; i < 10; i++ {
+ m.notifySubscribers()
+ }
+
+ assert.Len(t, m.dirty, 1)
+}
+
+func TestManager_PostQueueFull(t *testing.T) {
+ m := &Manager{
+ cmdq: make(chan cmd, 2),
+ stopChan: make(chan struct{}),
+ }
+
+ m.post(func() {})
+ m.post(func() {})
+ m.post(func() {})
+ m.post(func() {})
+
+ assert.Len(t, m.cmdq, 2)
+}
+
+func TestManager_GetStateNilState(t *testing.T) {
+ m := &Manager{}
+
+ s := m.GetState()
+ assert.NotNil(t, s.Outputs)
+ assert.Equal(t, uint32(0), s.Serial)
+}
+
+func TestManager_FatalErrorChannel(t *testing.T) {
+ m := &Manager{
+ fatalError: make(chan error, 1),
+ }
+
+ ch := m.FatalError()
+ assert.NotNil(t, ch)
+
+ m.fatalError <- assert.AnError
+ err := <-ch
+ assert.Error(t, err)
+}
+
+func TestOutputMode_Fields(t *testing.T) {
+ mode := OutputMode{
+ Width: 1920,
+ Height: 1080,
+ Refresh: 60000,
+ Preferred: true,
+ ID: 42,
+ }
+
+ assert.Equal(t, int32(1920), mode.Width)
+ assert.Equal(t, int32(1080), mode.Height)
+ assert.Equal(t, int32(60000), mode.Refresh)
+ assert.True(t, mode.Preferred)
+ assert.Equal(t, uint32(42), mode.ID)
+}
+
+func TestOutput_Fields(t *testing.T) {
+ out := Output{
+ Name: "eDP-1",
+ Description: "Built-in display",
+ Make: "BOE",
+ Model: "0x0ABC",
+ SerialNumber: "12345",
+ PhysicalWidth: 309,
+ PhysicalHeight: 174,
+ Enabled: true,
+ X: 0,
+ Y: 0,
+ Transform: 0,
+ Scale: 1.5,
+ AdaptiveSync: 1,
+ ID: 1,
+ }
+
+ assert.Equal(t, "eDP-1", out.Name)
+ assert.Equal(t, "Built-in display", out.Description)
+ assert.True(t, out.Enabled)
+ assert.Equal(t, float64(1.5), out.Scale)
+ assert.Equal(t, uint32(1), out.AdaptiveSync)
+}
+
+func TestHeadState_ModeIDsSlice(t *testing.T) {
+ head := &headState{
+ id: 1,
+ modeIDs: make([]uint32, 0),
+ }
+
+ head.modeIDs = append(head.modeIDs, 1, 2, 3)
+ assert.Len(t, head.modeIDs, 3)
+ assert.Equal(t, uint32(1), head.modeIDs[0])
+}
+
+func TestStateChanged_BothCurrentModeNil(t *testing.T) {
+ a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", CurrentMode: nil}}}
+ b := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", CurrentMode: nil}}}
+ assert.False(t, stateChanged(a, b))
+}
+
+func TestStateChanged_IndexOutOfBounds(t *testing.T) {
+ a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1"}}}
+ b := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1"}, {Name: "HDMI-A-1"}}}
+ assert.True(t, stateChanged(a, b))
+}
+
+func TestNewManager_GetRegistryError(t *testing.T) {
+ mockDisplay := mocks_wlclient.NewMockWaylandDisplay(t)
+
+ mockDisplay.EXPECT().Context().Return(nil)
+ mockDisplay.EXPECT().GetRegistry().Return(nil, errors.New("failed to get registry"))
+
+ _, err := NewManager(mockDisplay)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "failed to get registry")
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wlroutput/types.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wlroutput/types.go
new file mode 100644
index 0000000..9c79b1a
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wlroutput/types.go
@@ -0,0 +1,192 @@
+package wlroutput
+
+import (
+ "sync"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_output_management"
+ wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
+)
+
+type OutputMode struct {
+ Width int32 `json:"width"`
+ Height int32 `json:"height"`
+ Refresh int32 `json:"refresh"`
+ Preferred bool `json:"preferred"`
+ ID uint32 `json:"id"`
+}
+
+type Output struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Make string `json:"make"`
+ Model string `json:"model"`
+ SerialNumber string `json:"serialNumber"`
+ PhysicalWidth int32 `json:"physicalWidth"`
+ PhysicalHeight int32 `json:"physicalHeight"`
+ Enabled bool `json:"enabled"`
+ X int32 `json:"x"`
+ Y int32 `json:"y"`
+ Transform int32 `json:"transform"`
+ Scale float64 `json:"scale"`
+ CurrentMode *OutputMode `json:"currentMode"`
+ Modes []OutputMode `json:"modes"`
+ AdaptiveSync uint32 `json:"adaptiveSync"`
+ AdaptiveSyncSupported bool `json:"adaptiveSyncSupported"`
+ ID uint32 `json:"id"`
+}
+
+type State struct {
+ Outputs []Output `json:"outputs"`
+ Serial uint32 `json:"serial"`
+}
+
+type cmd struct {
+ fn func()
+}
+
+type Manager struct {
+ display wlclient.WaylandDisplay
+ ctx *wlclient.Context
+ registry *wlclient.Registry
+ manager *wlr_output_management.ZwlrOutputManagerV1
+
+ heads syncmap.Map[uint32, *headState]
+ modes syncmap.Map[uint32, *modeState]
+
+ serial uint32
+
+ wlMutex sync.Mutex
+ cmdq chan cmd
+ stopChan chan struct{}
+ wg sync.WaitGroup
+
+ subscribers syncmap.Map[string, chan State]
+ dirty chan struct{}
+ notifierWg sync.WaitGroup
+ lastNotified *State
+
+ stateMutex sync.RWMutex
+ state *State
+
+ fatalError chan error
+}
+
+type headState struct {
+ id uint32
+ handle *wlr_output_management.ZwlrOutputHeadV1
+ name string
+ description string
+ make string
+ model string
+ serialNumber string
+ physicalWidth int32
+ physicalHeight int32
+ enabled bool
+ x int32
+ y int32
+ transform int32
+ scale float64
+ currentModeID uint32
+ modeIDs []uint32
+ adaptiveSync uint32
+ adaptiveSyncSupported bool
+ finished bool
+ ready bool
+}
+
+type modeState struct {
+ id uint32
+ handle *wlr_output_management.ZwlrOutputModeV1
+ width int32
+ height int32
+ refresh int32
+ preferred bool
+ finished bool
+}
+
+func (m *Manager) GetState() State {
+ m.stateMutex.RLock()
+ defer m.stateMutex.RUnlock()
+ if m.state == nil {
+ return State{
+ Outputs: []Output{},
+ Serial: 0,
+ }
+ }
+ stateCopy := *m.state
+ return stateCopy
+}
+
+func (m *Manager) Subscribe(id string) chan State {
+ ch := make(chan State, 64)
+
+ m.subscribers.Store(id, ch)
+
+ return ch
+}
+
+func (m *Manager) Unsubscribe(id string) {
+
+ if val, ok := m.subscribers.LoadAndDelete(id); ok {
+ close(val)
+
+ }
+
+}
+
+func (m *Manager) notifySubscribers() {
+ select {
+ case m.dirty <- struct{}{}:
+ default:
+ }
+}
+
+func (m *Manager) FatalError() <-chan error {
+ return m.fatalError
+}
+
+func stateChanged(old, new *State) bool {
+ if old == nil || new == nil {
+ return true
+ }
+ if old.Serial != new.Serial {
+ return true
+ }
+ if len(old.Outputs) != len(new.Outputs) {
+ return true
+ }
+ for i := range new.Outputs {
+ if i >= len(old.Outputs) {
+ return true
+ }
+ oldOut := &old.Outputs[i]
+ newOut := &new.Outputs[i]
+ if oldOut.Name != newOut.Name || oldOut.Enabled != newOut.Enabled {
+ return true
+ }
+ if oldOut.X != newOut.X || oldOut.Y != newOut.Y {
+ return true
+ }
+ if oldOut.Transform != newOut.Transform || oldOut.Scale != newOut.Scale {
+ return true
+ }
+ if oldOut.AdaptiveSync != newOut.AdaptiveSync || oldOut.AdaptiveSyncSupported != newOut.AdaptiveSyncSupported {
+ return true
+ }
+ if (oldOut.CurrentMode == nil) != (newOut.CurrentMode == nil) {
+ return true
+ }
+ if oldOut.CurrentMode != nil && newOut.CurrentMode != nil {
+ if oldOut.CurrentMode.Width != newOut.CurrentMode.Width ||
+ oldOut.CurrentMode.Height != newOut.CurrentMode.Height ||
+ oldOut.CurrentMode.Refresh != newOut.CurrentMode.Refresh {
+ return true
+ }
+ }
+ if len(oldOut.Modes) != len(newOut.Modes) {
+ return true
+ }
+ }
+ return false
+}